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-hq-console-redes...

104 KiB

HQ Console Redesign Implementation Plan

STATUS: EXECUTED 2026-07-17. All 14 tasks landed and the §8 verification pass was recorded (commit 062ddd0); kept for the task-level rationale and rollback reference.

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: Restyle the HQ console into the approved warm ops-console design (spec: docs/superpowers/specs/2026-07-17-hq-console-redesign-design.md) — warm palette + teal accent, grouped sidebar, Ctrl+K palette, upgraded tables/states, and a Client 360 with a 12-month relationship-pulse ribbon.

Architecture: UI-only. packages/ui gets new tokens and new generic components (kept free of router/icon deps); apps/hq-web gets the new shell, page restyles, and the HQ-specific pulse ribbon. Zero backend/schema changes; every page keeps its existing data calls.

Tech Stack: React 19, react-router-dom 7 (HashRouter), Vite 6, vitest 3 (Node env — pure-function tests only, no jsdom), TypeScript strict + noUncheckedIndexedAccess, lucide-react (new, hq-web only).

Global Constraints

  • UI-only: no changes under apps/hq/src, apps/hq/schema.sql, or any packages/* other than packages/ui.
  • @sims/ui stays generic: no react-router, no lucide-react, no HQ domain types in it.
  • Strict TS: repo root npm run typecheck and npm test must pass after every task. 177 tests exist today; new tests only add.
  • Code style: no semicolons, single quotes, 2-space indent, props.x !== undefined guards (match surrounding files).
  • Money renders via formatINR only; never compute money client-side.
  • No silent caps: never .slice() a list without showing a count/"+n more" indicator.
  • Class-name conventions: shared component classes are wf-* and live in packages/ui/src/tokens.css; HQ shell classes are hq-* and live in apps/hq-web/src/app.css.
  • Commit after every task (message given per task). Do not push.
  • All commits end with: Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Verify commands (run from repo root C:\SiMS\hq):

  • Typecheck: npm run typecheck → exits 0
  • Tests: npm test → all pass
  • Dev app: terminal A cd apps/hq && npm start (API :5182), terminal B cd apps/hq-web && npm run dev (Vite :5183). Login admin@sims.com (owner password known to the user; ask them if a login is needed for visual checks).

Task 1: Warm tokens + teal accent + ink primary buttons

Files:

  • Modify: packages/ui/src/tokens.css (full rewrite below)
  • Modify: packages/ui/src/theme.ts:8 and :20
  • Modify: packages/ui/src/ThemeSwitcher.tsx:4-11

Interfaces:

  • Consumes: nothing new.

  • Produces: CSS variables consumed by every later task: --bg --bg-raised --bg-hover --bg-inset --border --border-strong --text --text-dim --ok --warn --err --*-soft --accent --accent-strong --accent-soft --radius --radius-sm --radius-lg --shadow-sm --shadow-md --shadow-lg --font --mono; utility classes .mono, .num; existing wf-* classes restyled in place. ACCENTS now includes 'teal' (the default).

  • Step 1: Rewrite packages/ui/src/tokens.css

Replace the entire file with:

/* ================================================================
   SiMS HQ design system — warm ops-console (spec 2026-07-17).
   Direction A v2 discipline (flat, border-defined, ink primary,
   accent-for-state) wearing the H-04 warm palette + teal accent.
   Fonts are bundled (Inter Variable + JetBrains Mono Variable).
   Mode + accent apply via <html data-theme=… data-accent=…> (theme.ts).
   Class names are stable: restyling here restyles every screen.
   ================================================================ */

:root {
  --font: 'Inter Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
  --mono: 'JetBrains Mono Variable', 'Cascadia Mono', Consolas, monospace;

  --radius-sm: 4px;
  --radius: 6px;
  --radius-lg: 10px;
  --pad: 12px;
  --speed: 140ms;

  /* Flat by default: resting surfaces are border-defined, not shadowed. */
  --shadow-sm: none;
  --shadow-md: 0 1px 2px rgba(20, 18, 12, 0.05);
  --shadow-lg: 0 16px 48px rgba(20, 18, 12, 0.22), 0 4px 12px rgba(20, 18, 12, 0.1);
}

/* ---- accents (accent paints STATE — active nav, chips, links, focus) ---- */
[data-accent='teal']    { --accent: #115e59; --accent-strong: #0d4f4a; --accent-contrast: #ffffff; }
[data-accent='indigo']  { --accent: #4f5df0; --accent-strong: #3f4cd9; --accent-contrast: #ffffff; }
[data-accent='blue']    { --accent: #2f7ce8; --accent-strong: #2368ca; --accent-contrast: #ffffff; }
[data-accent='emerald'] { --accent: #14957a; --accent-strong: #0f7f68; --accent-contrast: #ffffff; }
[data-accent='violet']  { --accent: #8250df; --accent-strong: #6f42c1; --accent-contrast: #ffffff; }
[data-accent='rose']    { --accent: #d4497c; --accent-strong: #bb3a6a; --accent-contrast: #ffffff; }
[data-accent='amber']   { --accent: #b97e10; --accent-strong: #a06a08; --accent-contrast: #ffffff; }

/* ---- light (default): warm paper ---- */
:root, [data-theme='light'] {
  --bg: #fafaf7;
  --bg-raised: #ffffff;
  --bg-hover: #f2f1ec;
  --bg-inset: #f2f1ec;
  --border: #e7e5de;
  --border-strong: #d6d4cc;
  --text: #1a1a17;
  --text-dim: #6b6a62;
  --ok: #047857;
  --warn: #a8702b;
  --err: #a64242;
  --accent-soft: color-mix(in srgb, var(--accent) 10%, var(--bg-raised));
  --ok-soft: #e2f4eb;
  --warn-soft: #faf1e0;
  --err-soft: #f6e5e5;
  --scrollbar: #d6d4cc;
  color-scheme: light;
}

/* ---- dark: warm night ---- */
[data-theme='dark'] {
  --bg: #131210;
  --bg-raised: #1b1a17;
  --bg-hover: #242320;
  --bg-inset: #0e0d0b;
  --border: #2b2a25;
  --border-strong: #3a3830;
  --text: #eae8e2;
  --text-dim: #a8a69c;
  --ok: #43c288;
  --warn: #e6b23e;
  --err: #ef6a63;
  --accent-soft: color-mix(in srgb, var(--accent) 16%, var(--bg-raised));
  --ok-soft: color-mix(in srgb, #43c288 14%, var(--bg-raised));
  --warn-soft: color-mix(in srgb, #e6b23e 14%, var(--bg-raised));
  --err-soft: color-mix(in srgb, #ef6a63 14%, var(--bg-raised));
  --scrollbar: #3a3830;
  --shadow-md: 0 1px 2px rgba(0, 0, 0, 0.4);
  --shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
  color-scheme: dark;
}

/* Dark-mode accents: explicit lighter values so every accent stays itself. */
[data-theme='dark'][data-accent='teal']    { --accent: #2da89a; --accent-strong: #22897d; }
[data-theme='dark'][data-accent='indigo']  { --accent: #7c8cff; --accent-strong: #6675f0; }
[data-theme='dark'][data-accent='blue']    { --accent: #66a3f2; --accent-strong: #4a8ce0; }
[data-theme='dark'][data-accent='emerald'] { --accent: #35b795; --accent-strong: #279c7e; }
[data-theme='dark'][data-accent='violet']  { --accent: #a97ff0; --accent-strong: #9367e0; }
[data-theme='dark'][data-accent='rose']    { --accent: #e57ba3; --accent-strong: #d4608c; }
[data-theme='dark'][data-accent='amber']   { --accent: #d9a247; --accent-strong: #c08a2e; }

/* ================= base ================= */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
  background: var(--bg);
  color: var(--text);
  font: 400 14px/1.5 var(--font);
  font-feature-settings: 'cv05', 'cv11';
  -webkit-font-smoothing: antialiased;
  transition: background var(--speed) ease, color var(--speed) ease;
}
h1, h2, h3 { letter-spacing: -0.015em; font-weight: 600; }
::selection { background: color-mix(in srgb, var(--accent) 25%, transparent); }
:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 1px; }
* { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }

/* Money and quantities: Inter with tabular figures. */
.num {
  font-family: var(--font);
  font-variant-numeric: tabular-nums lining-nums;
  letter-spacing: -0.005em;
}
/* Machine data: codes, doc numbers, GSTINs, ids. */
.mono {
  font-family: var(--mono);
  font-size: 0.93em;
  letter-spacing: 0.01em;
}

/* ================= controls ================= */
button.wf {
  background: var(--bg-raised); color: var(--text);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius); padding: 7px 14px;
  font: 500 13.5px/1.45 var(--font); cursor: pointer;
  transition: background var(--speed), border-color var(--speed), opacity var(--speed), transform 80ms;
}
button.wf:hover { background: var(--bg-hover); border-color: color-mix(in srgb, var(--accent) 35%, var(--border-strong)); }
button.wf:active { transform: translateY(1px); }
/* Ink primary — accent is reserved for state, not the main CTA. */
button.wf.primary {
  background: var(--text); color: var(--bg-raised);
  border-color: var(--text); font-weight: 600;
}
button.wf.primary:hover { opacity: 0.86; background: var(--text); }
button.wf.danger { border-color: color-mix(in srgb, var(--err) 55%, var(--border-strong)); color: var(--err); }
button.wf.danger:hover { background: var(--err-soft); }
button.wf kbd {
  background: color-mix(in srgb, currentColor 14%, transparent);
  border-radius: var(--radius-sm); padding: 0 5px; margin-left: 8px;
  font-family: var(--mono); font-size: 10.5px; font-weight: 500;
}

input.wf, select.wf, textarea.wf {
  background: var(--bg-raised); color: var(--text);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-sm); padding: 7px 11px;
  font: 400 13.5px/1.45 var(--font); width: 100%;
  transition: border-color var(--speed), box-shadow var(--speed);
}
input.wf::placeholder, textarea.wf::placeholder { color: var(--text-dim); }
input.wf:focus, select.wf:focus, textarea.wf:focus {
  outline: none; border-color: var(--accent);
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}

/* ================= page scaffolding ================= */
.wf-page { padding: 20px 24px 32px; max-width: 1400px; }
.wf-page-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; }
.wf-page-head h1 { font-size: 22px; margin: 0; }
.wf-page-desc { color: var(--text-dim); margin: 0 0 18px; max-width: 72ch; }
.wf-crumbs { display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--text-dim); margin: 0 0 6px; }
.wf-crumbs a { color: var(--text-dim); text-decoration: none; }
.wf-crumbs a:hover { color: var(--accent); }
.wf-toolbar { display: flex; gap: 8px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
.wf-toolbar .spacer { flex: 1; }

/* ================= tables ================= */
table.wf {
  border-collapse: separate; border-spacing: 0; width: 100%;
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius); overflow: hidden;
}
table.wf th, table.wf td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); }
table.wf tr:last-child td { border-bottom: none; }
table.wf th {
  position: sticky; top: 0; z-index: 1;
  background: var(--bg-inset);
  color: var(--text-dim); font-weight: 600; font-size: 11px;
  text-transform: uppercase; letter-spacing: 0.06em;
}
table.wf tbody tr { transition: background var(--speed); }
table.wf tbody tr:hover td { background: var(--accent-soft); }
table.wf td.num, table.wf th.num { text-align: right; }
table.wf td.mono { font-size: 12.5px; }
table.wf tbody tr.tone-err td { background: var(--err-soft); }
table.wf tbody tr.tone-warn td { background: var(--warn-soft); }
table.wf tbody tr.tone-ok td { background: var(--ok-soft); }
table.wf tbody tr.tone-err:hover td,
table.wf tbody tr.tone-warn:hover td,
table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); }

/* ================= badges ================= */
.wf-badge {
  display: inline-block; border-radius: 999px; padding: 2px 10px;
  font-size: 12px; font-weight: 500;
  background: var(--bg-inset); color: var(--text-dim);
  border: 1px solid var(--border);
}
.wf-badge.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.wf-badge.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
.wf-badge.err { color: var(--err); background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 25%, transparent); }
.wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
[data-theme='dark'] .wf-badge.accent { color: var(--accent); }

/* ================= stat cards ================= */
.wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; }
.wf-stat {
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 14px 16px;
  transition: border-color var(--speed);
}
.wf-stat:hover { border-color: var(--border-strong); }
.wf-stat .label { color: var(--text-dim); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.wf-stat .value { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin-top: 6px; }
.wf-stat .hint { color: var(--text-dim); font-size: 12px; margin-top: 3px; }
.wf-stat .hint.ok { color: var(--ok); }
.wf-stat .hint.warn { color: var(--warn); }
.wf-stat .hint.err { color: var(--err); }

/* ================= states ================= */
.wf-empty {
  border: 1px dashed var(--border-strong); border-radius: var(--radius);
  padding: 32px; text-align: center; color: var(--text-dim); margin: 14px 0;
  background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
}
.wf-skel { display: flex; flex-direction: column; gap: 10px; margin: 14px 0; }
.wf-skel-bar {
  height: 13px; border-radius: var(--radius-sm);
  background: linear-gradient(90deg, var(--bg-inset) 25%, var(--bg-hover) 50%, var(--bg-inset) 75%);
  background-size: 200% 100%;
  animation: wf-shimmer 1.4s ease infinite;
}
@keyframes wf-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
.wf-error {
  border: 1px solid color-mix(in srgb, var(--err) 35%, transparent);
  background: var(--err-soft); border-radius: var(--radius);
  padding: 14px 16px; margin: 14px 0;
  display: flex; align-items: center; gap: 12px; font-size: 13px;
}
.wf-error .msg { flex: 1; color: var(--err); }

/* ================= filter chips ================= */
.wf-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.wf-chip {
  border: 1px solid transparent; background: transparent; color: var(--text-dim);
  border-radius: 999px; padding: 4px 12px; font: 500 12.5px var(--font); cursor: pointer;
  transition: background var(--speed), color var(--speed), border-color var(--speed);
}
.wf-chip:hover { color: var(--text); background: var(--bg-hover); }
.wf-chip.active {
  background: var(--accent-soft); color: var(--accent-strong);
  border-color: color-mix(in srgb, var(--accent) 30%, transparent); font-weight: 600;
}
[data-theme='dark'] .wf-chip.active { color: var(--accent); }
.wf-chip .count { opacity: 0.75; margin-left: 5px; font-family: var(--mono); font-size: 11px; }

/* ================= tabs ================= */
.wf-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 14px 0 16px; overflow-x: auto; }
.wf-tab {
  border: none; background: none; cursor: pointer; white-space: nowrap;
  padding: 8px 12px 9px; font: 500 13.5px var(--font); color: var(--text-dim);
  border-bottom: 2px solid transparent; margin-bottom: -1px;
  transition: color var(--speed), border-color var(--speed);
}
.wf-tab:hover { color: var(--text); }
.wf-tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
.wf-tab .count { font-family: var(--mono); font-size: 11px; color: var(--text-dim); margin-left: 6px; }

/* ================= record header ================= */
.wf-rec { display: flex; gap: 14px; align-items: flex-start; margin: 2px 0 6px; }
.wf-avatar {
  width: 44px; height: 44px; border-radius: var(--radius);
  display: flex; align-items: center; justify-content: center;
  font-weight: 600; font-size: 16px; flex-shrink: 0;
}
.wf-rec-body { min-width: 0; flex: 1; }
.wf-rec-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.wf-rec-title h1 { font-size: 22px; margin: 0; }
.wf-rec-meta { color: var(--text-dim); font-size: 13px; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.wf-rec-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }

/* ================= misc surfaces ================= */
.wf-card {
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 14px 16px; margin: 10px 0;
}
.wf-card > h3:first-child { margin-top: 0; }

.wf-field { margin-bottom: 13px; }
.wf-field > label {
  display: block; color: var(--text-dim); font-size: 11px; font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;
}

.wf-modal-back {
  position: fixed; inset: 0; background: rgba(15, 14, 11, 0.45);
  backdrop-filter: blur(4px);
  display: flex; align-items: center; justify-content: center; z-index: 50;
}
.wf-modal {
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 22px;
  min-width: 430px; max-width: 740px; max-height: 85vh; overflow: auto;
  box-shadow: var(--shadow-lg);
}
.wf-modal h2 { margin: 0 0 14px; font-size: 17px; }

.wf-notice {
  border-radius: var(--radius); padding: 9px 13px; margin: 10px 0;
  border: 1px solid var(--border); background: var(--bg-raised);
  font-size: 13px;
}
.wf-notice.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-soft); }
.wf-notice.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-soft); }
.wf-notice.err { border-color: color-mix(in srgb, var(--err) 35%, transparent); background: var(--err-soft); }

/* ================= command palette ================= */
.wf-palette-back {
  position: fixed; inset: 0; background: rgba(15, 14, 11, 0.4);
  backdrop-filter: blur(3px); z-index: 60;
  display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh;
}
.wf-palette {
  width: 640px; max-width: calc(100vw - 32px);
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); overflow: hidden;
}
.wf-palette input {
  width: 100%; border: none; outline: none; background: transparent; color: var(--text);
  padding: 14px 16px; font: 400 15px var(--font);
  border-bottom: 1px solid var(--border);
}
.wf-palette-list { max-height: 46vh; overflow-y: auto; padding: 6px; }
.wf-palette-group {
  font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--text-dim); padding: 8px 10px 4px;
}
.wf-palette-item {
  display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
  border: none; background: none; cursor: pointer; color: var(--text);
  padding: 8px 10px; border-radius: var(--radius); font: 400 13.5px var(--font);
}
.wf-palette-item .hint { color: var(--text-dim); font-size: 12px; margin-left: auto; }
.wf-palette-item.sel { background: var(--accent-soft); }
.wf-palette-foot {
  border-top: 1px solid var(--border); padding: 7px 12px;
  display: flex; gap: 14px; font-size: 11px; color: var(--text-dim);
}
.wf-palette-foot kbd {
  font-family: var(--mono); font-size: 10px; border: 1px solid var(--border-strong);
  border-radius: 3px; padding: 0 4px; margin-right: 4px;
}

