refactor(inbox): kami literary rewrite — drop tiles, cards, red stripe
The Inbox rendered every row as a heavy card: a 36x36 solid-fill icon
tile in ink-blue / destructive / warm-silver, a rounded border, a 3pt
red left stripe on urgent items, and a hover that fired a drop shadow
plus translate-y. Six loud signals, all fighting for the reader, all
breaking kami rules (no pure-hue tiles, no red accents outside
destructive buttons, no hard drop shadows, no unnecessary chrome).
Per Jony + Jobs consensus the surface collapses to one quiet canvas:
- 36x36 color tile -> removed. Icon is an 18px inline glyph, single
ink-blue stroke for every type. Type meaning lives in the glyph
shape (ShieldCheck / XCircle / Eye / ChatCircle / ...), not in
background color.
- 3pt red severity stripe -> removed. Urgency compresses to one
signal: a 5px ink-blue dot before the timestamp, visible only for
critical/high rows. Title also shifts from font-medium to
font-semibold at the same threshold so the row carries a little
more weight without shouting.
- Card chrome (border, rounded, bg-card) -> removed. Rows sit flat
on parchment, separated by a single border-b at --border/60
hairline. Reads like a printed contents page instead of a trello
board of cards.
- Hover shadow-md + -translate-y-px -> removed (kami rule 8 bans
hard drop shadows). Hover tints the row with bg-muted/40, which
is the kami whisper-shadow equivalent for a list item.
AttentionRow gains a variant prop ("card" | "line", default "card")
so Dashboard's three existing call-sites keep their card rendering
untouched. The "line" branch rejects iconTileBg / rowBgClass /
borderAccentClass overrides — the two variants are orthogonal, not a
half-mixed code path.
inbox-helpers.tsx sheds every token that became dead after the
rewrite: typeConfig.color (constant var(--primary) for every type
after Jony's unification), severityColors, severityBorderClass, and
the unused bg field. Inbox routes icon color through tone="info"
instead of passing the redundant iconColor per row.
Note: the page shows 6 duplicate "Escalation from Maya Rodriguez"
rows and 5 duplicate "Escalation from Taylor Park" rows. That is a
backend dedup bug (same entity + same severity pushed multiple
times), not a visual issue. Flag for a dedicated follow-up; do not
paper over it in the renderer.
Reviewed by Karpathy against /karpathy-guidelines:
- Surgical: pass
- No overcomplication: pass
- Variants orthogonal: pass
- Orphans cleared: pass
- API stability for Dashboard callers: pass
- request_changes item (typeConfig.color is a constant dressed as
data) addressed in this commit by dropping the field and routing
through tone="info".
Typecheck and build:verify clean.
This commit is contained in:
1 parent
f9b505569c
commit
86ca568b90
3 files changed
+58
-41
No files matched your search
@@ -8,7 +8,7 @@ import {
|
||||
import type { InboxItem, InboxItemType, SectionId } from "@shared/types";
|
||||
import { timeAgo } from "../../lib/formatters";
|
||||
import { useT } from "../../i18n";
|
||||
import { typeConfig, severityBorderClass } from "./inbox-helpers";
|
||||
import { typeConfig } from "./inbox-helpers";
|
||||
import { AttentionRow } from "../ui";
|
||||
|
||||
interface InboxProps {
|
||||
@@ -104,12 +104,15 @@ export function Inbox({ items, onNavigate }: InboxProps) {
|
||||
<div className="mt-1 text-[12px] text-muted-foreground">{t("inbox.noItemsNow")}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<AnimatePresence>
|
||||
{filteredItems.map((item, i) => {
|
||||
const config = typeConfig[item.type];
|
||||
const Icon = config.icon;
|
||||
const title = <span className="truncate">{item.title}</span>;
|
||||
const isUrgent = item.severity === "critical" || item.severity === "high";
|
||||
const title = (
|
||||
<span className={`truncate ${isUrgent ? "font-semibold" : "font-medium"}`}>{item.title}</span>
|
||||
);
|
||||
const subtitle = (
|
||||
<span className="flex items-center gap-2">
|
||||
{item.agentName ? (
|
||||
@@ -119,6 +122,9 @@ export function Inbox({ items, onNavigate }: InboxProps) {
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">{item.subtitle.slice(0, 80)}</span>
|
||||
{isUrgent ? (
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="shrink-0 tabular-nums">{timeAgo(item.createdAt)}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -127,11 +133,9 @@ export function Inbox({ items, onNavigate }: InboxProps) {
|
||||
key={item.id}
|
||||
index={i}
|
||||
icon={Icon}
|
||||
iconColor={config.color}
|
||||
iconTileBg={config.bg}
|
||||
iconWeight="fill"
|
||||
rowBgClass="bg-card"
|
||||
borderAccentClass={`border-l-[3px] border-border hover:shadow-md hover:-translate-y-px ${severityBorderClass(item.severity)}`}
|
||||
tone="info"
|
||||
iconWeight={isUrgent ? "bold" : "regular"}
|
||||
variant="line"
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onClick={() => handleItemClick(item)}
|
||||
|
||||
@@ -10,27 +10,15 @@ import {
|
||||
import type { InboxItemType } from "@shared/types";
|
||||
import { Card } from "../ui";
|
||||
|
||||
export const typeConfig: Record<InboxItemType, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
||||
pending_approval: { icon: ShieldCheck, color: "var(--destructive)", bg: "var(--muted-foreground)", label: "Approval" },
|
||||
failed_run: { icon: XCircle, color: "var(--destructive)", bg: "var(--destructive)", label: "Failed Run" },
|
||||
budget_warning: { icon: CurrencyCircleDollar, color: "var(--destructive)", bg: "var(--muted-foreground)", label: "Budget" },
|
||||
blocked_task: { icon: Warning, color: "var(--destructive)", bg: "var(--destructive)", label: "Blocked" },
|
||||
review_needed: { icon: Eye, color: "var(--primary)", bg: "var(--primary)", label: "Review" },
|
||||
agent_message: { icon: ChatCircle, color: "var(--primary)", bg: "var(--primary)", label: "Message" },
|
||||
export const typeConfig: Record<InboxItemType, { icon: React.ElementType; label: string }> = {
|
||||
pending_approval: { icon: ShieldCheck, label: "Approval" },
|
||||
failed_run: { icon: XCircle, label: "Failed Run" },
|
||||
budget_warning: { icon: CurrencyCircleDollar, label: "Budget" },
|
||||
blocked_task: { icon: Warning, label: "Blocked" },
|
||||
review_needed: { icon: Eye, label: "Review" },
|
||||
agent_message: { icon: ChatCircle, label: "Message" },
|
||||
};
|
||||
|
||||
export const severityColors: Record<string, string> = {
|
||||
critical: "border-l-[color:var(--destructive)]",
|
||||
high: "border-l-[color:var(--destructive)]",
|
||||
medium: "border-l-[color:var(--primary)]",
|
||||
low: "border-l-[color:var(--border)]",
|
||||
};
|
||||
|
||||
export function severityBorderClass(severity: string | null | undefined): string {
|
||||
if (!severity) return "border-l-[color:var(--border)]";
|
||||
return severityColors[severity] ?? "border-l-[color:var(--border)]";
|
||||
}
|
||||
|
||||
export function CompactMetric({ label, value, tone = "default", subtitle }: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
|
||||
@@ -6,6 +6,7 @@ const ATTENTION_STAGGER_MAX = 8;
|
||||
const ATTENTION_STAGGER_STEP = 0.04;
|
||||
|
||||
export type AttentionTone = "warn" | "danger" | "info" | "neutral";
|
||||
export type AttentionVariant = "card" | "line";
|
||||
|
||||
export interface AttentionRowProps {
|
||||
icon: ElementType;
|
||||
@@ -21,6 +22,7 @@ export interface AttentionRowProps {
|
||||
borderAccentClass?: string;
|
||||
showArrow?: boolean;
|
||||
iconWeight?: "fill" | "bold" | "regular";
|
||||
variant?: AttentionVariant;
|
||||
}
|
||||
|
||||
const toneIconClass: Record<AttentionTone, string> = {
|
||||
@@ -51,30 +53,53 @@ export function AttentionRow({
|
||||
borderAccentClass,
|
||||
showArrow = true,
|
||||
iconWeight,
|
||||
variant = "card",
|
||||
}: AttentionRowProps) {
|
||||
const iconClass = iconColor ? "" : toneIconClass[tone];
|
||||
const bgClass = rowBgClass ?? toneBgClass[tone];
|
||||
const delay = Math.min(index, ATTENTION_STAGGER_MAX) * ATTENTION_STAGGER_STEP;
|
||||
const borderClass = borderAccentClass ?? "border-[color:var(--destructive)] border-opacity-20 hover:border-opacity-40";
|
||||
const isButton = typeof onClick === "function";
|
||||
const commonClass = `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 rounded-lg border ${borderClass} ${bgClass} px-4 py-3 text-left transition`;
|
||||
|
||||
const iconElement = iconTileBg ? (
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: iconTileBg }}
|
||||
>
|
||||
<Icon size={18} weight={iconWeight} className={iconClass} style={iconColor ? { color: iconColor } : undefined} />
|
||||
</div>
|
||||
) : (
|
||||
<Icon size={18} weight={iconWeight} className={`shrink-0 ${iconClass}`} style={iconColor ? { color: iconColor } : undefined} />
|
||||
);
|
||||
const chromeClass =
|
||||
variant === "line"
|
||||
? "border-b border-border/60 hover:bg-muted/40 px-2 py-3"
|
||||
: (() => {
|
||||
const bgClass = rowBgClass ?? toneBgClass[tone];
|
||||
const borderClass =
|
||||
borderAccentClass ??
|
||||
"border-[color:var(--destructive)] border-opacity-20 hover:border-opacity-40";
|
||||
return `rounded-lg border ${borderClass} ${bgClass} px-4 py-3`;
|
||||
})();
|
||||
|
||||
const commonClass = `focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 flex w-full items-center gap-3 text-left transition ${chromeClass}`;
|
||||
|
||||
const iconElement =
|
||||
variant === "card" && iconTileBg ? (
|
||||
<div
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: iconTileBg }}
|
||||
>
|
||||
<Icon
|
||||
size={18}
|
||||
weight={iconWeight}
|
||||
className={iconClass}
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Icon
|
||||
size={18}
|
||||
weight={iconWeight}
|
||||
className={`shrink-0 ${iconClass}`}
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
const titleWeightClass = variant === "line" ? "" : "font-medium";
|
||||
const inner = (
|
||||
<>
|
||||
{iconElement}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-[13px] font-medium text-foreground">{title}</div>
|
||||
<div className={`truncate text-[13px] ${titleWeightClass} text-foreground`}>{title}</div>
|
||||
{subtitle ? <div className="mt-0.5 text-[11px] text-muted-foreground">{subtitle}</div> : null}
|
||||
</div>
|
||||
{rightMeta}
|
||||
|
||||
Reference in new issue
Block a user