fix(iwa): final-review fixes — dialog layering/scrim/scroll-lock, a11y labels+disabled+alerts, settings guards+errors, edit-mode locks, strict offsets, titles

27 adversarially-confirmed findings from the multi-agent whole-branch review (2 deferred).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 4 days ago
parent 19fe037bb4
commit 60698fc08f

@ -80,11 +80,13 @@ export function Layout() {
}, [])
// Tab title tracks the active nav section (spec §3); record routes fall back to
// their section title since they aren't separate nav items.
// their section title since they aren't separate nav items. Exact match wins
// before prefix match so /documents/new titles as 'New Document', not 'Documents'.
useEffect(() => {
const item = NAV_GROUPS.flatMap((g) => g.items).find((i) =>
i.to === '/' ? location.pathname === '/' : location.pathname.startsWith(i.to))
document.title = item !== undefined && item.to !== '/' ? `${item.label} · SiMS HQ` : 'SiMS HQ'
const items = NAV_GROUPS.flatMap((g) => g.items)
const item = items.find((i) => location.pathname === i.to)
?? items.find((i) => i.to !== '/' && location.pathname.startsWith(i.to))
document.title = item !== undefined ? `${item.label} · SiMS HQ` : 'SiMS HQ'
}, [location.pathname])
const staticItems = useMemo<PaletteItem[]>(() => {
@ -200,7 +202,15 @@ export function Layout() {
)}
<ThemeSwitcher />
{/* D18 WS-E: the user chip opens a small menu — Profile / Logout. */}
<span className="hq-user-wrap" ref={userRef}>
<span
className="hq-user-wrap"
ref={userRef}
// Close when keyboard focus leaves the chip+menu — outside-close alone
// only fires on mousedown, so a Tab-away would leave the menu hanging open.
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setUserOpen(false)
}}
>
<button
type="button" className="hq-user" title="Account"
aria-haspopup="menu" aria-expanded={userOpen}