/* ================= theme switcher ================= */
.wf-themer { display: flex; align-items: center; gap: 6px; }
.wf-themer .swatch {
  width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
  border: 2px solid transparent; padding: 0;
  transition: transform 80ms, border-color var(--speed);
}
.wf-themer .swatch:hover { transform: scale(1.18); }
.wf-themer .swatch.active { border-color: var(--text); }
.wf-themer .mode {
  background: var(--bg-inset); border: 1px solid var(--border);
  border-radius: 999px; padding: 3px 10px; cursor: pointer;
  font: 500 12px var(--font); color: var(--text-dim);
}
.wf-themer .mode:hover { color: var(--text); border-color: var(--border-strong); }
  • Step 2: Add teal to packages/ui/src/theme.ts

Change line 8 to:

export const ACCENTS = ['teal', 'indigo', 'blue', 'emerald', 'violet', 'rose', 'amber'] as const

Change the fallback in getAccent (line 20) from 'indigo' to 'teal':

  return (ACCENTS as readonly string[]).includes(stored ?? '') ? (stored as Accent) : 'teal'
  • Step 3: Add the teal swatch to packages/ui/src/ThemeSwitcher.tsx

Replace the SWATCH map (lines 411) with:

const SWATCH: Record<Accent, string> = {
  teal: '#115e59',
  indigo: '#4f5df0',
  blue: '#2f7ce8',
  emerald: '#14957a',
  violet: '#8250df',
  rose: '#d4497c',
  amber: '#b97e10',
}
  • Step 4: Verify

Run: npm run typecheck → exit 0. Run: npm test → all pass (no test touches tokens).

  • Step 5: Visual smoke check

Start the dev app (see Global Constraints). Confirm: warm off-white background, teal active-nav bar, primary buttons are near-black ink (light mode) / light ink (dark mode), theme toggle works, all 7 swatches present, dark mode is warm-tinted not blue-black.

  • Step 6: Commit
git add packages/ui/src/tokens.css packages/ui/src/theme.ts packages/ui/src/ThemeSwitcher.tsx
git commit -m "feat(ui): warm token system — teal default accent, ink primary, flat surfaces"

Task 2: Avatar helper (TDD)

Files:

  • Create: packages/ui/src/avatar.ts
  • Test: packages/ui/test/avatar.test.ts

Interfaces:

  • Produces: initials(name: string): string, avatarHue(name: string): number (0359), avatarColors(name: string): { bg: string; fg: string } — used by RecordHeader (Task 4) and the Layout user chip (Task 6).

  • Step 1: Write the failing testpackages/ui/test/avatar.test.ts:

import { describe, expect, it } from 'vitest'
import { avatarColors, avatarHue, initials } from '../src/avatar'

describe('initials', () => {
  it('takes the first letter of the first two words', () => {
    expect(initials('Acme UK Compliance Group')).toBe('AU')
    expect(initials('hamiltel')).toBe('H')
  })
  it('falls back to ? for empty input', () => {
    expect(initials('')).toBe('?')
    expect(initials('   ')).toBe('?')
  })
})

describe('avatarHue', () => {
  it('is deterministic', () => {
    expect(avatarHue('Hamiltel')).toBe(avatarHue('Hamiltel'))
  })
  it('stays in [0, 360)', () => {
    for (const n of ['a', 'Acme', 'Zed Systems', '৪২']) {
      const h = avatarHue(n)
      expect(h).toBeGreaterThanOrEqual(0)
      expect(h).toBeLessThan(360)
    }
  })
})

describe('avatarColors', () => {
  it('returns hsl strings derived from the hue', () => {
    const { bg, fg } = avatarColors('Hamiltel')
    const h = avatarHue('Hamiltel')
    expect(bg).toContain(`hsl(${h}`)
    expect(fg).toContain(`hsl(${h}`)
  })
})
  • Step 2: Run it — expect FAILnpm test -- avatar → fails: cannot find ../src/avatar.

  • Step 3: Implementpackages/ui/src/avatar.ts:

/** Deterministic identity visuals: initials + a stable hue from the name. */

export function initials(name: string): string {
  const words = name.trim().split(/\s+/).filter((w) => w !== '')
  if (words.length === 0) return '?'
  const first = words[0]!.charAt(0)
  const second = words.length > 1 ? words[1]!.charAt(0) : ''
  return (first + second).toUpperCase()
}

/** FNV-1a over the name → hue bucket; same name always gets the same colour. */
export function avatarHue(name: string): number {
  let h = 0x811c9dc5
  for (let i = 0; i < name.length; i++) {
    h ^= name.charCodeAt(i)
    h = Math.imul(h, 0x01000193)
  }
  return (h >>> 0) % 360
}

/** Soft tinted background + readable foreground for both themes. */
export function avatarColors(name: string): { bg: string; fg: string } {
  const h = avatarHue(name)
  return {
    bg: `hsl(${h} 42% 50% / 0.16)`,
    fg: `hsl(${h} 45% 38%)`,
  }
}

Add to packages/ui/src/index.ts a new line:

export * from './avatar'
  • Step 4: Run tests — expect PASSnpm test -- avatar → 5 pass. Then npm run typecheck → exit 0.

  • Step 5: Commit

git add packages/ui/src/avatar.ts packages/ui/test/avatar.test.ts packages/ui/src/index.ts
git commit -m "feat(ui): deterministic avatar initials + hue helper"

Task 3: State components, FilterChips, Tabs

Files:

  • Create: packages/ui/src/states.tsx
  • Create: packages/ui/src/chips.tsx
  • Create: packages/ui/src/tabs.tsx
  • Modify: packages/ui/src/index.ts

Interfaces:

  • Consumes: CSS classes from Task 1 (wf-skel*, wf-error, wf-chips/wf-chip, wf-tabs/wf-tab).

  • Produces:

    • Skeleton(props: { rows?: number })
    • ErrorState(props: { message: string; onRetry?: () => void })
    • FilterChips(props: { chips: { key: string; label: string; count?: number }[]; active: string; onChange: (key: string) => void })
    • Tabs(props: { tabs: { key: string; label: string; count?: number }[]; active: string; onChange: (key: string) => void })
  • Step 1: Create packages/ui/src/states.tsx

/** Loading + error surfaces — every page swaps its "Loading…" text for these. */

const WIDTHS = ['92%', '70%', '84%', '55%', '78%']

