You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/docs/superpowers/plans/2026-07-17-ideal-web-app.md

1048 lines
53 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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) <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.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<void>; 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<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`**
```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<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`**
```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**
```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: `<ToastProvider><HashRouter>…</HashRouter></ToastProvider>` 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<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**
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 `<SendDialog open={sending} onClose={() => 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 && (
<ConfirmDialog
open
onClose={() => 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<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)`, an `Edit` button 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 `NewClientForm` function entirely; `creating` state now controls `<ClientFormDialog open={creating} onClose={() => setCreating(false)} onSaved={(c) => { reload(); nav(`/clients/${c.id}`) }} />`. The `?new=1` effect stays as-is. Remove now-unused imports (`Field`, possibly `Notice`).
- [ ] **Step 4: Verify** — typecheck; suite; manual sanity deferred to Task 10.
- [ ] **Step 5: Commit**
```bash
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. `PaymentForm` stays 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): `initial` prefills an edit; save calls create or update endpoint; `useToast().ok('AMC saved' | 'Plan saved' | 'Interaction saved')`; `FormGrid` layout; footer pills. Field sets and validation copy IDENTICAL to today's inline forms (they move, not change). Delete the replaced inline forms; keep `PaymentForm`, `AssignModuleForm`, `AccountOwnerSelect`, `ClientModuleStatusSelect`, `RowDate` unchanged.
- [ ] **Step 2: ClientDetail wiring** — per tab:
- AMC: toolbar `New AMC` button (isOwner) + per-row `Edit` button (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 interaction` button + per-row `Edit``InteractionDialog` (all roles). The inline follow-up date input in the table stays (quick edit).
- Reuse one `ConfirmDialog` slot in the page (same pattern as DocumentView Task 5).
- [ ] **Step 3: Employees.tsx** — the three inline forms (`AddEmployeeForm`, `EditEmployeeForm`, `ResetPasswordForm`) become Dialog-hosted (keep their internals; wrap render in `Dialog` with 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 price` button 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**
```bash
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 as `TemplatePanel`; 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 34 endpoints; existing `getCompanyProfile/putCompanyProfile`, `getEmailStatus`, `getAwsRanking`; `ACCENTS/getAccent/setAccent/getThemeMode/setThemeMode` from `@sims/ui`.
- Produces (api.ts):
```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 by `role()`), 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 thin `export 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):
```tsx
// 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 }` — import `Settings as SettingsIcon` from lucide-react; **no `ownerOnly`** (all roles reach Appearance). Employees keeps `ownerOnly`. 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**
```bash
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
<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:
```tsx
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 the `saved` Badge 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**
```bash
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 typecheck` exit 0; `npm test` zero failures (293 + ~10 new).
- [ ] **Step 2: E2E walk** (server from repo root + Vite; owner login):
1. Client 360 → Edit → change name, add a contact with email, save → toast, header updates.
2. Clients → New client (button AND Ctrl-K action) → dialog, create → lands on 360.
3. Document → Send → composer shows contact email prefilled; with Gmail disconnected the send is disabled with hint; Cancel/Esc restores focus.
4. Cancel/credit-note/convert/supersede → styled confirms; zero browser popups anywhere.
5. AMC/plan/interaction: create + edit via dialogs; deactivate confirm; toasts.
6. Employees + Modules dialogs; price add.
7. /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/template` redirects.
8. 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**
```bash
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 12; §3 edit-everywhere → 67; §4 send/confirms → 5; §5 hub + backend → 3, 4, 8; §6 polish → 9; §8 testing → TDD in 24 + Task 10. Payments stay inline per spec §3.
- **Type consistency:** `ScheduleRow` identical in repos (Task 3) and api client (Task 8); `ClientFormDialog.onSaved(c: Client)` matches both call sites; `ConfirmDialog` slot 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.