From 2b14c617f79ec2fda088d32f3ddd1f53fc3b80f8 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:29:01 -0700 Subject: [PATCH 1/4] chore(web): re-pull the 16 design-system components verbatim (HT-94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #103 stopped Biome from reformatting web/src/components/ds/ on arrival, but it could not un-mangle the 16 components already there. This does that: each file re-fetched from the Claude Design "Helpthread" project via DesignSync and written byte-for-byte — double quotes, semicolons, original import order and line wrapping restored. Equivalence was proven before overwriting, not assumed. Both the pre-change and post-change trees were formatted through one canonical Biome config and diffed; the diff is empty across all 32 files. So no style value, prop, or branch of logic changed — the whole historical drift really was formatting, and nothing had been hand-edited in the app or moved in the design project. Byte fidelity spot-checked against fresh get_file responses; all 32 files end with a newline and none carry CRLF. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0. Stacked on feat/ht-93-ds-new-primitives — the biome.json override there is a precondition for the biome gate to pass on verbatim files. --- web/src/components/ds/core/Avatar.d.ts | 14 +- web/src/components/ds/core/Avatar.jsx | 56 ++--- web/src/components/ds/core/Button.d.ts | 16 +- web/src/components/ds/core/Button.jsx | 77 ++----- web/src/components/ds/core/DropdownMenu.d.ts | 14 +- web/src/components/ds/core/DropdownMenu.jsx | 34 +-- web/src/components/ds/core/EmptyState.d.ts | 8 +- web/src/components/ds/core/EmptyState.jsx | 33 +-- web/src/components/ds/core/IconButton.d.ts | 16 +- web/src/components/ds/core/IconButton.jsx | 55 ++--- web/src/components/ds/core/Kbd.d.ts | 4 +- web/src/components/ds/core/Kbd.jsx | 22 +- web/src/components/ds/core/MenuItem.d.ts | 14 +- web/src/components/ds/core/MenuItem.jsx | 52 ++--- web/src/components/ds/core/Skeleton.d.ts | 10 +- web/src/components/ds/core/Skeleton.jsx | 20 +- web/src/components/ds/core/StatusPill.d.ts | 8 +- web/src/components/ds/core/StatusPill.jsx | 42 +--- web/src/components/ds/core/TagChip.d.ts | 6 +- web/src/components/ds/core/TagChip.jsx | 54 +---- web/src/components/ds/core/TextInput.d.ts | 14 +- web/src/components/ds/core/TextInput.jsx | 29 +-- web/src/components/ds/core/Toast.d.ts | 10 +- web/src/components/ds/core/Toast.jsx | 19 +- .../components/ds/inbox/ConversationRow.d.ts | 30 +-- .../components/ds/inbox/ConversationRow.jsx | 202 +++--------------- web/src/components/ds/inbox/FolderItem.d.ts | 14 +- web/src/components/ds/inbox/FolderItem.jsx | 59 ++--- web/src/components/ds/inbox/MessageBand.d.ts | 22 +- web/src/components/ds/inbox/MessageBand.jsx | 141 +++--------- web/src/components/ds/inbox/ToolbarBand.d.ts | 8 +- web/src/components/ds/inbox/ToolbarBand.jsx | 29 +-- 32 files changed, 306 insertions(+), 826 deletions(-) diff --git a/web/src/components/ds/core/Avatar.d.ts b/web/src/components/ds/core/Avatar.d.ts index b52c00e..0bdee97 100644 --- a/web/src/components/ds/core/Avatar.d.ts +++ b/web/src/components/ds/core/Avatar.d.ts @@ -1,13 +1,13 @@ export interface AvatarProps { /** initials + tone derive from this */ - email?: string + email?: string; /** explicit initials override */ - initials?: string - size?: number + initials?: string; + size?: number; /** the Agent avatar: accent fill, "S" */ - agent?: boolean + agent?: boolean; /** white ring + shadow (threads, context panel) */ - ring?: boolean - style?: React.CSSProperties + ring?: boolean; + style?: React.CSSProperties; } -export declare function Avatar(props: AvatarProps): JSX.Element +export declare function Avatar(props: AvatarProps): JSX.Element; diff --git a/web/src/components/ds/core/Avatar.jsx b/web/src/components/ds/core/Avatar.jsx index 7b1bf01..85abaf5 100644 --- a/web/src/components/ds/core/Avatar.jsx +++ b/web/src/components/ds/core/Avatar.jsx @@ -1,46 +1,32 @@ -import React from 'react' +import React from "react"; const PALETTE = [ - ['oklch(0.9 0.03 60)', 'oklch(0.42 0.07 60)'], - ['oklch(0.9 0.03 150)', 'oklch(0.4 0.06 150)'], - ['oklch(0.9 0.03 250)', 'oklch(0.42 0.06 250)'], - ['oklch(0.9 0.03 320)', 'oklch(0.42 0.06 320)'], - ['oklch(0.9 0.035 95)', 'oklch(0.42 0.06 95)'], -] + ["oklch(0.9 0.03 60)", "oklch(0.42 0.07 60)"], + ["oklch(0.9 0.03 150)", "oklch(0.4 0.06 150)"], + ["oklch(0.9 0.03 250)", "oklch(0.42 0.06 250)"], + ["oklch(0.9 0.03 320)", "oklch(0.42 0.06 320)"], + ["oklch(0.9 0.035 95)", "oklch(0.42 0.06 95)"], +]; function initialsOf(email) { - const local = String(email || '').split('@')[0] - const parts = local.split(/[._-]+/).filter(Boolean) - if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase() - return local.slice(0, 2).toUpperCase() + const local = String(email || "").split("@")[0]; + const parts = local.split(/[._-]+/).filter(Boolean); + if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase(); + return local.slice(0, 2).toUpperCase(); } /** Initials avatar. Tone is a stable hash of the email; agent=true fills with accent. * ring adds the white ring + shadow used in threads and the context panel. */ export function Avatar({ email, initials, size = 32, agent = false, ring = true, style }) { - let h = 0 - for (const ch of String(email || initials || '?')) h = (h * 31 + ch.charCodeAt(0)) >>> 0 - const [bg, fg] = agent ? ['var(--ht-accent)', 'var(--ht-on-accent)'] : PALETTE[h % PALETTE.length] + let h = 0; + for (const ch of String(email || initials || "?")) h = (h * 31 + ch.charCodeAt(0)) >>> 0; + const [bg, fg] = agent ? ["var(--ht-accent)", "var(--ht-on-accent)"] : PALETTE[h % PALETTE.length]; return ( -
= 48 ? '3px' : '2px') + ' solid var(--ht-surface)' : 'none', - boxShadow: ring ? (size >= 48 ? 'var(--ht-shadow-md)' : 'var(--ht-shadow-sm)') : 'none', - ...style, - }} - > - {initials || (agent ? 'S' : initialsOf(email))} +
= 48 ? "3px" : "2px") + " solid var(--ht-surface)" : "none", + boxShadow: ring ? (size >= 48 ? "var(--ht-shadow-md)" : "var(--ht-shadow-sm)") : "none", ...style }}> + {initials || (agent ? "S" : initialsOf(email))}
- ) + ); } diff --git a/web/src/components/ds/core/Button.d.ts b/web/src/components/ds/core/Button.d.ts index 278e31e..8c3936d 100644 --- a/web/src/components/ds/core/Button.d.ts +++ b/web/src/components/ds/core/Button.d.ts @@ -1,13 +1,13 @@ /** The brand's one button. Sentence case labels; destructive actions arm on first press instead of opening a modal. */ export interface ButtonProps { /** primary | outline | ghost | destructive */ - variant?: 'primary' | 'outline' | 'ghost' | 'destructive' + variant?: "primary" | "outline" | "ghost" | "destructive"; /** destructive two-step arm state ("Confirm" fill) */ - armed?: boolean - disabled?: boolean - title?: string - onClick?: () => void - style?: React.CSSProperties - children: React.ReactNode + armed?: boolean; + disabled?: boolean; + title?: string; + onClick?: () => void; + style?: React.CSSProperties; + children: React.ReactNode; } -export declare function Button(props: ButtonProps): JSX.Element +export declare function Button(props: ButtonProps): JSX.Element; diff --git a/web/src/components/ds/core/Button.jsx b/web/src/components/ds/core/Button.jsx index 580fa08..69830c2 100644 --- a/web/src/components/ds/core/Button.jsx +++ b/web/src/components/ds/core/Button.jsx @@ -1,72 +1,27 @@ -import React from 'react' +import React from "react"; /** Primary action button. variant: primary | outline | ghost | destructive. * Destructive supports the two-step arm pattern via the `armed` prop. */ -export function Button({ - variant = 'primary', - armed = false, - disabled = false, - onClick, - title, - style, - children, -}) { +export function Button({ variant = "primary", armed = false, disabled = false, onClick, title, style, children }) { const base = { - display: 'inline-flex', - alignItems: 'center', - gap: 8, - whiteSpace: 'nowrap', - font: 'inherit', - fontSize: '13.5px', - fontWeight: 600, - borderRadius: 'var(--ht-radius-md)', - padding: '8px 18px', - cursor: disabled ? 'default' : 'pointer', - border: 'none', - } + display: "inline-flex", alignItems: "center", gap: 8, whiteSpace: "nowrap", + font: "inherit", fontSize: "13.5px", fontWeight: 600, borderRadius: "var(--ht-radius-md)", + padding: "8px 18px", cursor: disabled ? "default" : "pointer", border: "none", + }; const variants = { - primary: { - color: 'var(--ht-on-accent)', - background: disabled - ? 'color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))' - : 'var(--ht-accent)', - }, - outline: { - color: 'var(--ht-ink)', - background: 'var(--ht-surface)', - border: '1px solid var(--ht-border)', - padding: '7px 17px', - }, - ghost: { color: 'var(--ht-ink-muted)', background: 'none' }, + primary: { color: "var(--ht-on-accent)", background: disabled ? "color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))" : "var(--ht-accent)" }, + outline: { color: "var(--ht-ink)", background: "var(--ht-surface)", border: "1px solid var(--ht-border)", padding: "7px 17px" }, + ghost: { color: "var(--ht-ink-muted)", background: "none" }, destructive: armed - ? { - color: 'var(--ht-surface)', - background: 'var(--ht-critical)', - border: '1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)', - padding: '7px 17px', - } - : { - color: 'var(--ht-critical)', - background: 'transparent', - border: '1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)', - padding: '7px 17px', - }, - } + ? { color: "var(--ht-surface)", background: "var(--ht-critical)", border: "1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)", padding: "7px 17px" } + : { color: "var(--ht-critical)", background: "transparent", border: "1px solid color-mix(in oklab, var(--ht-critical) 40%, transparent)", padding: "7px 17px" }, + }; return ( - - ) + ); } diff --git a/web/src/components/ds/core/DropdownMenu.d.ts b/web/src/components/ds/core/DropdownMenu.d.ts index 3185b32..13d88c5 100644 --- a/web/src/components/ds/core/DropdownMenu.d.ts +++ b/web/src/components/ds/core/DropdownMenu.d.ts @@ -1,9 +1,9 @@ export interface DropdownMenuProps { - open: boolean - onClose: () => void - align?: 'left' | 'right' - top?: number - minWidth?: number - children: React.ReactNode + open: boolean; + onClose: () => void; + align?: "left" | "right"; + top?: number; + minWidth?: number; + children: React.ReactNode; } -export declare function DropdownMenu(props: DropdownMenuProps): JSX.Element +export declare function DropdownMenu(props: DropdownMenuProps): JSX.Element; diff --git a/web/src/components/ds/core/DropdownMenu.jsx b/web/src/components/ds/core/DropdownMenu.jsx index d7d2f30..615077f 100644 --- a/web/src/components/ds/core/DropdownMenu.jsx +++ b/web/src/components/ds/core/DropdownMenu.jsx @@ -1,35 +1,17 @@ -import React from 'react' +import React from "react"; /** Anchored dropdown surface with backdrop dismissal. Wrap the trigger in a * position:relative parent and render this when open. */ -export function DropdownMenu({ - open, - onClose, - align = 'left', - top = 36, - minWidth = 160, - children, -}) { - if (!open) return null +export function DropdownMenu({ open, onClose, align = "left", top = 36, minWidth = 160, children }) { + if (!open) return null; return ( <> -
-
+
+
{children}
- ) + ); } diff --git a/web/src/components/ds/core/EmptyState.d.ts b/web/src/components/ds/core/EmptyState.d.ts index dc1bf43..807fac2 100644 --- a/web/src/components/ds/core/EmptyState.d.ts +++ b/web/src/components/ds/core/EmptyState.d.ts @@ -1,7 +1,7 @@ export interface EmptyStateProps { - title: string - body?: string + title: string; + body?: string; /** italic celebration ("Inbox zero.") */ - celebrate?: boolean + celebrate?: boolean; } -export declare function EmptyState(props: EmptyStateProps): JSX.Element +export declare function EmptyState(props: EmptyStateProps): JSX.Element; diff --git a/web/src/components/ds/core/EmptyState.jsx b/web/src/components/ds/core/EmptyState.jsx index f91fe32..93d057b 100644 --- a/web/src/components/ds/core/EmptyState.jsx +++ b/web/src/components/ds/core/EmptyState.jsx @@ -1,33 +1,12 @@ -import React from 'react' +import React from "react"; /** Typographic empty state. celebrate=true is the italic "Inbox zero." treatment. */ export function EmptyState({ title, body, celebrate = false }) { return ( -
-
- {title} -
- {body && ( -
- {body} -
- )} +
+
{title}
+ {body &&
{body}
}
- ) + ); } diff --git a/web/src/components/ds/core/IconButton.d.ts b/web/src/components/ds/core/IconButton.d.ts index 47597ad..1864c00 100644 --- a/web/src/components/ds/core/IconButton.d.ts +++ b/web/src/components/ds/core/IconButton.d.ts @@ -1,13 +1,13 @@ export interface IconButtonProps { - title: string - onClick?: () => void + title: string; + onClick?: () => void; /** lit background (menu open / active) */ - active?: boolean + active?: boolean; /** "header" = on the accent top bar */ - tone?: 'default' | 'header' - size?: number - style?: React.CSSProperties + tone?: "default" | "header"; + size?: number; + style?: React.CSSProperties; /** a solid 13-16px SVG glyph */ - children: React.ReactNode + children: React.ReactNode; } -export declare function IconButton(props: IconButtonProps): JSX.Element +export declare function IconButton(props: IconButtonProps): JSX.Element; diff --git a/web/src/components/ds/core/IconButton.jsx b/web/src/components/ds/core/IconButton.jsx index 83fcd4e..4b4bf10 100644 --- a/web/src/components/ds/core/IconButton.jsx +++ b/web/src/components/ds/core/IconButton.jsx @@ -1,49 +1,18 @@ -import React from 'react' +import React from "react"; /** 32px square icon action. tone "header" renders on the accent top bar. */ -export function IconButton({ - title, - onClick, - active = false, - tone = 'default', - size = 32, - style, - children, -}) { - const header = tone === 'header' - const fg = header - ? 'color-mix(in oklab, var(--ht-header-fg) 85%, transparent)' - : 'var(--ht-accent)' - const hoverBg = header - ? 'color-mix(in oklab, var(--ht-header-fg) 16%, transparent)' - : 'var(--ht-accent-soft)' +export function IconButton({ title, onClick, active = false, tone = "default", size = 32, style, children }) { + const header = tone === "header"; + const fg = header ? "color-mix(in oklab, var(--ht-header-fg) 85%, transparent)" : "var(--ht-accent)"; + const hoverBg = header ? "color-mix(in oklab, var(--ht-header-fg) 16%, transparent)" : "var(--ht-accent-soft)"; return ( - - ) + ); } diff --git a/web/src/components/ds/core/Kbd.d.ts b/web/src/components/ds/core/Kbd.d.ts index 85bda38..ec7e7a8 100644 --- a/web/src/components/ds/core/Kbd.d.ts +++ b/web/src/components/ds/core/Kbd.d.ts @@ -1,4 +1,4 @@ export interface KbdProps { - children: React.ReactNode + children: React.ReactNode; } -export declare function Kbd(props: KbdProps): JSX.Element +export declare function Kbd(props: KbdProps): JSX.Element; diff --git a/web/src/components/ds/core/Kbd.jsx b/web/src/components/ds/core/Kbd.jsx index 5534a82..94f3098 100644 --- a/web/src/components/ds/core/Kbd.jsx +++ b/web/src/components/ds/core/Kbd.jsx @@ -1,24 +1,12 @@ -import React from 'react' +import React from "react"; /** Keycap. Used in shortcut hints and menu affordances. */ export function Kbd({ children }) { return ( - + {children} - ) + ); } diff --git a/web/src/components/ds/core/MenuItem.d.ts b/web/src/components/ds/core/MenuItem.d.ts index ee96d77..630dab6 100644 --- a/web/src/components/ds/core/MenuItem.d.ts +++ b/web/src/components/ds/core/MenuItem.d.ts @@ -1,9 +1,9 @@ export interface MenuItemProps { - onClick?: () => void - icon?: React.ReactNode - shortcut?: React.ReactNode - selected?: boolean - destructive?: boolean - children: React.ReactNode + onClick?: () => void; + icon?: React.ReactNode; + shortcut?: React.ReactNode; + selected?: boolean; + destructive?: boolean; + children: React.ReactNode; } -export declare function MenuItem(props: MenuItemProps): JSX.Element +export declare function MenuItem(props: MenuItemProps): JSX.Element; diff --git a/web/src/components/ds/core/MenuItem.jsx b/web/src/components/ds/core/MenuItem.jsx index f49d431..d2dc28c 100644 --- a/web/src/components/ds/core/MenuItem.jsx +++ b/web/src/components/ds/core/MenuItem.jsx @@ -1,47 +1,19 @@ -import React from 'react' +import React from "react"; /** One dropdown row. Optional leading glyph and trailing shortcut/hint. */ -export function MenuItem({ - onClick, - icon, - shortcut, - selected = false, - destructive = false, - children, -}) { - const fg = destructive ? 'var(--ht-critical)' : selected ? 'var(--ht-accent)' : 'var(--ht-ink)' +export function MenuItem({ onClick, icon, shortcut, selected = false, destructive = false, children }) { + const fg = destructive ? "var(--ht-critical)" : selected ? "var(--ht-accent)" : "var(--ht-ink)"; return ( - - ) + ); } diff --git a/web/src/components/ds/core/Skeleton.d.ts b/web/src/components/ds/core/Skeleton.d.ts index 120ca0f..88dc2b6 100644 --- a/web/src/components/ds/core/Skeleton.d.ts +++ b/web/src/components/ds/core/Skeleton.d.ts @@ -1,7 +1,7 @@ export interface SkeletonProps { - width?: number | string - height?: number | string - radius?: number | string - style?: React.CSSProperties + width?: number | string; + height?: number | string; + radius?: number | string; + style?: React.CSSProperties; } -export declare function Skeleton(props: SkeletonProps): JSX.Element +export declare function Skeleton(props: SkeletonProps): JSX.Element; diff --git a/web/src/components/ds/core/Skeleton.jsx b/web/src/components/ds/core/Skeleton.jsx index 3800d66..a8ec11a 100644 --- a/web/src/components/ds/core/Skeleton.jsx +++ b/web/src/components/ds/core/Skeleton.jsx @@ -1,20 +1,12 @@ -import React from 'react' +import React from "react"; /** Pulsing placeholder bar/shape. */ -export function Skeleton({ width = '100%', height = 12, radius = 4, style }) { +export function Skeleton({ width = "100%", height = 12, radius = 4, style }) { return ( <> - -
+ +
- ) + ); } diff --git a/web/src/components/ds/core/StatusPill.d.ts b/web/src/components/ds/core/StatusPill.d.ts index fd96743..c3f41c2 100644 --- a/web/src/components/ds/core/StatusPill.d.ts +++ b/web/src/components/ds/core/StatusPill.d.ts @@ -1,8 +1,8 @@ export interface StatusPillProps { /** active | pending | closed | spam | note (unknown = neutral) */ - status: string + status: string; /** override the derived label */ - label?: string - style?: React.CSSProperties + label?: string; + style?: React.CSSProperties; } -export declare function StatusPill(props: StatusPillProps): JSX.Element +export declare function StatusPill(props: StatusPillProps): JSX.Element; diff --git a/web/src/components/ds/core/StatusPill.jsx b/web/src/components/ds/core/StatusPill.jsx index 1880301..a9baa7d 100644 --- a/web/src/components/ds/core/StatusPill.jsx +++ b/web/src/components/ds/core/StatusPill.jsx @@ -1,40 +1,20 @@ -import React from 'react' +import React from "react"; const META = { - active: { label: 'Active', fg: 'var(--ht-accent)', bg: 'var(--ht-accent-soft)' }, - pending: { - label: 'Pending', - fg: 'var(--ht-warn)', - bg: 'color-mix(in oklab, var(--ht-warn) 12%, transparent)', - }, - closed: { label: 'Closed', fg: 'var(--ht-ink-dim)', bg: 'var(--ht-surface-2)' }, - spam: { label: 'Spam', fg: 'var(--ht-critical)', bg: 'var(--ht-critical-soft)' }, - note: { - label: 'Internal note', - fg: 'var(--ht-warn)', - bg: 'color-mix(in oklab, var(--ht-warn) 14%, transparent)', - }, -} + active: { label: "Active", fg: "var(--ht-accent)", bg: "var(--ht-accent-soft)" }, + pending: { label: "Pending", fg: "var(--ht-warn)", bg: "color-mix(in oklab, var(--ht-warn) 12%, transparent)" }, + closed: { label: "Closed", fg: "var(--ht-ink-dim)", bg: "var(--ht-surface-2)" }, + spam: { label: "Spam", fg: "var(--ht-critical)", bg: "var(--ht-critical-soft)" }, + note: { label: "Internal note", fg: "var(--ht-warn)", bg: "color-mix(in oklab, var(--ht-warn) 14%, transparent)" }, +}; /** Uppercase status pill; unknown statuses fall back to a neutral pill. */ export function StatusPill({ status, label, style }) { - const m = META[status] || { label: status, fg: 'var(--ht-ink-dim)', bg: 'var(--ht-surface-2)' } + const m = META[status] || { label: status, fg: "var(--ht-ink-dim)", bg: "var(--ht-surface-2)" }; return ( - + {label || m.label} - ) + ); } diff --git a/web/src/components/ds/core/TagChip.d.ts b/web/src/components/ds/core/TagChip.d.ts index 26225b0..6c2acbf 100644 --- a/web/src/components/ds/core/TagChip.d.ts +++ b/web/src/components/ds/core/TagChip.d.ts @@ -1,6 +1,6 @@ export interface TagChipProps { - label: string + label: string; /** renders the × when provided */ - onRemove?: () => void + onRemove?: () => void; } -export declare function TagChip(props: TagChipProps): JSX.Element +export declare function TagChip(props: TagChipProps): JSX.Element; diff --git a/web/src/components/ds/core/TagChip.jsx b/web/src/components/ds/core/TagChip.jsx index 4d8e3be..3248797 100644 --- a/web/src/components/ds/core/TagChip.jsx +++ b/web/src/components/ds/core/TagChip.jsx @@ -1,55 +1,19 @@ -import React from 'react' +import React from "react"; /** Lowercase tag chip with optional remove. */ export function TagChip({ label, onRemove }) { return ( - + {label} {onRemove && ( - )} - ) + ); } diff --git a/web/src/components/ds/core/TextInput.d.ts b/web/src/components/ds/core/TextInput.d.ts index cb69dba..1f16aec 100644 --- a/web/src/components/ds/core/TextInput.d.ts +++ b/web/src/components/ds/core/TextInput.d.ts @@ -1,9 +1,9 @@ export interface TextInputProps { - value?: string - onChange?: (e: any) => void - onKeyDown?: (e: any) => void - placeholder?: string - id?: string - style?: React.CSSProperties + value?: string; + onChange?: (e: any) => void; + onKeyDown?: (e: any) => void; + placeholder?: string; + id?: string; + style?: React.CSSProperties; } -export declare function TextInput(props: TextInputProps): JSX.Element +export declare function TextInput(props: TextInputProps): JSX.Element; diff --git a/web/src/components/ds/core/TextInput.jsx b/web/src/components/ds/core/TextInput.jsx index 2ab209c..146b4cb 100644 --- a/web/src/components/ds/core/TextInput.jsx +++ b/web/src/components/ds/core/TextInput.jsx @@ -1,28 +1,11 @@ -import React from 'react' +import React from "react"; /** Small text input (tag popover style). */ export function TextInput({ value, onChange, onKeyDown, placeholder, id, style }) { return ( - - ) + + ); } diff --git a/web/src/components/ds/core/Toast.d.ts b/web/src/components/ds/core/Toast.d.ts index 9a661a4..cb2661c 100644 --- a/web/src/components/ds/core/Toast.d.ts +++ b/web/src/components/ds/core/Toast.d.ts @@ -1,8 +1,8 @@ export interface ToastProps { - message: string - detail?: string + message: string; + detail?: string; /** position fixed bottom-right */ - fixed?: boolean - style?: React.CSSProperties + fixed?: boolean; + style?: React.CSSProperties; } -export declare function Toast(props: ToastProps): JSX.Element +export declare function Toast(props: ToastProps): JSX.Element; diff --git a/web/src/components/ds/core/Toast.jsx b/web/src/components/ds/core/Toast.jsx index 39a9811..797d562 100644 --- a/web/src/components/ds/core/Toast.jsx +++ b/web/src/components/ds/core/Toast.jsx @@ -1,22 +1,13 @@ -import React from 'react' +import React from "react"; /** Inverse-fill toast, bottom-right. One msg line + optional detail. Auto-dismiss ~4.2s in-app. */ export function Toast({ message, detail, fixed = false, style }) { return ( -
+
{message}
{detail &&
{detail}
}
- ) + ); } diff --git a/web/src/components/ds/inbox/ConversationRow.d.ts b/web/src/components/ds/inbox/ConversationRow.d.ts index eabd682..b8c4135 100644 --- a/web/src/components/ds/inbox/ConversationRow.d.ts +++ b/web/src/components/ds/inbox/ConversationRow.d.ts @@ -1,23 +1,23 @@ /** One inbox table row; compose under a 48px header band. */ export interface ConversationRowProps { - customerName: string - customerEmail: string - subject: string + customerName: string; + customerEmail: string; + subject: string; /** excerpt of the latest message */ - preview?: string + preview?: string; /** thread count; empty hides the pill but keeps its column slot */ - count?: string + count?: string; /** human conversation number (#N) */ - number?: string + number?: string; /** relative waiting time */ - time?: string - starred?: boolean - onStar?: () => void - checked?: boolean - onCheck?: (e: any) => void - showCheckbox?: boolean + time?: string; + starred?: boolean; + onStar?: () => void; + checked?: boolean; + onCheck?: (e: any) => void; + showCheckbox?: boolean; /** j/k keyboard cursor state */ - selected?: boolean - onClick?: () => void + selected?: boolean; + onClick?: () => void; } -export declare function ConversationRow(props: ConversationRowProps): JSX.Element +export declare function ConversationRow(props: ConversationRowProps): JSX.Element; diff --git a/web/src/components/ds/inbox/ConversationRow.jsx b/web/src/components/ds/inbox/ConversationRow.jsx index 843ff48..e8d1d7d 100644 --- a/web/src/components/ds/inbox/ConversationRow.jsx +++ b/web/src/components/ds/inbox/ConversationRow.jsx @@ -1,187 +1,47 @@ -import React from 'react' -import { Avatar } from '../core/Avatar.jsx' +import React from "react"; +import { Avatar } from "../core/Avatar.jsx"; /** Full-bleed inbox table row: checkbox · customer · star · subject/preview · * count slot · #number · waiting time. Hairline bottom border. */ -export function ConversationRow({ - customerName, - customerEmail, - subject, - preview, - count = '', - number = '', - time = '', - starred = false, - onStar, - checked = false, - onCheck, - showCheckbox = true, - selected = false, - onClick, -}) { +export function ConversationRow({ customerName, customerEmail, subject, preview, count = "", + number = "", time = "", starred = false, onStar, checked = false, onCheck, showCheckbox = true, + selected = false, onClick }) { return ( -
{ - e.currentTarget.style.background = 'var(--ht-surface-2)' - }} - onMouseLeave={(e) => { - e.currentTarget.style.background = selected ? 'var(--ht-surface-2)' : 'transparent' - }} - > +
{ e.currentTarget.style.background = "var(--ht-surface-2)"; }} + onMouseLeave={(e) => { e.currentTarget.style.background = selected ? "var(--ht-surface-2)" : "transparent"; }}> {showCheckbox && ( - e.stopPropagation()} - style={{ - width: 15, - height: 15, - accentColor: 'var(--ht-accent)', - cursor: 'pointer', - margin: 0, - flexShrink: 0, - }} - /> + e.stopPropagation()} + style={{ width: 15, height: 15, accentColor: "var(--ht-accent)", cursor: "pointer", margin: 0, flexShrink: 0 }} /> )}
-
- {customerName} -
-
- {customerEmail} -
+
{customerName}
+
{customerEmail}
-
-
- {subject} -
-
- {preview} -
+
{subject}
+
{preview}
-
- - {count && ( - - {count} - - )} +
+ + {count && {count}} - - # - {number} - - - {time} + + # + {number} + {time}
- ) + ); } diff --git a/web/src/components/ds/inbox/FolderItem.d.ts b/web/src/components/ds/inbox/FolderItem.d.ts index 9c62a71..915a758 100644 --- a/web/src/components/ds/inbox/FolderItem.d.ts +++ b/web/src/components/ds/inbox/FolderItem.d.ts @@ -1,12 +1,12 @@ export interface FolderItemProps { /** solid 13px SVG glyph */ - icon: React.ReactNode - label: string + icon: React.ReactNode; + label: string; /** empty string hides the count (zero folders show none) */ - count?: string - active?: boolean + count?: string; + active?: boolean; /** items present = full-ink label; empty = dimmed */ - hasItems?: boolean - onClick?: () => void + hasItems?: boolean; + onClick?: () => void; } -export declare function FolderItem(props: FolderItemProps): JSX.Element +export declare function FolderItem(props: FolderItemProps): JSX.Element; diff --git a/web/src/components/ds/inbox/FolderItem.jsx b/web/src/components/ds/inbox/FolderItem.jsx index d62af32..4b96a9d 100644 --- a/web/src/components/ds/inbox/FolderItem.jsx +++ b/web/src/components/ds/inbox/FolderItem.jsx @@ -1,52 +1,21 @@ -import React from 'react' +import React from "react"; /** Sidebar folder row. Folders with items read stronger than empty ones; * the active folder is accent-highlighted. */ -export function FolderItem({ icon, label, count = '', active = false, hasItems = false, onClick }) { - const fg = active - ? 'var(--ht-accent)' - : hasItems - ? 'var(--ht-ink)' - : 'color-mix(in oklab, var(--ht-ink-dim) 60%, transparent)' +export function FolderItem({ icon, label, count = "", active = false, hasItems = false, onClick }) { + const fg = active ? "var(--ht-accent)" : hasItems ? "var(--ht-ink)" : "color-mix(in oklab, var(--ht-ink-dim) 60%, transparent)"; return ( - - ) + ); } diff --git a/web/src/components/ds/inbox/MessageBand.d.ts b/web/src/components/ds/inbox/MessageBand.d.ts index 585b7a0..798349f 100644 --- a/web/src/components/ds/inbox/MessageBand.d.ts +++ b/web/src/components/ds/inbox/MessageBand.d.ts @@ -1,19 +1,19 @@ export interface MessageBandProps { /** inbound (customer) | outbound (Agent reply) | note (internal) */ - kind?: 'inbound' | 'outbound' | 'note' - fromLabel: string - fromAddr?: string - time?: string + kind?: "inbound" | "outbound" | "note"; + fromLabel: string; + fromAddr?: string; + time?: string; /** outbound only: "sent" | "pending" */ - delivery?: string + delivery?: string; /** read receipt time — renders the eye line */ - viewedAt?: string + viewedAt?: string; /** loud failed-delivery banner */ - failed?: boolean + failed?: boolean; /** hairline top within same-speaker runs */ - sameSpeakerAsPrev?: boolean + sameSpeakerAsPrev?: boolean; /** drives the avatar tone */ - email?: string - children: React.ReactNode + email?: string; + children: React.ReactNode; } -export declare function MessageBand(props: MessageBandProps): JSX.Element +export declare function MessageBand(props: MessageBandProps): JSX.Element; diff --git a/web/src/components/ds/inbox/MessageBand.jsx b/web/src/components/ds/inbox/MessageBand.jsx index 0163971..90ae48b 100644 --- a/web/src/components/ds/inbox/MessageBand.jsx +++ b/web/src/components/ds/inbox/MessageBand.jsx @@ -1,131 +1,52 @@ -import React from 'react' -import { Avatar } from '../core/Avatar.jsx' -import { StatusPill } from '../core/StatusPill.jsx' +import React from "react"; +import { Avatar } from "../core/Avatar.jsx"; +import { StatusPill } from "../core/StatusPill.jsx"; /** Full-bleed thread band. Fill carries who's-who: customer = surface, * Agent reply = 6% accent tint, internal note = 9% warn tint + edge bar. * Hairline top only within a same-speaker run. */ -export function MessageBand({ - kind = 'inbound', - fromLabel, - fromAddr, - time, - delivery, - viewedAt, - failed = false, - sameSpeakerAsPrev = false, - email, - children, -}) { - const bg = - kind === 'note' - ? 'color-mix(in oklab, var(--ht-warn) 9%, var(--ht-surface))' - : kind === 'outbound' - ? 'color-mix(in oklab, var(--ht-accent) 6%, var(--ht-surface))' - : 'var(--ht-surface)' - const fromColor = - kind === 'note' ? 'var(--ht-warn)' : kind === 'inbound' ? 'var(--ht-ink)' : 'var(--ht-accent)' +export function MessageBand({ kind = "inbound", fromLabel, fromAddr, time, delivery, viewedAt, + failed = false, sameSpeakerAsPrev = false, email, children }) { + const bg = kind === "note" + ? "color-mix(in oklab, var(--ht-warn) 9%, var(--ht-surface))" + : kind === "outbound" + ? "color-mix(in oklab, var(--ht-accent) 6%, var(--ht-surface))" + : "var(--ht-surface)"; + const fromColor = kind === "note" ? "var(--ht-warn)" : kind === "inbound" ? "var(--ht-ink)" : "var(--ht-accent)"; return ( -
- +
+
-
+
{fromLabel} - {kind === 'note' && ( - - )} - - {fromAddr} - + {kind === "note" && } + {fromAddr} - {kind === 'outbound' && !failed && delivery && ( - - {delivery === 'pending' ? 'Sending…' : 'Sent'} + {kind === "outbound" && !failed && delivery && ( + + {delivery === "pending" ? "Sending…" : "Sent"} )} - - {time} - -
-
- {children} + {time}
+
{children}
{viewedAt && ( -
- - - +
+ Customer viewed {viewedAt}
)} {failed && ( -
- - This reply didn't reach the customer. - - - Delivery failed — Helpthread will keep retrying. - +
+ This reply didn't reach the customer. + Delivery failed — Helpthread will keep retrying.
)}
- ) + ); } diff --git a/web/src/components/ds/inbox/ToolbarBand.d.ts b/web/src/components/ds/inbox/ToolbarBand.d.ts index bbd763c..e71f414 100644 --- a/web/src/components/ds/inbox/ToolbarBand.d.ts +++ b/web/src/components/ds/inbox/ToolbarBand.d.ts @@ -1,7 +1,7 @@ export interface ToolbarBandProps { /** accent = over the sheet; panel = over the context panel */ - tone?: 'accent' | 'panel' - children?: React.ReactNode - style?: React.CSSProperties + tone?: "accent" | "panel"; + children?: React.ReactNode; + style?: React.CSSProperties; } -export declare function ToolbarBand(props: ToolbarBandProps): JSX.Element +export declare function ToolbarBand(props: ToolbarBandProps): JSX.Element; diff --git a/web/src/components/ds/inbox/ToolbarBand.jsx b/web/src/components/ds/inbox/ToolbarBand.jsx index 8ba65a6..ab1e45e 100644 --- a/web/src/components/ds/inbox/ToolbarBand.jsx +++ b/web/src/components/ds/inbox/ToolbarBand.jsx @@ -1,27 +1,16 @@ -import React from 'react' +import React from "react"; /** The 48px band that caps the work sheet. tone "accent" (8% tint) over the * thread/list; tone "panel" (neutral gray) continues over the context panel. */ -export function ToolbarBand({ tone = 'accent', children, style }) { +export function ToolbarBand({ tone = "accent", children, style }) { return ( -
+
{children}
- ) + ); } From e0ef28c0a34bf50d5b57fb76494c44985c191296 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:29:01 -0700 Subject: [PATCH 2/4] docs: UI fidelity is bidirectional, not design-first-only (HT-94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The section said improvements go upstream in the design project first. That has not been the working policy: HT-54's screens were built app-first and approved in the app, and TJ's call (2026-07-20) is that approval in the app is approval — the work flows back up. Documents both directions, why ds/ is excluded from Biome (a formatter pass breaks byte comparison as a drift detector), and that a semantic difference found during a re-pull is a finding to escalate rather than something to quietly resolve. --- CLAUDE.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 523205d..17da894 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,14 @@ Adapted in our own words from Andrej Karpathy's observations on LLM coding pitfa ## UI fidelity (TJ, 2026-07-12) -The Agent Inbox UI's pixel source of truth is the Claude Design prototype — `Helpthread App.dc.html` in the "Helpthread Agent Inbox Design" project (the "Helpthread" design-system project carries the same components). **The dogfood site must match it exactly — the whole designed surface, not a subset.** Design-system files under `web/src/components/ds/` stay verbatim copies; improvements go upstream in the design project first. Any deviation — visual, copy, or interaction — requires TJ's explicit sign-off. Remaining gaps are tracked as the fidelity checklist on [HT-23](https://resonantiq.atlassian.net/browse/HT-23); the ticket is not done until the checklist is. +The Agent Inbox UI's pixel source of truth is the Claude Design prototype — `Helpthread App.dc.html` in the "Helpthread Agent Inbox Design" project (the "Helpthread" design-system project carries the same components). **The dogfood site must match it exactly — the whole designed surface, not a subset.** Any deviation — visual, copy, or interaction — requires TJ's explicit sign-off. Remaining gaps are tracked as the fidelity checklist on [HT-23](https://resonantiq.atlassian.net/browse/HT-23); the ticket is not done until the checklist is. + +**Design and app reconcile in both directions (TJ, 2026-07-20).** The two are one system, and neither is allowed to silently drift from the other: + +- **Design → app.** Files under `web/src/components/ds/` are verbatim copies of the design project's components. Byte-for-byte: its quotes, its semicolons, its import order, its line wrapping. Biome is disabled for that path (`biome.json` override) precisely so a formatter pass can't quietly break the correspondence — without that, byte comparison stops working as a drift detector. New or changed design work comes down via DesignSync; it is not hand-edited in the app. +- **App → design.** If TJ approved something into the dev app, it is approved, and it goes back up into the design project. App-first work is normal — it just isn't finished until the design system has it. `.tsx` screens with API wiring convert to presentational `.jsx` with fixture data on the way up; every style value survives the conversion unchanged. + +The invariant underneath both directions is that **a difference between the two is always a bug in one of them** — so when a re-pull surfaces a semantic difference (a changed style value, prop, or logic, as opposed to formatting), that is a finding, not a merge conflict to resolve in passing. Stop and get TJ's call on which side wins. ## Ecosystem From f2977397a483537a6892e6751ab2a9c6208359cb Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:52:51 -0700 Subject: [PATCH 3/4] chore(web): close the loop on the five promoted primitives (HT-94) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four new primitives (plus their shared helpers) were promoted into the design project's components/core/ in this ticket. This re-pulls them back down verbatim, so ds/ no longer holds five files that were a conversion rather than a copy — every file under ds/ is now a byte-for-byte copy of its design-project counterpart, which is what CLAUDE.md claims. Round-trip proven byte-exact: the ten re-pulled files were `cmp`'d against the exact bytes uploaded, all identical. Two defects were caught by the normalized-diff pass and fixed upstream first, then re-pulled: - CommandMenu flattened the curly quotes in `Nothing matches “…”` to straight quotes. That is rendered output, so it is a fidelity break, not formatting. - CredentialRow.d.ts narrowed `lastUsed?: Date | null` to `Date`, losing the null the component actually branches on. Three .d.ts signatures deliberately differ from PR #103's: IconKey, IconReply and IconClock take a required `sz`, because unlike IconSearch and IconPlus they carry no default and render wrong without it. The biome.json override gains noAutofocus, useExhaustiveDependencies and noArrayIndexKey. PR #103 suppressed these with inline biome-ignore comments; those are app-lint artifacts and do not belong in verbatim design source, so the suppression moves to the override that already exists for exactly this purpose. The design rationale comments stay in the components. No rule is relaxed outside web/src/components/ds/**. Gates (each on its own exit code): biome check . = 0, npm run typecheck = 0, npm run build = 0. --- biome.json | 13 +- web/src/components/ds/core/CommandMenu.d.ts | 27 +- web/src/components/ds/core/CommandMenu.jsx | 240 ++++-------- web/src/components/ds/core/CredentialRow.d.ts | 35 +- web/src/components/ds/core/CredentialRow.jsx | 297 ++++---------- web/src/components/ds/core/SnoozePicker.d.ts | 15 +- web/src/components/ds/core/SnoozePicker.jsx | 361 ++++++------------ web/src/components/ds/core/SplitButton.d.ts | 32 +- web/src/components/ds/core/SplitButton.jsx | 180 +++------ .../ds/core/primitives-support.d.ts | 45 ++- .../components/ds/core/primitives-support.jsx | 135 +++---- 11 files changed, 454 insertions(+), 926 deletions(-) diff --git a/biome.json b/biome.json index 963833b..85ad4c8 100644 --- a/biome.json +++ b/biome.json @@ -36,14 +36,21 @@ "assist": { "actions": { "source": { "organizeImports": "off" } } }, "linter": { "rules": { - "correctness": { "noUnusedImports": "off" }, + "correctness": { + "noUnusedImports": "off", + "useExhaustiveDependencies": "off" + }, "complexity": { "useOptionalChain": "off" }, "style": { "useTemplate": "off" }, - "suspicious": { "noExplicitAny": "off" }, + "suspicious": { + "noExplicitAny": "off", + "noArrayIndexKey": "off" + }, "a11y": { "noSvgWithoutTitle": "off", "useKeyWithClickEvents": "off", - "noStaticElementInteractions": "off" + "noStaticElementInteractions": "off", + "noAutofocus": "off" } } } diff --git a/web/src/components/ds/core/CommandMenu.d.ts b/web/src/components/ds/core/CommandMenu.d.ts index b0ba9cf..7e7afca 100644 --- a/web/src/components/ds/core/CommandMenu.d.ts +++ b/web/src/components/ds/core/CommandMenu.d.ts @@ -1,15 +1,20 @@ export interface CommandMenuItem { - label: string - snippet?: string - keywords?: string - shortcut?: string + label: string; + /** shown under the label, and matched against the query */ + snippet?: string; + /** extra text matched against the query but not shown in the row */ + keywords?: string; + shortcut?: React.ReactNode; } + +/** Searchable inserter for saved replies. */ export interface CommandMenuProps { - items?: CommandMenuItem[] - placeholder?: string - onPick?: (item: CommandMenuItem) => void - inline?: boolean - width?: number - initialQuery?: string + items?: CommandMenuItem[]; + placeholder?: string; + onPick?: (item: CommandMenuItem) => void; + /** drops the rise-in animation and autofocus, for embedding inline */ + inline?: boolean; + width?: number; + initialQuery?: string; } -export declare function CommandMenu(props: CommandMenuProps): JSX.Element +export declare function CommandMenu(props: CommandMenuProps): JSX.Element; diff --git a/web/src/components/ds/core/CommandMenu.jsx b/web/src/components/ds/core/CommandMenu.jsx index 4b6960a..b48667f 100644 --- a/web/src/components/ds/core/CommandMenu.jsx +++ b/web/src/components/ds/core/CommandMenu.jsx @@ -1,204 +1,109 @@ -import React from 'react' -import { IconReply, IconSearch } from './primitives-support' +import React from "react"; +import { IconReply, IconSearch } from "./primitives-support.jsx"; /** Searchable inserter for saved replies. Filters as you type; * ↑↓ moves, ↵ inserts, esc clears. */ -export function CommandMenu({ - items = [], - placeholder = 'Search saved replies…', - onPick, - inline, - width = 320, - initialQuery = '', -}) { - const [q, setQ] = React.useState(initialQuery) - const [hi, setHi] = React.useState(0) - const inputRef = React.useRef(null) - const listRef = React.useRef(null) +export function CommandMenu({ items = [], placeholder = "Search saved replies…", onPick, inline, + width = 320, initialQuery = "" }) { + const [q, setQ] = React.useState(initialQuery); + const [hi, setHi] = React.useState(0); + const inputRef = React.useRef(null); + const listRef = React.useRef(null); const filtered = React.useMemo(() => { - const s = q.trim().toLowerCase() - if (!s) return items - return items.filter((it) => - `${it.label} ${it.snippet || ''} ${it.keywords || ''}`.toLowerCase().includes(s), - ) - }, [q, items]) + const s = q.trim().toLowerCase(); + if (!s) return items; + return items.filter((it) => `${it.label} ${it.snippet || ""} ${it.keywords || ""}`.toLowerCase().includes(s)); + }, [q, items]); // `q` is the trigger here, not a read: the highlight resets to the top of the // list whenever the query changes. - // biome-ignore lint/correctness/useExhaustiveDependencies: q is a trigger, not a read React.useEffect(() => { - setHi(0) - }, [q]) + setHi(0); + }, [q]); React.useEffect(() => { - const el = listRef.current?.children[hi] + const el = listRef.current?.children[hi]; if (el?.scrollIntoViewIfNeeded) { - el.scrollIntoViewIfNeeded() + el.scrollIntoViewIfNeeded(); } else if (el) { - const p = listRef.current - if (el.offsetTop < p.scrollTop) p.scrollTop = el.offsetTop + const p = listRef.current; + if (el.offsetTop < p.scrollTop) p.scrollTop = el.offsetTop; else if (el.offsetTop + el.offsetHeight > p.scrollTop + p.clientHeight) - p.scrollTop = el.offsetTop + el.offsetHeight - p.clientHeight + p.scrollTop = el.offsetTop + el.offsetHeight - p.clientHeight; } - }, [hi]) + }, [hi]); const key = (e) => { - if (e.key === 'ArrowDown') { - e.preventDefault() - setHi((i) => Math.min(i + 1, filtered.length - 1)) - } else if (e.key === 'ArrowUp') { - e.preventDefault() - setHi((i) => Math.max(i - 1, 0)) - } else if (e.key === 'Enter') { - e.preventDefault() - const it = filtered[hi] - if (it) onPick?.(it) - } else if (e.key === 'Escape') { - e.preventDefault() - setQ('') - e.target.blur() + if (e.key === "ArrowDown") { + e.preventDefault(); + setHi((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHi((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const it = filtered[hi]; + if (it) onPick?.(it); + } else if (e.key === "Escape") { + e.preventDefault(); + setQ(""); + e.target.blur(); } - } + }; return ( -
-
- {IconSearch(15)} - setQ(e.target.value)} - onKeyDown={key} +
+
+ {IconSearch(15)} + setQ(e.target.value)} onKeyDown={key} placeholder={placeholder} // The menu opens in response to an explicit user action and the search // field is its entire purpose; not focusing it would strand keyboard users. - // biome-ignore lint/a11y/noAutofocus: deliberate in the design source autoFocus={!inline} - style={{ - flex: 1, - font: 'inherit', - fontFamily: 'var(--ht-sans)', - fontSize: 13.5, - color: 'var(--ht-ink)', - background: 'none', - border: 'none', - outline: 'none', - padding: 0, - }} - /> - + style={{ flex: 1, font: "inherit", fontFamily: "var(--ht-sans)", fontSize: 13.5, + color: "var(--ht-ink)", background: "none", border: "none", outline: "none", padding: 0 }} /> + esc
{filtered.length === 0 ? ( -
-
- No matching replies -
-
+
+
No matching replies
+
{`Nothing matches “${q}”. Try a shorter term.`}
) : ( -
+
{filtered.map((it, i) => ( -
) : ( -
+
{name}
)} {!renaming ? ( -
- {`Added ${fmtDate(cred.added)} · Last used ${cred.lastUsed ? rel(cred.lastUsed) : 'never'}`} +
+ {`Added ${fmtDate(cred.added)} · Last used ${cred.lastUsed ? rel(cred.lastUsed) : "never"}`}
) : null}
{!renaming ? ( -
+
{armed ? ( - ) : ( <> - - @@ -222,7 +110,7 @@ export function CredentialRow({ cred, demo, onRename, onRevoke, first }) {
) : null}
- ) + ); } /** The passkey list: registered credentials plus the add affordance, or the @@ -230,55 +118,25 @@ export function CredentialRow({ cred, demo, onRename, onRevoke, first }) { export function PasskeyList({ creds = [], empty, onAdd }) { const addBtn = ( - ) + ); return ( -
+
{empty || creds.length === 0 ? ( -
-
+
+
{IconKey(24)}
-
+
No passkeys yet
-
+
Add a passkey to sign in with your fingerprint, face, or security key — no password to remember.
@@ -289,19 +147,12 @@ export function PasskeyList({ creds = [], empty, onAdd }) { {creds.map((c, i) => ( ))} -
+
{addBtn}
)}
- ) + ); } diff --git a/web/src/components/ds/core/SnoozePicker.d.ts b/web/src/components/ds/core/SnoozePicker.d.ts index d5678f5..207f646 100644 --- a/web/src/components/ds/core/SnoozePicker.d.ts +++ b/web/src/components/ds/core/SnoozePicker.d.ts @@ -1,7 +1,12 @@ +/** Choose a wake time: quick presets or a custom calendar + time. */ export interface SnoozePickerProps { - onSnooze?: (when: Date) => void - inline?: boolean - initialCustom?: boolean - initialSel?: Date + /** fires with the resolved Date, from either a preset or the custom picker */ + onSnooze?: (when: Date) => void; + /** drops the rise-in animation, for embedding inline */ + inline?: boolean; + /** opens directly into the custom calendar + time view */ + initialCustom?: boolean; + /** seed date for the custom calendar */ + initialSel?: Date; } -export declare function SnoozePicker(props: SnoozePickerProps): JSX.Element +export declare function SnoozePicker(props: SnoozePickerProps): JSX.Element; diff --git a/web/src/components/ds/core/SnoozePicker.jsx b/web/src/components/ds/core/SnoozePicker.jsx index ec9ba87..d3c5dc1 100644 --- a/web/src/components/ds/core/SnoozePicker.jsx +++ b/web/src/components/ds/core/SnoozePicker.jsx @@ -1,319 +1,180 @@ -import React from 'react' -import { MenuItem } from './MenuItem' -import { chevron, fmtDay, fmtTime, IconClock, MO, now, WD } from './primitives-support' +import React from "react"; +import { MenuItem } from "./MenuItem.jsx"; +import { chevron, fmtDay, fmtTime, IconClock, MO, now, WD } from "./primitives-support.jsx"; function laterToday() { - const d = now() - d.setHours(17, 0, 0, 0) - return d + const d = now(); + d.setHours(17, 0, 0, 0); + return d; } function tomorrow8() { - const d = now() - d.setDate(d.getDate() + 1) - d.setHours(8, 0, 0, 0) - return d + const d = now(); + d.setDate(d.getDate() + 1); + d.setHours(8, 0, 0, 0); + return d; } function thisWeekend() { - const d = now() - const day = d.getDay() - const add = (6 - day + 7) % 7 || 7 - d.setDate(d.getDate() + add) - d.setHours(8, 0, 0, 0) - return d + const d = now(); + const day = d.getDay(); + const add = (6 - day + 7) % 7 || 7; + d.setDate(d.getDate() + add); + d.setHours(8, 0, 0, 0); + return d; } function nextWeek() { - const d = now() - const add = (1 - d.getDay() + 7) % 7 || 7 - d.setDate(d.getDate() + add) - d.setHours(8, 0, 0, 0) - return d + const d = now(); + const add = (1 - d.getDay() + 7) % 7 || 7; + d.setDate(d.getDate() + add); + d.setHours(8, 0, 0, 0); + return d; } function MiniCalendar({ value, onChange }) { - const [view, setView] = React.useState(new Date(value.getFullYear(), value.getMonth(), 1)) - const y = view.getFullYear() - const m = view.getMonth() - const first = new Date(y, m, 1).getDay() - const days = new Date(y, m + 1, 0).getDate() - const ref = now() - const today = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate()) - const cells = [] - for (let i = 0; i < first; i++) cells.push(null) - for (let d = 1; d <= days; d++) cells.push(d) + const [view, setView] = React.useState(new Date(value.getFullYear(), value.getMonth(), 1)); + const y = view.getFullYear(); + const m = view.getMonth(); + const first = new Date(y, m, 1).getDay(); + const days = new Date(y, m + 1, 0).getDate(); + const ref = now(); + const today = new Date(ref.getFullYear(), ref.getMonth(), ref.getDate()); + const cells = []; + for (let i = 0; i < first; i++) cells.push(null); + for (let d = 1; d <= days; d++) cells.push(d); const btn = { - width: 30, - height: 30, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - fontSize: 12.5, - fontWeight: 600, - border: 'none', - borderRadius: 'var(--ht-radius-sm)', - cursor: 'pointer', - background: 'none', - color: 'var(--ht-ink)', - fontVariantNumeric: 'tabular-nums', - } + width: 30, height: 30, display: "inline-flex", alignItems: "center", justifyContent: "center", + fontSize: 12.5, fontWeight: 600, border: "none", borderRadius: "var(--ht-radius-sm)", + cursor: "pointer", background: "none", color: "var(--ht-ink)", fontVariantNumeric: "tabular-nums", + }; return (
-
- {`${MO[m]} ${y}`} -
-
- {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((w, i) => ( - +
+ {["S", "M", "T", "W", "T", "F", "S"].map((w, i) => ( + // Weekday initials repeat (two T, two S), and the row is fixed-length + // and never reordered. + {w} ))}
-
+
{cells.map((d, i) => { // Leading blanks before the 1st of the month carry no identity of their // own; the index is the only stable key available. - // biome-ignore lint/suspicious/noArrayIndexKey: padding cells have no identity - if (d === null) return - const date = new Date(y, m, d) - const sel = date.toDateString() === value.toDateString() - const isToday = date.toDateString() === today.toDateString() - const past = date < today + if (d === null) return ; + const date = new Date(y, m, d); + const sel = date.toDateString() === value.toDateString(); + const isToday = date.toDateString() === today.toDateString(); + const past = date < today; return ( - - ) + ); })}
- ) + ); } /** Choose a wake time: quick presets or a custom calendar + time. The resolved * absolute date/time is shown as confirmation before committing. */ export function SnoozePicker({ onSnooze, inline, initialCustom = false, initialSel }) { const presets = [ - { label: 'Later today', when: laterToday() }, - { label: 'Tomorrow', when: tomorrow8() }, - { label: 'This weekend', when: thisWeekend() }, - { label: 'Next week', when: nextWeek() }, - ] - const [custom, setCustom] = React.useState(initialCustom) - const [sel, setSel] = React.useState(initialSel || nextWeek()) - const [time, setTime] = React.useState('08:00') + { label: "Later today", when: laterToday() }, + { label: "Tomorrow", when: tomorrow8() }, + { label: "This weekend", when: thisWeekend() }, + { label: "Next week", when: nextWeek() }, + ]; + const [custom, setCustom] = React.useState(initialCustom); + const [sel, setSel] = React.useState(initialSel || nextWeek()); + const [time, setTime] = React.useState("08:00"); const resolved = React.useMemo(() => { - const [h, mm] = time.split(':').map(Number) - const d = new Date(sel) - d.setHours(h, mm, 0, 0) - return d - }, [sel, time]) + const [h, mm] = time.split(":").map(Number); + const d = new Date(sel); + d.setHours(h, mm, 0, 0); + return d; + }, [sel, time]); return ( -
-
+
+
Snooze until
{!custom ? (
{presets.map((p) => ( - - {p.when.toDateString() === now().toDateString() - ? fmtTime(p.when) - : `${WD[p.when.getDay()]} ${fmtTime(p.when)}`} + + {p.when.toDateString() === now().toDateString() ? fmtTime(p.when) : `${WD[p.when.getDay()]} ${fmtTime(p.when)}`} } - onClick={() => onSnooze?.(p.when)} - > + onClick={() => onSnooze?.(p.when)}> {p.label} ))} -
- setCustom(true)} - shortcut={chevron('right', 13)} - > +
+ setCustom(true)} shortcut={chevron("right", 13)}> Pick date & time
) : ( -
- -
- - {IconClock(15)} - - setTime(e.target.value)} - style={{ - flex: 1, - fontFamily: 'var(--ht-mono)', - fontSize: 13, - color: 'var(--ht-ink)', - background: 'var(--ht-bg)', - border: '1px solid var(--ht-border)', - borderRadius: 'var(--ht-radius-sm)', - padding: '6px 10px', - outline: 'none', - }} - /> +
+ {IconClock(15)} + setTime(e.target.value)} + style={{ flex: 1, fontFamily: "var(--ht-mono)", fontSize: 13, color: "var(--ht-ink)", + background: "var(--ht-bg)", border: "1px solid var(--ht-border)", borderRadius: "var(--ht-radius-sm)", + padding: "6px 10px", outline: "none" }} />
)} -
-
- Wakes {fmtDay(resolved)} - - {` · ${fmtTime(resolved)}`} - +
+
+ Wakes {fmtDay(resolved)} + {` · ${fmtTime(resolved)}`}
{custom ? ( - ) : null}
- ) + ); } diff --git a/web/src/components/ds/core/SplitButton.d.ts b/web/src/components/ds/core/SplitButton.d.ts index d41db9b..9e7b888 100644 --- a/web/src/components/ds/core/SplitButton.d.ts +++ b/web/src/components/ds/core/SplitButton.d.ts @@ -1,17 +1,23 @@ export interface SplitButtonOption { - label: string - icon?: React.ReactNode + label: string; + icon?: React.ReactNode; } + +/** A primary action with an attached caret. */ export interface SplitButtonProps { - label?: string - options?: SplitButtonOption[] - variant?: 'primary' | 'outline' - loading?: boolean - disabled?: boolean - /** Forces a visual state for specimen rendering. */ - demo?: 'hover' | 'focus' | 'active' - onAction?: (option: SplitButtonOption & { primary?: boolean }) => void - /** Skip the click-outside overlay (for inline/specimen rendering). */ - inline?: boolean + label?: string; + /** dropdown options shown when the caret is opened */ + options?: SplitButtonOption[]; + /** "primary" gets the accent fill; anything else renders the bordered treatment */ + variant?: "primary" | "outline"; + /** shows a spinner and "Sending…" in place of the label */ + loading?: boolean; + disabled?: boolean; + /** forces a visual state for specimen rendering: hover | focus | active */ + demo?: "hover" | "focus" | "active"; + /** fires for the main button ({ label, primary: true }) and for a chosen option */ + onAction?: (action: SplitButtonOption & { primary?: boolean }) => void; + /** drops the fixed-position click-away scrim, for embedding inline */ + inline?: boolean; } -export declare function SplitButton(props: SplitButtonProps): JSX.Element +export declare function SplitButton(props: SplitButtonProps): JSX.Element; diff --git a/web/src/components/ds/core/SplitButton.jsx b/web/src/components/ds/core/SplitButton.jsx index 10c17cb..7b07924 100644 --- a/web/src/components/ds/core/SplitButton.jsx +++ b/web/src/components/ds/core/SplitButton.jsx @@ -1,157 +1,77 @@ -import React from 'react' -import { MenuItem } from './MenuItem' -import { chevron, RING, useFocusRing } from './primitives-support' +import React from "react"; +import { MenuItem } from "./MenuItem.jsx"; +import { chevron, RING, useFocusRing } from "./primitives-support.jsx"; /** A primary action with an attached caret. The caret opens a DropdownMenu of * MenuItems — "Send and close" / "Send and snooze" and friends. * `demo` forces a visual state (hover | focus | active) for specimen rendering. */ -export function SplitButton({ - label = 'Send', - options = [], - variant = 'primary', - loading = false, - disabled = false, - demo, - onAction, - inline, -}) { - const [open, setOpen] = React.useState(false) - const [hovMain, setHovMain] = React.useState(demo === 'hover') - const [hovCaret, setHovCaret] = React.useState(false) - const [fMain, focMain] = useFocusRing() - const [fCaret, focCaret] = useFocusRing() - const focusMain = demo === 'focus' || fMain - const activeMain = demo === 'active' - const primary = variant === 'primary' - const isDisabled = disabled || loading +export function SplitButton({ label = "Send", options = [], variant = "primary", loading = false, + disabled = false, demo, onAction, inline }) { + const [open, setOpen] = React.useState(false); + const [hovMain, setHovMain] = React.useState(demo === "hover"); + const [hovCaret, setHovCaret] = React.useState(false); + const [fMain, focMain] = useFocusRing(); + const [fCaret, focCaret] = useFocusRing(); + const focusMain = demo === "focus" || fMain; + const activeMain = demo === "active"; + const primary = variant === "primary"; + const isDisabled = disabled || loading; const fills = primary - ? { - fg: 'var(--ht-on-accent)', - bg: isDisabled - ? 'color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))' - : 'var(--ht-accent)', - seam: 'color-mix(in oklab, var(--ht-on-accent) 22%, transparent)', - } - : { - fg: 'var(--ht-ink)', - bg: 'var(--ht-surface)', - border: '1px solid var(--ht-border)', - seam: 'var(--ht-border)', - } + ? { fg: "var(--ht-on-accent)", + bg: isDisabled ? "color-mix(in oklab, var(--ht-accent) 42%, var(--ht-bg))" : "var(--ht-accent)", + seam: "color-mix(in oklab, var(--ht-on-accent) 22%, transparent)" } + : { fg: "var(--ht-ink)", bg: "var(--ht-surface)", border: "1px solid var(--ht-border)", seam: "var(--ht-border)" }; const seg = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - gap: 8, - font: 'inherit', - fontSize: '13.5px', - fontWeight: 600, - border: 'none', - cursor: isDisabled ? 'default' : 'pointer', - color: fills.fg, - background: fills.bg, - position: 'relative', - transition: 'filter .12s', - } + display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 8, + font: "inherit", fontSize: "13.5px", fontWeight: 600, border: "none", + cursor: isDisabled ? "default" : "pointer", color: fills.fg, background: fills.bg, + position: "relative", transition: "filter .12s", + }; return ( -
- - {open && options.length ? ( <> {inline ? null : ( -
setOpen(false)} - style={{ position: 'fixed', inset: 0, zIndex: 39 }} - /> +
setOpen(false)} style={{ position: "fixed", inset: 0, zIndex: 39 }} /> )} -
+
{options.map((o, i) => ( - { - setOpen(false) - onAction?.(o) - }} - > + { setOpen(false); onAction?.(o); }}> {o.label} ))} @@ -159,5 +79,5 @@ export function SplitButton({ ) : null}
- ) + ); } diff --git a/web/src/components/ds/core/primitives-support.d.ts b/web/src/components/ds/core/primitives-support.d.ts index 2def91f..a8c8b6e 100644 --- a/web/src/components/ds/core/primitives-support.d.ts +++ b/web/src/components/ds/core/primitives-support.d.ts @@ -1,17 +1,28 @@ -export declare const RING: string -export declare function chevron(dir?: 'down' | 'up' | 'left' | 'right', sz?: number): JSX.Element -export declare function IconKey(sz?: number): JSX.Element -export declare function IconSearch(sz?: number): JSX.Element -export declare function IconReply(sz?: number): JSX.Element -export declare function IconClock(sz?: number): JSX.Element -export declare function IconPlus(sz?: number): JSX.Element -export declare function IconPencil(sz?: number): JSX.Element -export declare function IconTrash(sz?: number): JSX.Element -export declare function useFocusRing(): [boolean, { onFocus: () => void; onBlur: () => void }] -export declare function now(): Date -export declare const WD: string[] -export declare const MO: string[] -export declare function fmtTime(d: Date): string -export declare function fmtDay(d: Date): string -export declare function fmtDate(d: Date): string -export declare function rel(d: Date): string +/** Keyboard focus ring, matching the shipped token set. */ +export declare const RING: string; + +/** Directional chevron glyph. */ +export declare function chevron(dir?: "down" | "up" | "left" | "right", sz?: number): JSX.Element; + +export declare function IconKey(sz: number): JSX.Element; +export declare function IconSearch(sz?: number): JSX.Element; +export declare function IconReply(sz: number): JSX.Element; +export declare function IconClock(sz: number): JSX.Element; +export declare function IconPlus(sz?: number): JSX.Element; +export declare function IconPencil(sz?: number): JSX.Element; +export declare function IconTrash(sz?: number): JSX.Element; + +/** Focusable helper: manages a :focus-visible-like ring via keyboard focus. */ +export declare function useFocusRing(): [boolean, { onFocus: () => void; onBlur: () => void }]; + +/** The live clock. The design source froze this reference; the app always uses real time. */ +export declare function now(): Date; + +export declare const WD: string[]; +export declare const MO: string[]; + +export declare function fmtTime(d: Date): string; +export declare function fmtDay(d: Date): string; +export declare function fmtDate(d: Date): string; +/** Relative time string ("just now", "5m ago", …), falling back to fmtDate for older dates. */ +export declare function rel(d: Date): string; diff --git a/web/src/components/ds/core/primitives-support.jsx b/web/src/components/ds/core/primitives-support.jsx index 84ce46a..1fdde1f 100644 --- a/web/src/components/ds/core/primitives-support.jsx +++ b/web/src/components/ds/core/primitives-support.jsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from "react"; /** * Shared helpers for the four primitives added 2026-07-19 (HT-93): @@ -11,90 +11,62 @@ import React from 'react' */ /** Keyboard focus ring, matching the shipped token set. */ -export const RING = '0 0 0 3px var(--ht-accent-soft)' +export const RING = "0 0 0 3px var(--ht-accent-soft)"; const svg = (d, sz = 15, extra) => ( -) +); -export const chevron = (dir = 'down', sz = 14) => { +export const chevron = (dir = "down", sz = 14) => { const pts = { - down: '6 9 12 15 18 9', - up: '18 15 12 9 6 15', - left: '15 18 9 12 15 6', - right: '9 18 15 12 9 6', - }[dir] + down: "6 9 12 15 18 9", up: "18 15 12 9 6 15", left: "15 18 9 12 15 6", right: "9 18 15 12 9 6", + }[dir]; return ( - + - ) -} + ); +}; export const IconKey = (sz) => - svg( - 'M7 14a5 5 0 1 1 4.9-6H21v3h-2v3h-3v-3h-1.1A5 5 0 0 1 7 14zm-1-5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z', - sz, - ) + svg("M7 14a5 5 0 1 1 4.9-6H21v3h-2v3h-3v-3h-1.1A5 5 0 0 1 7 14zm-1-5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3z", sz); export const IconSearch = (sz = 15) => ( - + -) +); -export const IconReply = (sz) => svg('M10 9V5l-7 7 7 7v-4c5 0 8 1.5 10 5 .5-6-2.5-11-10-11z', sz) +export const IconReply = (sz) => svg("M10 9V5l-7 7 7 7v-4c5 0 8 1.5 10 5 .5-6-2.5-11-10-11z", sz); export const IconClock = (sz) => - svg('M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 10.6V7h-2v6l4.8 2.9 1-1.7-3.8-2.6z', sz) + svg("M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 10.6V7h-2v6l4.8 2.9 1-1.7-3.8-2.6z", sz); export const IconPlus = (sz = 14) => ( - + -) +); export const IconPencil = (sz = 15) => svg( - 'M3 17.25V21h3.75L17.8 9.94l-3.75-3.75L3 17.25zM20.7 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.58z', + "M3 17.25V21h3.75L17.8 9.94l-3.75-3.75L3 17.25zM20.7 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.58z", sz, - ) + ); -export const IconTrash = (sz = 15) => svg('M6 7h12l-1 14H7L6 7zm3-3h6l1 2H8l1-2z', sz) +export const IconTrash = (sz = 15) => svg("M6 7h12l-1 14H7L6 7zm3-3h6l1 2H8l1-2z", sz); /** Focusable helper: manages :focus-visible-like ring via keyboard focus. */ export function useFocusRing() { - const [f, setF] = React.useState(false) - return [f, { onFocus: () => setF(true), onBlur: () => setF(false) }] + const [f, setF] = React.useState(false); + return [f, { onFocus: () => setF(true), onBlur: () => setF(false) }]; } /** @@ -103,45 +75,32 @@ export function useFocusRing() { * a frozen clock would make SnoozePicker compute "tomorrow" from a past date. * This is the one intentional behavioral difference from the design source. */ -export const now = () => new Date() - -export const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] -export const MO = [ - 'Jan', - 'Feb', - 'Mar', - 'Apr', - 'May', - 'Jun', - 'Jul', - 'Aug', - 'Sep', - 'Oct', - 'Nov', - 'Dec', -] +export const now = () => new Date(); + +export const WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +export const MO = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; export const fmtTime = (d) => { - let h = d.getHours() - const m = d.getMinutes() - const ap = h < 12 ? 'AM' : 'PM' - h = h % 12 || 12 - return `${h}:${String(m).padStart(2, '0')} ${ap}` -} + let h = d.getHours(); + const m = d.getMinutes(); + const ap = h < 12 ? "AM" : "PM"; + h = h % 12 || 12; + return `${h}:${String(m).padStart(2, "0")} ${ap}`; +}; -export const fmtDay = (d) => `${WD[d.getDay()]}, ${MO[d.getMonth()]} ${d.getDate()}` +export const fmtDay = (d) => `${WD[d.getDay()]}, ${MO[d.getMonth()]} ${d.getDate()}`; -export const fmtDate = (d) => `${MO[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}` +export const fmtDate = (d) => `${MO[d.getMonth()]} ${d.getDate()}, ${d.getFullYear()}`; export const rel = (d) => { - const s = (now() - d) / 1000 - if (s < 90) return 'just now' - const m = s / 60 - if (m < 60) return `${Math.round(m)}m ago` - const h = m / 60 - if (h < 24) return `${Math.round(h)}h ago` - const dd = h / 24 - if (dd < 2) return 'yesterday' - if (dd < 7) return `${Math.round(dd)}d ago` - return fmtDate(d) -} + const s = (now() - d) / 1000; + if (s < 90) return "just now"; + const m = s / 60; + if (m < 60) return `${Math.round(m)}m ago`; + const h = m / 60; + if (h < 24) return `${Math.round(h)}h ago`; + const dd = h / 24; + if (dd < 2) return "yesterday"; + if (dd < 7) return `${Math.round(dd)}d ago`; + return fmtDate(d); +}; From 8abcaaf784ccd8e1051f0d7974861cdd15cfd5f9 Mon Sep 17 00:00:00 2001 From: TJ Baker <1617679+zaridan@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:10:11 -0700 Subject: [PATCH 4/4] docs: record the design project's three-folder component taxonomy (HT-94) TJ approved components/app/ as the home for app-level surface upstream, so the rule it implies gets written down rather than living in one PR thread. core/ is primitives, inbox/ is inbox-specific composition, app/ is whole screens plus the chrome framing them; the test for app/ is that the thing owns a route or wraps all of them. Also records the one place the two sides are deliberately NOT byte-identical: the app/ screens are .tsx here and are converted to presentational .jsx on the way up, unlike ds/ which is a copy. The same taxonomy note is now in the design project's own readme.md. --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 17da894..410c407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,6 +51,8 @@ The Agent Inbox UI's pixel source of truth is the Claude Design prototype — `H - **Design → app.** Files under `web/src/components/ds/` are verbatim copies of the design project's components. Byte-for-byte: its quotes, its semicolons, its import order, its line wrapping. Biome is disabled for that path (`biome.json` override) precisely so a formatter pass can't quietly break the correspondence — without that, byte comparison stops working as a drift detector. New or changed design work comes down via DesignSync; it is not hand-edited in the app. - **App → design.** If TJ approved something into the dev app, it is approved, and it goes back up into the design project. App-first work is normal — it just isn't finished until the design system has it. `.tsx` screens with API wiring convert to presentational `.jsx` with fixture data on the way up; every style value survives the conversion unchanged. +**Where things live upstream (TJ, 2026-07-20).** The design project has three component folders: `components/core/` for primitives (reusable, context-free, no knowledge of a screen), `components/inbox/` for inbox-specific composition, and `components/app/` for app-level surface — whole screens plus the chrome that frames every screen. The test for `app/` is that the thing owns a route or wraps all of them; anything smaller and reusable is `core/`. Screens upstream are presentational: fixture data, callback props, no API. The app keeps the wiring, the design project keeps the pixels. `web/src/components/ds/` mirrors `core/` and `inbox/`; the `app/` screens live at `web/src/components/` as `.tsx` and are *converted* on the way up, not copied — so they are the one part of the surface where the two sides are deliberately not byte-identical. + The invariant underneath both directions is that **a difference between the two is always a bug in one of them** — so when a re-pull surfaces a semantic difference (a changed style value, prop, or logic, as opposed to formatting), that is a finding, not a merge conflict to resolve in passing. Stop and get TJ's call on which side wins. ## Ecosystem