diff --git a/biome.json b/biome.json
index d5b37d7..963833b 100644
--- a/biome.json
+++ b/biome.json
@@ -32,6 +32,8 @@
"overrides": [
{
"includes": ["web/src/components/ds/**"],
+ "formatter": { "enabled": false },
+ "assist": { "actions": { "source": { "organizeImports": "off" } } },
"linter": {
"rules": {
"correctness": { "noUnusedImports": "off" },
diff --git a/web/src/components/ds/core/CommandMenu.d.ts b/web/src/components/ds/core/CommandMenu.d.ts
new file mode 100644
index 0000000..b0ba9cf
--- /dev/null
+++ b/web/src/components/ds/core/CommandMenu.d.ts
@@ -0,0 +1,15 @@
+export interface CommandMenuItem {
+ label: string
+ snippet?: string
+ keywords?: string
+ shortcut?: string
+}
+export interface CommandMenuProps {
+ items?: CommandMenuItem[]
+ placeholder?: string
+ onPick?: (item: CommandMenuItem) => void
+ inline?: boolean
+ width?: number
+ initialQuery?: string
+}
+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
new file mode 100644
index 0000000..4b6960a
--- /dev/null
+++ b/web/src/components/ds/core/CommandMenu.jsx
@@ -0,0 +1,225 @@
+import React from 'react'
+import { IconReply, IconSearch } from './primitives-support'
+
+/** 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)
+
+ 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])
+
+ // `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])
+
+ React.useEffect(() => {
+ const el = listRef.current?.children[hi]
+ if (el?.scrollIntoViewIfNeeded) {
+ el.scrollIntoViewIfNeeded()
+ } else if (el) {
+ 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
+ }
+ }, [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()
+ }
+ }
+
+ return (
+
+
+ {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,
+ }}
+ />
+
+ esc
+
+
+ {filtered.length === 0 ? (
+
+
+ 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',
+ }}
+ >
+
+
+ {IconReply(14)}
+
+
+ {it.label}
+
+ {it.shortcut ? (
+
+ {it.shortcut}
+
+ ) : null}
+
+ {it.snippet ? (
+
+ {it.snippet}
+
+ ) : null}
+
+ ))}
+
+ )}
+
+ {`${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
new file mode 100644
index 0000000..9be288f
--- /dev/null
+++ b/web/src/components/ds/core/CredentialRow.d.ts
@@ -0,0 +1,21 @@
+export interface Credential {
+ name: string
+ added: Date
+ lastUsed?: Date | null
+}
+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
+}
+export declare function CredentialRow(props: CredentialRowProps): JSX.Element
+
+export interface PasskeyListProps {
+ creds?: Credential[]
+ empty?: boolean
+ onAdd?: () => void
+}
+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
new file mode 100644
index 0000000..a75fbb9
--- /dev/null
+++ b/web/src/components/ds/core/CredentialRow.jsx
@@ -0,0 +1,307 @@
+import React from 'react'
+import { Button } from './Button'
+import { fmtDate, IconKey, IconPencil, IconPlus, IconTrash, RING, rel } from './primitives-support'
+
+/** 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 arm = () => {
+ if (armed) {
+ setArmed(false)
+ clearTimeout(timer.current)
+ onRevoke?.(cred)
+ } else {
+ 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',
+ }}
+ >
+
+ {IconKey(20)}
+
+
+ {renaming ? (
+
+ setName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') {
+ setRenaming(false)
+ onRename?.(cred, name)
+ }
+ 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,
+ }}
+ />
+ {
+ 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'}`}
+
+ ) : 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'
+ }}
+ >
+ {IconPencil(15)}
+
+ {
+ e.currentTarget.style.background = 'var(--ht-critical-soft)'
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.background = 'none'
+ }}
+ >
+ {IconTrash(15)}
+
+ >
+ )}
+
+ ) : null}
+
+ )
+}
+
+/** The passkey list: registered credentials plus the add affordance, or the
+ * empty state when nothing is registered yet. */
+export function PasskeyList({ creds = [], empty, onAdd }) {
+ const addBtn = (
+ onAdd?.()}>
+ {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.
+
+ {addBtn}
+
+ ) : (
+ <>
+ {creds.map((c, i) => (
+
+ ))}
+
+ {addBtn}
+
+ >
+ )}
+
+ )
+}
diff --git a/web/src/components/ds/core/SnoozePicker.d.ts b/web/src/components/ds/core/SnoozePicker.d.ts
new file mode 100644
index 0000000..d5678f5
--- /dev/null
+++ b/web/src/components/ds/core/SnoozePicker.d.ts
@@ -0,0 +1,7 @@
+export interface SnoozePickerProps {
+ onSnooze?: (when: Date) => void
+ inline?: boolean
+ initialCustom?: boolean
+ initialSel?: Date
+}
+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
new file mode 100644
index 0000000..ec9ba87
--- /dev/null
+++ b/web/src/components/ds/core/SnoozePicker.jsx
@@ -0,0 +1,319 @@
+import React from 'react'
+import { MenuItem } from './MenuItem'
+import { chevron, fmtDay, fmtTime, IconClock, MO, now, WD } from './primitives-support'
+
+function laterToday() {
+ 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
+}
+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
+}
+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
+}
+
+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 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',
+ }
+ return (
+
+
+ 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)}
+
+
+
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((w, i) => (
+
+ {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
+ 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',
+ }}
+ >
+ {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')
+ 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])
+
+ return (
+
+
+ Snooze until
+
+ {!custom ? (
+
+ {presets.map((p) => (
+
+ {p.when.toDateString() === now().toDateString()
+ ? fmtTime(p.when)
+ : `${WD[p.when.getDay()]} ${fmtTime(p.when)}`}
+
+ }
+ onClick={() => onSnooze?.(p.when)}
+ >
+ {p.label}
+
+ ))}
+
+
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)}
+ 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',
+ }}
+ />
+
+
+ )}
+
+
+ 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',
+ }}
+ >
+ Snooze
+
+ ) : null}
+
+
+ )
+}
diff --git a/web/src/components/ds/core/SplitButton.d.ts b/web/src/components/ds/core/SplitButton.d.ts
new file mode 100644
index 0000000..d41db9b
--- /dev/null
+++ b/web/src/components/ds/core/SplitButton.d.ts
@@ -0,0 +1,17 @@
+export interface SplitButtonOption {
+ label: string
+ icon?: React.ReactNode
+}
+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
+}
+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
new file mode 100644
index 0000000..10c17cb
--- /dev/null
+++ b/web/src/components/ds/core/SplitButton.jsx
@@ -0,0 +1,163 @@
+import React from 'react'
+import { MenuItem } from './MenuItem'
+import { chevron, RING, useFocusRing } from './primitives-support'
+
+/** 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
+
+ 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)',
+ }
+ 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',
+ }
+
+ 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',
+ }}
+ >
+ {loading ? (
+
+ ) : null}
+ {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,
+ 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)}
+
+ {open && options.length ? (
+ <>
+ {inline ? null : (
+
setOpen(false)}
+ style={{ position: 'fixed', inset: 0, zIndex: 39 }}
+ />
+ )}
+
+ {options.map((o, i) => (
+ {
+ setOpen(false)
+ onAction?.(o)
+ }}
+ >
+ {o.label}
+
+ ))}
+
+ >
+ ) : null}
+
+ )
+}
diff --git a/web/src/components/ds/core/primitives-support.d.ts b/web/src/components/ds/core/primitives-support.d.ts
new file mode 100644
index 0000000..2def91f
--- /dev/null
+++ b/web/src/components/ds/core/primitives-support.d.ts
@@ -0,0 +1,17 @@
+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
diff --git a/web/src/components/ds/core/primitives-support.jsx b/web/src/components/ds/core/primitives-support.jsx
new file mode 100644
index 0000000..84ce46a
--- /dev/null
+++ b/web/src/components/ds/core/primitives-support.jsx
@@ -0,0 +1,147 @@
+import React from 'react'
+
+/**
+ * Shared helpers for the four primitives added 2026-07-19 (HT-93):
+ * SplitButton · CommandMenu · SnoozePicker · CredentialRow.
+ *
+ * Icon glyphs, the focus-ring token, and the date formatters live here rather
+ * than being duplicated per component — the design source carried one copy of
+ * each in a single file, and splitting that file per component must not turn
+ * one definition into four.
+ */
+
+/** Keyboard focus ring, matching the shipped token set. */
+export const RING = '0 0 0 3px var(--ht-accent-soft)'
+
+const svg = (d, sz = 15, extra) => (
+
+
+
+)
+
+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]
+ 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,
+ )
+
+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 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)
+
+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',
+ 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) }]
+}
+
+/**
+ * The design source froze a reference clock (`NOW = 2026-07-19 14:30`) so its
+ * specimen times wouldn't drift between renders. The app needs real time —
+ * 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 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}`
+}
+
+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 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)
+}