export function Skeleton(props: { rows?: number }) {
  const rows = props.rows ?? 3
  return (
    <div className="wf-skel" aria-busy="true" aria-label="Loading">
      {Array.from({ length: rows }, (_x, i) => (
        <div key={i} className="wf-skel-bar" style={{ width: WIDTHS[i % WIDTHS.length] }} />
      ))}
    </div>
  )
}

export function ErrorState(props: { message: string; onRetry?: () => void }) {
  return (
    <div className="wf-error" role="alert">
      <span className="msg">{props.message}</span>
      {props.onRetry !== undefined && (
        <button type="button" className="wf" onClick={props.onRetry}>Try again</button>
      )}
    </div>
  )
}
  • Step 2: Create packages/ui/src/chips.tsx
/** Saved-views-lite: a pill row that filters a list by one dimension. */

export interface ChipDef { key: string; label: string; count?: number }

export function FilterChips(props: {
  chips: ChipDef[]
  active: string
  onChange: (key: string) => void
}) {
  return (
    <div className="wf-chips" role="tablist">
      {props.chips.map((c) => (
        <button
          key={c.key}
          type="button"
          role="tab"
          aria-selected={c.key === props.active}
          className={`wf-chip${c.key === props.active ? ' active' : ''}`}
          onClick={() => props.onChange(c.key)}
        >
          {c.label}
          {c.count !== undefined && <span className="count">{c.count}</span>}
        </button>
      ))}
    </div>
  )
}
  • Step 3: Create packages/ui/src/tabs.tsx
/** Underline tabs with optional mono counts (Direction A v2 pattern). */

export interface TabDef { key: string; label: string; count?: number }

export function Tabs(props: {
  tabs: TabDef[]
  active: string
  onChange: (key: string) => void
}) {
  return (
    <div className="wf-tabs" role="tablist">
      {props.tabs.map((t) => (
        <button
          key={t.key}
          type="button"
          role="tab"
          aria-selected={t.key === props.active}
          className={`wf-tab${t.key === props.active ? ' active' : ''}`}
          onClick={() => props.onChange(t.key)}
        >
          {t.label}
          {t.count !== undefined && <span className="count">{t.count}</span>}
        </button>
      ))}
    </div>
  )
}
  • Step 4: Export from packages/ui/src/index.ts — append:
export * from './states'
export * from './chips'
export * from './tabs'
  • Step 5: Verifynpm run typecheck → exit 0; npm test → all pass.

  • Step 6: Commit

git add packages/ui/src/states.tsx packages/ui/src/chips.tsx packages/ui/src/tabs.tsx packages/ui/src/index.ts
git commit -m "feat(ui): Skeleton, ErrorState, FilterChips, Tabs"

Task 4: RecordHeader + PageHeader breadcrumb + DataTable tone/mono

Files:

  • Create: packages/ui/src/record.tsx
  • Modify: packages/ui/src/components.tsx (PageHeader lines 517, Column/DataTable lines 4273)
  • Modify: packages/ui/src/index.ts

Interfaces:

  • Consumes: initials/avatarColors (Task 2), CSS wf-rec*/wf-avatar/wf-crumbs/tone-* (Task 1).

  • Produces:

    • RecordHeader(props: { name: string; avatarSeed?: string; status?: ReactNode; meta?: ReactNode; actions?: ReactNode })
    • PageHeader gains optional breadcrumb?: ReactNode (rendered above the title row).
    • Column gains mono?: boolean; DataTable gains rowTone?: (index: number) => 'ok' | 'warn' | 'err' | undefined.
  • Step 1: Create packages/ui/src/record.tsx

import type { ReactNode } from 'react'
import { avatarColors, initials } from './avatar'

/** Entity header: avatar + name + status pill + one meta line + action cluster. */
export function RecordHeader(props: {
  name: string
  avatarSeed?: string
  status?: ReactNode
  meta?: ReactNode
  actions?: ReactNode
}) {
  const seed = props.avatarSeed ?? props.name
  const { bg, fg } = avatarColors(seed)
  return (
    <div className="wf-rec">
      <div className="wf-avatar" style={{ background: bg, color: fg }}>{initials(seed)}</div>
      <div className="wf-rec-body">
        <div className="wf-rec-title">
          <h1>{props.name}</h1>
          {props.status}
        </div>
        {props.meta !== undefined && <div className="wf-rec-meta">{props.meta}</div>}
      </div>
      {props.actions !== undefined && <div className="wf-rec-actions">{props.actions}</div>}
    </div>
  )
}
  • Step 2: Upgrade PageHeader in packages/ui/src/components.tsx

Replace the PageHeader function (lines 517) with:

export function PageHeader(props: {
  title: string
  desc?: string
  badge?: string
  breadcrumb?: ReactNode
  actions?: ReactNode
}) {
  return (
    <>
      {props.breadcrumb !== undefined && <div className="wf-crumbs">{props.breadcrumb}</div>}
      <div className="wf-page-head">
        <h1>{props.title}</h1>
        {props.badge !== undefined && <Badge tone="accent">{props.badge}</Badge>}
        <div style={{ flex: 1 }} />
        {props.actions}
      </div>
      {props.desc !== undefined && <p className="wf-page-desc">{props.desc}</p>}
    </>
  )
}
  • Step 3: Upgrade DataTable in packages/ui/src/components.tsx

Replace the Column interface and DataTable function (lines 4273) with:

export interface Column {
  key: string
  label: string
  numeric?: boolean
  mono?: boolean
}

export function DataTable(props: {
  columns: Column[]
  rows: Record<string, ReactNode>[]
  onRowClick?: (row: Record<string, ReactNode>, index: number) => void
  rowTone?: (index: number) => 'ok' | 'warn' | 'err' | undefined
}) {
  const cellClass = (c: Column): string | undefined => {
    const cls = [c.numeric === true ? 'num' : '', c.mono === true ? 'mono' : ''].filter((x) => x !== '').join(' ')
    return cls !== '' ? cls : undefined
  }
  return (
    <table className="wf">
      <thead>
        <tr>
          {props.columns.map((c) => (
            <th key={c.key} className={c.numeric === true ? 'num' : undefined}>{c.label}</th>
          ))}
        </tr>
      </thead>
      <tbody>
        {props.rows.map((row, i) => {
          const tone = props.rowTone?.(i)
          return (
            <tr
              key={i}
              className={tone !== undefined ? `tone-${tone}` : undefined}
              onClick={() => props.onRowClick?.(row, i)}
              style={props.onRowClick !== undefined ? { cursor: 'pointer' } : undefined}
            >
              {props.columns.map((c) => (
                <td key={c.key} className={cellClass(c)}>{row[c.key]}</td>
              ))}
            </tr>
          )
        })}
      </tbody>
    </table>
  )
}
  • Step 4: Export RecordHeader — append to packages/ui/src/index.ts:
export * from './record'
  • Step 5: Verifynpm run typecheck → exit 0 (all new props optional, existing call sites unaffected); npm test → pass.

  • Step 6: Commit

git add packages/ui/src/record.tsx packages/ui/src/components.tsx packages/ui/src/index.ts
git commit -m "feat(ui): RecordHeader; PageHeader breadcrumb; DataTable rowTone + mono columns"

Task 5: Command palette (TDD on matching)

Files:

  • Create: packages/ui/src/palette-match.ts
  • Create: packages/ui/src/palette.tsx
  • Modify: packages/ui/src/index.ts
  • Test: packages/ui/test/palette-match.test.ts

Interfaces:

  • Consumes: CSS wf-palette* (Task 1).

  • Produces:

    • PaletteItem { id: string; label: string; group: string; hint?: string }
    • matchItems(items: PaletteItem[], q: string, limit?: number): PaletteItem[]
    • CommandPalette(props: { open: boolean; onClose: () => void; items: PaletteItem[]; onSelect: (item: PaletteItem) => void; onQuery?: (q: string) => void; placeholder?: string }) — parent owns async data: it listens to onQuery and feeds merged results back through items.
  • Step 1: Write the failing testpackages/ui/test/palette-match.test.ts:

import { describe, expect, it } from 'vitest'
import { matchItems, type PaletteItem } from '../src/palette-match'

const items: PaletteItem[] = [
  { id: 'nav:dash', label: 'Dashboard', group: 'Go to' },
  { id: 'nav:clients', label: 'Clients', group: 'Go to' },
  { id: 'act:newdoc', label: 'New document', group: 'Actions', hint: 'quotation proforma invoice' },
  { id: 'client:1', label: 'CL-0007 · Hamiltel', group: 'Clients' },
]

describe('matchItems', () => {
  it('returns everything (to limit) for an empty query', () => {
    expect(matchItems(items, '').map((i) => i.id)).toEqual(['nav:dash', 'nav:clients', 'act:newdoc', 'client:1'])
    expect(matchItems(items, '', 2)).toHaveLength(2)
  })
  it('matches case-insensitively on label', () => {
    expect(matchItems(items, 'hamil').map((i) => i.id)).toEqual(['client:1'])
  })
  it('matches on hint text too', () => {
    expect(matchItems(items, 'proforma').map((i) => i.id)).toEqual(['act:newdoc'])
  })
  it('matches every whitespace-separated term (AND)', () => {
    expect(matchItems(items, 'new doc').map((i) => i.id)).toEqual(['act:newdoc'])
    expect(matchItems(items, 'new xyz')).toHaveLength(0)
  })
})
  • Step 2: Run it — expect FAILnpm test -- palette-match → module not found.

  • Step 3: Implement packages/ui/src/palette-match.ts

/** Pure matcher for the command palette — every query term must appear. */

export interface PaletteItem {
  id: string
  label: string
  group: string
  hint?: string
}

export function matchItems(items: PaletteItem[], q: string, limit = 12): PaletteItem[] {
  const terms = q.trim().toLowerCase().split(/\s+/).filter((t) => t !== '')
  if (terms.length === 0) return items.slice(0, limit)
  const out: PaletteItem[] = []
  for (const item of items) {
    const hay = `${item.label} ${item.hint ?? ''}`.toLowerCase()
    if (terms.every((t) => hay.includes(t))) {
      out.push(item)
      if (out.length >= limit) break
    }
  }
  return out
}
  • Step 4: Run tests — expect PASSnpm test -- palette-match → 4 pass.

  • Step 5: Create packages/ui/src/palette.tsx

import { useEffect, useRef, useState } from 'react'
import { matchItems, type PaletteItem } from './palette-match'

/**
 * Ctrl+K overlay. Generic: the parent supplies `items` (static + any async
 * results it fetched after `onQuery`) and receives the picked item.
 */
export function CommandPalette(props: {
  open: boolean
  onClose: () => void
  items: PaletteItem[]
  onSelect: (item: PaletteItem) => void
  onQuery?: (q: string) => void
  placeholder?: string
}) {
  const [q, setQ] = useState('')
  const [sel, setSel] = useState(0)
  const inputRef = useRef<HTMLInputElement>(null)

  useEffect(() => {
    if (props.open) { setQ(''); setSel(0); props.onQuery?.(''); inputRef.current?.focus() }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [props.open])

  if (!props.open) return null

  const hits = matchItems(props.items, q)
  const groups: { name: string; items: { item: PaletteItem; index: number }[] }[] = []
  hits.forEach((item, index) => {
    const g = groups.find((x) => x.name === item.group)
    if (g !== undefined) g.items.push({ item, index })
    else groups.push({ name: item.group, items: [{ item, index }] })
  })

  const pick = (item: PaletteItem) => { props.onSelect(item); props.onClose() }

  const onKey = (e: React.KeyboardEvent) => {
    if (e.key === 'Escape') { e.preventDefault(); props.onClose() }
    else if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, hits.length - 1)) }
    else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
    else if (e.key === 'Enter') {
      e.preventDefault()
      const item = hits[sel]
      if (item !== undefined) pick(item)
    }
  }

  return (
    <div className="wf-palette-back" onClick={props.onClose}>
      <div className="wf-palette" onClick={(e) => e.stopPropagation()} onKeyDown={onKey}>
        <input
          ref={inputRef}
          value={q}
          placeholder={props.placeholder ?? 'Search…'}
          onChange={(e) => { setQ(e.target.value); setSel(0); props.onQuery?.(e.target.value) }}
        />
        <div className="wf-palette-list">
          {hits.length === 0 && <div className="wf-palette-group">No matches</div>}
          {groups.map((g) => (
            <div key={g.name}>
              <div className="wf-palette-group">{g.name}</div>
              {g.items.map(({ item, index }) => (
                <button
                  key={item.id}
                  type="button"
                  className={`wf-palette-item${index === sel ? ' sel' : ''}`}
                  onMouseEnter={() => setSel(index)}
                  onClick={() => pick(item)}
                >
                  {item.label}
                  {item.hint !== undefined && <span className="hint">{item.hint}</span>}
                </button>
              ))}
            </div>
          ))}
        </div>
        <div className="wf-palette-foot">
          <span><kbd>↑↓</kbd> navigate</span>
          <span><kbd></kbd> open</span>
          <span><kbd>esc</kbd> close</span>
        </div>
      </div>
    </div>
  )
}
  • Step 6: Export — append to packages/ui/src/index.ts:
