# 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) ` **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 (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.