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

2529 lines
104 KiB
Markdown

This file contains ambiguous Unicode characters!

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

# 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:
```css
/* ================================================================
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:
```ts
export const ACCENTS = ['teal', 'indigo', 'blue', 'emerald', 'violet', 'rose', 'amber'] as const
```
Change the fallback in `getAccent` (line 20) from `'indigo'` to `'teal'`:
```ts
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:
```ts
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**
```bash
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 test**`packages/ui/test/avatar.test.ts`:
```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 FAIL**`npm test -- avatar` → fails: cannot find `../src/avatar`.
- [ ] **Step 3: Implement**`packages/ui/src/avatar.ts`:
```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:
```ts
export * from './avatar'
```
- [ ] **Step 4: Run tests — expect PASS**`npm test -- avatar` → 5 pass. Then `npm run typecheck` → exit 0.
- [ ] **Step 5: Commit**
```bash
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`**
```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`**
```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`**
```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:
```ts
export * from './states'
export * from './chips'
export * from './tabs'
```
- [ ] **Step 5: Verify**`npm run typecheck` → exit 0; `npm test` → all pass.
- [ ] **Step 6: Commit**
```bash
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`**
```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:
```tsx
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:
```tsx
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`:
```ts
export * from './record'
```
- [ ] **Step 5: Verify**`npm run typecheck` → exit 0 (all new props optional, existing call sites unaffected); `npm test` → pass.
- [ ] **Step 6: Commit**
```bash
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 test**`packages/ui/test/palette-match.test.ts`:
```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 FAIL**`npm test -- palette-match` → module not found.
- [ ] **Step 3: Implement `packages/ui/src/palette-match.ts`**
```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 PASS**`npm test -- palette-match` → 4 pass.
- [ ] **Step 5: Create `packages/ui/src/palette.tsx`**
```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`:
```ts
export * from './palette-match'
export * from './palette'
```
- [ ] **Step 7: Verify**`npm run typecheck` → exit 0; `npm test` → all pass.
- [ ] **Step 8: Commit**
```bash
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`**
```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`**
```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`**
```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:
```ts
import { useNavigate, useSearchParams } from 'react-router-dom'
```
and inside `Clients()` replace `const [creating, setCreating] = useState(false)` with:
```ts
const [sp] = useSearchParams()
const [creating, setCreating] = useState(sp.get('new') === '1')
```
- [ ] **Step 6: Verify**`npm 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**
```bash
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:
```ts
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
```
2. Replace the loading/error block (lines 2830) with:
```tsx
{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).
3. Give the stat cards toned hints — replace the `<Stats>` block (lines 3237) with:
```tsx
<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:
```tsx
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 } : {})}`.
4. Row-tint the reminder queue — add to the queue `DataTable` (after the `columns` prop):
```tsx
rowTone={(i) => {
const s = v.queue[i]?.status
return s === 'failed' ? 'err' : undefined
}}
```
5. 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:
```tsx
<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: Verify**`npm 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**
```bash
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:
```ts
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'
```
2. Inside `Clients()` add status-filter state and a filtered view (after the `useData` line):
```ts
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)
```
3. Replace the `<Toolbar>` block with:
```tsx
<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>
```
4. 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:
```tsx
{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: Verify**`npm 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**
```bash
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:
```tsx
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:
```ts
import { PaymentForm } from './client-forms'
```
- [ ] **Step 3: Rewrite `apps/hq-web/src/pages/ClientDetail.tsx`**
```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: Verify**`npm 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**
```bash
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 test**`apps/hq-web/test/pulse.test.ts`:
```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 FAIL**`npm test -- pulse` → module not found.
- [ ] **Step 3: Implement `apps/hq-web/src/pulse.ts`**
```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 PASS**`npm test -- pulse` → 4 pass.
- [ ] **Step 5: Create `apps/hq-web/src/components/PulseRibbon.tsx`**
```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`:
```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:
```ts
import { PulseRibbon } from '../components/PulseRibbon'
import { type PulseEvent } from '../pulse'
```
Inside `ClientDetail()` (after `lastInteraction`), assemble the events:
```ts
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:
```tsx
<PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
```
- [ ] **Step 8: Verify**`npm 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**
```bash
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:
```tsx
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>
```
3. Replace the `<PageHeader …/>` call (lines 9095) with a breadcrumb + RecordHeader:
```tsx
<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>
</>
}
/>
```
4. Timeline + Email log side by side — wrap those two sections (from `<h3 …>Timeline</h3>` through the email `DataTable`'s closing `)}`) in:
```tsx
<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: 8``borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)'`.
3. No other changes — the composer's live-preview machinery stays untouched.
- [ ] **Step 3: Verify**`npm 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**
```bash
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):
```tsx
const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues')
```
After `<PageHeader …/>` render:
```tsx
<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:
```tsx
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.error``ErrorState` 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: Verify**`npm 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**
```bash
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:
```tsx
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: Verify**`npm run typecheck` → exit 0. Visual: dark warm brand panel left, form right; brand panel hides below 800px; login still works.
- [ ] **Step 3: Commit**
```bash
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 test`**186 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 docs**`README.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**
```bash
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.