From e919a6b871efeb194e4894ad12b0b7f8d34760c8 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Fri, 17 Jul 2026 01:43:07 +0530 Subject: [PATCH] =?UTF-8?q?docs(plan):=20HQ=20console=20redesign=20?= =?UTF-8?q?=E2=80=94=2014-task=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-07-17-hq-console-redesign.md | 2525 +++++++++++++++++ 1 file changed, 2525 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-hq-console-redesign.md diff --git a/docs/superpowers/plans/2026-07-17-hq-console-redesign.md b/docs/superpowers/plans/2026-07-17-hq-console-redesign.md new file mode 100644 index 0000000..25c7d1b --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-hq-console-redesign.md @@ -0,0 +1,2525 @@ +# HQ Console Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 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) ` + +**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@tecnostac.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 (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 4–11) with: + +```ts +const SWATCH: Record = { + 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` (0–359), `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 ( +
+ {Array.from({ length: rows }, (_x, i) => ( +
+ ))} +
+ ) +} + +export function ErrorState(props: { message: string; onRetry?: () => void }) { + return ( +
+ {props.message} + {props.onRetry !== undefined && ( + + )} +
+ ) +} +``` + +- [ ] **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 ( +
+ {props.chips.map((c) => ( + + ))} +
+ ) +} +``` + +- [ ] **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 ( +
+ {props.tabs.map((t) => ( + + ))} +
+ ) +} +``` + +- [ ] **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 5–17, Column/DataTable lines 42–73) +- 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 ( +
+
{initials(seed)}
+
+
+

{props.name}

+ {props.status} +
+ {props.meta !== undefined &&
{props.meta}
} +
+ {props.actions !== undefined &&
{props.actions}
} +
+ ) +} +``` + +- [ ] **Step 2: Upgrade PageHeader in `packages/ui/src/components.tsx`** + +Replace the `PageHeader` function (lines 5–17) with: + +```tsx +export function PageHeader(props: { + title: string + desc?: string + badge?: string + breadcrumb?: ReactNode + actions?: ReactNode +}) { + return ( + <> + {props.breadcrumb !== undefined &&
{props.breadcrumb}
} +
+

{props.title}

+ {props.badge !== undefined && {props.badge}} +
+ {props.actions} +
+ {props.desc !== undefined &&

{props.desc}

} + + ) +} +``` + +- [ ] **Step 3: Upgrade DataTable in `packages/ui/src/components.tsx`** + +Replace the `Column` interface and `DataTable` function (lines 42–73) with: + +```tsx +export interface Column { + key: string + label: string + numeric?: boolean + mono?: boolean +} + +export function DataTable(props: { + columns: Column[] + rows: Record[] + onRowClick?: (row: Record, 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 ( + + + + {props.columns.map((c) => ( + + ))} + + + + {props.rows.map((row, i) => { + const tone = props.rowTone?.(i) + return ( + props.onRowClick?.(row, i)} + style={props.onRowClick !== undefined ? { cursor: 'pointer' } : undefined} + > + {props.columns.map((c) => ( + + ))} + + ) + })} + +
{c.label}
{row[c.key]}
+ ) +} +``` + +- [ ] **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(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 ( +
+
e.stopPropagation()} onKeyDown={onKey}> + { setQ(e.target.value); setSel(0); props.onQuery?.(e.target.value) }} + /> +
+ {hits.length === 0 &&
No matches
} + {groups.map((g) => ( +
+
{g.name}
+ {g.items.map(({ item, index }) => ( + + ))} +
+ ))} +
+
+ ↑↓ navigate + open + esc close +
+
+
+ ) +} +``` + +- [ ] **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() + const [menuOpen, setMenuOpen] = useState(false) + const [paletteOpen, setPaletteOpen] = useState(false) + const [clientHits, setClientHits] = useState([]) + const queryTimer = useRef(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(() => { + 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 + + 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 ( +
+ {menuOpen &&
setMenuOpen(false)} />} + +
+ {gmail === 'down' && ( +
+ Gmail disconnected — sends are queued to manual. Run gmail-connect on the server. +
+ )} +
+ + + + {gmail !== 'unknown' && ( + + + {gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'} + + )} + + + {initials(name)} + {name} + {role()} + + +
+
+ +
+
+ setPaletteOpen(false)} + items={[...clientHits, ...staticItems]} + onSelect={onPaletteSelect} + onQuery={onPaletteQuery} + placeholder="Search clients, pages, actions…" + /> +
+ ) +} +``` + +- [ ] **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 28–30) with: + +```tsx + {dash.error !== undefined && } + {err !== '' && {err}} + {v === undefined && dash.error === undefined ? : 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 `` block (lines 32–37) with: + +```tsx + + + + 0 ? { hint: 'needs attention' } : {})} /> + + +``` + +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 ( +
+
{props.label}
+
{props.value}
+ {props.hint !== undefined &&
{props.hint}
} +
+ ) +} +``` + +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 `
` calls for "Due this week", "Renewals this month", "Follow-ups today", "Recent payments" in: + +```tsx +
+
…Due this week…
+
…Renewals this month…
+
…Follow-ups today…
+
…Recent payments…
+
+``` + +("Reminder queue" and "Overdue" stay full-width above the grid; each `
` moves inside its own `
` 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() + 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 `` block with: + +```tsx + + setQ(e.target.value)} + /> + ({ key: s, label: s, count: counts.get(s) ?? 0 })), + ]} + /> + + {shown.length} shown + +``` + +4. Replace the loading/empty/table block: loading → ``; error → ``; the table maps over `shown` instead of `data` (row click uses `shown[i]!.id`); mark the code column mono: + +```tsx + {error !== undefined && } + {data === undefined && error === undefined ? + : shown.length === 0 ? No clients{q !== '' ? ` matching “${q}”` : statusFilter !== 'all' ? ` with status ${statusFilter}` : ' yet — add the first one'}. : ( + nav(`/clients/${shown[i]!.id}`)} + rows={shown.map((c) => ({ + code: c.code, name: c.name, + status: {c.status}, + state: c.stateCode, + contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—', + }))} + /> + )} +``` + +(The old `` 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 324–597): `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 `
` 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 = { paid: 'ok', unpaid: 'err', unbilled: 'warn' } +const OUTCOME_TONE: Record = { 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() + + 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 + if (c === undefined) return
+ + 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 ( +
+
Clients/{c.code}
+ {c.status}} + meta={ + <> + {c.code} + · State {c.stateCode} + {c.gstin !== undefined && c.gstin !== '' && · {c.gstin}} + {c.contacts[0] !== undefined && ( + · {[c.contacts[0].name, c.contacts[0].email, c.contacts[0].phone].filter((x) => x !== undefined && x !== '').join(' · ')} + )} + + } + actions={ + <> + + + + } + /> + {canRoute && employees.data !== undefined && ( + + { + setActionErr(undefined) + setClientOwner(id, ownerId) + .then(client.reload).catch((err: Error) => setActionErr(err.message)) + }} + /> + + )} + {actionErr !== undefined && } + + + + {tab === 'overview' && ( + <> + + + + + + + {/* Pulse ribbon lands here in the next task. */} +

Recent documents

+ {docs.length === 0 ? No documents yet. : ( + 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: {d.status}, + }))} + /> + )} + {docs.length > 5 && ( + + )} + + )} + + {tab === 'modules' && ( + <> + {modules.data !== undefined && ( + { + setActionErr(undefined) + assignClientModule(id, { moduleId, kind }) + .then(() => { cms.reload(); ledger.reload() }) + .catch((err: Error) => setActionErr(err.message)) + }} + /> + )} + {cms.error !== undefined && } + {cms.data === undefined && cms.error === undefined ? + : (cms.data ?? []).length === 0 ? No modules assigned yet. : ( + { + const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId) + return { + module: moduleName(cm.moduleId), + kind: KIND_LABEL[cm.kind], + status: cms.reload()} onError={setActionErr} />, + installed: cms.reload()} onError={setActionErr} />, + completed: cms.reload()} onError={setActionErr} />, + trained: cms.reload()} onError={setActionErr} />, + billed: paid !== undefined ? inr(paid.billedPaise) : '—', + settled: paid !== undefined ? inr(paid.settledPaise) : '—', + } + })} + /> + )} + + )} + + {tab === 'documents' && ( + <> + {ledger.error !== undefined && } + {ledger.data === undefined && ledger.error === undefined ? + : docs.length === 0 ? No documents yet — compose one from New Document. : ( + nav(`/documents/${docs[i]!.id}`)} + rows={docs.map((d) => ({ + no: d.docNo ?? draft, + type: d.docType, date: d.docDate, + payable: inr(d.payablePaise), + status: {d.status}, + }))} + /> + )} + + )} + + {tab === 'payments' && ( + <> + {ledger.data !== undefined && ( + + + + )} + { ledger.reload(); cms.reload(); amc.reload() }} /> + {ledger.data !== undefined && ledger.data.payments.length > 0 && ( + ({ + on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—', + amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—', + }))} + /> + )} +

Recurring plans

+ {isOwner && cms.data !== undefined && ( + cm.active).map((cm) => ({ + id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`, + }))} + onDone={() => plans.reload()} + /> + )} + {plans.error !== undefined && } + {plans.data === undefined && plans.error === undefined ? + : (plans.data ?? []).length === 0 ? No recurring plans. : ( + { + 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: {plan.policy}, + active: plan.active ? 'yes' : 'no', + act: plan.active ? ( + + ) : '—', + } + })} + /> + )} + + )} + + {tab === 'amc' && ( + <> + {isOwner && amc.reload()} />} + {amc.error !== undefined && } + {amc.data === undefined && amc.error === undefined ? + : (amc.data ?? []).length === 0 ? No AMC contracts. : ( + ({ + coverage: a.coverage !== '' ? a.coverage : '—', + period: `${a.periodFrom} → ${a.periodTo}`, + amount: inr(a.amountPaise), + days: a.renewalReminderDays, + paid: {a.paidStatus}, + act: ( +
+ {a.paidStatus !== 'unpaid' && ( + + )} + {a.active && ( + + )} +
+ ), + }))} + /> + )} + + )} + + {tab === 'interactions' && ( + <> + {types.data !== undefined && ( + interactions.reload()} /> + )} + {interactions.error !== undefined && } + {interactions.data === undefined && interactions.error === undefined ? + : (interactions.data ?? []).length === 0 ? No interactions logged yet. : ( + ({ + on: i.onDate, + type: types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode, + notes: i.notes !== '' ? i.notes : '—', + outcome: i.outcome !== null ? {i.outcome} : '—', + follow: ( + { + 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 && } + {awsUsage.data === undefined && awsUsage.error === undefined ? + : (awsUsage.data ?? []).length === 0 ? No AWS usage recorded for this client. : ( + ({ + month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source, + }))} + /> + )} + + )} +
+ ) +} +``` + +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` — 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 { + const index = new Map() + months.forEach((m, i) => index.set(m, i)) + const out = new Map() + 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 = { + 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() + + return ( +
+
+

Relationship pulse — last 12 months

+
+ {LANES.map((lane, i) => ( + {lane.toUpperCase()} + ))} +
+
+
+ {LANES.map((lane, li) => ( +
+
{lane}
+ {months.map((m, mi) => { + const evs = cells.get(`${li}:${mi}`) ?? [] + return ( +
+ {evs.slice(0, 3).map((ev) => ( +
+ ) + })} +
+ ))} +
+ {months.map((m) => ( +
{m.slice(5)}/{m.slice(2, 4)}
+ ))} +
+
+ {focus !== undefined + ? <>{focus.date} · {focus.label} open → + : 'Hover an event · click to open it'} +
+
+ ) +} +``` + +- [ ] **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 = { + 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 + +``` + +- [ ] **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 3–4). + +- [ ] **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 59–60) with: + +```tsx + if (full.error !== undefined) return
+ if (doc === undefined || full.data === undefined) return
+``` + +3. Replace the `` call (lines 90–95) with a breadcrumb + RecordHeader: + +```tsx +
+ Clients/ + {client.data !== undefined ? {client.data.name} : } + /{doc.docNo ?? 'draft'} +
+ {doc.status}} + meta={ + <> + {doc.docNo ?? '(draft)'} + · {doc.fy} + · {doc.docDate} + · Payable {formatINR(doc.payablePaise)} + + } + /> +``` + +4. Timeline + Email log side by side — wrap those two sections (from `

Timeline

` through the email `DataTable`'s closing `)}`) in: + +```tsx +
+
…Timeline block…
+
…Email log block…
+
+``` + +- [ ] **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 `` render: + +```tsx + 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 `

` titles (the chip is the title). Add per-section states: loading → ``, error → ``. 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 `` (two places: module list line 37, price list line 202), errors for ``; 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…` → ``, `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 ( +
+
+

SiMS HQ

+

The ops console — clients, documents, payments, AMC and reminders in one place.

+
+
+
+

Sign in

+ + setEmail(e.target.value)} + /> + + + setPw(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && void submit()} + /> + + + {error !== undefined && {error}} +
+
+
+ ) +``` + +- [ ] **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 3–5 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 5–6; components §4 → Tasks 2–5 (+ StatCard hintTone in Task 7); pages §5 → Tasks 7–13; ribbon → Task 10; states §7 → Tasks 3, 7–12; 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.