export * from './palette-match'
export * from './palette'
  • Step 7: Verifynpm run typecheck → exit 0; npm test → all pass.

  • Step 8: Commit

git add packages/ui/src/palette-match.ts packages/ui/src/palette.tsx packages/ui/test/palette-match.test.ts packages/ui/src/index.ts
git commit -m "feat(ui): command palette with pure AND-term matcher"

Task 6: App shell — grouped sidebar, top bar, Ctrl+K wiring, responsive-lite

Files:

  • Modify: apps/hq-web/package.json (add lucide-react)
  • Create: apps/hq-web/src/nav.ts
  • Modify: apps/hq-web/src/Layout.tsx (full rewrite below)
  • Modify: apps/hq-web/src/app.css (full rewrite below)
  • Modify: apps/hq-web/src/pages/Clients.tsx (accept ?new=1, small diff below)

Interfaces:

  • Consumes: CommandPalette/PaletteItem (Task 5), avatarColors/initials (Task 2), Badge, ThemeSwitcher; api: getEmailStatus, getReminders, getClients, hasSession, displayName, role, clearSession.

  • Produces: NAV_GROUPS (apps/hq-web/src/nav.ts) — { label: string; items: { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean }[] }[]. Later tasks rely only on routes staying unchanged.

  • Step 1: Install lucide-react

Run at repo root: npm install lucide-react -w apps/hq-web Expected: apps/hq-web/package.json dependencies gain "lucide-react": "^0.x".

  • Step 2: Create apps/hq-web/src/nav.ts
import {
  BarChart3, Boxes, FilePlus2, FileText, LayoutDashboard, UserCog, Users,
  type LucideIcon,
} from 'lucide-react'

export interface NavItem { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean }
export interface NavGroup { label: string; items: NavItem[] }

/** Grouped sidebar (spec §3). Pipeline slots into WORK when the funnel spec lands. */
export const NAV_GROUPS: NavGroup[] = [
  {
    label: 'Work',
    items: [
      { to: '/', label: 'Dashboard', icon: LayoutDashboard },
      { to: '/clients', label: 'Clients', icon: Users },
      { to: '/documents/new', label: 'New Document', icon: FilePlus2 },
    ],
  },
  { label: 'Catalog', items: [{ to: '/modules', label: 'Modules', icon: Boxes }] },
  { label: 'Insight', items: [{ to: '/reports', label: 'Reports', icon: BarChart3 }] },
  {
    label: 'Admin',
    items: [
      { to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
      { to: '/settings/template', label: 'Document Template', icon: FileText, ownerOnly: true },
    ],
  },
]
  • Step 3: Rewrite apps/hq-web/src/Layout.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { LogOut, Menu, Search } from 'lucide-react'
import {
  avatarColors, Badge, CommandPalette, initials, ThemeSwitcher, type PaletteItem,
} from '@sims/ui'
import { clearSession, displayName, getClients, getEmailStatus, getReminders, hasSession, role } from './api'
import { NAV_GROUPS } from './nav'

const APP_VERSION = 'v0.1.0'

/** App shell: grouped sidebar + top bar + Ctrl+K palette + Gmail-health banner. */
export function Layout() {
  const nav = useNavigate()
  const location = useLocation()
  const [gmail, setGmail] = useState<'ok' | 'down' | 'unknown'>('unknown')
  const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 })
  const [healthy, setHealthy] = useState<boolean | undefined>()
  const [menuOpen, setMenuOpen] = useState(false)
  const [paletteOpen, setPaletteOpen] = useState(false)
  const [clientHits, setClientHits] = useState<PaletteItem[]>([])
  const queryTimer = useRef<number | undefined>(undefined)

  useEffect(() => {
    if (!hasSession()) return
    getEmailStatus()
      .then((s) => setGmail(!s.connected || s.dead ? 'down' : 'ok'))
      .catch(() => { /* 401 already redirected; other failures keep it unknown */ })
    fetch('/api/health').then((r) => setHealthy(r.ok)).catch(() => setHealthy(false))
  }, [])

  // Reminder-queue badge (Dashboard nav item) — refreshed on every route change.
  useEffect(() => {
    if (!hasSession()) return
    Promise.all([getReminders('queued'), getReminders('failed')])
      .then(([q, f]) => setQueueCounts({ queued: q.length, failed: f.length }))
      .catch(() => { /* badge is best-effort */ })
  }, [location.pathname])

  // Close the mobile drawer when the route changes.
  useEffect(() => { setMenuOpen(false) }, [location.pathname])

  // Ctrl+K / Cmd+K opens the palette.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault()
        setPaletteOpen((v) => !v)
      }
    }
    window.addEventListener('keydown', onKey)
    return () => window.removeEventListener('keydown', onKey)
  }, [])

  const staticItems = useMemo<PaletteItem[]>(() => {
    const isOwner = role() === 'owner'
    const navItems = NAV_GROUPS.flatMap((g) => g.items)
      .filter((i) => i.ownerOnly !== true || isOwner)
      .map((i) => ({ id: `nav:${i.to}`, label: i.label, group: 'Go to' }))
    return [
      ...navItems,
      { id: 'act:/documents/new', label: 'New document', group: 'Actions', hint: 'quotation · proforma · invoice' },
      { id: 'act:/clients?new=1', label: 'New client', group: 'Actions' },
    ]
  }, [])

  const onPaletteQuery = (q: string) => {
    window.clearTimeout(queryTimer.current)
    if (q.trim().length < 2) { setClientHits([]); return }
    queryTimer.current = window.setTimeout(() => {
      getClients(q)
        .then((cs) => setClientHits(cs.slice(0, 8).map((c) => ({
          id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status,
        }))))
        .catch(() => setClientHits([]))
    }, 200)
  }

  const onPaletteSelect = (item: PaletteItem) => {
    if (item.id.startsWith('nav:')) nav(item.id.slice(4))
    else if (item.id.startsWith('act:')) nav(item.id.slice(4))
    else if (item.id.startsWith('client:')) nav(`/clients/${item.id.slice(7)}`)
  }

  if (!hasSession()) return <Navigate to="/login" replace />

  const isOwner = role() === 'owner'
  const groups = NAV_GROUPS
    .map((g) => ({ ...g, items: g.items.filter((i) => i.ownerOnly !== true || isOwner) }))
    .filter((g) => g.items.length > 0)
  const name = displayName()
  const av = avatarColors(name)
  const badgeCount = queueCounts.queued + queueCounts.failed

  return (
    <div className="hq-shell">
      {menuOpen && <div className="hq-scrim" onClick={() => setMenuOpen(false)} />}
      <nav className={`hq-side${menuOpen ? ' open' : ''}`}>
        <div className="hq-brand">
          SiMS HQ
          <small>Ops console</small>
        </div>
        {groups.map((g) => (
          <div key={g.label} className="hq-group">
            <div className="hq-eyebrow">{g.label}</div>
            {g.items.map((n) => (
              <NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/'}>
                <n.icon size={15} strokeWidth={1.8} aria-hidden />
                <span className="lbl">{n.label}</span>
                {n.to === '/' && badgeCount > 0 && (
                  <span className={`hq-count${queueCounts.failed > 0 ? ' err' : ''}`}>{badgeCount}</span>
                )}
              </NavLink>
            ))}
          </div>
        ))}
        <div className="hq-side-foot">
          {APP_VERSION}
          {healthy !== undefined && (
            <span className={`hq-dot${healthy ? ' ok' : ' err'}`} title={healthy ? 'Server healthy' : 'Server unreachable'} />
          )}
        </div>
      </nav>
      <div className="hq-main">
        {gmail === 'down' && (
          <div className="hq-banner">
            Gmail disconnected  sends are queued to manual. Run gmail-connect on the server.
          </div>
        )}
        <header className="hq-top">
          <button type="button" className="wf hq-burger" aria-label="Menu" onClick={() => setMenuOpen((v) => !v)}>
            <Menu size={16} />
          </button>
          <button type="button" className="hq-search" onClick={() => setPaletteOpen(true)}>
            <Search size={14} aria-hidden />
            <span>Search clients, pages</span>
            <kbd>Ctrl K</kbd>
          </button>
          <span style={{ flex: 1 }} />
          {gmail !== 'unknown' && (
            <span className={`hq-pill${gmail === 'ok' ? ' ok' : ' warn'}`}>
              <span className={`hq-dot${gmail === 'ok' ? ' ok' : ' warn'}`} />
              {gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'}
            </span>
          )}
          <ThemeSwitcher />
          <span className="hq-user">
            <span className="hq-user-av" style={{ background: av.bg, color: av.fg }}>{initials(name)}</span>
            <span className="hq-user-name">{name}</span>
            <Badge>{role()}</Badge>
          </span>
          <button
            type="button" className="wf" title="Logout" aria-label="Logout"
            onClick={() => { clearSession(); nav('/login') }}
          >
            <LogOut size={14} />
          </button>
        </header>
        <div className="hq-content">
          <Outlet />
        </div>
      </div>
      <CommandPalette
        open={paletteOpen}
        onClose={() => setPaletteOpen(false)}
        items={[...clientHits, ...staticItems]}
        onSelect={onPaletteSelect}
        onQuery={onPaletteQuery}
        placeholder="Search clients, pages, actions…"
      />
    </div>
  )
}
  • Step 4: Rewrite apps/hq-web/src/app.css
/* HQ shell — warm ops console (spec 2026-07-17 §3). */
.hq-shell { display: grid; grid-template-columns: 224px 1fr; height: 100vh; }
.hq-side {
  background: var(--bg-raised); border-right: 1px solid var(--border);
  overflow-y: auto; padding: 12px 0 0; display: flex; flex-direction: column;
}
.hq-brand { font-weight: 700; font-size: 16px; padding: 4px 16px 14px; letter-spacing: -0.01em; }
.hq-brand small { display: block; color: var(--text-dim); font-weight: 400; font-size: 11.5px; }
.hq-group { margin-bottom: 10px; }
.hq-eyebrow {
  font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--text-dim); padding: 4px 16px;
}
.hq-side a {
  display: flex; align-items: center; gap: 9px; padding: 7px 16px 7px 14px;
  color: var(--text); text-decoration: none; font-size: 13.5px;
  border-left: 2px solid transparent;
}
.hq-side a .lbl { flex: 1; }
.hq-side a:hover { background: var(--bg-hover); }
.hq-side a.active {
  background: var(--accent-soft); border-left-color: var(--accent); font-weight: 600;
}
.hq-count {
  font-family: var(--mono); font-size: 10.5px; font-weight: 600;
  background: var(--warn-soft); color: var(--warn);
  border-radius: 999px; padding: 1px 7px;
}
.hq-count.err { background: var(--err-soft); color: var(--err); }
.hq-side-foot {
  margin-top: auto; padding: 10px 16px; border-top: 1px solid var(--border);
  font-size: 11px; color: var(--text-dim); font-family: var(--mono);
  display: flex; align-items: center; gap: 7px;
}
.hq-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: var(--text-dim); }
.hq-dot.ok { background: var(--ok); }
.hq-dot.warn { background: var(--warn); }
.hq-dot.err { background: var(--err); }

