53 KiB
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 !== undefinedguards. @sims/uistays generic: no router, no lucide, no HQ types.- Money renders via
formatINRonly; never compute money client-side. - Backend: every mutation audits via
writeAuditin the same transaction; dated config rows are append-only (a cadence change is a NEWreminder_schedulerow — never UPDATE/DELETE); portable SQL only. - Repo gates after EVERY task:
npm run typecheckexits 0 andnpm testhas 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 addonly 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) <noreply@anthropic.com>- 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 -- <name>.
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.pillrule) - Modify:
packages/ui/src/components.tsx(Buttonpillprop; deleteModal) - 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 })andConfirmDialog(props: { open: boolean; onClose: () => void; onConfirm: () => void | Promise<void>; title: string; body?: ReactNode; confirmLabel?: string; tone?: 'danger' }).Buttongainspill?: 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
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<HTMLElement | null>(null)
const cardRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (props.open) {
prevFocus.current = document.activeElement as HTMLElement | null
const first = cardRef.current?.querySelector<HTMLElement>(
'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<HTMLElement>(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 (
<div className="wf-dialog-back" onClick={props.onClose}>
<div
ref={cardRef}
className="wf-dialog"
style={{ maxWidth: WIDTH[props.size ?? 'md'] }}
role="dialog"
aria-modal="true"
aria-label={props.title}
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
onKeyDown={onKey}
>
<div className="wf-dialog-head">
<h2>{props.title}</h2>
<button type="button" className="wf-dialog-x" aria-label="Close" onClick={props.onClose}>✕</button>
</div>
<div className="wf-dialog-body">{props.children}</div>
{props.footer !== undefined && <div className="wf-dialog-foot">{props.footer}</div>}
</div>
</div>
)
}
/** Small confirm variant — kills window.confirm. Busy state while onConfirm resolves. */
export function ConfirmDialog(props: {
open: boolean
onClose: () => void
onConfirm: () => void | Promise<void>
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 (
<Dialog
open={props.open}
onClose={props.onClose}
title={props.title}
size="sm"
footer={
<>
<button type="button" className="wf pill" onClick={props.onClose}>Cancel</button>
<button
type="button"
className={`wf pill ${props.tone === 'danger' ? 'danger' : 'primary'}`}
onClick={confirm}
disabled={busy}
>
{busy ? '…' : props.confirmLabel ?? 'Confirm'}
</button>
</>
}
>
{props.body !== undefined && <div style={{ fontSize: 13.5, color: 'var(--text-dim)' }}>{props.body}</div>}
</Dialog>
)
}
- Step 3: CSS — append to
packages/ui/src/tokens.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:
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:
export * from './dialog'
-
Step 5: Verify —
npm run typecheckexits 0 (proves Modal truly had no call sites);npm testzero failures. -
Step 6: Commit
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(wrapToastProvider)
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:
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
/** 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
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<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)
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<ToastApi>(() => ({
ok: (msg) => push('ok', msg),
err: (msg) => push('err', msg),
}), [push])
return (
<Ctx.Provider value={api}>
{props.children}
<div className="wf-toasts" aria-live="polite">
{state.toasts.map((t) => (
<div key={t.id} className={`wf-toast ${t.tone}`}>
<span className="msg">{t.message}</span>
<button type="button" aria-label="Dismiss" onClick={() => setState((s) => dismissToast(s, t.id))}>✕</button>
</div>
))}
</div>
</Ctx.Provider>
)
}
export function useToast(): ToastApi {
const api = useContext(Ctx)
if (api === null) throw new Error('useToast requires <ToastProvider> at the app root')
return api
}
- Step 6: Create
packages/ui/src/formgrid.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 <div className="wf-formgrid">{props.children}</div>
}
export function FormField(props: { label: string; wide?: boolean; children: ReactNode }) {
return (
<div className={props.wide === true ? 'wide' : undefined}>
<Field label={props.label}>{props.children}</Field>
</div>
)
}
- Step 7: CSS — append to tokens.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.tsgainsexport * from './toast'andexport * from './formgrid'(NOT toast-store — internal). Inapps/hq-web/src/main.tsximportToastProviderfrom@sims/uiand wrap:<ToastProvider><HashRouter>…</HashRouter></ToastProvider>insideApp. -
Step 9: Verify — typecheck 0;
npm testzero failures (3 new). -
Step 10: Commit
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(exportparseDayOffsets; addlistSchedules,insertSchedule) - Modify:
apps/hq/src/api.ts(three routes beside the existing/settings/companyblock) - Test:
apps/hq/test/settings-reminders.test.ts
Interfaces:
-
Consumes: existing
SCHEDULE_DEFAULTS,resolveSchedule,setSetting,writeAudit,requireOwner; seeded setting keysreminders.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/remindersbody{ overdueDays?, renewalDays? }(ints ≥ 1, owner, audited viasetSetting);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 ofapps/hq/test/reminder-schedule.test.ts(in-memoryopenDb(':memory:')+seedIfEmpty). Test cases:
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/listSchedulesnot exported. -
Step 3: Implement in
repos-reminders.ts
Export the existing private parseDayOffsets. Then append:
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/companyblock. Read how the scan readsreminders.overdue_days(grep it) and reuse that helper for GET:
// ---------- 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 testzero failures. -
Step 7: Commit
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(mintSharedefault 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/sharingbody{ defaultExpiryDays: number | null }(owner, audited viasetSetting).mintShareunchanged signature; explicitopts.expiresDaysalways wins. -
Step 1: Failing test —
apps/hq/test/settings-sharing.test.ts(mirrorshares.test.tssetup — it creates a client + document to mint against; copy that arrange block):
// 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, abovemintShare:
/** 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:
const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays
Routes in api.ts (beside the other settings routes):
// ---------- 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
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,Fieldfrom@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
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<EmailStatus | undefined>()
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 (
<Dialog
open={props.open}
onClose={props.onClose}
title="Send by email"
footer={
<>
<Button pill onClick={props.onClose}>Cancel</Button>
<Button pill tone="primary" onClick={() => { if (valid) { props.onSend(to, subject.trim()); props.onClose() } }}>
Send
</Button>
</>
}
>
{down && (
<Notice tone="warn">Gmail is disconnected — run gmail-connect on the server, then retry.</Notice>
)}
<Field label="To">
<select className="wf" value={pick} onChange={(e) => setPick(e.target.value)}>
{emails.map((e) => <option key={e} value={e}>{e}</option>)}
<option value={OTHER}>Other…</option>
</select>
</Field>
{pick === OTHER && (
<Field label="Email address">
<input className="wf" type="email" autoFocus value={other} onChange={(e) => setOther(e.target.value)} />
</Field>
)}
<Field label="Subject">
<input className="wf" value={subject} onChange={(e) => setSubject(e.target.value)} />
</Field>
{gmail?.address !== undefined && (
<p style={{ fontSize: 12, color: 'var(--text-dim)', margin: 0 }}>Sends from {gmail.address}.</p>
)}
</Dialog>
)
}
- Step 2: Refit DocumentView
- Imports: add
ConfirmDialog, useToastto the@sims/uiimport;import { SendDialog } from '../components/SendDialog'. - 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>(). - Replace the whole
send()prompt function: the Send button doessetSending(true); render<SendDialog open={sending} onClose={() => setSending(false)} doc={doc} client={client.data} onSend={(to, subject) => act('send', { to, subject })} />. - Replace EVERY
window.confirm(...)site withsetConfirm({ title, body, label, tone, run: () => act(...) })using each site's existing copy (cancel + supersede gettone: 'danger'); render once near the end:
{confirm !== undefined && (
<ConfirmDialog
open
onClose={() => setConfirm(undefined)}
onConfirm={confirm.run}
title={confirm.title}
body={confirm.body}
confirmLabel={confirm.label}
{...(confirm.tone !== undefined ? { tone: confirm.tone } : {})}
/>
)}
- Toasts in
act(): on successtoast.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 replacesetActionErr(friendly(...))withtoast.err(friendly(...))and delete theactionErrstate + Notice. - 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
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 ClientContactfrom../api. -
Produces:
ClientFormDialog(props: { open: boolean; onClose: () => void; initial?: Client; onSaved: (c: Client) => void })— create mode wheninitialis undefined. -
Step 1: Create
ClientFormDialog.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<ContactDraft[]>([])
const [error, setError] = useState<string | undefined>()
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 (
<Dialog
open={props.open}
onClose={props.onClose}
title={editing ? `Edit ${props.initial!.name}` : 'New client'}
size="lg"
footer={
<>
<Button pill onClick={props.onClose}>Cancel</Button>
<Button pill tone="primary" onClick={save}>{saving ? 'Saving…' : editing ? 'Save changes' : 'Create client'}</Button>
</>
}
>
<FormGrid>
<FormField label="Name" wide><input className="wf" autoFocus value={f.name} onChange={set('name')} /></FormField>
<FormField label="GSTIN (optional)"><input className="wf mono" value={f.gstin} onChange={set('gstin')} /></FormField>
<FormField label="State code"><input className="wf" style={{ width: 80 }} value={f.stateCode} onChange={set('stateCode')} /></FormField>
<FormField label="Address" wide><textarea className="wf" rows={2} value={f.address} onChange={set('address')} /></FormField>
<FormField label="Notes" wide><textarea className="wf" rows={2} value={f.notes} onChange={set('notes')} /></FormField>
</FormGrid>
<h3 style={{ margin: '14px 0 6px', fontSize: 13 }}>Contacts</h3>
{contacts.map((c, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 6 }}>
<input className="wf" placeholder="Name" value={c.name} onChange={setContact(i, 'name')} />
<input className="wf" placeholder="Email" type="email" value={c.email} onChange={setContact(i, 'email')} />
<input className="wf" placeholder="Phone" value={c.phone} onChange={setContact(i, 'phone')} />
<input className="wf" placeholder="Role" style={{ maxWidth: 110 }} value={c.role} onChange={setContact(i, 'role')} />
<Button onClick={() => setContacts((prev) => prev.filter((_x, j) => j !== i))}>✕</Button>
</div>
))}
<Button onClick={() => setContacts((prev) => [...prev, { ...EMPTY_CONTACT }])}>+ Add contact</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</Dialog>
)
}
Note: patchClient body includes gstin: '' when cleared — verify the server accepts empty-string gstin (repos-clients assertGstin only runs when non-null/non-undefined; '' may fail — if it does, send gstin: f.gstin.trim() === '' ? null : f.gstin.trim().toUpperCase() and loosen the api.ts client type accordingly; test against the running dev server or the repo tests).
-
Step 2: ClientDetail — add
const [editing, setEditing] = useState(false), anEditbutton FIRST in the RecordHeader actions cluster, and render<ClientFormDialog open={editing} onClose={() => setEditing(false)} initial={c} onSaved={() => client.reload()} />. -
Step 3: Clients — delete the inline
NewClientFormfunction entirely;creatingstate now controls<ClientFormDialog open={creating} onClose={() => setCreating(false)} onSaved={(c) => { reload(); nav(/clients/${c.id}) }} />. The?new=1effect stays as-is. Remove now-unused imports (Field, possiblyNotice). -
Step 4: Verify — typecheck; suite; manual sanity deferred to Task 10.
-
Step 5: Commit
git add apps/hq-web/src/components/ClientFormDialog.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/Clients.tsx
git commit -m "feat(hq-web): client create/edit dialog with contacts repeater — clients finally editable"
Task 7: Remaining edit/create dialogs (AMC, plans, interactions, employees, modules)
Files:
- Modify:
apps/hq-web/src/pages/client-forms.tsx(forms become dialog-hosted; keep exports) - Modify:
apps/hq-web/src/pages/ClientDetail.tsx(buttons open dialogs; Edit per AMC/plan/interaction row; deactivate confirms) - Modify:
apps/hq-web/src/pages/Employees.tsx,apps/hq-web/src/pages/Modules.tsx
Interfaces:
-
Consumes: kit + existing api calls —
updateAmc,updateRecurringPlan,updateInteraction(all exist, currently unused for full edits),patchEmployee,resetEmployeePassword,createModule,addPrice. -
Produces (client-forms.tsx, replacing the inline variants):
AmcDialog({ open, onClose, clientId, initial?: AmcContract, onDone }),PlanDialog({ open, onClose, clientId, options, initial?: RecurringPlan, onDone }),InteractionDialog({ open, onClose, clientId, types, initial?: Interaction, onDone }). Employees/Modules keep their forms in-file but dialog-hosted.PaymentFormstays an inline card (fast entry — spec §3 excludes it). -
Step 1: client-forms.tsx — convert
NewAmcForm→AmcDialog,NewRecurringForm→PlanDialog,LogInteractionForm→InteractionDialog. Pattern per dialog (same as ClientFormDialog):initialprefills an edit; save calls create or update endpoint;useToast().ok('AMC saved' | 'Plan saved' | 'Interaction saved');FormGridlayout; footer pills. Field sets and validation copy IDENTICAL to today's inline forms (they move, not change). Delete the replaced inline forms; keepPaymentForm,AssignModuleForm,AccountOwnerSelect,ClientModuleStatusSelect,RowDateunchanged. -
Step 2: ClientDetail wiring — per tab:
- AMC: toolbar
New AMCbutton (isOwner) + per-rowEditbutton (isOwner) →AmcDialog;Deactivate→ ConfirmDialog (danger). One dialog-state slot per kind:const [amcDialog, setAmcDialog] = useState<{ initial?: AmcContract } | undefined>(). - Payments & plans: same for
PlanDialog(isOwner). - Interactions:
Log interactionbutton + per-rowEdit→InteractionDialog(all roles). The inline follow-up date input in the table stays (quick edit). - Reuse one
ConfirmDialogslot in the page (same pattern as DocumentView Task 5).
- AMC: toolbar
-
Step 3: Employees.tsx — the three inline forms (
AddEmployeeForm,EditEmployeeForm,ResetPasswordForm) become Dialog-hosted (keep their internals; wrap render inDialogwith pill footer, single open-state slot).Deactivate→ ConfirmDialog (danger, body names the employee). Toasts on all four mutations. -
Step 4: Modules.tsx —
NewModuleForm→ dialog; price-book add-row toolbar →Add pricebutton opening a small dialog (kind/edition/₹/effective-from — same fields). Quote-content editor stays inline (live preview). Toasts on module create / price add / quote-content save. -
Step 5: Verify — typecheck; full suite;
grep -rn "window.confirm" apps/hq-web/src→ 0. -
Step 6: Commit
git add apps/hq-web/src/pages/client-forms.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/Employees.tsx apps/hq-web/src/pages/Modules.tsx
git commit -m "feat(hq-web): AMC/plan/interaction/employee/module dialogs — edit everywhere, confirmed deactivations"
Task 8: Settings hub
Files:
- Modify:
apps/hq-web/src/api.ts(new settings calls) - Create:
apps/hq-web/src/pages/Settings.tsx - Modify:
apps/hq-web/src/pages/DocumentTemplate.tsx(export the editor asTemplatePanel; page shell removed) - Modify:
apps/hq-web/src/main.tsx(routes),apps/hq-web/src/nav.ts(Settings item),apps/hq-web/src/Layout.tsx(palette static items)
Interfaces:
- Consumes: Tasks 3–4 endpoints; existing
getCompanyProfile/putCompanyProfile,getEmailStatus,getAwsRanking;ACCENTS/getAccent/setAccent/getThemeMode/setThemeModefrom@sims/ui. - Produces (api.ts):
export interface ScheduleRow {
id: string; ruleKind: string; effectiveFrom: string; effectiveTo: string | null
dayOffsets: string; subject: string | null; body: string | null
}
export interface ReminderSettings { overdueDays: number; renewalDays: number; schedules: ScheduleRow[]; total: number }
export const getReminderSettings = (): Promise<ReminderSettings> => apiFetch('/settings/reminders')
export const putReminderSettings = (body: { overdueDays?: number; renewalDays?: number }): Promise<void> =>
apiFetch('/settings/reminders', { method: 'PUT', body: JSON.stringify(body) })
export const createSchedule = (body: { ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string }): Promise<ScheduleRow> =>
apiFetch<{ schedule: ScheduleRow }>('/settings/schedules', { method: 'POST', body: JSON.stringify(body) }).then((r) => r.schedule)
export const getSharingSettings = (): Promise<{ defaultExpiryDays: number | null }> => apiFetch('/settings/sharing')
export const putSharingSettings = (defaultExpiryDays: number | null): Promise<void> =>
apiFetch('/settings/sharing', { method: 'PUT', body: JSON.stringify({ defaultExpiryDays }) })
-
Produces (Settings page): route
/settings, section in?s=(company | template | reminders | sharing | integrations | appearance), Pinterest left rail. Owner sees all; staff/manager see only Appearance (rail filtered byrole()), default section = first visible. -
Step 1: api.ts additions (code above, placed near the other settings calls).
-
Step 2: DocumentTemplate.tsx — rename the default page export: extract everything inside the current page (both panes: form column + live preview) into
export function TemplatePanel(); keep a thinexport function DocumentTemplate()that just returns<TemplatePanel />(compat; removed from routes next step). Drop its owner-redirect (role() !== 'owner') — Settings gates it. -
Step 3: Settings.tsx — new page. Structure (full sections, ~250 lines):
// Rail: <aside className="set-rail"> buttons; active from useSearchParams ?s=
// Sections (each a function component in-file):
// CompanyPanel — form over getCompanyProfile/putCompanyProfile (same field list as
// COMPANY_FIELDS in DocumentTemplate.tsx), FormGrid, Save pill, toast.
// TemplatePanel — imported from './DocumentTemplate'.
// RemindersPanel — useData(getReminderSettings); two int inputs + Save (putReminderSettings,
// toast); DataTable of schedules (Rule · From · To · Day offsets(mono) ·
// Subject) + 'New dated row' Button → dialog (ruleKind select
// quote_followup|invoice_overdue, effectiveFrom date, dayOffsets text with
// hint '3,7,14', subject/body optional textareas) → createSchedule → reload
// + toast. Note under the table: 'Rows are never edited — a cadence change
// is a new dated row.'
// SharingPanel — radio: expire after [n] days / never; Save → putSharingSettings, toast.
// IntegrationsPanel — Gmail card (getEmailStatus → ok pill / warn pill + reconnect hint);
// AWS card (getAwsRanking() → 'Last pull month X · ₹total' or EmptyState).
// AppearancePanel — mode toggle + accent swatch row (reuse ThemeSwitcher internals:
// ACCENTS map with labels, setAccent/setThemeMode).
// Owner-only: all but Appearance. Rail hides gated sections for non-owners.
CSS (append to app.css): .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: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 } @media (max-width:900px){ .set-wrap{flex-direction:column} .set-rail{flex-direction:row; flex-wrap:wrap} }.
- Step 4: Routes + nav — main.tsx:
<Route path="/settings" element={<Settings />} />and/settings/template→<Navigate to="/settings?s=template" replace />(import Settings; DocumentTemplate import becomes unused — remove). nav.ts ADMIN: replace the Document Template item with{ to: '/settings', label: 'Settings', icon: SettingsIcon }— importSettings as SettingsIconfrom lucide-react; noownerOnly(all roles reach Appearance). Employees keepsownerOnly. Layout.tsx: palette static items — nothing hardcodes Document Template (they derive from NAV_GROUPS) — verify by grep and adjust if needed. - Step 5: Verify — typecheck; suite.
- Step 6: Commit
git add apps/hq-web/src/api.ts apps/hq-web/src/pages/Settings.tsx apps/hq-web/src/pages/DocumentTemplate.tsx apps/hq-web/src/main.tsx apps/hq-web/src/nav.ts apps/hq-web/src/Layout.tsx apps/hq-web/src/app.css
git commit -m "feat(hq-web): /settings hub — company, template, dated reminder cadences, sharing, integrations, appearance"
Task 9: Favicon, tab titles, toast sweep
Files:
-
Create:
apps/hq-web/public/favicon.svg -
Modify:
apps/hq-web/index.html,apps/hq-web/src/Layout.tsx -
Modify: remaining silent-save sites (grep-driven)
-
Step 1: Favicon —
apps/hq-web/public/favicon.svg:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#115e59"/>
<text x="16" y="21.5" font-family="Segoe UI, Arial, sans-serif" font-size="13" font-weight="700" fill="#ffffff" text-anchor="middle">HQ</text>
</svg>
index.html <head> gains <link rel="icon" type="image/svg+xml" href="/favicon.svg" /> and <meta name="theme-color" content="#115e59" />.
- Step 2: Tab titles — in Layout.tsx add:
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'
}, [location.pathname])
(Record routes /clients/:id, /documents/:id resolve to their section title — acceptable.)
- Step 3: Toast sweep — grep for remaining mutations that succeed silently and add
toast.ok: ClientDetail owner-select change ('Owner updated'), client-status select ('Status updated'), module status/date row edits (skip — too chatty on every keystroke; leave), Modules quote-content save (replace thesavedBadge with toast), DocumentTemplate/Settings saves (done in Task 8), Dashboard reminder Send/Dismiss ('Reminder sent' / 'Dismissed'). Judgment rule: toast once per explicit user action; never on blur-autosave rows. - Step 4: Verify — typecheck; suite; dev-server spot check: favicon in tab, title changes per page.
- Step 5: Commit
git add apps/hq-web/public/favicon.svg apps/hq-web/index.html apps/hq-web/src/Layout.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/Modules.tsx apps/hq-web/src/pages/Dashboard.tsx
git commit -m "feat(hq-web): favicon, per-route tab titles, success-toast sweep"
Task 10: Full verification pass
Files: none new (fix-forward + docs).
- Step 1: Gates —
npm run typecheckexit 0;npm testzero failures (293 + ~10 new). - Step 2: E2E walk (server from repo root + Vite; owner login):
- Client 360 → Edit → change name, add a contact with email, save → toast, header updates.
- Clients → New client (button AND Ctrl-K action) → dialog, create → lands on 360.
- Document → Send → composer shows contact email prefilled; with Gmail disconnected the send is disabled with hint; Cancel/Esc restores focus.
- Cancel/credit-note/convert/supersede → styled confirms; zero browser popups anywhere.
- AMC/plan/interaction: create + edit via dialogs; deactivate confirm; toasts.
- Employees + Modules dialogs; price add.
- /settings: every section round-trips (company save, template preview, new dated schedule row appears + old rows untouched, sharing default honored by a fresh share link, integrations status, accent change from Appearance); staff login sees only Appearance;
/settings/templateredirects. - Favicon + tab titles; dark mode + 420px pass over dialogs/toasts/settings rail.
- Step 3: Docs — STATUS.md (screens table: Settings row replaces Document Template row; test count), README (Settings + editing bullet touch-up), CLAUDE.md (pages list + test count), spec gets a DELIVERED stamp.
- Step 4: Commit
git add -A
git commit -m "docs: record ideal-web-app pass verification"
Self-review (done at plan-writing time)
- Spec coverage: §2 kit → Tasks 1–2; §3 edit-everywhere → 6–7; §4 send/confirms → 5; §5 hub + backend → 3, 4, 8; §6 polish → 9; §8 testing → TDD in 2–4 + Task 10. Payments stay inline per spec §3.
- Type consistency:
ScheduleRowidentical in repos (Task 3) and api client (Task 8);ClientFormDialog.onSaved(c: Client)matches both call sites;ConfirmDialogslot pattern identical in Tasks 5/7. - No placeholders: every code step carries real code or a grep-anchored edit instruction; the two READ-FIRST warnings mark the dual-workstream files.