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) => ( + + ))} +
+ )} +
+ {`${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, + }} + /> + +
+ ) : ( +
+ {name} +
+ )} + {!renaming ? ( +
+ {`Added ${fmtDate(cred.added)} · Last used ${cred.lastUsed ? rel(cred.lastUsed) : 'never'}`} +
+ ) : null} +
+ {!renaming ? ( +
+ {armed ? ( + + ) : ( + <> + + + + )} +
+ ) : 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 = ( + + ) + 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 ( +
+
+ + {`${MO[m]} ${y}`} + +
+
+ {['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 ( + + ) + })} +
+
+ ) +} + +/** 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 + +
+ ) : ( +
+ + +
+ + {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 ? ( + + ) : 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 ( +
+ + + {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) +}