@ -27,6 +27,12 @@ export function Login() {
return (
<div className="login-wrap">
<div className="login-brand">
{/* Same rounded-square HQ mark as public/favicon.svg (spec §6: favicon reused
as the login brand accent). */}
<svg className="login-mark" width="44" height="44" viewBox="0 0 32 32" aria-hidden="true">
<rect width="32" height="32" rx="7" fill="#115e59" />
<text x="16" y="21.5" fontFamily="Segoe UI, Arial, sans-serif" fontSize="13" fontWeight="700" fill="#ffffff" textAnchor="middle">HQ</text>
</svg>
<h1>SiMS HQ</h1>
<p>The ops console clients, documents, payments, AMC and reminders in one place.</p>
</div>
@ -46,7 +52,7 @@ export function Login() {
onKeyDown={(e) => e.key === 'Enter' && void submit()}
/>
</Field>
<Button tone="primary" onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
<Button tone="primary" disabled={busy} onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</div>

@ -92,7 +92,9 @@
}
.hq-user:hover, .hq-user[aria-expanded='true'] { background: var(--bg-hover); border-color: var(--border); }
.hq-user-menu {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 60;
/* Below the dialog scrim (50) so an opened modal covers the dropdown; above the
mobile drawer (40) and page chrome. */
position: absolute; top: calc(100% + 6px); right: 0; z-index: 45;
min-width: 150px; padding: 4px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg);
@ -125,6 +127,7 @@
flex: 1; background: #1a1a17; color: #eae8e2;
display: flex; flex-direction: column; justify-content: center; padding: 48px 56px;
}
.login-brand .login-mark { margin-bottom: 16px; }
.login-brand h1 { font-size: 34px; margin: 0 0 8px; letter-spacing: -0.02em; }
.login-brand p { color: #a8a69c; max-width: 44ch; }
.login-form { flex: 0 0 440px; display: flex; align-items: center; justify-content: center; padding: 24px; }
@ -204,10 +207,18 @@ table.wf { display: block; overflow-x: auto; }
/* ================= Settings hub (rail + panels) ================= */
.set-wrap { display: flex; gap: 24px; }
.set-rail { flex: 0 0 190px; display: flex; flex-direction: column; gap: 2px; }
.set-rail button { text-align: left; border: none; background: none; padding: 8px 12px; border-radius: 8px; font: 500 13.5px var(--font); color: var(--text-dim); cursor: pointer; }
.set-rail button { text-align: left; border: none; background: none; padding: 8px 12px; border-radius: var(--radius); font: 500 13.5px var(--font); color: var(--text-dim); cursor: pointer; }
.set-rail button:hover { background: var(--bg-hover); color: var(--text); }
.set-rail button.active { background: var(--accent-soft); color: var(--accent-strong); font-weight: 600; }
.set-panel { flex: 1; min-width: 0; max-width: 860px; }
/* Panel section headers sit under the 22px page h1 pin them to the app's type
scale instead of the ~21px UA default that nearly ties with the page title. */
.set-panel h2 { font-size: 17px; margin: 0 0 4px; }
.set-card { border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; }
.set-card h3 { margin: 0 0 8px; font-size: 14px; }
@media (max-width: 900px) { .set-wrap { flex-direction: column; } .set-rail { flex-direction: row; flex-wrap: wrap; } }
@media (max-width: 900px) {
.set-wrap { flex-direction: column; }
/* flex-basis: auto the desktop 190px basis is a width in row layout, but once
.set-wrap flips to column it would pin the rail's HEIGHT to 190px. */
.set-rail { flex-direction: row; flex-wrap: wrap; flex-basis: auto; }
}

@ -92,7 +92,7 @@ export function ClientFormDialog(props: {
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'Create client'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'Create client'}</Button>
</>
}
>

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Button, Dialog, Field, Notice } from '@sims/ui'
import { getEmailStatus, type Client, type Doc, type EmailStatus } from '../api'
@ -20,13 +20,19 @@ export function SendDialog(props: {
const [other, setOther] = useState('')
const [subject, setSubject] = useState('')
const [gmail, setGmail] = useState<EmailStatus | undefined>()
const wasOpen = useRef(false)
// Prefill ONLY on the false→true open transition — emails/doc are read fresh at
// that moment, but a late client fetch (or doc identity change) while the dialog
// is open must not wipe a half-typed recipient or an edited subject.
useEffect(() => {
if (!props.open) return
if (props.open && !wasOpen.current) {
setPick(emails[0] ?? OTHER)
setOther('')
setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim())
getEmailStatus().then(setGmail).catch(() => setGmail(undefined))
}
wasOpen.current = props.open
}, [props.open, emails, props.doc])
const to = pick === OTHER ? other.trim() : pick

@ -178,7 +178,7 @@ function EmployeeDialog(props: { state: DialogState | undefined; onClose: () =>
footer={
<>
<Button pill onClick={close}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : saveLabel}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : saveLabel}</Button>
</>
}
>

@ -156,7 +156,9 @@ function ModuleClients(props: { module: Module }) {
</Button>
</Toolbar>
{assigning && (
<div className="wf-form">
// wf-card, not the dead 'wf-form' class (no rules exist for it anywhere) —
// the assign panel gets the same contained-card chrome as every other inline panel.
<div className="wf-card">
<Field label="Client (search by name/code)">
<input value={assignPick !== undefined ? `${assignPick.code} · ${assignPick.name}` : assignQ}
onChange={(e) => searchAssign(e.target.value)} />
@ -182,7 +184,7 @@ function ModuleClients(props: { module: Module }) {
</div>
)}
{notifying && managerial && (
<div className="wf-form">
<div className="wf-card">
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} /></Field>
<Field label="Message">
<textarea rows={4} value={bodyText} onChange={(e) => setBodyText(e.target.value)} />
@ -244,7 +246,7 @@ function ModuleClients(props: { module: Module }) {
renewal: <input className="wf" type="date" value={edit.renewal} onChange={(e) => setEdit((p) => p && ({ ...p, renewal: e.target.value }))} />,
actions: (
<span style={{ display: 'flex', gap: 6 }}>
<Button tone="primary" onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button tone="primary" disabled={busy} onClick={saveEdit}>{busy ? 'Saving…' : 'Save'}</Button>
<Button onClick={() => setEdit(undefined)}>Cancel</Button>
</span>
),
@ -374,7 +376,7 @@ function NewModuleDialog(props: { open: boolean; onClose: () => void; onCreated:
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save module'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save module'}</Button>
</>
}
>
@ -485,7 +487,7 @@ function AddPriceDialog(props: { open: boolean; onClose: () => void; module: Mod
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Add price'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Add price'}</Button>
</>
}
>

@ -53,6 +53,7 @@ export function Settings() {
key={s.key}
type="button"
className={s.key === active ? 'active' : undefined}
aria-current={s.key === active ? 'true' : undefined}
onClick={() => setSp({ s: s.key })}
>
{s.label}
@ -91,6 +92,7 @@ function CompanyPanel() {
}, [])
const save = () => {
if (saving) return
setSaving(true)
putCompanyProfile(company)
.then((s) => {
@ -117,7 +119,7 @@ function CompanyPanel() {
))}
</FormGrid>
<div style={{ marginTop: 14 }}>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</div>
</>
)}
@ -148,6 +150,7 @@ function RemindersPanel() {
}, [data])
const save = () => {
if (saving) return
const o = Number(overdueDays)
const r = Number(renewalDays)
if (!Number.isInteger(o) || o < 1 || !Number.isInteger(r) || r < 1) {
@ -179,7 +182,7 @@ function RemindersPanel() {
</FormField>
</FormGrid>
<div style={{ margin: '14px 0 22px' }}>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</div>
<h3>Dated cadences</h3>
@ -224,6 +227,7 @@ function NewScheduleDialog(props: { open: boolean; onClose: () => void; onDone:
const close = () => { if (!saving) props.onClose() }
const save = () => {
if (saving) return
if (effectiveFrom === '' || dayOffsets.trim() === '') {
setError('Effective date and day offsets are required')
return
@ -248,7 +252,7 @@ function NewScheduleDialog(props: { open: boolean; onClose: () => void; onDone:
footer={(
<>
<Button pill onClick={close}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Create'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Create'}</Button>
</>
)}
>
@ -291,6 +295,7 @@ function SharingPanel() {
}, [data])
const save = () => {
if (saving) return
let n: number | null = null
if (mode === 'days') {
n = Number(days)
@ -325,7 +330,7 @@ function SharingPanel() {
Never expires
</label>
</div>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : 'Save'}</Button>
</>
)}
</div>
@ -345,7 +350,8 @@ function IntegrationsPanel() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, marginTop: 10 }}>
<div className="set-card">
<h3>Gmail</h3>
{email.data === undefined ? <Skeleton rows={1} /> : (
{email.error !== undefined ? <ErrorState message={email.error} onRetry={email.reload} />
: email.data === undefined ? <Skeleton rows={1} /> : (
<>
<span className={`hq-pill${emailOk ? ' ok' : ' warn'}`}>
<span className={`hq-dot${emailOk ? ' ok' : ' warn'}`} />
@ -361,7 +367,8 @@ function IntegrationsPanel() {
</div>
<div className="set-card">
<h3>AWS cost recovery</h3>
{aws.data === undefined ? <Skeleton rows={1} />
{aws.error !== undefined ? <ErrorState message={aws.error} onRetry={aws.reload} />
: aws.data === undefined ? <Skeleton rows={1} />
: aws.data.rows.length === 0 ? <EmptyState>No AWS cost recorded yet.</EmptyState>
: <p style={{ margin: 0 }}>Last pull month {aws.data.month} · {inr(aws.data.total)}</p>}
</div>

@ -63,7 +63,7 @@ export function PlanDialog(props: {
const save = () => {
if (saving) return
if (f.clientModuleId === '') { setError('Pick a subscribed module'); return }
if (!editing && f.clientModuleId === '') { setError('Pick a subscribed module'); return }
const amount = f.amountRs === '' ? undefined : Number(f.amountRs)
if (amount !== undefined && (!Number.isFinite(amount) || amount <= 0)) {
setError('Amount must be a positive rupee value (or blank for the price book)'); return
@ -71,7 +71,10 @@ export function PlanDialog(props: {
setError(undefined)
setSaving(true)
const body = {
clientModuleId: f.clientModuleId, cadence: f.cadence, nextRun: f.nextRun, policy: f.policy,
cadence: f.cadence, nextRun: f.nextRun, policy: f.policy,
// PATCH /recurring/:id cannot re-point a plan at another module, so edit mode
// never sends clientModuleId (the select is disabled to match).
...(editing ? {} : { clientModuleId: f.clientModuleId }),
// Edit mode: blank means "revert to price book" — send an explicit null so it clears.
// Create mode: blank just omits the override, letting the price book resolve at generation time.
...(editing
@ -93,13 +96,13 @@ export function PlanDialog(props: {
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'New recurring plan'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'New recurring plan'}</Button>
</>
}
>
<FormGrid>
<FormField label="Module">
<select className="wf" autoFocus value={f.clientModuleId} onChange={set('clientModuleId')}>
<select className="wf" autoFocus={!editing} value={f.clientModuleId} onChange={set('clientModuleId')} disabled={editing}>
<option value="">Module</option>
{props.options.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
@ -176,7 +179,7 @@ export function AmcDialog(props: {
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'New AMC'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'New AMC'}</Button>
</>
}
>
@ -222,7 +225,11 @@ export function InteractionDialog(props: {
setError(undefined)
setSaving(true)
const body = {
typeCode: f.typeCode, onDate: f.onDate, notes: f.notes,
notes: f.notes,
// PATCH /interactions/:id cannot change type or date, so edit mode never sends
// typeCode/onDate (the fields are disabled to match) — a silently-ignored
// "correction" with a success toast is worse than a locked field.
...(editing ? {} : { typeCode: f.typeCode, onDate: f.onDate }),
// Edit mode: blank means "clear it" — send an explicit null so the server clears the column
// (same semantics as ClientDetail's inline follow-up quick-edit). Create mode omits when blank.
...(editing
@ -247,18 +254,18 @@ export function InteractionDialog(props: {
footer={
<>
<Button pill onClick={() => { if (!saving) props.onClose() }}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? (editing ? 'Saving…' : 'Logging…') : editing ? 'Save changes' : 'Log interaction'}</Button>
<Button pill tone="primary" disabled={saving} onClick={save}>{saving ? (editing ? 'Saving…' : 'Logging…') : editing ? 'Save changes' : 'Log interaction'}</Button>
</>
}
>
<FormGrid>
<FormField label="Type">
<select className="wf" autoFocus value={f.typeCode} onChange={set('typeCode')}>
<select className="wf" autoFocus={!editing} value={f.typeCode} onChange={set('typeCode')} disabled={editing}>
<option value="">Type</option>
{props.types.map((t) => <option key={t.code} value={t.code}>{t.label}</option>)}
</select>
</FormField>
<FormField label="On"><input type="date" className="wf" value={f.onDate} onChange={set('onDate')} /></FormField>
<FormField label="On"><input type="date" className="wf" value={f.onDate} onChange={set('onDate')} disabled={editing} /></FormField>
<FormField label="Outcome">
<select className="wf" value={f.outcome} onChange={set('outcome')}>
<option value=""></option>
@ -375,7 +382,7 @@ export function PaymentForm(props: { clientId: string; onDone: () => void }) {
</Field>
<Field label="Reference"><input className="wf" value={f.reference} onChange={set('reference')} /></Field>
<Field label="Received on"><input type="date" className="wf" value={f.receivedOn} onChange={set('receivedOn')} /></Field>
<Button tone="primary" onClick={save}>{saving ? 'Recording…' : 'Record payment'}</Button>
<Button tone="primary" disabled={saving} onClick={save}>{saving ? 'Recording…' : 'Record payment'}</Button>
</div>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>

@ -1154,10 +1154,14 @@ export function apiRouter(
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) throw new Error(`${field} must be a positive integer`)
toSet.push([key, String(v)])
}
// Second pass: write all validated settings
// Second pass: write all validated settings — one transaction, so a failure
// mid-loop cannot persist overdueDays without renewalDays (and each setting
// lands with its audit row atomically).
db.transaction(() => {
for (const [key, value] of toSet) {
setSetting(db, staffId(res), key, value)
}
})()
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })

@ -234,7 +234,9 @@ export const SCHEDULE_DEFAULTS: Record<
invoice_overdue: { dayOffsets: [7, 15, 30], subject: null, body: null },
}
/** Parse a day_offsets CSV ('3,7,14') → ascending unique positive integers. */
/** Parse a day_offsets CSV ('3,7,14') ascending unique positive integers.
* Lenient invalid tokens are dropped. Only for READING stored rows, where a
* bad token must not silence the whole cadence (resolveSchedule falls back). */
export function parseDayOffsets(csv: string): number[] {
const days = csv.split(',')
.map((s) => Number(s.trim()))
@ -242,6 +244,19 @@ export function parseDayOffsets(csv: string): number[] {
return [...new Set(days)].sort((a, b) => a - b)
}
/** Strict variant for NEW input (insertSchedule): any token that is not a positive
* integer rejects the whole CSV '3,7,I4' must error, never silently save as
* '3,7' and drop a follow-up from the cadence. Spaces and dupes still normalize. */
export function parseDayOffsetsStrict(csv: string): number[] {
if (csv.trim() === '') throw new Error('dayOffsets must contain at least one positive integer')
const days = csv.split(',').map((s) => {
const n = Number(s.trim())
if (!Number.isInteger(n) || n <= 0) throw new Error(`Invalid day offset '${s.trim()}' — use positive integers like 3,7,14`)
return n
})
return [...new Set(days)].sort((a, b) => a - b)
}
/** Resolve the schedule active on `today` (YYYY-MM-DD): the reminder_schedule row
* where effective_from <= today AND (effective_to IS NULL OR effective_to > today),
* latest effective_from winning. No usable row the code-constant default. A row
@ -292,8 +307,7 @@ export function insertSchedule(db: DB, userId: string, input: {
}): ScheduleRow {
if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`)
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD')
const offsets = parseDayOffsets(input.dayOffsets)
if (offsets.length === 0) throw new Error('dayOffsets must contain at least one positive integer')
const offsets = parseDayOffsetsStrict(input.dayOffsets)
const id = uuidv7()
const csv = offsets.join(',')
db.transaction(() => {

@ -29,6 +29,10 @@ describe('schedule settings', () => {
expect(() => insertSchedule(db, 'u1', { ruleKind: 'nope', effectiveFrom: '2026-08-01', dayOffsets: '3' })).toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '01-08-2026', dayOffsets: '3' })).toThrow()
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: 'x,-2' })).toThrow()
// Strict CSV: partial garbage must reject the WHOLE input, never save '3,7'
// with the typo'd 14-day follow-up silently dropped from the cadence.
expect(() => insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: '3,7,I4' })).toThrow(/Invalid day offset/)
expect(listSchedules(db)).toHaveLength(2) // nothing appended by any rejected attempt
})
it('audits the insert', () => {
const db = fresh()
@ -68,4 +72,19 @@ describe('settings reminders routes', () => {
const after = await call(token, 'GET', '/settings/reminders')
expect(after.json.overdueDays).toBe(initialOverdue)
})
it('day settings round-trip: a valid PUT persists and GET reflects it', async () => {
const token = await login('owner@test.in', 'owner-password')
const put = await call(token, 'PUT', '/settings/reminders', { overdueDays: 9, renewalDays: 21 })
expect(put.status).toBe(200)
expect(put.json.ok).toBe(true)
const after = await call(token, 'GET', '/settings/reminders')
expect(after.json.overdueDays).toBe(9)
expect(after.json.renewalDays).toBe(21)
// Both writes were audited (same-transaction rule)
const audits = db.prepare(
`SELECT entity_id FROM audit_log WHERE entity='setting' AND entity_id IN ('reminders.overdue_days','reminders.renewal_days')`,
).all() as { entity_id: string }[]
expect(new Set(audits.map((a) => a.entity_id)).size).toBe(2)
})
})

@ -1,4 +1,7 @@
import type { ReactNode } from 'react'
import {
Children, cloneElement, Fragment, isValidElement, useId,
type MouseEvent, type ReactElement, type ReactNode,
} from 'react'
/** Wireframe kit: structure-first components shared by POS and back office. */
@ -29,14 +32,21 @@ export function Toolbar(props: { children: ReactNode }) {
export function Button(props: {
children: ReactNode
onClick?: () => void
onClick?: (e: MouseEvent<HTMLButtonElement>) => void
tone?: 'default' | 'primary' | 'danger'
hotkey?: string
pill?: boolean
disabled?: boolean
}) {
const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}${props.pill === true ? ' pill' : ''}`
return (
<button type="button" className={cls} onClick={props.onClick}>
<button
type="button"
className={cls}
onClick={props.onClick}
disabled={props.disabled === true}
aria-disabled={props.disabled === true ? true : undefined}
>
{props.children}
{props.hotkey !== undefined && <kbd>{props.hotkey}</kbd>}
</button>
@ -113,14 +123,29 @@ export function EmptyState(props: { children: ReactNode }) {
}
export function Field(props: { label: string; children: ReactNode }) {
const generated = useId()
// Programmatic label association (WCAG 1.3.1/4.1.2): when the child is a single
// element, clone it to inject a generated id (respecting an existing one) and
// point the label at it. Fragments/arrays fall back to the bare-label wrapper.
const child = isValidElement(props.children) && props.children.type !== Fragment
? Children.only(props.children) as ReactElement<{ id?: string }>
: null
const controlId = child !== null ? child.props.id ?? generated : undefined
return (
<div className="wf-field">
<label>{props.label}</label>
{props.children}
<label htmlFor={controlId}>{props.label}</label>
{child !== null ? cloneElement(child, { id: controlId }) : props.children}
</div>
)
}
export function Notice(props: { tone?: 'ok' | 'warn' | 'err'; children: ReactNode }) {
return <div className={`wf-notice${props.tone !== undefined ? ` ${props.tone}` : ''}`}>{props.children}</div>
return (
<div
role={props.tone === 'err' ? 'alert' : 'status'}
className={`wf-notice${props.tone !== undefined ? ` ${props.tone}` : ''}`}
>
{props.children}
</div>
)
}

@ -3,6 +3,11 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'
const WIDTH: Record<'sm' | 'md' | 'lg', number> = { sm: 420, md: 560, lg: 720 }
const FOCUSABLE = 'input, select, textarea, button, a[href], [tabindex]:not([tabindex="-1"])'
// Open-dialog layer stack: every open Dialog pushes a symbol, pops on close/unmount.
// Only the top-of-stack dialog reacts to Escape/Tab, so a confirm raised over a form
// dialog closes one layer per Escape instead of both at once.
const dialogStack: symbol[] = []
/** Pinterest-chrome modal: centered rounded card, scrim, focus trap + restore. */
export function Dialog(props: {
open: boolean
@ -15,6 +20,10 @@ export function Dialog(props: {
const cardRef = useRef<HTMLDivElement>(null)
const onCloseRef = useRef(props.onClose)
onCloseRef.current = props.onClose
// True only while a pointer press that STARTED on the scrim is in flight — a
// text-selection drag that starts inside the card and releases over the scrim
// retargets the click at the scrim, and must not close (and discard) the form.
const scrimPress = useRef(false)
useEffect(() => {
if (!props.open) return
@ -32,7 +41,15 @@ export function Dialog(props: {
// (e.g. after a click on a disabled control lands focus on <body>).
useEffect(() => {
if (!props.open) return
const layer = Symbol('dialog-layer')
dialogStack.push(layer)
const onKey = (e: KeyboardEvent) => {
// Layer-aware: yield to overlays above us. The Ctrl+K palette handles its
// keys via React's synthetic onKeyDown, which is dispatched at the React
// root container — an ANCESTOR of body/document in the bubble path — so its
// preventDefault always lands before this document-level listener runs.
if (e.defaultPrevented) return
if (dialogStack[dialogStack.length - 1] !== layer) return
if (e.key === 'Escape') { e.preventDefault(); onCloseRef.current(); return }
if (e.key !== 'Tab') return
const nodes = cardRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE)
@ -46,13 +63,38 @@ export function Dialog(props: {
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus() }
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
// Scroll lock: the page behind the scrim must not wheel-scroll. body alone is
// not enough — the HQ shell scrolls `.hq-content`, so lock every scrollable
// ancestor of the card too; each lock restores its previous inline value.
const locked: { el: HTMLElement; prev: string }[] = []
const lock = (el: HTMLElement) => { locked.push({ el, prev: el.style.overflow }); el.style.overflow = 'hidden' }
lock(document.body)
for (let el = cardRef.current?.parentElement ?? null; el !== null; el = el.parentElement) {
const o = getComputedStyle(el).overflowY
if (o === 'auto' || o === 'scroll') lock(el)
}
return () => {
document.removeEventListener('keydown', onKey)
const i = dialogStack.indexOf(layer)
if (i !== -1) dialogStack.splice(i, 1)
for (const { el, prev } of locked) el.style.overflow = prev
}
}, [props.open])
if (!props.open) return null
return (
<div className="wf-dialog-back" onClick={props.onClose}>
<div
className="wf-dialog-back"
onPointerDown={(e) => { scrimPress.current = e.target === e.currentTarget }}
onPointerUp={(e) => { scrimPress.current = scrimPress.current && e.target === e.currentTarget }}
onClick={(e) => {
// Close only when the press started AND ended on the scrim itself.
const pressed = scrimPress.current
scrimPress.current = false
if (pressed && e.target === e.currentTarget) props.onClose()
}}
>
<div
ref={cardRef}
className="wf-dialog"

@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
import { createContext, useCallback, useContext, useMemo, useRef, useState, type ReactNode } from 'react'
import { dismissToast, initialToasts, pushToast, type Toast, type ToastState } from './toast-store'
const AUTO_DISMISS_MS = 4000
@ -9,15 +9,35 @@ const Ctx = createContext<ToastApi | null>(null)
/** Mount once at app root; renders the bottom-right stack. */
export function ToastProvider(props: { children: ReactNode }) {
const [state, setState] = useState<ToastState>(initialToasts)
// Auto-dismiss timers keyed by toast id, so hover/focus can pause one toast
// without touching its neighbours (WCAG 2.2.1 — timing adjustable).
const timers = useRef(new Map<number, number>())
const arm = useCallback((id: number) => {
window.clearTimeout(timers.current.get(id))
timers.current.set(id, window.setTimeout(() => {
timers.current.delete(id)
setState((s) => dismissToast(s, id))
}, AUTO_DISMISS_MS))
}, [])
const pause = useCallback((id: number) => {
window.clearTimeout(timers.current.get(id))
timers.current.delete(id)
}, [])
const dismiss = useCallback((id: number) => {
pause(id)
setState((s) => dismissToast(s, id))
}, [pause])
const push = useCallback((tone: Toast['tone'], message: string) => {
setState((s) => {
const next = pushToast(s, tone, message)
const id = next.toasts[next.toasts.length - 1]!.id
window.setTimeout(() => setState((s2) => dismissToast(s2, id)), AUTO_DISMISS_MS)
arm(next.toasts[next.toasts.length - 1]!.id)
return next
})
}, [])
}, [arm])
const api = useMemo<ToastApi>(() => ({
ok: (msg) => push('ok', msg),
@ -29,9 +49,16 @@ export function ToastProvider(props: { children: ReactNode }) {
{props.children}
<div className="wf-toasts" aria-live="polite">
{state.toasts.map((t) => (
<div key={t.id} className={`wf-toast ${t.tone}`}>
<div
key={t.id}
className={`wf-toast ${t.tone}`}
onMouseEnter={() => pause(t.id)}
onMouseLeave={() => arm(t.id)}
onFocus={() => pause(t.id)}
onBlur={() => arm(t.id)}
>
<span className="msg">{t.message}</span>
<button type="button" aria-label="Dismiss" onClick={() => setState((s) => dismissToast(s, t.id))}></button>
<button type="button" aria-label="Dismiss" onClick={() => dismiss(t.id)}></button>
</div>
))}
</div>

Loading…
Cancel
Save