.hq-main { display: flex; flex-direction: column; min-width: 0; }
.hq-banner {
  padding: 8px 20px; font-size: 13px; color: var(--warn); background: var(--warn-soft);
  border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
}
.hq-top {
  display: flex; gap: 10px; align-items: center; padding: 8px 20px;
  border-bottom: 1px solid var(--border); background: var(--bg-raised); min-height: 48px;
}
.hq-burger { display: none; padding: 6px 9px; }
.hq-search {
  display: flex; align-items: center; gap: 8px; min-width: 260px;
  background: var(--bg-inset); color: var(--text-dim);
  border: 1px solid var(--border); border-radius: 999px;
  padding: 6px 12px; font: 400 13px var(--font); cursor: pointer;
  transition: border-color var(--speed);
}
.hq-search:hover { border-color: var(--border-strong); }
.hq-search kbd {
  margin-left: auto; font-family: var(--mono); font-size: 10px;
  border: 1px solid var(--border-strong); border-radius: 3px; padding: 0 5px;
}
.hq-pill {
  display: inline-flex; align-items: center; gap: 6px;
  border-radius: 999px; padding: 3px 11px; font-size: 12px; font-weight: 500;
  border: 1px solid var(--border); color: var(--text-dim);
}
.hq-pill.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.hq-pill.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
.hq-user { display: inline-flex; align-items: center; gap: 8px; }
.hq-user-av {
  width: 26px; height: 26px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 11px; font-weight: 600;
}
.hq-user-name { font-size: 13px; font-weight: 500; }
.hq-content { overflow-y: auto; flex: 1; padding: 4px 20px 20px; }
.hq-scrim { display: none; }

/* Dashboard section grid (Task 7). */
.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 24px; align-items: start; }
.dash-grid > section { min-width: 0; }
@media (max-width: 1100px) { .dash-grid { grid-template-columns: 1fr; } }

/* Login — split panel (Task 13). */
.login-wrap { height: 100vh; display: flex; align-items: stretch; justify-content: center; }
.login-brand {
  flex: 1; background: #1a1a17; color: #eae8e2;
  display: flex; flex-direction: column; justify-content: center; padding: 48px 56px;
}
.login-brand h1 { font-size: 34px; margin: 0 0 8px; letter-spacing: -0.02em; }
.login-brand p { color: #a8a69c; max-width: 44ch; }
.login-form { flex: 0 0 440px; display: flex; align-items: center; justify-content: center; padding: 24px; }
.login-card {
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius-lg); padding: 28px; width: 100%; max-width: 380px;
}
@media (max-width: 800px) {
  .login-brand { display: none; }
  .login-form { flex: 1; }
}

/* Responsive-lite: drawer sidebar under 900px (APEX checklist item h). */
@media (max-width: 900px) {
  .hq-shell { grid-template-columns: 1fr; }
  .hq-side {
    position: fixed; inset: 0 auto 0 0; width: 240px; z-index: 40;
    transform: translateX(-100%); transition: transform var(--speed) ease;
    box-shadow: var(--shadow-lg);
  }
  .hq-side.open { transform: translateX(0); }
  .hq-scrim { display: block; position: fixed; inset: 0; z-index: 39; background: rgba(15, 14, 11, 0.4); }
  .hq-burger { display: inline-flex; }
  .hq-search { min-width: 0; flex: 1; }
  .hq-search span { display: none; }
  .hq-user-name { display: none; }
  .hq-content { padding: 4px 12px 16px; }
  .wf-page { padding: 16px 4px 24px; }
}
/* Tables scroll inside their page rather than breaking the layout. */
.hq-content { overflow-x: hidden; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
  • Step 5: ?new=1 support in Clients

In apps/hq-web/src/pages/Clients.tsx: change the react-router import (line 2) to:

import { useNavigate, useSearchParams } from 'react-router-dom'

and inside Clients() replace const [creating, setCreating] = useState(false) with:

  const [sp] = useSearchParams()
  const [creating, setCreating] = useState(sp.get('new') === '1')
  • Step 6: Verifynpm run typecheck → exit 0; npm test → pass.

  • Step 7: Visual smoke check

Dev app: sidebar shows WORK/CATALOG/INSIGHT groups (+ ADMIN for owner) with icons; Dashboard shows an amber count badge when reminders are queued; Ctrl+K opens the palette, typing 2+ chars lists matching clients, Enter navigates; top bar shows search pill, Gmail pill, avatar chip; below 900px the sidebar becomes a drawer behind the burger; palette "New client" opens Clients with the create form open.

  • Step 8: Commit
git add apps/hq-web/package.json package-lock.json apps/hq-web/src/nav.ts apps/hq-web/src/Layout.tsx apps/hq-web/src/app.css apps/hq-web/src/pages/Clients.tsx
git commit -m "feat(hq-web): grouped sidebar shell, top bar, Ctrl+K palette, responsive-lite"

Task 7: Dashboard restyle

Files:

  • Modify: apps/hq-web/src/pages/Dashboard.tsx

Interfaces:

  • Consumes: Skeleton, ErrorState (Task 3), DataTable.rowTone (Task 4), .dash-grid CSS (Task 6). Data calls unchanged.

  • Step 1: Apply these edits to apps/hq-web/src/pages/Dashboard.tsx

  1. Extend the ui import (line 4) to:
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
  1. Replace the loading/error block (lines 2830) with:
      {dash.error !== undefined && <ErrorState message={dash.error} onRetry={dash.reload} />}
      {err !== '' && <Notice tone="err">{err}</Notice>}
      {v === undefined && dash.error === undefined ? <Skeleton rows={5} /> : v === undefined ? null : (

(and keep the matching closing )} at the end of the JSX — the body below replaces the current <>…</> content).

  1. Give the stat cards toned hints — replace the <Stats> block (lines 3237) with:
          <Stats>
            <StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} />
            <StatCard label="Queued reminders" value={String(v.totals.queued)} />
            <StatCard label="Failed sends" value={String(v.totals.failed)} {...(v.totals.failed > 0 ? { hint: 'needs attention' } : {})} />
            <StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
          </Stats>

Note: StatCard has no toned hint prop; add one now in packages/ui/src/components.tsx — change StatCard to:

export function StatCard(props: { label: string; value: ReactNode; hint?: string; hintTone?: 'ok' | 'warn' | 'err' }) {
  return (
    <div className="wf-stat">
      <div className="label">{props.label}</div>
      <div className="value num">{props.value}</div>
      {props.hint !== undefined && <div className={`hint${props.hintTone !== undefined ? ` ${props.hintTone}` : ''}`}>{props.hint}</div>}
    </div>
  )
}

then use hintTone="err" on the Failed card: {...(v.totals.failed > 0 ? { hint: 'needs attention', hintTone: 'err' as const } : {})}.

  1. Row-tint the reminder queue — add to the queue DataTable (after the columns prop):
              rowTone={(i) => {
                const s = v.queue[i]?.status
                return s === 'failed' ? 'err' : undefined
              }}
  1. Two-column layout for the secondary sections — wrap the four <Section …/> calls for "Due this week", "Renewals this month", "Follow-ups today", "Recent payments" in:
          <div className="dash-grid">
            <section>Due this week</section>
            <section>Renewals this month</section>
            <section>Follow-ups today</section>
            <section>Recent payments</section>
          </div>

("Reminder queue" and "Overdue" stay full-width above the grid; each <Section> moves inside its own <section> element unchanged.)

  • Step 2: Verifynpm run typecheck → exit 0; npm test → pass. Visual: dashboard shows skeleton on load, failed reminder rows tinted red, four cards in two columns on a wide window, one column below 1100px.

  • Step 3: Commit

git add apps/hq-web/src/pages/Dashboard.tsx packages/ui/src/components.tsx
git commit -m "feat(hq-web): dashboard — skeletons, toned stats, tinted queue, 2-col grid"

Task 8: Clients restyle — filter chips + mono codes

Files:

  • Modify: apps/hq-web/src/pages/Clients.tsx

