diff --git a/CLAUDE.md b/CLAUDE.md index 523205d..410c407 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,16 @@ 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. + +**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 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/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/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/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/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/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/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); +}; 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}
- ) + ); }