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

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 !== 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
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: Verifynpm run typecheck exits 0 (proves Modal truly had no call sites); npm test zero 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 (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 testpackages/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 FAILnpm 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 PASSnpm 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 + mountindex.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

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 testapps/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:

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 FAILnpm test -- settings-remindersinsertSchedule/listSchedules not 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 PASSnpm 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:

  // ---------- 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

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 testapps/hq/test/settings-sharing.test.ts (mirror shares.test.ts setup — 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: FAILnpm test -- settings-sharing.

  • Step 3: Implement — in repos-shares.ts, above mintShare:

/** 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, 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
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:
      {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 } : {})}
        />
      )}
  1. 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.
  2. 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 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

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

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 NewAmcFormAmcDialog, NewRecurringFormPlanDialog, LogInteractionFormInteractionDialog. 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 EditInteractionDialog (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.tsxNewModuleForm → 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

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):
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):

// 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
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: Faviconapps/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 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
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: Gatesnpm 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
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.