Interfaces:

  • Consumes: FilterChips (Task 3), Skeleton/ErrorState (Task 3), Column.mono (Task 4), CLIENT_STATUSES from ../api.

  • Step 1: Edits to apps/hq-web/src/pages/Clients.tsx

  1. Import changes:
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { createClient, getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api'
  1. Inside Clients() add status-filter state and a filtered view (after the useData line):
  const [statusFilter, setStatusFilter] = useState('all')
  const counts = new Map<string, number>()
  for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1)
  const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter)
  1. Replace the <Toolbar> block with:
      <Toolbar>
        <input
          className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…"
          value={q} onChange={(e) => setQ(e.target.value)}
        />
        <FilterChips
          active={statusFilter}
          onChange={setStatusFilter}
          chips={[
            { key: 'all', label: 'All', count: data?.length ?? 0 },
            ...CLIENT_STATUSES.map((s) => ({ key: s, label: s, count: counts.get(s) ?? 0 })),
          ]}
        />
        <span className="spacer" />
        <Badge>{shown.length} shown</Badge>
      </Toolbar>
  1. Replace the loading/empty/table block: loading → <Skeleton rows={6} />; error → <ErrorState message={error} onRetry={reload} />; the table maps over shown instead of data (row click uses shown[i]!.id); mark the code column mono:
      {error !== undefined && <ErrorState message={error} onRetry={reload} />}
      {data === undefined && error === undefined ? <Skeleton rows={6} />
        : shown.length === 0 ? <EmptyState>No clients{q !== '' ? ` matching “${q}”` : statusFilter !== 'all' ? ` with status ${statusFilter}` : ' yet — add the first one'}.</EmptyState> : (
        <DataTable
          columns={[
            { key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
            { key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
            { key: 'contact', label: 'Contact' },
          ]}
          onRowClick={(_row, i) => nav(`/clients/${shown[i]!.id}`)}
          rows={shown.map((c) => ({
            code: c.code, name: c.name,
            status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
            state: c.stateCode,
            contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',
          }))}
        />
      )}

(The old <Notice tone="err"> for error is replaced by the ErrorState above.)

  • Step 2: Verifynpm run typecheck → exit 0; npm test → pass. Visual: chips filter locally with counts; codes render mono; "N shown" tracks the filter (no silent truncation).

  • Step 3: Commit

git add apps/hq-web/src/pages/Clients.tsx
git commit -m "feat(hq-web): clients — status filter chips, mono codes, skeleton/error states"

Task 9: Client 360 structure — forms extracted, RecordHeader, KPIs, tabs

Files:

  • Create: apps/hq-web/src/pages/client-forms.tsx
  • Modify: apps/hq-web/src/pages/ClientDetail.tsx (restructured, code below)
  • Modify: apps/hq-web/src/pages/DocumentView.tsx:10 (import path)

Interfaces:

  • Consumes: RecordHeader, Tabs, Skeleton, ErrorState, StatCard/Stats, Badge from @sims/ui; all existing api calls.

  • Produces: client-forms.tsx exports (used by ClientDetail and DocumentView): PaymentForm, NewRecurringForm, NewAmcForm, LogInteractionForm, AssignModuleForm, ClientModuleStatusSelect, RowDate, AccountOwnerSelect — signatures identical to today's ClientDetail.tsx:324-597. ClientDetail renders tabs keyed overview | modules | documents | payments | amc | interactions | aws with the tab in the ?tab= search param (Task 10 adds the ribbon into overview).

  • Step 1: Create apps/hq-web/src/pages/client-forms.tsx

Move — verbatim, no logic changes — these functions out of apps/hq-web/src/pages/ClientDetail.tsx (current lines 324597): AccountOwnerSelect, NewRecurringForm, NewAmcForm, LogInteractionForm, AssignModuleForm, ClientModuleStatusSelect, RowDate, PaymentForm. All become export function … in the new file. File header + imports:

import { useState } from 'react'
import { fromRupees } from '@sims/domain'
import { Button, Field, Notice, Toolbar } from '@sims/ui'
import {
  createAmc, createInteraction, createRecurringPlan, patchClientModule, recordPayment,
  CLIENT_MODULE_STATUSES, KIND_LABEL, PAYMENT_MODES,
  type ClientModule, type ClientModuleStatus, type Employee, type InteractionType,
  type Kind, type Module, type PaymentMode,
} from '../api'

const today = () => new Date().toISOString().slice(0, 10)

Wrap each form's outer <div style={{ border: '1px solid var(--border)' … }}> container class as className="wf-card" with style removed (same for all five form containers) — the only styling change.

  • Step 2: Point DocumentView at the new module

apps/hq-web/src/pages/DocumentView.tsx line 10:

import { PaymentForm } from './client-forms'
  • Step 3: Rewrite apps/hq-web/src/pages/ClientDetail.tsx
import { useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
  Badge, Button, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
  StatCard, Stats, Tabs, Toolbar,
} from '@sims/ui'
import {
  assignClientModule, getClient, getClientModules, getLedger, getModules,
  patchClient, role, isManagerial, getEmployees, setClientOwner,
  getRecurringPlans, deactivateRecurringPlan,
  getAmc, deactivateAmc, generateAmcRenewalInvoice,
  getInteractions, getInteractionTypes, updateInteraction,
  getClientAwsUsage,
  CLIENT_STATUSES, KIND_LABEL,
  type AmcPaidStatus, type ClientStatus, type Outcome,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
  AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect,
  LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate,
} from './client-forms'

const inr = (p: number) => formatINR(p, { symbol: false })

const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', neutral: 'warn', negative: 'err' }

const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
type TabKey = (typeof TAB_KEYS)[number]

/** Client 360° — record header, KPIs, pulse ribbon (Task 10), tabbed sections. */
export function ClientDetail() {
  const id = useParams()['id'] ?? ''
  const nav = useNavigate()
  const [sp, setSp] = useSearchParams()
  const client = useData(() => getClient(id), [id])
  const modules = useData(getModules, [])
  const cms = useData(() => getClientModules(id), [id])
  const ledger = useData(() => getLedger(id), [id])
  const plans = useData(() => getRecurringPlans(id), [id])
  const amc = useData(() => getAmc(id), [id])
  const interactions = useData(() => getInteractions(id), [id])
  const types = useData(getInteractionTypes, [])
  const awsUsage = useData(() => getClientAwsUsage(id), [id])
  const employees = useData(() => getEmployees(), [])
  const isOwner = role() === 'owner'
  const canRoute = isManagerial()
  const [actionErr, setActionErr] = useState<string | undefined>()

  const rawTab = sp.get('tab') ?? 'overview'
  const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
  const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true })

  const c = client.data
  if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
  if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>

  const moduleName = (moduleId: string) =>
    modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId

  const docs = ledger.data?.documents ?? []
  const openDocs = docs.filter((d) => d.status === 'draft' || d.status === 'sent' || d.status === 'part_paid')
  const activeModules = (cms.data ?? []).filter((m) => m.active)
  const lastInteraction = interactions.data?.[0]?.onDate

  return (
    <div className="wf-page">
      <div className="wf-crumbs"><Link to="/clients">Clients</Link><span>/</span><span>{c.code}</span></div>
      <RecordHeader
        name={c.name}
        status={<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>}
        meta={
          <>
            <span className="mono">{c.code}</span>
            <span>· State {c.stateCode}</span>
            {c.gstin !== undefined && c.gstin !== '' && <span className="mono">· {c.gstin}</span>}
            {c.contacts[0] !== undefined && (
              <span>· {[c.contacts[0].name, c.contacts[0].email, c.contacts[0].phone].filter((x) => x !== undefined && x !== '').join(' · ')}</span>
            )}
          </>
        }
        actions={
          <>
            <select
              className="wf" style={{ width: 'auto' }} value={c.status}
              onChange={(e) => {
                setActionErr(undefined)
                patchClient(id, { status: e.target.value as ClientStatus })
                  .then(client.reload).catch((err: Error) => setActionErr(err.message))
              }}
            >
              {CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
            </select>
            <Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
          </>
        }
      />
      {canRoute && employees.data !== undefined && (
        <Toolbar>
          <AccountOwnerSelect
            ownerId={c.ownerId}
            employees={employees.data.employees}
            onChange={(ownerId) => {
              setActionErr(undefined)
              setClientOwner(id, ownerId)
                .then(client.reload).catch((err: Error) => setActionErr(err.message))
            }}
          />
        </Toolbar>
      )}
      {actionErr !== undefined && <ErrorState message={actionErr} />}

      <Tabs
        active={tab}
        onChange={setTab}
        tabs={[
          { key: 'overview', label: 'Overview' },
          { key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
          { key: 'documents', label: 'Documents', count: docs.length },
          { key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
          { key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
          { key: 'interactions', label: 'Interactions', count: (interactions.data ?? []).length },
          { key: 'aws', label: 'AWS', count: (awsUsage.data ?? []).length },
        ]}
      />

      {tab === 'overview' && (
        <>
          <Stats>
            <StatCard label="Active modules" value={String(activeModules.length)} />
            <StatCard label="Advance on account" value={ledger.data !== undefined ? formatINR(ledger.data.advancePaise) : '…'} hint="unallocated payments" />
            <StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
            <StatCard label="Last interaction" value={lastInteraction ?? '—'} />
          </Stats>
          {/* Pulse ribbon lands here in the next task. */}
          <h3 style={{ marginTop: 18 }}>Recent documents</h3>
          {docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : (
            <DataTable
              columns={[
                { key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
                { key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
                { key: 'status', label: 'Status' },
              ]}
              onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
              rows={docs.slice(0, 5).map((d) => ({
                no: d.docNo ?? 'draft', type: d.docType, date: d.docDate,
                payable: inr(d.payablePaise),
                status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
              }))}
            />
          )}
          {docs.length > 5 && (
            <Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents </Button></Toolbar>
          )}
        </>
      )}

      {tab === 'modules' && (
        <>
          {modules.data !== undefined && (
            <AssignModuleForm
              modules={modules.data}
              onAssign={(moduleId, kind) => {
                setActionErr(undefined)
                assignClientModule(id, { moduleId, kind })
                  .then(() => { cms.reload(); ledger.reload() })
                  .catch((err: Error) => setActionErr(err.message))
              }}
            />
          )}
          {cms.error !== undefined && <ErrorState message={cms.error} onRetry={cms.reload} />}
          {cms.data === undefined && cms.error === undefined ? <Skeleton />
            : (cms.data ?? []).length === 0 ? <EmptyState>No modules assigned yet.</EmptyState> : (
              <DataTable
                columns={[
                  { key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
                  { key: 'status', label: 'Status' },
                  { key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
                  { key: 'trained', label: 'Trained' },
                  { key: 'billed', label: 'Billed', numeric: true },
                  { key: 'settled', label: 'Settled', numeric: true },
                ]}
                rows={(cms.data ?? []).map((cm) => {
                  const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId)
                  return {
                    module: moduleName(cm.moduleId),
                    kind: KIND_LABEL[cm.kind],
                    status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={setActionErr} />,
                    installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
                    completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
                    trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
                    billed: paid !== undefined ? inr(paid.billedPaise) : '—',
                    settled: paid !== undefined ? inr(paid.settledPaise) : '—',
                  }
                })}
              />
            )}
        </>
      )}

      {tab === 'documents' && (
        <>
          {ledger.error !== undefined && <ErrorState message={ledger.error} onRetry={ledger.reload} />}
          {ledger.data === undefined && ledger.error === undefined ? <Skeleton />
            : docs.length === 0 ? <EmptyState>No documents yet  compose one from New Document.</EmptyState> : (
              <DataTable
                columns={[
                  { key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
                  { key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
                  { key: 'status', label: 'Status' },
                ]}
                onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
                rows={docs.map((d) => ({
                  no: d.docNo ?? <Badge tone="warn">draft</Badge>,
                  type: d.docType, date: d.docDate,
                  payable: inr(d.payablePaise),
                  status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
                }))}
              />
            )}
        </>
      )}

      {tab === 'payments' && (
        <>
          {ledger.data !== undefined && (
            <Stats>
              <StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
            </Stats>
          )}
          <PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} />
          {ledger.data !== undefined && ledger.data.payments.length > 0 && (
            <DataTable
              columns={[
                { key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
                { key: 'ref', label: 'Reference', mono: true },
                { key: 'amount', label: 'Amount', numeric: true },
                { key: 'tds', label: 'TDS', numeric: true },
              ]}
              rows={ledger.data.payments.map((p) => ({
                on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
                amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
              }))}
            />
          )}
          <h3 style={{ marginTop: 20 }}>Recurring plans</h3>
          {isOwner && cms.data !== undefined && (
            <NewRecurringForm
              clientId={id}
              options={(cms.data ?? []).filter((cm) => cm.active).map((cm) => ({
                id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`,
              }))}
              onDone={() => plans.reload()}
            />
          )}
          {plans.error !== undefined && <ErrorState message={plans.error} onRetry={plans.reload} />}
          {plans.data === undefined && plans.error === undefined ? <Skeleton />
            : (plans.data ?? []).length === 0 ? <EmptyState>No recurring plans.</EmptyState> : (
              <DataTable
                columns={[
                  { key: 'module', label: 'Module' }, { key: 'cadence', label: 'Cadence' },
                  { key: 'amount', label: 'Amount', numeric: true }, { key: 'next', label: 'Next run' },
                  { key: 'policy', label: 'Policy' }, { key: 'active', label: 'Active' }, { key: 'act', label: '' },
                ]}
                rows={(plans.data ?? []).map((plan) => {
                  const cm = cms.data?.find((x) => x.id === plan.clientModuleId)
                  return {
                    module: cm !== undefined ? moduleName(cm.moduleId) : '—',
                    cadence: plan.cadence,
                    amount: plan.amountPaise !== null ? inr(plan.amountPaise) : 'price book',
                    next: plan.nextRun,
                    policy: <Badge tone={plan.policy === 'auto' ? 'accent' : 'warn'}>{plan.policy}</Badge>,
                    active: plan.active ? 'yes' : 'no',
                    act: plan.active ? (
                      <Button onClick={() => {
                        setActionErr(undefined)
                        deactivateRecurringPlan(plan.id)
                          .then(() => plans.reload()).catch((err: Error) => setActionErr(err.message))
                      }}>Deactivate</Button>
                    ) : '—',
                  }
                })}
              />
            )}
        </>
      )}

      {tab === 'amc' && (
        <>
          {isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />}
          {amc.error !== undefined && <ErrorState message={amc.error} onRetry={amc.reload} />}
          {amc.data === undefined && amc.error === undefined ? <Skeleton />
            : (amc.data ?? []).length === 0 ? <EmptyState>No AMC contracts.</EmptyState> : (
              <DataTable
                columns={[
                  { key: 'coverage', label: 'Coverage' }, { key: 'period', label: 'Period' },
                  { key: 'amount', label: 'Amount', numeric: true },
                  { key: 'days', label: 'Reminder days', numeric: true },
                  { key: 'paid', label: 'Paid' }, { key: 'act', label: '' },
                ]}
                rows={(amc.data ?? []).map((a) => ({
                  coverage: a.coverage !== '' ? a.coverage : '—',
                  period: `${a.periodFrom}${a.periodTo}`,
                  amount: inr(a.amountPaise),
                  days: a.renewalReminderDays,
                  paid: <Badge tone={AMC_TONE[a.paidStatus]}>{a.paidStatus}</Badge>,
                  act: (
                    <div style={{ display: 'flex', gap: 6 }}>
                      {a.paidStatus !== 'unpaid' && (
                        <Button tone="primary" onClick={() => {
                          setActionErr(undefined)
                          generateAmcRenewalInvoice(a.id)
                            .then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => setActionErr(err.message))
                        }}>Generate renewal invoice</Button>
                      )}
                      {a.active && (
                        <Button onClick={() => {
                          setActionErr(undefined)
                          deactivateAmc(a.id)
                            .then(() => amc.reload()).catch((err: Error) => setActionErr(err.message))
                        }}>Deactivate</Button>
                      )}
                    </div>
                  ),
                }))}
              />
            )}
        </>
      )}

      {tab === 'interactions' && (
        <>
          {types.data !== undefined && (
            <LogInteractionForm clientId={id} types={types.data} onDone={() => interactions.reload()} />
          )}
          {interactions.error !== undefined && <ErrorState message={interactions.error} onRetry={interactions.reload} />}
          {interactions.data === undefined && interactions.error === undefined ? <Skeleton />
            : (interactions.data ?? []).length === 0 ? <EmptyState>No interactions logged yet.</EmptyState> : (
              <DataTable
                columns={[
                  { key: 'on', label: 'Date' }, { key: 'type', label: 'Type' },
                  { key: 'notes', label: 'Notes' }, { key: 'outcome', label: 'Outcome' },
                  { key: 'follow', label: 'Follow-up' },
                ]}
                rows={(interactions.data ?? []).map((i) => ({
                  on: i.onDate,
                  type: types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode,
                  notes: i.notes !== '' ? i.notes : '—',
                  outcome: i.outcome !== null ? <Badge tone={OUTCOME_TONE[i.outcome]}>{i.outcome}</Badge> : '—',
                  follow: (
                    <input
                      type="date" className="wf" style={{ width: 140 }}
                      value={i.followUpOn ?? ''}
                      onChange={(e) => {
                        setActionErr(undefined)
                        updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null })
                          .then(() => interactions.reload()).catch((err: Error) => setActionErr(err.message))
                      }}
                    />
                  ),
                }))}
              />
            )}
        </>
      )}

      {tab === 'aws' && (
        <>
          {awsUsage.error !== undefined && <ErrorState message={awsUsage.error} onRetry={awsUsage.reload} />}
          {awsUsage.data === undefined && awsUsage.error === undefined ? <Skeleton />
            : (awsUsage.data ?? []).length === 0 ? <EmptyState>No AWS usage recorded for this client.</EmptyState> : (
              <DataTable
                columns={[
                  { key: 'month', label: 'Month', mono: true }, { key: 'storage', label: 'Storage GB', numeric: true },
                  { key: 'transfer', label: 'Transfer GB', numeric: true }, { key: 'cost', label: 'Cost', numeric: true },
                  { key: 'source', label: 'Source' },
                ]}
                rows={(awsUsage.data ?? []).map((u) => ({
                  month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source,
                }))}
              />
            )}
        </>
      )}
    </div>
  )
}

Notes for the implementer:

  • The "Recent documents" overview table shows 5 with an explicit "All N documents →" button — count visible, so no silent cap.

  • useData in Clients.tsx returns reload — every ErrorState gets it.

  • formatINR import stays; fromRupees moved with the forms.

  • Step 4: Verifynpm run typecheck → exit 0; npm test → pass. Visual: Client 360 shows avatar header, KPI row, 7 tabs with counts; ?tab=documents deep-links; all CRUD forms still work in their tabs (assign module, record payment, log interaction, AMC, recurring); DocumentView "Record payment" still works.

  • Step 5: Commit

git add apps/hq-web/src/pages/client-forms.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/DocumentView.tsx
git commit -m "feat(hq-web): Client 360 — record header, KPI row, tabbed sections, forms extracted"

Task 10: Relationship-pulse ribbon (TDD on bucketing)

Files:

  • Create: apps/hq-web/src/pulse.ts
  • Create: apps/hq-web/src/components/PulseRibbon.tsx
  • Modify: apps/hq-web/src/pages/ClientDetail.tsx (overview tab)
  • Test: apps/hq-web/test/pulse.test.ts

Interfaces:

  • Consumes: ledger/interactions/amc/cms data already loaded by ClientDetail (Task 9).

  • Produces:

    • PulseEvent { id: string; lane: number; date: string; label: string; tone: 'accent' | 'ok' | 'warn' | 'err' }
    • LANES: readonly string[] (['Documents', 'Payments', 'Interactions', 'AMC & renewals'])
    • lastMonths(todayIso: string, n?: number): string[] — n 'YYYY-MM' keys ending at today's month
    • groupPulse(events: PulseEvent[], months: string[]): Map<string, PulseEvent[]> — key ${laneIndex}:${monthIndex}; out-of-window events dropped
    • PulseRibbon(props: { events: PulseEvent[]; todayIso: string; onOpen: (ev: PulseEvent) => void })
  • Step 1: Write the failing testapps/hq-web/test/pulse.test.ts:

import { describe, expect, it } from 'vitest'
import { groupPulse, lastMonths, type PulseEvent } from '../src/pulse'

describe('lastMonths', () => {
  it('returns 12 months ending at the given date, oldest first', () => {
    const m = lastMonths('2026-07-17')
    expect(m).toHaveLength(12)
    expect(m[0]).toBe('2025-08')
    expect(m[11]).toBe('2026-07')
  })
  it('crosses year boundaries correctly', () => {
    expect(lastMonths('2026-01-05', 3)).toEqual(['2025-11', '2025-12', '2026-01'])
  })
})

describe('groupPulse', () => {
  const ev = (lane: number, date: string, id: string): PulseEvent =>
    ({ id, lane, date, label: id, tone: 'accent' })
  const months = lastMonths('2026-07-17')

  it('buckets events by lane and month', () => {
    const g = groupPulse([ev(0, '2026-07-01', 'a'), ev(0, '2026-07-30', 'b'), ev(2, '2025-08-09', 'c')], months)
    expect(g.get('0:11')?.map((e) => e.id)).toEqual(['a', 'b'])
    expect(g.get('2:0')?.map((e) => e.id)).toEqual(['c'])
  })
  it('drops events outside the window', () => {
    const g = groupPulse([ev(1, '2024-01-01', 'old'), ev(1, '2027-01-01', 'future')], months)
    expect(g.size).toBe(0)
  })
})
  • Step 2: Run it — expect FAILnpm test -- pulse → module not found.

  • Step 3: Implement apps/hq-web/src/pulse.ts

/** Pure math for the relationship-pulse ribbon (Client 360 overview). */

export interface PulseEvent {
  id: string
  lane: number
  date: string // ISO yyyy-mm-dd
  label: string
  tone: 'accent' | 'ok' | 'warn' | 'err'
}

export const LANES = ['Documents', 'Payments', 'Interactions', 'AMC & renewals'] as const

/** n month keys ('YYYY-MM') ending at todayIso's month, oldest first. */
export function lastMonths(todayIso: string, n = 12): string[] {
  const year = Number(todayIso.slice(0, 4))
  const month = Number(todayIso.slice(5, 7)) // 1-based
  const out: string[] = []
  for (let i = n - 1; i >= 0; i--) {
    const total = year * 12 + (month - 1) - i
    const y = Math.floor(total / 12)
    const m = total % 12 + 1
    out.push(`${y}-${String(m).padStart(2, '0')}`)
  }
  return out
}

/** Bucket events into `${lane}:${monthIndex}` cells; out-of-window events are dropped. */
export function groupPulse(events: PulseEvent[], months: string[]): Map<string, PulseEvent[]> {
  const index = new Map<string, number>()
  months.forEach((m, i) => index.set(m, i))
  const out = new Map<string, PulseEvent[]>()
  for (const ev of events) {
    const mi = index.get(ev.date.slice(0, 7))
    if (mi === undefined) continue
    const key = `${ev.lane}:${mi}`
    const cell = out.get(key)
    if (cell !== undefined) cell.push(ev)
    else out.set(key, [ev])
  }
  return out
}
  • Step 4: Run tests — expect PASSnpm test -- pulse → 4 pass.

  • Step 5: Create apps/hq-web/src/components/PulseRibbon.tsx

import { useState } from 'react'
import { groupPulse, LANES, lastMonths, type PulseEvent } from '../pulse'

const TONE_VAR: Record<PulseEvent['tone'], string> = {
  accent: 'var(--accent)', ok: 'var(--ok)', warn: 'var(--warn)', err: 'var(--err)',
}

/**
 * Relationship pulse — last 12 months (H-04's timeline ribbon).
 * Four lanes on a shared month axis; hover fills the readout bar; click opens
 * the record. Plain DOM dots — no chart library.
 */
export function PulseRibbon(props: {
  events: PulseEvent[]
  todayIso: string
  onOpen: (ev: PulseEvent) => void
}) {
  const months = lastMonths(props.todayIso)
  const cells = groupPulse(props.events, months)
  const [focus, setFocus] = useState<PulseEvent | undefined>()

  return (
    <div className="pulse">
      <div className="pulse-head">
        <h3>Relationship pulse  last 12 months</h3>
        <div className="pulse-legend">
          {LANES.map((lane, i) => (
            <span key={lane}><span className="dot" style={{ background: TONE_VAR[i === 0 ? 'accent' : i === 1 ? 'ok' : i === 2 ? 'warn' : 'err'] }} />{lane.toUpperCase()}</span>
          ))}
        </div>
      </div>
      <div className="pulse-grid" style={{ gridTemplateColumns: `110px repeat(${months.length}, 1fr)` }}>
        {LANES.map((lane, li) => (
          <div key={lane} style={{ display: 'contents' }}>
            <div className="pulse-lane">{lane}</div>
            {months.map((m, mi) => {
              const evs = cells.get(`${li}:${mi}`) ?? []
              return (
                <div key={m} className="pulse-cell">
                  {evs.slice(0, 3).map((ev) => (
                    <button
                      key={ev.id}
                      type="button"
                      className="pulse-dot"
                      style={{ background: TONE_VAR[ev.tone] }}
                      title={ev.label}
                      onMouseEnter={() => setFocus(ev)}
                      onFocus={() => setFocus(ev)}
                      onClick={() => props.onOpen(ev)}
                      aria-label={ev.label}
                    />
                  ))}
                  {evs.length > 3 && <span className="pulse-more">+{evs.length - 3}</span>}
                </div>
              )
            })}
          </div>
        ))}
        <div className="pulse-lane" />
        {months.map((m) => (
          <div key={m} className="pulse-month">{m.slice(5)}/{m.slice(2, 4)}</div>
        ))}
      </div>
      <div className="pulse-readout">
        {focus !== undefined
          ? <><span className="mono">{focus.date}</span> · {focus.label} <span className="pulse-open">open </span></>
          : 'Hover an event · click to open it'}
      </div>
    </div>
  )
}
  • Step 6: Ribbon CSS — append to apps/hq-web/src/app.css:
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
  background: var(--bg-raised); border: 1px solid var(--border);
  border-radius: var(--radius); padding: 14px 16px; margin: 16px 0;
}
.pulse-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.pulse-head h3 { margin: 0; font-size: 15px; }
.pulse-legend { margin-left: auto; display: flex; gap: 12px; font-size: 9.5px; letter-spacing: 0.06em; color: var(--text-dim); }
.pulse-legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.pulse-grid { display: grid; gap: 2px 0; margin-top: 12px; }
.pulse-lane {
  font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--text-dim); padding: 6px 8px 6px 0; white-space: nowrap;
}
.pulse-cell {
  border-left: 1px solid var(--border);
  min-height: 26px; display: flex; align-items: center; gap: 3px; padding: 2px 3px;
  flex-wrap: wrap;
}
.pulse-dot {
  width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
  transition: transform 80ms;
}
.pulse-dot:hover { transform: scale(1.5); }
.pulse-more { font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); }
.pulse-month {
  border-left: 1px solid var(--border);
  font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); padding: 3px;
}
.pulse-readout {
  margin-top: 10px; border-left: 2px solid var(--accent);
  background: var(--bg-inset); border-radius: var(--radius-sm);
  padding: 7px 10px; font-size: 12.5px; color: var(--text);
  min-height: 32px; display: flex; align-items: center; gap: 6px;
}
.pulse-open { color: var(--accent-strong); margin-left: auto; font-size: 11.5px; }
@media (max-width: 900px) { .pulse-grid { display: none; } .pulse-readout { display: none; }
  .pulse::after { content: 'Timeline hidden on small screens.'; color: var(--text-dim); font-size: 12px; } }
  • Step 7: Wire it into ClientDetail's overview tab

In apps/hq-web/src/pages/ClientDetail.tsx:

Add imports:

import { PulseRibbon } from '../components/PulseRibbon'
import { type PulseEvent } from '../pulse'

Inside ClientDetail() (after lastInteraction), assemble the events:

  const todayIso = new Date().toISOString().slice(0, 10)
  const DOC_PULSE_TONE: Record<string, PulseEvent['tone']> = {
    paid: 'ok', part_paid: 'warn', lost: 'err', cancelled: 'err',
  }
  const pulseEvents: PulseEvent[] = [
    ...docs.map((d): PulseEvent => ({
      id: `doc:${d.id}`, lane: 0, date: d.docDate,
      label: `${d.docType} ${d.docNo ?? '(draft)'}${formatINR(d.payablePaise)} · ${d.status}`,
      tone: DOC_PULSE_TONE[d.status] ?? 'accent',
    })),
    ...(ledger.data?.payments ?? []).map((p): PulseEvent => ({
      id: `pay:${p.id}`, lane: 1, date: p.receivedOn,
      label: `Payment ${formatINR(p.amountPaise)} · ${p.mode}`, tone: 'ok',
    })),
    ...(interactions.data ?? []).map((i): PulseEvent => ({
      id: `int:${i.id}`, lane: 2, date: i.onDate,
      label: `${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? ` — ${i.notes.slice(0, 60)}` : ''}`,
      tone: i.outcome === 'negative' ? 'err' : i.outcome === 'positive' ? 'ok' : 'warn',
    })),
    ...(amc.data ?? []).flatMap((a): PulseEvent[] => [
      { id: `amc-from:${a.id}`, lane: 3, date: a.periodFrom, label: `AMC starts — ${a.coverage !== '' ? a.coverage : 'contract'}`, tone: 'accent' },
      { id: `amc-to:${a.id}`, lane: 3, date: a.periodTo, label: `AMC renewal — ${a.paidStatus}`, tone: a.paidStatus === 'unpaid' ? 'err' : 'warn' },
    ]),
    ...activeModules.filter((cm) => cm.nextRenewal !== null).map((cm): PulseEvent => ({
      id: `ren:${cm.id}`, lane: 3, date: cm.nextRenewal!,
      label: `${moduleName(cm.moduleId)} renews`, tone: 'warn',
    })),
  ]
  const openPulse = (ev: PulseEvent) => {
    if (ev.id.startsWith('doc:')) nav(`/documents/${ev.id.slice(4)}`)
    else if (ev.id.startsWith('pay:')) setTab('payments')
    else if (ev.id.startsWith('int:')) setTab('interactions')
    else setTab('amc')
  }

Replace the placeholder comment {/* Pulse ribbon lands here in the next task. */} with:

          <PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
  • Step 8: Verifynpm run typecheck → exit 0; npm test → all pass (including 4 new pulse tests). Visual: overview shows the 4-lane ribbon with month axis; hovering a dot fills the readout; clicking a document dot opens the document; clicking others switches tab.

  • Step 9: Commit

git add apps/hq-web/src/pulse.ts apps/hq-web/test/pulse.test.ts apps/hq-web/src/components/PulseRibbon.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/app.css
git commit -m "feat(hq-web): relationship-pulse ribbon on Client 360 overview"

Task 11: DocumentView + NewDocument polish

Files:

  • Modify: apps/hq-web/src/pages/DocumentView.tsx
  • Modify: apps/hq-web/src/pages/NewDocument.tsx

Interfaces:

  • Consumes: RecordHeader, Skeleton, ErrorState, Column.mono (Tasks 34).

  • Step 1: DocumentView edits

  1. Import ErrorState, RecordHeader, Skeleton from @sims/ui (extend line 4) and Link from react-router-dom (extend line 2).
  2. Replace the loading/error early-returns (lines 5960) with:
  if (full.error !== undefined) return <div className="wf-page"><ErrorState message={full.error} onRetry={full.reload} /></div>
  if (doc === undefined || full.data === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
  1. Replace the <PageHeader …/> call (lines 9095) with a breadcrumb + RecordHeader:
      <div className="wf-crumbs">
        <Link to="/clients">Clients</Link><span>/</span>
        {client.data !== undefined ? <Link to={`/clients/${doc.clientId}`}>{client.data.name}</Link> : <span></span>}
        <span>/</span><span className="mono">{doc.docNo ?? 'draft'}</span>
      </div>
      <RecordHeader
        name={TITLE[doc.docType]}
        avatarSeed={client.data?.name ?? doc.clientId}
        status={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
        meta={
          <>
            <span className="mono">{doc.docNo ?? '(draft)'}</span>
            <span>· {doc.fy}</span>
            <span>· {doc.docDate}</span>
            <span>· Payable <strong className="num">{formatINR(doc.payablePaise)}</strong></span>
          </>
        }
      />
  1. Timeline + Email log side by side — wrap those two sections (from <h3 …>Timeline</h3> through the email DataTable's closing )}) in:
      <div className="dash-grid">
        <section>Timeline block</section>
        <section>Email log block</section>
      </div>
  • Step 2: NewDocument edits
  1. Wrap each line-item row in a card: in the lines.map JSX (line 203) change the outer div to className="wf-card" keeping key={i} and replacing its style with style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}.
  2. Swap the client search dropdown suggestion container (line 172) borderRadius: 8borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)'.
  3. No other changes — the composer's live-preview machinery stays untouched.
  • Step 3: Verifynpm run typecheck → exit 0; npm test → pass. Visual: document page shows breadcrumb → record header with mono doc-no; timeline/email side-by-side on wide screens; composer lines render as cards; live preview + totals strip still update on blur.

  • Step 4: Commit

git add apps/hq-web/src/pages/DocumentView.tsx apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): document view record header + composer line cards"

Task 12: Reports, Modules, Employees, DocumentTemplate polish

Files:

  • Modify: apps/hq-web/src/pages/Reports.tsx (restructure below)
  • Modify: apps/hq-web/src/pages/Modules.tsx (small diffs)
  • Modify: apps/hq-web/src/pages/Employees.tsx (small diffs)
  • Modify: apps/hq-web/src/pages/DocumentTemplate.tsx (small diffs)

Interfaces:

  • Consumes: FilterChips, Skeleton, ErrorState, Column.mono, DataTable.rowTone.

  • Step 1: Reports — one report at a time behind FilterChips

Rework Reports() so a report state picks which section renders (all four useData hooks stay as they are — they are cheap and keep the code simple):

  const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues')

After <PageHeader …/> render:

      <FilterChips
        active={report}
        onChange={(k) => setReport(k as typeof report)}
        chips={[
          { key: 'dues', label: 'Dues aging' },
          { key: 'revenue', label: 'Module revenue' },
          { key: 'profit', label: 'Profitability' },
          { key: 'aws', label: 'AWS costs' },
        ]}
      />

then wrap each existing section in {report === 'dues' && (…)} etc., dropping the <h3> titles (the chip is the title). Add per-section states: loading → <Skeleton rows={5} />, error → <ErrorState message={…} onRetry={….reload} />. On the dues table add:

            rowTone={(i) => ((dues.data![i]!.b90p > 0) ? 'err' : dues.data![i]!.b61_90 > 0 ? 'warn' : undefined)}

Import ErrorState, FilterChips, Skeleton from @sims/ui; drop the now-unused Notice/EmptyState only if genuinely unused (empty lists still use EmptyState).

  • Step 2: Modules — swap Loading… EmptyStates for <Skeleton rows={5} /> (two places: module list line 37, price list line 202), errors for <ErrorState message={…} onRetry={….reload}/>; mark columns mono: module list code, price book none; form containers (NewModuleForm outer div line 136) → className="wf-card" (drop border/radius/padding from style). Extend the ui import accordingly.

  • Step 3: Employees — same treatment: Loading…<Skeleton rows={4} />, list.errorErrorState with list.reload; email column mono: true; the three form containers → className="wf-card".

  • Step 4: DocumentTemplate — no structural change (owner-only page with live preview); only swap its plain bordered containers: none exist — skip. Verify it still renders under the new tokens (its preview panel uses --bg-sunken which is undefined → falls back to #e9e9ee; change the two background: 'var(--bg-sunken, #e9e9ee)' occurrences (lines 113 in DocumentTemplate.tsx; also NewDocument.tsx lines 286/299 and Modules.tsx line 93) to background: 'var(--bg-inset)').

  • Step 5: Verifynpm run typecheck → exit 0; npm test → pass. Visual: Reports shows one report at a time via chips; dues rows with 90+ money tint red; Modules/Employees show skeletons and cards; template editor preview panel matches the warm theme.

  • Step 6: Commit

git add apps/hq-web/src/pages/Reports.tsx apps/hq-web/src/pages/Modules.tsx apps/hq-web/src/pages/Employees.tsx apps/hq-web/src/pages/DocumentTemplate.tsx apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): reports chips + skeleton/error states across catalog pages"

Task 13: Login split panel

Files:

  • Modify: apps/hq-web/src/Login.tsx

Interfaces:

  • Consumes: .login-wrap/.login-brand/.login-form/.login-card CSS (Task 6).

  • Step 1: Rewrite the Login JSX (state + submit logic unchanged) — replace the return block:

  return (
    <div className="login-wrap">
      <div className="login-brand">
        <h1>SiMS HQ</h1>
        <p>The ops console  clients, documents, payments, AMC and reminders in one place.</p>
      </div>
      <div className="login-form">
        <div className="login-card">
          <h2 style={{ marginTop: 0 }}>Sign in</h2>
          <Field label="Email">
            <input
              className="wf" type="email" value={email} autoFocus
              onChange={(e) => setEmail(e.target.value)}
            />
          </Field>
          <Field label="Password">
            <input
              className="wf" type="password" value={pw}
              onChange={(e) => setPw(e.target.value)}
              onKeyDown={(e) => e.key === 'Enter' && void submit()}
            />
          </Field>
          <Button tone="primary" onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
          {error !== undefined && <Notice tone="err">{error}</Notice>}
        </div>
      </div>
    </div>
  )
  • Step 2: Verifynpm run typecheck → exit 0. Visual: dark warm brand panel left, form right; brand panel hides below 800px; login still works.

  • Step 3: Commit

git add apps/hq-web/src/Login.tsx
git commit -m "feat(hq-web): split-panel login"

Task 14: Full verification pass

Files: none (verification only; fix-forward anything found, committing fixes separately).

  • Step 1: Static gates — from repo root:

  • npm run typecheck → exit 0

  • npm test186 tests (177 existing + 5 avatar + 4 palette-match… count may differ; assert: all pass, none skipped)

  • Step 2: End-to-end walk (dev app running, logged in as owner) — each item must visibly work:

  1. Theme: toggle dark/light; click every accent swatch; teal is default on a fresh profile (clear localStorage ui.accent).
  2. Shell: groups + icons; Dashboard badge appears when a reminder is queued; health dot in footer; Gmail pill state matches server; Ctrl+K → type a client fragment → Enter opens the client; "New client" action opens Clients with the form open.
  3. Dashboard: skeleton flash on load; queue actions (Preview/Send/Dismiss) still work; failed rows tinted.
  4. Clients: chips filter; counts correct; search still server-side; row → Client 360.
  5. Client 360: header, KPIs, ribbon (hover readout, click-through), all 7 tabs, every form still saves (assign module, payment, plan, AMC, interaction, follow-up date), ?tab= deep link.
  6. Documents: open a document — breadcrumb, mono number, actions, share links, PDF iframe; New Document composer: pick client, add line, live preview + totals, Save Draft.
  7. Reports: all four chips; AWS month picker; dues row tinting.
  8. Modules / Employees / Document Template: load, create/edit flows, template live preview.
  9. Responsive: ≤900px — drawer nav opens/closes, tables scroll horizontally, composer single-column, login brand panel hidden.
  10. Dark mode: repeat a spot-check of 35 screens in dark.
  • Step 3: Update docsREADME.md/STATUS.md if they describe the old UI (check grep -l "left nav" README.md STATUS.md docs/ -r); update the verified-state line in CLAUDE.md (test count) if the number changed.

  • Step 4: Final commit

git add -A
git commit -m "docs: record redesign verification (typecheck + tests + e2e walk)"

Self-review checklist (ran at plan-writing time)

  • Spec coverage: tokens §2 → Task 1; shell/palette/responsive §3 → Tasks 56; components §4 → Tasks 25 (+ StatCard hintTone in Task 7); pages §5 → Tasks 713; ribbon → Task 10; states §7 → Tasks 3, 712; testing §8 → Tasks 2, 5, 10, 14. Nav slot for Pipeline: nav.ts comment. Employees-in-nav gap: Task 6.
  • Type consistency: PaletteItem defined once (Task 5), consumed in Task 6; PulseEvent/lastMonths/groupPulse defined Task 10 and used only there; client-forms.tsx exports named identically to today's functions; rowTone/mono signatures match between Task 4 (definition) and Tasks 7, 8, 12 (use).
  • No placeholders: every code step carries real code; the only "move verbatim" instruction (Task 9 Step 1) names exact source lines and the exact new imports/exports.