# Ideal-Web-App Pass Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Deliver the approved spec `docs/superpowers/specs/2026-07-17-ideal-web-app-design.md` — Pinterest-chrome dialogs, toasts, edit-everywhere, a real send-email modal, a `/settings` hub, favicon + titles. **Architecture:** Generic chrome kit (`Dialog`, `ConfirmDialog`, `Toaster`, `FormGrid`) in `packages/ui`; two small additive backend slices (`/settings/reminders|schedules|sharing`); page refits in `apps/hq-web`. No schema changes (settings ride the `setting` table; cadence changes are new dated `reminder_schedule` rows). **Tech Stack:** React 19, react-router-dom 7 (HashRouter), Vite 6, vitest 3 (Node env, no DOM — component behavior verified e2e), TypeScript strict + `noUncheckedIndexedAccess`, better-sqlite3 backend. ## Global Constraints - Code style: no semicolons, single quotes, 2-space indent, `x !== undefined` guards. - `@sims/ui` stays generic: no router, no lucide, no HQ types. - Money renders via `formatINR` only; never compute money client-side. - Backend: every mutation audits via `writeAudit` in the same transaction; dated config rows are append-only (a cadence change is a NEW `reminder_schedule` row — never UPDATE/DELETE); portable SQL only. - Repo gates after EVERY task: `npm run typecheck` exits 0 and `npm test` has zero failures (suite currently 293 green; new tests only add). - Several page files were touched by two workstreams this week — implementers must READ the current file and anchor edits on code text, not line numbers. - `git add` only your task's files by explicit path. Commit after every task. All commit messages end with: `Co-Authored-By: Claude Opus 4.8 (1M context) ` - z-order contract: drawer 40 < dialog 50 < palette 60 < toasts 70. **Verify commands** (repo root `C:\SiMS\hq`): `npm run typecheck` · `npm test` · focused: `npm test -- `. --- ### Task 1: Dialog + ConfirmDialog (+ pill buttons, Modal removed) **Files:** - Create: `packages/ui/src/dialog.tsx` - Modify: `packages/ui/src/tokens.css` (append CSS + `button.wf.pill` rule) - Modify: `packages/ui/src/components.tsx` (Button `pill` prop; delete `Modal`) - Modify: `packages/ui/src/index.ts` **Interfaces:** - Consumes: tokens (`--radius-lg`, `--shadow-lg`, `--bg-raised`, `--border`). - Produces: `Dialog(props: { open: boolean; onClose: () => void; title: string; size?: 'sm' | 'md' | 'lg'; footer?: ReactNode; children: ReactNode })` and `ConfirmDialog(props: { open: boolean; onClose: () => void; onConfirm: () => void | Promise; title: string; body?: ReactNode; confirmLabel?: string; tone?: 'danger' })`. `Button` gains `pill?: boolean`. - [ ] **Step 1: Confirm Modal is dead code** Run: `grep -rn "Modal" apps/hq-web/src --include=*.tsx` — expect zero component usages (only the word in comments, if any). If a call site exists, STOP and report BLOCKED. - [ ] **Step 2: Create `packages/ui/src/dialog.tsx`** ```tsx 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"])' /** Pinterest-chrome modal: centered rounded card, scrim, focus trap + restore. */ export function Dialog(props: { open: boolean onClose: () => void title: string size?: 'sm' | 'md' | 'lg' footer?: ReactNode children: ReactNode }) { const prevFocus = useRef(null) const cardRef = useRef(null) useEffect(() => { if (props.open) { prevFocus.current = document.activeElement as HTMLElement | null const first = cardRef.current?.querySelector( 'input, select, textarea, button:not(.wf-dialog-x)', ) ;(first ?? cardRef.current ?? undefined)?.focus() } }, [props.open]) useEffect(() => { if (!props.open) prevFocus.current?.focus() }, [props.open]) if (!props.open) return null const onKey = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); props.onClose(); return } if (e.key !== 'Tab') return const nodes = cardRef.current?.querySelectorAll(FOCUSABLE) const list = nodes === undefined ? [] : [...nodes].filter((n) => !n.hasAttribute('disabled')) const first = list[0] const last = list[list.length - 1] if (first === undefined || last === undefined) { e.preventDefault(); return } if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() } } return (
e.stopPropagation()} onKeyDown={onKey} >

{props.title}

