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 (
-
{
- if (!disabled) e.currentTarget.style.filter = 'brightness(0.95)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.filter = ''
- }}
- >
+ onMouseEnter={(e) => { if (!disabled) e.currentTarget.style.filter = "brightness(0.95)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.filter = ""; }}>
{children}
- )
+ );
}
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) => (
-
onPick?.(it)}
- onMouseMove={() => setHi(i)}
- style={{
- display: 'block',
- width: '100%',
- boxSizing: 'border-box',
- textAlign: 'left',
- border: 'none',
- cursor: 'pointer',
- borderRadius: 'var(--ht-radius-sm)',
- padding: '7px 10px',
- background: i === hi ? 'var(--ht-accent-soft)' : 'transparent',
- }}
- >
-
-
+ onPick?.(it)} onMouseMove={() => setHi(i)}
+ style={{ display: "block", width: "100%", boxSizing: "border-box", textAlign: "left",
+ border: "none", cursor: "pointer", borderRadius: "var(--ht-radius-sm)", padding: "7px 10px",
+ background: i === hi ? "var(--ht-accent-soft)" : "transparent" }}>
+
+
{IconReply(14)}
-
+
{it.label}
{it.shortcut ? (
-
+
{it.shortcut}
) : null}
{it.snippet ? (
-
+
{it.snippet}
) : null}
@@ -206,20 +111,11 @@ export function CommandMenu({
))}
)}
-
-
{`${filtered.length} ${filtered.length === 1 ? 'reply' : 'replies'}`}
-
↑↓ move · ↵ insert
+
+ {`${filtered.length} ${filtered.length === 1 ? "reply" : "replies"}`}
+ ↑↓ move · ↵ insert
- )
+ );
}
diff --git a/web/src/components/ds/core/CredentialRow.d.ts b/web/src/components/ds/core/CredentialRow.d.ts
index 9be288f..aaf13e8 100644
--- a/web/src/components/ds/core/CredentialRow.d.ts
+++ b/web/src/components/ds/core/CredentialRow.d.ts
@@ -1,21 +1,28 @@
export interface Credential {
- name: string
- added: Date
- lastUsed?: Date | null
+ name: string;
+ added: Date;
+ lastUsed?: Date | null;
}
+
+/** A registered passkey: key icon, name, added / last-used metadata, inline
+ * rename, and revoke behind a two-step arm. */
export interface CredentialRowProps {
- cred: Credential
- /** Forces a visual state for specimen rendering. */
- demo?: 'hover' | 'rename' | 'armed'
- onRename?: (cred: Credential, name: string) => void
- onRevoke?: (cred: Credential) => void
- first?: boolean
+ cred: Credential;
+ /** forces a visual state for specimen rendering: hover | rename | armed */
+ demo?: "hover" | "rename" | "armed";
+ onRename?: (cred: Credential, name: string) => void;
+ onRevoke?: (cred: Credential) => void;
+ /** true for the first row in the list; suppresses the top divider */
+ first?: boolean;
}
-export declare function CredentialRow(props: CredentialRowProps): JSX.Element
+export declare function CredentialRow(props: CredentialRowProps): JSX.Element;
+/** The passkey list: registered credentials plus the add affordance, or the
+ * empty state when nothing is registered yet. */
export interface PasskeyListProps {
- creds?: Credential[]
- empty?: boolean
- onAdd?: () => void
+ creds?: Credential[];
+ /** forces the empty state even when creds is non-empty */
+ empty?: boolean;
+ onAdd?: () => void;
}
-export declare function PasskeyList(props: PasskeyListProps): JSX.Element
+export declare function PasskeyList(props: PasskeyListProps): JSX.Element;
diff --git a/web/src/components/ds/core/CredentialRow.jsx b/web/src/components/ds/core/CredentialRow.jsx
index a75fbb9..d4cf5a2 100644
--- a/web/src/components/ds/core/CredentialRow.jsx
+++ b/web/src/components/ds/core/CredentialRow.jsx
@@ -1,220 +1,108 @@
-import React from 'react'
-import { Button } from './Button'
-import { fmtDate, IconKey, IconPencil, IconPlus, IconTrash, RING, rel } from './primitives-support'
+import React from "react";
+import { Button } from "./Button.jsx";
+import { fmtDate, IconKey, IconPencil, IconPlus, IconTrash, RING, rel } from "./primitives-support.jsx";
/** A registered passkey: key icon, name, added / last-used metadata, inline
* rename, and revoke behind a two-step arm.
* `demo` forces a visual state (hover | rename | armed) for specimen rendering. */
export function CredentialRow({ cred, demo, onRename, onRevoke, first }) {
- const [hover, setHover] = React.useState(demo === 'hover')
- const [renaming, setRenaming] = React.useState(demo === 'rename')
- const [name, setName] = React.useState(cred.name)
- const [armed, setArmed] = React.useState(demo === 'armed')
- const timer = React.useRef(null)
- React.useEffect(() => () => clearTimeout(timer.current), [])
+ const [hover, setHover] = React.useState(demo === "hover");
+ const [renaming, setRenaming] = React.useState(demo === "rename");
+ const [name, setName] = React.useState(cred.name);
+ const [armed, setArmed] = React.useState(demo === "armed");
+ const timer = React.useRef(null);
+ React.useEffect(() => () => clearTimeout(timer.current), []);
const arm = () => {
if (armed) {
- setArmed(false)
- clearTimeout(timer.current)
- onRevoke?.(cred)
+ setArmed(false);
+ clearTimeout(timer.current);
+ onRevoke?.(cred);
} else {
- setArmed(true)
- timer.current = setTimeout(() => setArmed(false), 3500)
+ setArmed(true);
+ timer.current = setTimeout(() => setArmed(false), 3500);
}
- }
+ };
return (
- !demo && setHover(true)}
- onMouseLeave={() => !demo && setHover(false)}
- style={{
- display: 'flex',
- alignItems: 'center',
- gap: 13,
- padding: '13px 14px',
- borderTop: first ? 'none' : '1px solid var(--ht-divider)',
- background: hover ? 'var(--ht-surface-2)' : 'transparent',
- transition: 'background .1s',
- }}
- >
-
+
!demo && setHover(true)} onMouseLeave={() => !demo && setHover(false)}
+ style={{ display: "flex", alignItems: "center", gap: 13, padding: "13px 14px",
+ borderTop: first ? "none" : "1px solid var(--ht-divider)",
+ background: hover ? "var(--ht-surface-2)" : "transparent", transition: "background .1s" }}>
+
{IconKey(20)}
{renaming ? (
-
+
setName(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter') {
- setRenaming(false)
- onRename?.(cred, name)
+ if (e.key === "Enter") {
+ setRenaming(false);
+ onRename?.(cred, name);
}
- if (e.key === 'Escape') {
- setName(cred.name)
- setRenaming(false)
+ if (e.key === "Escape") {
+ setName(cred.name);
+ setRenaming(false);
}
}}
- style={{
- flex: 1,
- minWidth: 0,
- fontFamily: 'var(--ht-sans)',
- fontSize: 13.5,
- fontWeight: 600,
- color: 'var(--ht-ink)',
- background: 'var(--ht-bg)',
- border: '1px solid var(--ht-border)',
- borderRadius: 'var(--ht-radius-sm)',
- padding: '5px 9px',
- outline: 'none',
- boxShadow: RING,
- }}
+ style={{ flex: 1, minWidth: 0, fontFamily: "var(--ht-sans)", fontSize: 13.5, fontWeight: 600,
+ color: "var(--ht-ink)", background: "var(--ht-bg)", border: "1px solid var(--ht-border)",
+ borderRadius: "var(--ht-radius-sm)", padding: "5px 9px", outline: "none", boxShadow: RING }}
/>
- {
- setRenaming(false)
- onRename?.(cred, name)
- }}
- style={{
- font: 'inherit',
- fontSize: 12.5,
- fontWeight: 600,
- color: 'var(--ht-on-accent)',
- background: 'var(--ht-accent)',
- border: 'none',
- borderRadius: 'var(--ht-radius-sm)',
- padding: '5px 12px',
- cursor: 'pointer',
- }}
- >
+ { setRenaming(false); onRename?.(cred, name); }}
+ style={{ font: "inherit", fontSize: 12.5, fontWeight: 600, color: "var(--ht-on-accent)",
+ background: "var(--ht-accent)", border: "none", borderRadius: "var(--ht-radius-sm)",
+ padding: "5px 12px", cursor: "pointer" }}>
Save
) : (
-
+
{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 ? (
-
+
Confirm revoke
) : (
<>
- setRenaming(true)}
- style={{
- width: 30,
- height: 30,
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- color: 'var(--ht-ink-muted)',
- background: 'none',
- border: 'none',
- borderRadius: 'var(--ht-radius-sm)',
- cursor: 'pointer',
- }}
- onMouseEnter={(e) => {
- e.currentTarget.style.background = 'var(--ht-surface)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = 'none'
- }}
- >
+ setRenaming(true)}
+ style={{ width: 30, height: 30, display: "inline-flex", alignItems: "center",
+ justifyContent: "center", color: "var(--ht-ink-muted)", background: "none", border: "none",
+ borderRadius: "var(--ht-radius-sm)", cursor: "pointer" }}
+ onMouseEnter={(e) => { e.currentTarget.style.background = "var(--ht-surface)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}>
{IconPencil(15)}
- {
- e.currentTarget.style.background = 'var(--ht-critical-soft)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = 'none'
- }}
- >
+ { e.currentTarget.style.background = "var(--ht-critical-soft)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}>
{IconTrash(15)}
>
@@ -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 = (
onAdd?.()}>
- {IconPlus(14)}
+ {IconPlus(14)}
Add a passkey
- )
+ );
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) => (
))}
-
- )
+ );
}
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 (
-
{
- e.currentTarget.style.background = hoverBg
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = active ? hoverBg : 'transparent'
- }}
- >
+ { e.currentTarget.style.background = hoverBg; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = active ? hoverBg : "transparent"; }}>
{children}
- )
+ );
}
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 (
- {
- e.currentTarget.style.background = destructive
- ? 'var(--ht-critical-soft)'
- : 'var(--ht-surface-2)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = selected ? 'var(--ht-accent-soft)' : 'transparent'
- }}
- >
- {icon && {icon} }
+ { e.currentTarget.style.background = destructive ? "var(--ht-critical-soft)" : "var(--ht-surface-2)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = selected ? "var(--ht-accent-soft)" : "transparent"; }}>
+ {icon && {icon} }
{children}
{shortcut}
- )
+ );
}
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 (
-
-
setView(new Date(y, m - 1, 1))}
- style={{ ...btn, width: 28, height: 28, color: 'var(--ht-ink-muted)' }}
- >
- {chevron('left', 15)}
+
+ setView(new Date(y, m - 1, 1))}
+ style={{ ...btn, width: 28, height: 28, color: "var(--ht-ink-muted)" }}>
+ {chevron("left", 15)}
{`${MO[m]} ${y}`}
- setView(new Date(y, m + 1, 1))}
- style={{ ...btn, width: 28, height: 28, color: 'var(--ht-ink-muted)' }}
- >
- {chevron('right', 15)}
+ setView(new Date(y, m + 1, 1))}
+ style={{ ...btn, width: 28, height: 28, color: "var(--ht-ink-muted)" }}>
+ {chevron("right", 15)}
-
- {['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 (
- {
- const nd = new Date(value)
- nd.setFullYear(y, m, d)
- onChange(nd)
- }}
- onMouseEnter={(e) => {
- if (!sel && !past) e.currentTarget.style.background = 'var(--ht-surface-2)'
- }}
- onMouseLeave={(e) => {
- if (!sel) e.currentTarget.style.background = 'none'
- }}
- style={{
- ...btn,
- cursor: past ? 'default' : 'pointer',
- color: past ? 'var(--ht-ink-dim)' : sel ? 'var(--ht-on-accent)' : 'var(--ht-ink)',
- opacity: past ? 0.5 : 1,
- background: sel ? 'var(--ht-accent)' : 'none',
- boxShadow: isToday && !sel ? 'inset 0 0 0 1px var(--ht-border)' : 'none',
- }}
- >
+ { const nd = new Date(value); nd.setFullYear(y, m, d); onChange(nd); }}
+ onMouseEnter={(e) => { if (!sel && !past) e.currentTarget.style.background = "var(--ht-surface-2)"; }}
+ onMouseLeave={(e) => { if (!sel) e.currentTarget.style.background = "none"; }}
+ style={{ ...btn, cursor: past ? "default" : "pointer",
+ color: past ? "var(--ht-ink-dim)" : sel ? "var(--ht-on-accent)" : "var(--ht-ink)",
+ opacity: past ? 0.5 : 1, background: sel ? "var(--ht-accent)" : "none",
+ boxShadow: isToday && !sel ? "inset 0 0 0 1px var(--ht-border)" : "none" }}>
{d}
- )
+ );
})}
- )
+ );
}
/** 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
) : (
-
-
setCustom(false)}
- style={{
- display: 'inline-flex',
- alignItems: 'center',
- gap: 4,
- marginBottom: 10,
- font: 'inherit',
- fontSize: 12,
- fontWeight: 600,
- color: 'var(--ht-accent)',
- background: 'none',
- border: 'none',
- cursor: 'pointer',
- padding: 0,
- }}
- >
- {chevron('left', 13)}
+
+
setCustom(false)}
+ style={{ display: "inline-flex", alignItems: "center", gap: 4, marginBottom: 10, font: "inherit",
+ fontSize: 12, fontWeight: 600, color: "var(--ht-accent)", background: "none", border: "none",
+ cursor: "pointer", padding: 0 }}>
+ {chevron("left", 13)}
Presets
-
-
- {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 ? (
-
onSnooze?.(resolved)}
- style={{
- font: 'inherit',
- fontSize: 12.5,
- fontWeight: 600,
- color: 'var(--ht-on-accent)',
- background: 'var(--ht-accent)',
- border: 'none',
- borderRadius: 'var(--ht-radius-sm)',
- padding: '6px 14px',
- cursor: 'pointer',
- }}
- >
+ onSnooze?.(resolved)}
+ style={{ font: "inherit", fontSize: 12.5, fontWeight: 600, color: "var(--ht-on-accent)",
+ background: "var(--ht-accent)", border: "none", borderRadius: "var(--ht-radius-sm)",
+ padding: "6px 14px", cursor: "pointer" }}>
Snooze
) : 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 (
-
-
+ !isDisabled && setHovMain(true)}
onMouseLeave={() => !demo && setHovMain(false)}
onClick={() => onAction?.({ label, primary: true })}
- style={{
- ...seg,
- padding: primary ? '8px 16px' : '7px 15px',
- borderTop: fills.border,
- borderLeft: fills.border,
- borderBottom: fills.border,
- borderRadius: 'var(--ht-radius-md) 0 0 var(--ht-radius-md)',
- filter: (hovMain || activeMain) && !isDisabled ? 'brightness(0.94)' : 'none',
- boxShadow: focusMain ? RING : 'none',
- zIndex: focusMain ? 1 : 'auto',
- }}
- >
+ style={{ ...seg, padding: primary ? "8px 16px" : "7px 15px",
+ borderTop: fills.border, borderLeft: fills.border, borderBottom: fills.border,
+ borderRadius: "var(--ht-radius-md) 0 0 var(--ht-radius-md)",
+ filter: (hovMain || activeMain) && !isDisabled ? "brightness(0.94)" : "none",
+ boxShadow: focusMain ? RING : "none", zIndex: focusMain ? 1 : "auto" }}>
{loading ? (
-
+
) : null}
- {loading ? 'Sending…' : label}
+ {loading ? "Sending…" : label}
- !isDisabled && setHovCaret(true)}
onMouseLeave={() => setHovCaret(false)}
onClick={() => setOpen((v) => !v)}
- style={{
- ...seg,
- width: 34,
- padding: 0,
- borderTop: fills.border,
- borderRight: fills.border,
- borderBottom: fills.border,
+ style={{ ...seg, width: 34, padding: 0,
+ borderTop: fills.border, borderRight: fills.border, borderBottom: fills.border,
borderLeft: `1px solid ${fills.seam}`,
- borderRadius: '0 var(--ht-radius-md) var(--ht-radius-md) 0',
- filter: (hovCaret || open) && !isDisabled ? 'brightness(0.94)' : 'none',
- boxShadow: fCaret ? RING : 'none',
- zIndex: fCaret || open ? 1 : 'auto',
- }}
- >
- {chevron(open ? 'up' : 'down', 15)}
+ borderRadius: "0 var(--ht-radius-md) var(--ht-radius-md) 0",
+ filter: (hovCaret || open) && !isDisabled ? "brightness(0.94)" : "none",
+ boxShadow: fCaret ? RING : "none", zIndex: fCaret || open ? 1 : "auto" }}>
+ {chevron(open ? "up" : "down", 15)}
{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}
-
{
- e.stopPropagation()
- onStar && onStar()
- }}
- style={{
- width: 22,
- height: 22,
- flexShrink: 0,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- color: starred ? 'var(--ht-accent)' : 'var(--ht-ink-dim)',
- background: 'none',
- border: 'none',
- borderRadius: 'var(--ht-radius-sm)',
- cursor: 'pointer',
- padding: 0,
- }}
- >
-
-
-
+ { e.stopPropagation(); onStar && onStar(); }}
+ style={{ width: 22, height: 22, flexShrink: 0, display: "flex", alignItems: "center", justifyContent: "center",
+ color: starred ? "var(--ht-accent)" : "var(--ht-ink-dim)", background: "none", border: "none",
+ borderRadius: "var(--ht-radius-sm)", cursor: "pointer", padding: 0 }}>
+
-
- {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 (
- {
- e.currentTarget.style.background = 'var(--ht-surface-2)'
- }}
- onMouseLeave={(e) => {
- e.currentTarget.style.background = active ? 'var(--ht-accent-soft)' : 'transparent'
- }}
- >
- {icon}
+ { e.currentTarget.style.background = "var(--ht-surface-2)"; }}
+ onMouseLeave={(e) => { e.currentTarget.style.background = active ? "var(--ht-accent-soft)" : "transparent"; }}>
+ {icon}
{label}
-
- {count}
-
+ {count}
- )
+ );
}
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 (
-