{props.children}
{props.footer !== undefined &&
{props.footer}
}
) } /** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */ export function ConfirmDialog(props: { open: boolean onClose: () => void onConfirm: () => void | Promise title: string body?: ReactNode confirmLabel?: string tone?: 'danger' }) { const [busy, setBusy] = useState(false) const confirm = () => { const out = props.onConfirm() if (out instanceof Promise) { setBusy(true) out.finally(() => { setBusy(false); props.onClose() }) } else { props.onClose() } } return ( } > {props.body !== undefined &&
{props.body}
}
) } ``` - [ ] **Step 3: CSS — append to `packages/ui/src/tokens.css`** ```css /* ================= dialogs (Pinterest chrome) ================= */ .wf-dialog-back { position: fixed; inset: 0; background: rgba(15, 14, 11, 0.45); backdrop-filter: blur(4px); z-index: 50; display: flex; align-items: center; justify-content: center; padding: 20px; } .wf-dialog { background: var(--bg-raised); border: 1px solid var(--border); border-radius: 16px; box-shadow: var(--shadow-lg); width: 100%; max-height: 86vh; display: flex; flex-direction: column; } .wf-dialog-head { display: flex; align-items: center; gap: 10px; padding: 18px 20px 0; } .wf-dialog-head h2 { margin: 0; font-size: 17px; flex: 1; } .wf-dialog-x { border: none; background: var(--bg-inset); color: var(--text-dim); width: 30px; height: 30px; border-radius: 50%; cursor: pointer; font-size: 13px; } .wf-dialog-x:hover { background: var(--bg-hover); color: var(--text); } .wf-dialog-body { padding: 14px 20px; overflow-y: auto; } .wf-dialog-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 20px 18px; } button.wf.pill { border-radius: 999px; padding: 8px 18px; } ``` Also delete the now-unused `.wf-modal-back` / `.wf-modal` rules. - [ ] **Step 4: components.tsx — pill prop, Modal deleted** In `Button`, extend props with `pill?: boolean` and the class computation to append `' pill'` when set: ```tsx const cls = `wf${props.tone === 'primary' ? ' primary' : props.tone === 'danger' ? ' danger' : ''}${props.pill === true ? ' pill' : ''}` ``` Delete the entire `Modal` function. Export `Dialog`/`ConfirmDialog` via `packages/ui/src/index.ts`: ```ts export * from './dialog' ``` - [ ] **Step 5: Verify** — `npm run typecheck` exits 0 (proves Modal truly had no call sites); `npm test` zero failures. - [ ] **Step 6: Commit** ```bash git add packages/ui/src/dialog.tsx packages/ui/src/tokens.css packages/ui/src/components.tsx packages/ui/src/index.ts git commit -m "feat(ui): Dialog + ConfirmDialog (Pinterest chrome), pill buttons; Modal removed" ``` --- ### Task 2: Toaster (TDD) + FormGrid **Files:** - Create: `packages/ui/src/toast-store.ts`, `packages/ui/src/toast.tsx`, `packages/ui/src/formgrid.tsx` - Test: `packages/ui/test/toast-store.test.ts` - Modify: `packages/ui/src/tokens.css` (toast + formgrid CSS), `packages/ui/src/index.ts`, `apps/hq-web/src/main.tsx` (wrap `ToastProvider`) **Interfaces:** - Produces: `ToastProvider({ children })`, `useToast(): { ok: (msg: string) => void; err: (msg: string) => void }`; pure store: `Toast { id: number; tone: 'ok' | 'err'; message: string }`, `ToastState { nextId: number; toasts: Toast[] }`, `initialToasts`, `TOAST_LIMIT = 3`, `pushToast(s, tone, message): ToastState`, `dismissToast(s, id): ToastState`. `FormGrid({ children })`, `FormField({ label, wide?, children })`. - [ ] **Step 1: Write the failing test** — `packages/ui/test/toast-store.test.ts`: ```ts import { describe, expect, it } from 'vitest' import { dismissToast, initialToasts, pushToast, TOAST_LIMIT } from '../src/toast-store' describe('toast store', () => { it('pushes with increasing ids', () => { let s = pushToast(initialToasts, 'ok', 'saved') s = pushToast(s, 'err', 'failed') expect(s.toasts.map((t) => t.id)).toEqual([1, 2]) expect(s.toasts[1]).toMatchObject({ tone: 'err', message: 'failed' }) }) it('keeps only the newest TOAST_LIMIT toasts', () => { let s = initialToasts for (let i = 0; i < TOAST_LIMIT + 2; i++) s = pushToast(s, 'ok', `m${i}`) expect(s.toasts).toHaveLength(TOAST_LIMIT) expect(s.toasts[0]!.message).toBe('m2') }) it('dismisses by id and ignores unknown ids', () => { let s = pushToast(initialToasts, 'ok', 'a') s = pushToast(s, 'ok', 'b') s = dismissToast(s, 1) expect(s.toasts.map((t) => t.message)).toEqual(['b']) expect(dismissToast(s, 99).toasts).toHaveLength(1) }) }) ``` - [ ] **Step 2: Run — expect FAIL** — `npm test -- toast-store` → cannot find module. - [ ] **Step 3: Implement `packages/ui/src/toast-store.ts`** ```ts /** Pure toast state — testable without a DOM. */ export interface Toast { id: number; tone: 'ok' | 'err'; message: string } export interface ToastState { nextId: number; toasts: Toast[] } export const TOAST_LIMIT = 3 export const initialToasts: ToastState = { nextId: 1, toasts: [] } export function pushToast(s: ToastState, tone: Toast['tone'], message: string): ToastState { const toast: Toast = { id: s.nextId, tone, message } const toasts = [...s.toasts, toast].slice(-TOAST_LIMIT) return { nextId: s.nextId + 1, toasts } } export function dismissToast(s: ToastState, id: number): ToastState { return { ...s, toasts: s.toasts.filter((t) => t.id !== id) } } ``` - [ ] **Step 4: Run — expect PASS** — `npm test -- toast-store` → 3 pass. - [ ] **Step 5: Create `packages/ui/src/toast.tsx`** ```tsx 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 interface ToastApi { ok: (msg: string) => void; err: (msg: string) => void } const Ctx = createContext(null) /** Mount once at app root; renders the bottom-right stack. */ export function ToastProvider(props: { children: ReactNode }) { const [state, setState] = useState(initialToasts) const stateRef = useRef(state) stateRef.current = state const push = useCallback((tone: Toast['tone'], message: string) => { const id = stateRef.current.nextId setState((s) => pushToast(s, tone, message)) window.setTimeout(() => setState((s) => dismissToast(s, id)), AUTO_DISMISS_MS) }, []) const api = useMemo(() => ({ ok: (msg) => push('ok', msg), err: (msg) => push('err', msg), }), [push]) return ( {props.children}
{state.toasts.map((t) => (
{t.message}
))}
) } export function useToast(): ToastApi { const api = useContext(Ctx) if (api === null) throw new Error('useToast requires at the app root') return api } ``` - [ ] **Step 6: Create `packages/ui/src/formgrid.tsx`** ```tsx import type { ReactNode } from 'react' import { Field } from './components' /** Two-column field grid for dialog bodies; single column under 560px. */ export function FormGrid(props: { children: ReactNode }) { return
{props.children}
} export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) { return (
{props.children}
) } ``` - [ ] **Step 7: CSS — append to tokens.css** ```css /* ================= toasts ================= */ .wf-toasts { position: fixed; right: 16px; bottom: 16px; z-index: 70; display: flex; flex-direction: column; gap: 8px; max-width: 360px; } .wf-toast { display: flex; align-items: center; gap: 10px; background: var(--bg-raised); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 10px 14px; font-size: 13px; } .wf-toast .msg { flex: 1; } .wf-toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); } .wf-toast.err { border-color: color-mix(in srgb, var(--err) 45%, transparent); background: var(--err-soft); } .wf-toast button { border: none; background: none; color: var(--text-dim); cursor: pointer; font-size: 11px; padding: 2px; } /* ================= form grid (dialog bodies) ================= */ .wf-formgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 12px; } .wf-formgrid .wide { grid-column: 1 / -1; } @media (max-width: 560px) { .wf-formgrid { grid-template-columns: 1fr; } } ``` - [ ] **Step 8: Export + mount** — `index.ts` gains `export * from './toast'` and `export * from './formgrid'` (NOT toast-store — internal). In `apps/hq-web/src/main.tsx` import `ToastProvider` from `@sims/ui` and wrap: `` inside `App`. - [ ] **Step 9: Verify** — typecheck 0; `npm test` zero failures (3 new). - [ ] **Step 10: Commit** ```bash git add packages/ui/src/toast-store.ts packages/ui/src/toast.tsx packages/ui/src/formgrid.tsx packages/ui/test/toast-store.test.ts packages/ui/src/tokens.css packages/ui/src/index.ts apps/hq-web/src/main.tsx git commit -m "feat(ui): Toaster with pure store (TDD) + FormGrid; provider mounted at root" ``` --- ### Task 3: Backend — reminder settings + schedule endpoints (TDD) **Files:** - Modify: `apps/hq/src/repos-reminders.ts` (export `parseDayOffsets`; add `listSchedules`, `insertSchedule`) - Modify: `apps/hq/src/api.ts` (three routes beside the existing `/settings/company` block) - Test: `apps/hq/test/settings-reminders.test.ts` **Interfaces:** - Consumes: existing `SCHEDULE_DEFAULTS`, `resolveSchedule`, `setSetting`, `writeAudit`, `requireOwner`; seeded setting keys `reminders.overdue_days` (default '7') / `reminders.renewal_days` (default '15'). - Produces: `ScheduleRow { id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null; dayOffsets: string; subject: string | null; body: string | null }`; `listSchedules(db): ScheduleRow[]`; `insertSchedule(db, userId, input: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): ScheduleRow`. Routes: `GET /settings/reminders` → `{ ok, overdueDays: number, renewalDays: number, schedules: ScheduleRow[], total: number }` (owner); `PUT /settings/reminders` body `{ overdueDays?, renewalDays? }` (ints ≥ 1, owner, audited via `setSetting`); `POST /settings/schedules` → `{ ok, schedule }` (owner, audited, append-only). - [ ] **Step 1: Write the failing test** — `apps/hq/test/settings-reminders.test.ts`. Mirror the setup style of `apps/hq/test/reminder-schedule.test.ts` (in-memory `openDb(':memory:')` + `seedIfEmpty`). Test cases: ```ts import { describe, expect, it } from 'vitest' import { openDb } from '../src/db' import { seedIfEmpty } from '../src/seed' import { insertSchedule, listSchedules, resolveSchedule } from '../src/repos-reminders' const fresh = () => { const db = openDb(':memory:'); seedIfEmpty(db); return db } describe('schedule settings', () => { it('lists the two seeded open-ended rows', () => { const rows = listSchedules(fresh()) expect(rows.map((r) => r.ruleKind).sort()).toEqual(['invoice_overdue', 'quote_followup']) }) it('insertSchedule appends a dated row that resolveSchedule picks from its date', () => { const db = fresh() const row = insertSchedule(db, 'u1', { ruleKind: 'quote_followup', effectiveFrom: '2026-08-01', dayOffsets: ' 2, 5,5, 9 ', }) expect(row.dayOffsets).toBe('2,5,9') // normalized: trimmed, deduped, sorted expect(resolveSchedule(db, 'quote_followup', '2026-08-02').dayOffsets).toEqual([2, 5, 9]) expect(resolveSchedule(db, 'quote_followup', '2026-07-20').dayOffsets).toEqual([3, 7, 14]) expect(listSchedules(db)).toHaveLength(3) // append-only: old row still there }) it('rejects bad input', () => { const db = fresh() 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() }) it('audits the insert', () => { const db = fresh() const row = insertSchedule(db, 'u1', { ruleKind: 'invoice_overdue', effectiveFrom: '2026-09-01', dayOffsets: '10,20' }) const audit = db.prepare(`SELECT * FROM audit_log WHERE entity='reminder_schedule' AND entity_id=?`).get(row.id) expect(audit).toBeDefined() }) }) ``` Adjust the audit-table column names to whatever `apps/hq/test/` siblings assert against (read one first — e.g. `clients.test.ts`); `resolveSchedule`'s exact signature likewise (read `repos-reminders.ts:246+`). Keep assertions semantically identical. - [ ] **Step 2: Run — expect FAIL** — `npm test -- settings-reminders` → `insertSchedule`/`listSchedules` not exported. - [ ] **Step 3: Implement in `repos-reminders.ts`** Export the existing private `parseDayOffsets`. Then append: ```ts export interface ScheduleRow { id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null dayOffsets: string; subject: string | null; body: string | null } const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS) /** All dated schedule rows, newest first within each rule kind (bounded set). */ export function listSchedules(db: DB): ScheduleRow[] { const rows = db.prepare( `SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`, ).all() as { id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }[] return rows.map((r) => ({ id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to, dayOffsets: r.day_offsets, subject: r.subject, body: r.body, })) } /** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */ export function insertSchedule(db: DB, userId: string, input: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): 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 id = uuidv7() const csv = offsets.join(',') db.transaction(() => { db.prepare( `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) VALUES (?, ?, ?, NULL, ?, ?, ?)`, ).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null) writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, { ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv, }) })() return listSchedules(db).find((r) => r.id === id)! } ``` (Match the file's existing imports — `uuidv7`, `writeAudit`, `DB` are already imported there.) - [ ] **Step 4: Run — expect PASS** — `npm test -- settings-reminders`. - [ ] **Step 5: Routes in `apps/hq/src/api.ts`** — add beside the `/settings/company` block. Read how the scan reads `reminders.overdue_days` (grep it) and reuse that helper for GET: ```ts // ---------- reminder cadences (owner; dated rows are append-only — rule 3) ---------- r.get('/settings/reminders', requireAuth, requireOwner, (_req, res) => { const schedules = listSchedules(db) res.json({ ok: true, overdueDays: readIntSetting('reminders.overdue_days', 7), renewalDays: readIntSetting('reminders.renewal_days', 15), schedules, total: schedules.length, }) }) r.put('/settings/reminders', requireAuth, requireOwner, (req, res) => { const body = req.body as { overdueDays?: unknown; renewalDays?: unknown } try { for (const [field, key] of [['overdueDays', 'reminders.overdue_days'], ['renewalDays', 'reminders.renewal_days']] as const) { const v = body[field] if (v === undefined) continue if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) throw new Error(`${field} must be a positive integer`) setSetting(db, staffId(res), key, String(v)) } res.json({ ok: true }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) r.post('/settings/schedules', requireAuth, requireOwner, (req, res) => { const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string } try { const schedule = insertSchedule(db, staffId(res), { ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '', ...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}), ...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}), }) res.json({ ok: true, schedule }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) ``` `readIntSetting` — write it local to api.ts if no equivalent exists (grep first): reads `setting` value, `Number()` it, fallback on NaN. `staffId(res)` — use whatever the neighboring routes use to get the acting user id. - [ ] **Step 6: Verify** — typecheck 0; full `npm test` zero failures. - [ ] **Step 7: Commit** ```bash git add apps/hq/src/repos-reminders.ts apps/hq/src/api.ts apps/hq/test/settings-reminders.test.ts git commit -m "feat(settings): reminder day-settings + append-only dated schedule endpoints (owner, audited)" ``` --- ### Task 4: Backend — sharing default (TDD) **Files:** - Modify: `apps/hq/src/repos-shares.ts` (`mintShare` default from setting) - Modify: `apps/hq/src/api.ts` (GET/PUT `/settings/sharing`) - Test: `apps/hq/test/settings-sharing.test.ts` **Interfaces:** - Produces: setting row `share.default_expiry_days` (`'never'` or a positive-int string; absent → 30). `GET /settings/sharing` → `{ ok, defaultExpiryDays: number | null }` (null = never; owner). `PUT /settings/sharing` body `{ defaultExpiryDays: number | null }` (owner, audited via `setSetting`). `mintShare` unchanged signature; explicit `opts.expiresDays` always wins. - [ ] **Step 1: Failing test** — `apps/hq/test/settings-sharing.test.ts` (mirror `shares.test.ts` setup — it creates a client + document to mint against; copy that arrange block): ```ts // cases (exact arrange code copied from shares.test.ts): // 1. no setting row → mintShare default expiry ≈ now+30d (assert expiresAt date prefix) // 2. setting '7' → expiry ≈ now+7d // 3. setting 'never' → expiresAt null // 4. explicit opts.expiresDays: 3 wins over setting '7'; explicit null wins too // 5. setting garbage 'abc' → falls back to 30 ``` - [ ] **Step 2: FAIL** — `npm test -- settings-sharing`. - [ ] **Step 3: Implement** — in `repos-shares.ts`, above `mintShare`: ```ts /** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */ function defaultExpiryDays(db: DB): number | null { const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) .get() as { value: string } | undefined if (row === undefined) return 30 if (row.value === 'never') return null const n = Number(row.value) return Number.isInteger(n) && n > 0 ? n : 30 } ``` and in `mintShare` change the default line to: ```ts const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays ``` Routes in api.ts (beside the other settings routes): ```ts // ---------- sharing defaults (owner) ---------- r.get('/settings/sharing', requireAuth, requireOwner, (_req, res) => { const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`) .get() as { value: string } | undefined const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value) res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days }) }) r.put('/settings/sharing', requireAuth, requireOwner, (req, res) => { const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) { res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' }) return } setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v)) res.json({ ok: true, defaultExpiryDays: v }) }) ``` - [ ] **Step 4: PASS** — focused, then full suite + typecheck. - [ ] **Step 5: Commit** ```bash git add apps/hq/src/repos-shares.ts apps/hq/src/api.ts apps/hq/test/settings-sharing.test.ts git commit -m "feat(settings): configurable default share-link expiry (owner, audited)" ``` --- ### Task 5: Send-email modal + document ConfirmDialogs **Files:** - Create: `apps/hq-web/src/components/SendDialog.tsx` - Modify: `apps/hq-web/src/pages/DocumentView.tsx` **Interfaces:** - Consumes: `Dialog`, `ConfirmDialog`, `useToast`, `Field` from `@sims/ui`; `getEmailStatus`, `documentAction`, types from `../api`. - Produces: `SendDialog(props: { open: boolean; onClose: () => void; doc: Doc; client: Client | undefined; onSend: (to: string, subject: string) => void })`. **READ THE CURRENT FILE FIRST** — DocumentView was modified by two workstreams (it now also has convert-and-send + supersede confirms). Enumerate every popup with `grep -n "window.prompt\|window.confirm" apps/hq-web/src/pages/DocumentView.tsx` and convert ALL of them. - [ ] **Step 1: Create `SendDialog.tsx`** ```tsx import { useEffect, useMemo, useState } from 'react' import { Button, Dialog, Field, Notice } from '@sims/ui' import { getEmailStatus, type Client, type Doc, type EmailStatus } from '../api' const OTHER = '__other__' /** Real send composer — To from the client's contacts, Subject prefilled, Gmail state inline. */ export function SendDialog(props: { open: boolean onClose: () => void doc: Doc client: Client | undefined onSend: (to: string, subject: string) => void }) { const emails = useMemo( () => (props.client?.contacts ?? []).map((c) => c.email).filter((e): e is string => e !== undefined && e !== ''), [props.client], ) const [pick, setPick] = useState('') const [other, setOther] = useState('') const [subject, setSubject] = useState('') const [gmail, setGmail] = useState() useEffect(() => { if (!props.open) return setPick(emails[0] ?? OTHER) setOther('') setSubject(`${props.doc.docType} ${props.doc.docNo ?? ''}`.trim()) getEmailStatus().then(setGmail).catch(() => setGmail(undefined)) }, [props.open, emails, props.doc]) const to = pick === OTHER ? other.trim() : pick const down = gmail !== undefined && (!gmail.connected || gmail.dead) const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to) && subject.trim() !== '' && !down return ( } > {down && ( Gmail is disconnected — run gmail-connect on the server, then retry. )} {pick === OTHER && ( setOther(e.target.value)} /> )} setSubject(e.target.value)} /> {gmail?.address !== undefined && (

Sends from {gmail.address}.

)}
) } ``` - [ ] **Step 2: Refit DocumentView** 1. Imports: add `ConfirmDialog, useToast` to the `@sims/ui` import; `import { SendDialog } from '../components/SendDialog'`. 2. State: `const toast = useToast()`, `const [sending, setSending] = useState(false)`, and one confirm slot: `const [confirm, setConfirm] = useState<{ title: string; body: string; label: string; tone?: 'danger'; run: () => void } | undefined>()`. 3. Replace the whole `send()` prompt function: the Send button does `setSending(true)`; render ` setSending(false)} doc={doc} client={client.data} onSend={(to, subject) => act('send', { to, subject })} />`. 4. Replace EVERY `window.confirm(...)` site with `setConfirm({ title, body, label, tone, run: () => act(...) })` using each site's existing copy (cancel + supersede get `tone: 'danger'`); render once near the end: ```tsx {confirm !== undefined && ( setConfirm(undefined)} onConfirm={confirm.run} title={confirm.title} body={confirm.body} confirmLabel={confirm.label} {...(confirm.tone !== undefined ? { tone: confirm.tone } : {})} /> )} ``` 5. Toasts in `act()`: on success `toast.ok(...)` with a friendly label per action (`issue` → 'Number issued', `send` → \`Sent to ${to}\` — pass a message argument through act or toast at call sites); on failure replace `setActionErr(friendly(...))` with `toast.err(friendly(...))` and delete the `actionErr` state + Notice. 6. Keep ShareActions untouched except: its revoke button gets a ConfirmDialog too (same slot pattern, inside ShareActions with its own local state) — optional if time-boxed; if skipped, note it in the report. - [ ] **Step 3: Verify** — typecheck; full suite; `grep -c "window\." apps/hq-web/src/pages/DocumentView.tsx` → 0. - [ ] **Step 4: Commit** ```bash git add apps/hq-web/src/components/SendDialog.tsx apps/hq-web/src/pages/DocumentView.tsx git commit -m "feat(hq-web): send-email composer dialog + styled confirms — window.prompt/confirm eliminated" ``` --- ### Task 6: Client edit + New-client dialog **Files:** - Create: `apps/hq-web/src/components/ClientFormDialog.tsx` - Modify: `apps/hq-web/src/pages/ClientDetail.tsx` (Edit button), `apps/hq-web/src/pages/Clients.tsx` (replace inline NewClientForm) **Interfaces:** - Consumes: `Dialog`, `FormGrid`, `FormField`, `Button`, `Notice`, `useToast`; `createClient`, `patchClient`, `type Client`, `type ClientContact` from `../api`. - Produces: `ClientFormDialog(props: { open: boolean; onClose: () => void; initial?: Client; onSaved: (c: Client) => void })` — create mode when `initial` is undefined. - [ ] **Step 1: Create `ClientFormDialog.tsx`** ```tsx import { useEffect, useState } from 'react' import { Button, Dialog, FormField, FormGrid, Notice, useToast } from '@sims/ui' import { createClient, patchClient, type Client, type ClientContact } from '../api' interface ContactDraft { name: string; email: string; phone: string; role: string } const EMPTY_CONTACT: ContactDraft = { name: '', email: '', phone: '', role: '' } /** Create/edit a client — identity, address, notes, and the contacts repeater. */ export function ClientFormDialog(props: { open: boolean onClose: () => void initial?: Client onSaved: (c: Client) => void }) { const toast = useToast() const editing = props.initial !== undefined const [f, setF] = useState({ name: '', gstin: '', stateCode: '32', address: '', notes: '' }) const [contacts, setContacts] = useState([]) const [error, setError] = useState() const [saving, setSaving] = useState(false) useEffect(() => { if (!props.open) return const c = props.initial setError(undefined) setF({ name: c?.name ?? '', gstin: c?.gstin ?? '', stateCode: c?.stateCode ?? '32', address: c?.address ?? '', notes: c?.notes ?? '', }) setContacts((c?.contacts ?? []).map((ct) => ({ name: ct.name, email: ct.email ?? '', phone: ct.phone ?? '', role: ct.role ?? '', }))) }, [props.open, props.initial]) const set = (k: keyof typeof f) => (e: { target: { value: string } }) => setF((p) => ({ ...p, [k]: e.target.value })) const setContact = (i: number, k: keyof ContactDraft) => (e: { target: { value: string } }) => setContacts((prev) => prev.map((c, j) => (j === i ? { ...c, [k]: e.target.value } : c))) const save = () => { if (f.name.trim() === '') { setError('Name is required'); return } if (!/^\d{2}$/.test(f.stateCode.trim())) { setError('State code must be two digits'); return } const cleaned: ClientContact[] = contacts .filter((c) => c.name.trim() !== '' || c.email.trim() !== '' || c.phone.trim() !== '') .map((c) => ({ name: c.name.trim() !== '' ? c.name.trim() : (c.email.trim() !== '' ? c.email.trim() : c.phone.trim()), ...(c.email.trim() !== '' ? { email: c.email.trim() } : {}), ...(c.phone.trim() !== '' ? { phone: c.phone.trim() } : {}), ...(c.role.trim() !== '' ? { role: c.role.trim() } : {}), })) const body = { name: f.name.trim(), stateCode: f.stateCode.trim(), gstin: f.gstin.trim().toUpperCase(), address: f.address, notes: f.notes, contacts: cleaned, } setError(undefined) setSaving(true) const call = editing ? patchClient(props.initial!.id, body) : createClient(body) call .then((c) => { toast.ok(editing ? 'Client saved' : `Client ${c.code} created`); props.onSaved(c); props.onClose() }) .catch((e: Error) => setError(e.message)) .finally(() => setSaving(false)) } return ( } >