104 KiB
HQ Console Redesign Implementation Plan
STATUS: EXECUTED 2026-07-17. All 14 tasks landed and the §8 verification pass was recorded (commit
062ddd0); kept for the task-level rationale and rollback reference.
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restyle the HQ console into the approved warm ops-console design (spec: docs/superpowers/specs/2026-07-17-hq-console-redesign-design.md) — warm palette + teal accent, grouped sidebar, Ctrl+K palette, upgraded tables/states, and a Client 360 with a 12-month relationship-pulse ribbon.
Architecture: UI-only. packages/ui gets new tokens and new generic components (kept free of router/icon deps); apps/hq-web gets the new shell, page restyles, and the HQ-specific pulse ribbon. Zero backend/schema changes; every page keeps its existing data calls.
Tech Stack: React 19, react-router-dom 7 (HashRouter), Vite 6, vitest 3 (Node env — pure-function tests only, no jsdom), TypeScript strict + noUncheckedIndexedAccess, lucide-react (new, hq-web only).
Global Constraints
- UI-only: no changes under
apps/hq/src,apps/hq/schema.sql, or anypackages/*other thanpackages/ui. @sims/uistays generic: no react-router, no lucide-react, no HQ domain types in it.- Strict TS: repo root
npm run typecheckandnpm testmust pass after every task. 177 tests exist today; new tests only add. - Code style: no semicolons, single quotes, 2-space indent,
props.x !== undefinedguards (match surrounding files). - Money renders via
formatINRonly; 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 inpackages/ui/src/tokens.css; HQ shell classes arehq-*and live inapps/hq-web/src/app.css. - Commit after every task (message given per task). Do not push.
- All commits end with:
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Verify commands (run from repo root C:\SiMS\hq):
- Typecheck:
npm run typecheck→ exits 0 - Tests:
npm test→ all pass - Dev app: terminal A
cd apps/hq && npm start(API :5182), terminal Bcd apps/hq-web && npm run dev(Vite :5183). Loginadmin@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:8and: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; existingwf-*classes restyled in place.ACCENTSnow includes'teal'(the default). -
Step 1: Rewrite
packages/ui/src/tokens.css
Replace the entire file with:
/* ================================================================
SiMS HQ design system — warm ops-console (spec 2026-07-17).
Direction A v2 discipline (flat, border-defined, ink primary,
accent-for-state) wearing the H-04 warm palette + teal accent.
Fonts are bundled (Inter Variable + JetBrains Mono Variable).
Mode + accent apply via <html data-theme=… data-accent=…> (theme.ts).
Class names are stable: restyling here restyles every screen.
================================================================ */
:root {
--font: 'Inter Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
--mono: 'JetBrains Mono Variable', 'Cascadia Mono', Consolas, monospace;
--radius-sm: 4px;
--radius: 6px;
--radius-lg: 10px;
--pad: 12px;
--speed: 140ms;
/* Flat by default: resting surfaces are border-defined, not shadowed. */
--shadow-sm: none;
--shadow-md: 0 1px 2px rgba(20, 18, 12, 0.05);
--shadow-lg: 0 16px 48px rgba(20, 18, 12, 0.22), 0 4px 12px rgba(20, 18, 12, 0.1);
}
/* ---- accents (accent paints STATE — active nav, chips, links, focus) ---- */
[data-accent='teal'] { --accent: #115e59; --accent-strong: #0d4f4a; --accent-contrast: #ffffff; }
[data-accent='indigo'] { --accent: #4f5df0; --accent-strong: #3f4cd9; --accent-contrast: #ffffff; }
[data-accent='blue'] { --accent: #2f7ce8; --accent-strong: #2368ca; --accent-contrast: #ffffff; }
[data-accent='emerald'] { --accent: #14957a; --accent-strong: #0f7f68; --accent-contrast: #ffffff; }
[data-accent='violet'] { --accent: #8250df; --accent-strong: #6f42c1; --accent-contrast: #ffffff; }
[data-accent='rose'] { --accent: #d4497c; --accent-strong: #bb3a6a; --accent-contrast: #ffffff; }
[data-accent='amber'] { --accent: #b97e10; --accent-strong: #a06a08; --accent-contrast: #ffffff; }
/* ---- light (default): warm paper ---- */
:root, [data-theme='light'] {
--bg: #fafaf7;
--bg-raised: #ffffff;
--bg-hover: #f2f1ec;
--bg-inset: #f2f1ec;
--border: #e7e5de;
--border-strong: #d6d4cc;
--text: #1a1a17;
--text-dim: #6b6a62;
--ok: #047857;
--warn: #a8702b;
--err: #a64242;
--accent-soft: color-mix(in srgb, var(--accent) 10%, var(--bg-raised));
--ok-soft: #e2f4eb;
--warn-soft: #faf1e0;
--err-soft: #f6e5e5;
--scrollbar: #d6d4cc;
color-scheme: light;
}
/* ---- dark: warm night ---- */
[data-theme='dark'] {
--bg: #131210;
--bg-raised: #1b1a17;
--bg-hover: #242320;
--bg-inset: #0e0d0b;
--border: #2b2a25;
--border-strong: #3a3830;
--text: #eae8e2;
--text-dim: #a8a69c;
--ok: #43c288;
--warn: #e6b23e;
--err: #ef6a63;
--accent-soft: color-mix(in srgb, var(--accent) 16%, var(--bg-raised));
--ok-soft: color-mix(in srgb, #43c288 14%, var(--bg-raised));
--warn-soft: color-mix(in srgb, #e6b23e 14%, var(--bg-raised));
--err-soft: color-mix(in srgb, #ef6a63 14%, var(--bg-raised));
--scrollbar: #3a3830;
--shadow-md: 0 1px 2px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.6);
color-scheme: dark;
}
/* Dark-mode accents: explicit lighter values so every accent stays itself. */
[data-theme='dark'][data-accent='teal'] { --accent: #2da89a; --accent-strong: #22897d; }
[data-theme='dark'][data-accent='indigo'] { --accent: #7c8cff; --accent-strong: #6675f0; }
[data-theme='dark'][data-accent='blue'] { --accent: #66a3f2; --accent-strong: #4a8ce0; }
[data-theme='dark'][data-accent='emerald'] { --accent: #35b795; --accent-strong: #279c7e; }
[data-theme='dark'][data-accent='violet'] { --accent: #a97ff0; --accent-strong: #9367e0; }
[data-theme='dark'][data-accent='rose'] { --accent: #e57ba3; --accent-strong: #d4608c; }
[data-theme='dark'][data-accent='amber'] { --accent: #d9a247; --accent-strong: #c08a2e; }
/* ================= base ================= */
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body {
background: var(--bg);
color: var(--text);
font: 400 14px/1.5 var(--font);
font-feature-settings: 'cv05', 'cv11';
-webkit-font-smoothing: antialiased;
transition: background var(--speed) ease, color var(--speed) ease;
}
h1, h2, h3 { letter-spacing: -0.015em; font-weight: 600; }
::selection { background: color-mix(in srgb, var(--accent) 25%, transparent); }
:focus-visible { outline: 2px solid color-mix(in srgb, var(--accent) 55%, transparent); outline-offset: 1px; }
* { scrollbar-width: thin; scrollbar-color: var(--scrollbar) transparent; }
@media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
/* Money and quantities: Inter with tabular figures. */
.num {
font-family: var(--font);
font-variant-numeric: tabular-nums lining-nums;
letter-spacing: -0.005em;
}
/* Machine data: codes, doc numbers, GSTINs, ids. */
.mono {
font-family: var(--mono);
font-size: 0.93em;
letter-spacing: 0.01em;
}
/* ================= controls ================= */
button.wf {
background: var(--bg-raised); color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 7px 14px;
font: 500 13.5px/1.45 var(--font); cursor: pointer;
transition: background var(--speed), border-color var(--speed), opacity var(--speed), transform 80ms;
}
button.wf:hover { background: var(--bg-hover); border-color: color-mix(in srgb, var(--accent) 35%, var(--border-strong)); }
button.wf:active { transform: translateY(1px); }
/* Ink primary — accent is reserved for state, not the main CTA. */
button.wf.primary {
background: var(--text); color: var(--bg-raised);
border-color: var(--text); font-weight: 600;
}
button.wf.primary:hover { opacity: 0.86; background: var(--text); }
button.wf.danger { border-color: color-mix(in srgb, var(--err) 55%, var(--border-strong)); color: var(--err); }
button.wf.danger:hover { background: var(--err-soft); }
button.wf kbd {
background: color-mix(in srgb, currentColor 14%, transparent);
border-radius: var(--radius-sm); padding: 0 5px; margin-left: 8px;
font-family: var(--mono); font-size: 10.5px; font-weight: 500;
}
input.wf, select.wf, textarea.wf {
background: var(--bg-raised); color: var(--text);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); padding: 7px 11px;
font: 400 13.5px/1.45 var(--font); width: 100%;
transition: border-color var(--speed), box-shadow var(--speed);
}
input.wf::placeholder, textarea.wf::placeholder { color: var(--text-dim); }
input.wf:focus, select.wf:focus, textarea.wf:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
/* ================= page scaffolding ================= */
.wf-page { padding: 20px 24px 32px; max-width: 1400px; }
.wf-page-head { display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px; }
.wf-page-head h1 { font-size: 22px; margin: 0; }
.wf-page-desc { color: var(--text-dim); margin: 0 0 18px; max-width: 72ch; }
.wf-crumbs { display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--text-dim); margin: 0 0 6px; }
.wf-crumbs a { color: var(--text-dim); text-decoration: none; }
.wf-crumbs a:hover { color: var(--accent); }
.wf-toolbar { display: flex; gap: 8px; align-items: center; margin: 14px 0; flex-wrap: wrap; }
.wf-toolbar .spacer { flex: 1; }
/* ================= tables ================= */
table.wf {
border-collapse: separate; border-spacing: 0; width: 100%;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
table.wf th, table.wf td { text-align: left; padding: 9px 14px; border-bottom: 1px solid var(--border); }
table.wf tr:last-child td { border-bottom: none; }
table.wf th {
position: sticky; top: 0; z-index: 1;
background: var(--bg-inset);
color: var(--text-dim); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.06em;
}
table.wf tbody tr { transition: background var(--speed); }
table.wf tbody tr:hover td { background: var(--accent-soft); }
table.wf td.num, table.wf th.num { text-align: right; }
table.wf td.mono { font-size: 12.5px; }
table.wf tbody tr.tone-err td { background: var(--err-soft); }
table.wf tbody tr.tone-warn td { background: var(--warn-soft); }
table.wf tbody tr.tone-ok td { background: var(--ok-soft); }
table.wf tbody tr.tone-err:hover td,
table.wf tbody tr.tone-warn:hover td,
table.wf tbody tr.tone-ok:hover td { filter: brightness(0.97); }
/* ================= badges ================= */
.wf-badge {
display: inline-block; border-radius: 999px; padding: 2px 10px;
font-size: 12px; font-weight: 500;
background: var(--bg-inset); color: var(--text-dim);
border: 1px solid var(--border);
}
.wf-badge.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.wf-badge.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
.wf-badge.err { color: var(--err); background: var(--err-soft); border-color: color-mix(in srgb, var(--err) 25%, transparent); }
.wf-badge.accent { color: var(--accent-strong); background: var(--accent-soft); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
[data-theme='dark'] .wf-badge.accent { color: var(--accent); }
/* ================= stat cards ================= */
.wf-stats { display: grid; grid-template-columns: repeat(auto-fill, minmax(215px, 1fr)); gap: 14px; margin: 16px 0; }
.wf-stat {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px;
transition: border-color var(--speed);
}
.wf-stat:hover { border-color: var(--border-strong); }
.wf-stat .label { color: var(--text-dim); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
.wf-stat .value { font-size: 26px; font-weight: 600; letter-spacing: -0.02em; margin-top: 6px; }
.wf-stat .hint { color: var(--text-dim); font-size: 12px; margin-top: 3px; }
.wf-stat .hint.ok { color: var(--ok); }
.wf-stat .hint.warn { color: var(--warn); }
.wf-stat .hint.err { color: var(--err); }
/* ================= states ================= */
.wf-empty {
border: 1px dashed var(--border-strong); border-radius: var(--radius);
padding: 32px; text-align: center; color: var(--text-dim); margin: 14px 0;
background: color-mix(in srgb, var(--bg-raised) 55%, transparent);
}
.wf-skel { display: flex; flex-direction: column; gap: 10px; margin: 14px 0; }
.wf-skel-bar {
height: 13px; border-radius: var(--radius-sm);
background: linear-gradient(90deg, var(--bg-inset) 25%, var(--bg-hover) 50%, var(--bg-inset) 75%);
background-size: 200% 100%;
animation: wf-shimmer 1.4s ease infinite;
}
@keyframes wf-shimmer { from { background-position: 200% 0; } to { background-position: -200% 0; } }
.wf-error {
border: 1px solid color-mix(in srgb, var(--err) 35%, transparent);
background: var(--err-soft); border-radius: var(--radius);
padding: 14px 16px; margin: 14px 0;
display: flex; align-items: center; gap: 12px; font-size: 13px;
}
.wf-error .msg { flex: 1; color: var(--err); }
/* ================= filter chips ================= */
.wf-chips { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.wf-chip {
border: 1px solid transparent; background: transparent; color: var(--text-dim);
border-radius: 999px; padding: 4px 12px; font: 500 12.5px var(--font); cursor: pointer;
transition: background var(--speed), color var(--speed), border-color var(--speed);
}
.wf-chip:hover { color: var(--text); background: var(--bg-hover); }
.wf-chip.active {
background: var(--accent-soft); color: var(--accent-strong);
border-color: color-mix(in srgb, var(--accent) 30%, transparent); font-weight: 600;
}
[data-theme='dark'] .wf-chip.active { color: var(--accent); }
.wf-chip .count { opacity: 0.75; margin-left: 5px; font-family: var(--mono); font-size: 11px; }
/* ================= tabs ================= */
.wf-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: 14px 0 16px; overflow-x: auto; }
.wf-tab {
border: none; background: none; cursor: pointer; white-space: nowrap;
padding: 8px 12px 9px; font: 500 13.5px var(--font); color: var(--text-dim);
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color var(--speed), border-color var(--speed);
}
.wf-tab:hover { color: var(--text); }
.wf-tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
.wf-tab .count { font-family: var(--mono); font-size: 11px; color: var(--text-dim); margin-left: 6px; }
/* ================= record header ================= */
.wf-rec { display: flex; gap: 14px; align-items: flex-start; margin: 2px 0 6px; }
.wf-avatar {
width: 44px; height: 44px; border-radius: var(--radius);
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 16px; flex-shrink: 0;
}
.wf-rec-body { min-width: 0; flex: 1; }
.wf-rec-title { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.wf-rec-title h1 { font-size: 22px; margin: 0; }
.wf-rec-meta { color: var(--text-dim); font-size: 13px; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.wf-rec-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* ================= misc surfaces ================= */
.wf-card {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; margin: 10px 0;
}
.wf-card > h3:first-child { margin-top: 0; }
.wf-field { margin-bottom: 13px; }
.wf-field > label {
display: block; color: var(--text-dim); font-size: 11px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 5px;
}
.wf-modal-back {
position: fixed; inset: 0; background: rgba(15, 14, 11, 0.45);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center; z-index: 50;
}
.wf-modal {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 22px;
min-width: 430px; max-width: 740px; max-height: 85vh; overflow: auto;
box-shadow: var(--shadow-lg);
}
.wf-modal h2 { margin: 0 0 14px; font-size: 17px; }
.wf-notice {
border-radius: var(--radius); padding: 9px 13px; margin: 10px 0;
border: 1px solid var(--border); background: var(--bg-raised);
font-size: 13px;
}
.wf-notice.ok { border-color: color-mix(in srgb, var(--ok) 35%, transparent); background: var(--ok-soft); }
.wf-notice.warn { border-color: color-mix(in srgb, var(--warn) 35%, transparent); background: var(--warn-soft); }
.wf-notice.err { border-color: color-mix(in srgb, var(--err) 35%, transparent); background: var(--err-soft); }
/* ================= command palette ================= */
.wf-palette-back {
position: fixed; inset: 0; background: rgba(15, 14, 11, 0.4);
backdrop-filter: blur(3px); z-index: 60;
display: flex; justify-content: center; align-items: flex-start; padding-top: 12vh;
}
.wf-palette {
width: 640px; max-width: calc(100vw - 32px);
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); overflow: hidden;
}
.wf-palette input {
width: 100%; border: none; outline: none; background: transparent; color: var(--text);
padding: 14px 16px; font: 400 15px var(--font);
border-bottom: 1px solid var(--border);
}
.wf-palette-list { max-height: 46vh; overflow-y: auto; padding: 6px; }
.wf-palette-group {
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); padding: 8px 10px 4px;
}
.wf-palette-item {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
border: none; background: none; cursor: pointer; color: var(--text);
padding: 8px 10px; border-radius: var(--radius); font: 400 13.5px var(--font);
}
.wf-palette-item .hint { color: var(--text-dim); font-size: 12px; margin-left: auto; }
.wf-palette-item.sel { background: var(--accent-soft); }
.wf-palette-foot {
border-top: 1px solid var(--border); padding: 7px 12px;
display: flex; gap: 14px; font-size: 11px; color: var(--text-dim);
}
.wf-palette-foot kbd {
font-family: var(--mono); font-size: 10px; border: 1px solid var(--border-strong);
border-radius: 3px; padding: 0 4px; margin-right: 4px;
}
/* ================= theme switcher ================= */
.wf-themer { display: flex; align-items: center; gap: 6px; }
.wf-themer .swatch {
width: 16px; height: 16px; border-radius: 50%; cursor: pointer;
border: 2px solid transparent; padding: 0;
transition: transform 80ms, border-color var(--speed);
}
.wf-themer .swatch:hover { transform: scale(1.18); }
.wf-themer .swatch.active { border-color: var(--text); }
.wf-themer .mode {
background: var(--bg-inset); border: 1px solid var(--border);
border-radius: 999px; padding: 3px 10px; cursor: pointer;
font: 500 12px var(--font); color: var(--text-dim);
}
.wf-themer .mode:hover { color: var(--text); border-color: var(--border-strong); }
- Step 2: Add teal to
packages/ui/src/theme.ts
Change line 8 to:
export const ACCENTS = ['teal', 'indigo', 'blue', 'emerald', 'violet', 'rose', 'amber'] as const
Change the fallback in getAccent (line 20) from 'indigo' to 'teal':
return (ACCENTS as readonly string[]).includes(stored ?? '') ? (stored as Accent) : 'teal'
- Step 3: Add the teal swatch to
packages/ui/src/ThemeSwitcher.tsx
Replace the SWATCH map (lines 4–11) with:
const SWATCH: Record<Accent, string> = {
teal: '#115e59',
indigo: '#4f5df0',
blue: '#2f7ce8',
emerald: '#14957a',
violet: '#8250df',
rose: '#d4497c',
amber: '#b97e10',
}
- Step 4: Verify
Run: npm run typecheck → exit 0. Run: npm test → all pass (no test touches tokens).
- Step 5: Visual smoke check
Start the dev app (see Global Constraints). Confirm: warm off-white background, teal active-nav bar, primary buttons are near-black ink (light mode) / light ink (dark mode), theme toggle works, all 7 swatches present, dark mode is warm-tinted not blue-black.
- Step 6: Commit
git add packages/ui/src/tokens.css packages/ui/src/theme.ts packages/ui/src/ThemeSwitcher.tsx
git commit -m "feat(ui): warm token system — teal default accent, ink primary, flat surfaces"
Task 2: Avatar helper (TDD)
Files:
- Create:
packages/ui/src/avatar.ts - Test:
packages/ui/test/avatar.test.ts
Interfaces:
-
Produces:
initials(name: string): string,avatarHue(name: string): number(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:
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:
/** Deterministic identity visuals: initials + a stable hue from the name. */
export function initials(name: string): string {
const words = name.trim().split(/\s+/).filter((w) => w !== '')
if (words.length === 0) return '?'
const first = words[0]!.charAt(0)
const second = words.length > 1 ? words[1]!.charAt(0) : ''
return (first + second).toUpperCase()
}
/** FNV-1a over the name → hue bucket; same name always gets the same colour. */
export function avatarHue(name: string): number {
let h = 0x811c9dc5
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return (h >>> 0) % 360
}
/** Soft tinted background + readable foreground for both themes. */
export function avatarColors(name: string): { bg: string; fg: string } {
const h = avatarHue(name)
return {
bg: `hsl(${h} 42% 50% / 0.16)`,
fg: `hsl(${h} 45% 38%)`,
}
}
Add to packages/ui/src/index.ts a new line:
export * from './avatar'
-
Step 4: Run tests — expect PASS —
npm test -- avatar→ 5 pass. Thennpm run typecheck→ exit 0. -
Step 5: Commit
git add packages/ui/src/avatar.ts packages/ui/test/avatar.test.ts packages/ui/src/index.ts
git commit -m "feat(ui): deterministic avatar initials + hue helper"
Task 3: State components, FilterChips, Tabs
Files:
- Create:
packages/ui/src/states.tsx - Create:
packages/ui/src/chips.tsx - Create:
packages/ui/src/tabs.tsx - Modify:
packages/ui/src/index.ts
Interfaces:
-
Consumes: CSS classes from Task 1 (
wf-skel*,wf-error,wf-chips/wf-chip,wf-tabs/wf-tab). -
Produces:
Skeleton(props: { rows?: number })ErrorState(props: { message: string; onRetry?: () => void })FilterChips(props: { chips: { key: string; label: string; count?: number }[]; active: string; onChange: (key: string) => void })Tabs(props: { tabs: { key: string; label: string; count?: number }[]; active: string; onChange: (key: string) => void })
-
Step 1: Create
packages/ui/src/states.tsx
/** Loading + error surfaces — every page swaps its "Loading…" text for these. */
const WIDTHS = ['92%', '70%', '84%', '55%', '78%']
export function Skeleton(props: { rows?: number }) {
const rows = props.rows ?? 3
return (
<div className="wf-skel" aria-busy="true" aria-label="Loading">
{Array.from({ length: rows }, (_x, i) => (
<div key={i} className="wf-skel-bar" style={{ width: WIDTHS[i % WIDTHS.length] }} />
))}
</div>
)
}
export function ErrorState(props: { message: string; onRetry?: () => void }) {
return (
<div className="wf-error" role="alert">
<span className="msg">{props.message}</span>
{props.onRetry !== undefined && (
<button type="button" className="wf" onClick={props.onRetry}>Try again</button>
)}
</div>
)
}
- Step 2: Create
packages/ui/src/chips.tsx
/** Saved-views-lite: a pill row that filters a list by one dimension. */
export interface ChipDef { key: string; label: string; count?: number }
export function FilterChips(props: {
chips: ChipDef[]
active: string
onChange: (key: string) => void
}) {
return (
<div className="wf-chips" role="tablist">
{props.chips.map((c) => (
<button
key={c.key}
type="button"
role="tab"
aria-selected={c.key === props.active}
className={`wf-chip${c.key === props.active ? ' active' : ''}`}
onClick={() => props.onChange(c.key)}
>
{c.label}
{c.count !== undefined && <span className="count">{c.count}</span>}
</button>
))}
</div>
)
}
- Step 3: Create
packages/ui/src/tabs.tsx
/** Underline tabs with optional mono counts (Direction A v2 pattern). */
export interface TabDef { key: string; label: string; count?: number }
export function Tabs(props: {
tabs: TabDef[]
active: string
onChange: (key: string) => void
}) {
return (
<div className="wf-tabs" role="tablist">
{props.tabs.map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={t.key === props.active}
className={`wf-tab${t.key === props.active ? ' active' : ''}`}
onClick={() => props.onChange(t.key)}
>
{t.label}
{t.count !== undefined && <span className="count">{t.count}</span>}
</button>
))}
</div>
)
}
- Step 4: Export from
packages/ui/src/index.ts— append:
export * from './states'
export * from './chips'
export * from './tabs'
-
Step 5: Verify —
npm run typecheck→ exit 0;npm test→ all pass. -
Step 6: Commit
git add packages/ui/src/states.tsx packages/ui/src/chips.tsx packages/ui/src/tabs.tsx packages/ui/src/index.ts
git commit -m "feat(ui): Skeleton, ErrorState, FilterChips, Tabs"
Task 4: RecordHeader + PageHeader breadcrumb + DataTable tone/mono
Files:
- Create:
packages/ui/src/record.tsx - Modify:
packages/ui/src/components.tsx(PageHeader lines 5–17, Column/DataTable lines 42–73) - Modify:
packages/ui/src/index.ts
Interfaces:
-
Consumes:
initials/avatarColors(Task 2), CSSwf-rec*/wf-avatar/wf-crumbs/tone-*(Task 1). -
Produces:
RecordHeader(props: { name: string; avatarSeed?: string; status?: ReactNode; meta?: ReactNode; actions?: ReactNode })PageHeadergains optionalbreadcrumb?: ReactNode(rendered above the title row).Columngainsmono?: boolean;DataTablegainsrowTone?: (index: number) => 'ok' | 'warn' | 'err' | undefined.
-
Step 1: Create
packages/ui/src/record.tsx
import type { ReactNode } from 'react'
import { avatarColors, initials } from './avatar'
/** Entity header: avatar + name + status pill + one meta line + action cluster. */
export function RecordHeader(props: {
name: string
avatarSeed?: string
status?: ReactNode
meta?: ReactNode
actions?: ReactNode
}) {
const seed = props.avatarSeed ?? props.name
const { bg, fg } = avatarColors(seed)
return (
<div className="wf-rec">
<div className="wf-avatar" style={{ background: bg, color: fg }}>{initials(seed)}</div>
<div className="wf-rec-body">
<div className="wf-rec-title">
<h1>{props.name}</h1>
{props.status}
</div>
{props.meta !== undefined && <div className="wf-rec-meta">{props.meta}</div>}
</div>
{props.actions !== undefined && <div className="wf-rec-actions">{props.actions}</div>}
</div>
)
}
- Step 2: Upgrade PageHeader in
packages/ui/src/components.tsx
Replace the PageHeader function (lines 5–17) with:
export function PageHeader(props: {
title: string
desc?: string
badge?: string
breadcrumb?: ReactNode
actions?: ReactNode
}) {
return (
<>
{props.breadcrumb !== undefined && <div className="wf-crumbs">{props.breadcrumb}</div>}
<div className="wf-page-head">
<h1>{props.title}</h1>
{props.badge !== undefined && <Badge tone="accent">{props.badge}</Badge>}
<div style={{ flex: 1 }} />
{props.actions}
</div>
{props.desc !== undefined && <p className="wf-page-desc">{props.desc}</p>}
</>
)
}
- Step 3: Upgrade DataTable in
packages/ui/src/components.tsx
Replace the Column interface and DataTable function (lines 42–73) with:
export interface Column {
key: string
label: string
numeric?: boolean
mono?: boolean
}
export function DataTable(props: {
columns: Column[]
rows: Record<string, ReactNode>[]
onRowClick?: (row: Record<string, ReactNode>, index: number) => void
rowTone?: (index: number) => 'ok' | 'warn' | 'err' | undefined
}) {
const cellClass = (c: Column): string | undefined => {
const cls = [c.numeric === true ? 'num' : '', c.mono === true ? 'mono' : ''].filter((x) => x !== '').join(' ')
return cls !== '' ? cls : undefined
}
return (
<table className="wf">
<thead>
<tr>
{props.columns.map((c) => (
<th key={c.key} className={c.numeric === true ? 'num' : undefined}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{props.rows.map((row, i) => {
const tone = props.rowTone?.(i)
return (
<tr
key={i}
className={tone !== undefined ? `tone-${tone}` : undefined}
onClick={() => props.onRowClick?.(row, i)}
style={props.onRowClick !== undefined ? { cursor: 'pointer' } : undefined}
>
{props.columns.map((c) => (
<td key={c.key} className={cellClass(c)}>{row[c.key]}</td>
))}
</tr>
)
})}
</tbody>
</table>
)
}
- Step 4: Export RecordHeader — append to
packages/ui/src/index.ts:
export * from './record'
-
Step 5: Verify —
npm run typecheck→ exit 0 (all new props optional, existing call sites unaffected);npm test→ pass. -
Step 6: Commit
git add packages/ui/src/record.tsx packages/ui/src/components.tsx packages/ui/src/index.ts
git commit -m "feat(ui): RecordHeader; PageHeader breadcrumb; DataTable rowTone + mono columns"
Task 5: Command palette (TDD on matching)
Files:
- Create:
packages/ui/src/palette-match.ts - Create:
packages/ui/src/palette.tsx - Modify:
packages/ui/src/index.ts - Test:
packages/ui/test/palette-match.test.ts
Interfaces:
-
Consumes: CSS
wf-palette*(Task 1). -
Produces:
PaletteItem { id: string; label: string; group: string; hint?: string }matchItems(items: PaletteItem[], q: string, limit?: number): PaletteItem[]CommandPalette(props: { open: boolean; onClose: () => void; items: PaletteItem[]; onSelect: (item: PaletteItem) => void; onQuery?: (q: string) => void; placeholder?: string })— parent owns async data: it listens toonQueryand feeds merged results back throughitems.
-
Step 1: Write the failing test —
packages/ui/test/palette-match.test.ts:
import { describe, expect, it } from 'vitest'
import { matchItems, type PaletteItem } from '../src/palette-match'
const items: PaletteItem[] = [
{ id: 'nav:dash', label: 'Dashboard', group: 'Go to' },
{ id: 'nav:clients', label: 'Clients', group: 'Go to' },
{ id: 'act:newdoc', label: 'New document', group: 'Actions', hint: 'quotation proforma invoice' },
{ id: 'client:1', label: 'CL-0007 · Hamiltel', group: 'Clients' },
]
describe('matchItems', () => {
it('returns everything (to limit) for an empty query', () => {
expect(matchItems(items, '').map((i) => i.id)).toEqual(['nav:dash', 'nav:clients', 'act:newdoc', 'client:1'])
expect(matchItems(items, '', 2)).toHaveLength(2)
})
it('matches case-insensitively on label', () => {
expect(matchItems(items, 'hamil').map((i) => i.id)).toEqual(['client:1'])
})
it('matches on hint text too', () => {
expect(matchItems(items, 'proforma').map((i) => i.id)).toEqual(['act:newdoc'])
})
it('matches every whitespace-separated term (AND)', () => {
expect(matchItems(items, 'new doc').map((i) => i.id)).toEqual(['act:newdoc'])
expect(matchItems(items, 'new xyz')).toHaveLength(0)
})
})
-
Step 2: Run it — expect FAIL —
npm test -- palette-match→ module not found. -
Step 3: Implement
packages/ui/src/palette-match.ts
/** Pure matcher for the command palette — every query term must appear. */
export interface PaletteItem {
id: string
label: string
group: string
hint?: string
}
export function matchItems(items: PaletteItem[], q: string, limit = 12): PaletteItem[] {
const terms = q.trim().toLowerCase().split(/\s+/).filter((t) => t !== '')
if (terms.length === 0) return items.slice(0, limit)
const out: PaletteItem[] = []
for (const item of items) {
const hay = `${item.label} ${item.hint ?? ''}`.toLowerCase()
if (terms.every((t) => hay.includes(t))) {
out.push(item)
if (out.length >= limit) break
}
}
return out
}
-
Step 4: Run tests — expect PASS —
npm test -- palette-match→ 4 pass. -
Step 5: Create
packages/ui/src/palette.tsx
import { useEffect, useRef, useState } from 'react'
import { matchItems, type PaletteItem } from './palette-match'
/**
* Ctrl+K overlay. Generic: the parent supplies `items` (static + any async
* results it fetched after `onQuery`) and receives the picked item.
*/
export function CommandPalette(props: {
open: boolean
onClose: () => void
items: PaletteItem[]
onSelect: (item: PaletteItem) => void
onQuery?: (q: string) => void
placeholder?: string
}) {
const [q, setQ] = useState('')
const [sel, setSel] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (props.open) { setQ(''); setSel(0); props.onQuery?.(''); inputRef.current?.focus() }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.open])
if (!props.open) return null
const hits = matchItems(props.items, q)
const groups: { name: string; items: { item: PaletteItem; index: number }[] }[] = []
hits.forEach((item, index) => {
const g = groups.find((x) => x.name === item.group)
if (g !== undefined) g.items.push({ item, index })
else groups.push({ name: item.group, items: [{ item, index }] })
})
const pick = (item: PaletteItem) => { props.onSelect(item); props.onClose() }
const onKey = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); props.onClose() }
else if (e.key === 'ArrowDown') { e.preventDefault(); setSel((s) => Math.min(s + 1, hits.length - 1)) }
else if (e.key === 'ArrowUp') { e.preventDefault(); setSel((s) => Math.max(s - 1, 0)) }
else if (e.key === 'Enter') {
e.preventDefault()
const item = hits[sel]
if (item !== undefined) pick(item)
}
}
return (
<div className="wf-palette-back" onClick={props.onClose}>
<div className="wf-palette" onClick={(e) => e.stopPropagation()} onKeyDown={onKey}>
<input
ref={inputRef}
value={q}
placeholder={props.placeholder ?? 'Search…'}
onChange={(e) => { setQ(e.target.value); setSel(0); props.onQuery?.(e.target.value) }}
/>
<div className="wf-palette-list">
{hits.length === 0 && <div className="wf-palette-group">No matches</div>}
{groups.map((g) => (
<div key={g.name}>
<div className="wf-palette-group">{g.name}</div>
{g.items.map(({ item, index }) => (
<button
key={item.id}
type="button"
className={`wf-palette-item${index === sel ? ' sel' : ''}`}
onMouseEnter={() => setSel(index)}
onClick={() => pick(item)}
>
{item.label}
{item.hint !== undefined && <span className="hint">{item.hint}</span>}
</button>
))}
</div>
))}
</div>
<div className="wf-palette-foot">
<span><kbd>↑↓</kbd> navigate</span>
<span><kbd>↵</kbd> open</span>
<span><kbd>esc</kbd> close</span>
</div>
</div>
</div>
)
}
- Step 6: Export — append to
packages/ui/src/index.ts:
export * from './palette-match'
export * from './palette'
-
Step 7: Verify —
npm run typecheck→ exit 0;npm test→ all pass. -
Step 8: Commit
git add packages/ui/src/palette-match.ts packages/ui/src/palette.tsx packages/ui/test/palette-match.test.ts packages/ui/src/index.ts
git commit -m "feat(ui): command palette with pure AND-term matcher"
Task 6: App shell — grouped sidebar, top bar, Ctrl+K wiring, responsive-lite
Files:
- Modify:
apps/hq-web/package.json(addlucide-react) - Create:
apps/hq-web/src/nav.ts - Modify:
apps/hq-web/src/Layout.tsx(full rewrite below) - Modify:
apps/hq-web/src/app.css(full rewrite below) - Modify:
apps/hq-web/src/pages/Clients.tsx(accept?new=1, small diff below)
Interfaces:
-
Consumes:
CommandPalette/PaletteItem(Task 5),avatarColors/initials(Task 2),Badge,ThemeSwitcher; api:getEmailStatus,getReminders,getClients,hasSession,displayName,role,clearSession. -
Produces:
NAV_GROUPS(apps/hq-web/src/nav.ts) —{ label: string; items: { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean }[] }[]. Later tasks rely only on routes staying unchanged. -
Step 1: Install lucide-react
Run at repo root: npm install lucide-react -w apps/hq-web
Expected: apps/hq-web/package.json dependencies gain "lucide-react": "^0.x".
- Step 2: Create
apps/hq-web/src/nav.ts
import {
BarChart3, Boxes, FilePlus2, FileText, LayoutDashboard, UserCog, Users,
type LucideIcon,
} from 'lucide-react'
export interface NavItem { to: string; label: string; icon: LucideIcon; ownerOnly?: boolean }
export interface NavGroup { label: string; items: NavItem[] }
/** Grouped sidebar (spec §3). Pipeline slots into WORK when the funnel spec lands. */
export const NAV_GROUPS: NavGroup[] = [
{
label: 'Work',
items: [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
{ to: '/clients', label: 'Clients', icon: Users },
{ to: '/documents/new', label: 'New Document', icon: FilePlus2 },
],
},
{ label: 'Catalog', items: [{ to: '/modules', label: 'Modules', icon: Boxes }] },
{ label: 'Insight', items: [{ to: '/reports', label: 'Reports', icon: BarChart3 }] },
{
label: 'Admin',
items: [
{ to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
{ to: '/settings/template', label: 'Document Template', icon: FileText, ownerOnly: true },
],
},
]
- Step 3: Rewrite
apps/hq-web/src/Layout.tsx
import { useEffect, useMemo, useRef, useState } from 'react'
import { Navigate, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
import { LogOut, Menu, Search } from 'lucide-react'
import {
avatarColors, Badge, CommandPalette, initials, ThemeSwitcher, type PaletteItem,
} from '@sims/ui'
import { clearSession, displayName, getClients, getEmailStatus, getReminders, hasSession, role } from './api'
import { NAV_GROUPS } from './nav'
const APP_VERSION = 'v0.1.0'
/** App shell: grouped sidebar + top bar + Ctrl+K palette + Gmail-health banner. */
export function Layout() {
const nav = useNavigate()
const location = useLocation()
const [gmail, setGmail] = useState<'ok' | 'down' | 'unknown'>('unknown')
const [queueCounts, setQueueCounts] = useState({ queued: 0, failed: 0 })
const [healthy, setHealthy] = useState<boolean | undefined>()
const [menuOpen, setMenuOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
const [clientHits, setClientHits] = useState<PaletteItem[]>([])
const queryTimer = useRef<number | undefined>(undefined)
useEffect(() => {
if (!hasSession()) return
getEmailStatus()
.then((s) => setGmail(!s.connected || s.dead ? 'down' : 'ok'))
.catch(() => { /* 401 already redirected; other failures keep it unknown */ })
fetch('/api/health').then((r) => setHealthy(r.ok)).catch(() => setHealthy(false))
}, [])
// Reminder-queue badge (Dashboard nav item) — refreshed on every route change.
useEffect(() => {
if (!hasSession()) return
Promise.all([getReminders('queued'), getReminders('failed')])
.then(([q, f]) => setQueueCounts({ queued: q.length, failed: f.length }))
.catch(() => { /* badge is best-effort */ })
}, [location.pathname])
// Close the mobile drawer when the route changes.
useEffect(() => { setMenuOpen(false) }, [location.pathname])
// Ctrl+K / Cmd+K opens the palette.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
setPaletteOpen((v) => !v)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [])
const staticItems = useMemo<PaletteItem[]>(() => {
const isOwner = role() === 'owner'
const navItems = NAV_GROUPS.flatMap((g) => g.items)
.filter((i) => i.ownerOnly !== true || isOwner)
.map((i) => ({ id: `nav:${i.to}`, label: i.label, group: 'Go to' }))
return [
...navItems,
{ id: 'act:/documents/new', label: 'New document', group: 'Actions', hint: 'quotation · proforma · invoice' },
{ id: 'act:/clients?new=1', label: 'New client', group: 'Actions' },
]
}, [])
const onPaletteQuery = (q: string) => {
window.clearTimeout(queryTimer.current)
if (q.trim().length < 2) { setClientHits([]); return }
queryTimer.current = window.setTimeout(() => {
getClients(q)
.then((cs) => setClientHits(cs.slice(0, 8).map((c) => ({
id: `client:${c.id}`, label: `${c.code} · ${c.name}`, group: 'Clients', hint: c.status,
}))))
.catch(() => setClientHits([]))
}, 200)
}
const onPaletteSelect = (item: PaletteItem) => {
if (item.id.startsWith('nav:')) nav(item.id.slice(4))
else if (item.id.startsWith('act:')) nav(item.id.slice(4))
else if (item.id.startsWith('client:')) nav(`/clients/${item.id.slice(7)}`)
}
if (!hasSession()) return <Navigate to="/login" replace />
const isOwner = role() === 'owner'
const groups = NAV_GROUPS
.map((g) => ({ ...g, items: g.items.filter((i) => i.ownerOnly !== true || isOwner) }))
.filter((g) => g.items.length > 0)
const name = displayName()
const av = avatarColors(name)
const badgeCount = queueCounts.queued + queueCounts.failed
return (
<div className="hq-shell">
{menuOpen && <div className="hq-scrim" onClick={() => setMenuOpen(false)} />}
<nav className={`hq-side${menuOpen ? ' open' : ''}`}>
<div className="hq-brand">
SiMS HQ
<small>Ops console</small>
</div>
{groups.map((g) => (
<div key={g.label} className="hq-group">
<div className="hq-eyebrow">{g.label}</div>
{g.items.map((n) => (
<NavLink key={n.to} to={n.to} className={({ isActive }) => (isActive ? 'active' : '')} end={n.to === '/'}>
<n.icon size={15} strokeWidth={1.8} aria-hidden />
<span className="lbl">{n.label}</span>
{n.to === '/' && badgeCount > 0 && (
<span className={`hq-count${queueCounts.failed > 0 ? ' err' : ''}`}>{badgeCount}</span>
)}
</NavLink>
))}
</div>
))}
<div className="hq-side-foot">
{APP_VERSION}
{healthy !== undefined && (
<span className={`hq-dot${healthy ? ' ok' : ' err'}`} title={healthy ? 'Server healthy' : 'Server unreachable'} />
)}
</div>
</nav>
<div className="hq-main">
{gmail === 'down' && (
<div className="hq-banner">
Gmail disconnected — sends are queued to manual. Run gmail-connect on the server.
</div>
)}
<header className="hq-top">
<button type="button" className="wf hq-burger" aria-label="Menu" onClick={() => setMenuOpen((v) => !v)}>
<Menu size={16} />
</button>
<button type="button" className="hq-search" onClick={() => setPaletteOpen(true)}>
<Search size={14} aria-hidden />
<span>Search clients, pages…</span>
<kbd>Ctrl K</kbd>
</button>
<span style={{ flex: 1 }} />
{gmail !== 'unknown' && (
<span className={`hq-pill${gmail === 'ok' ? ' ok' : ' warn'}`}>
<span className={`hq-dot${gmail === 'ok' ? ' ok' : ' warn'}`} />
{gmail === 'ok' ? 'Gmail' : 'Gmail disconnected'}
</span>
)}
<ThemeSwitcher />
<span className="hq-user">
<span className="hq-user-av" style={{ background: av.bg, color: av.fg }}>{initials(name)}</span>
<span className="hq-user-name">{name}</span>
<Badge>{role()}</Badge>
</span>
<button
type="button" className="wf" title="Logout" aria-label="Logout"
onClick={() => { clearSession(); nav('/login') }}
>
<LogOut size={14} />
</button>
</header>
<div className="hq-content">
<Outlet />
</div>
</div>
<CommandPalette
open={paletteOpen}
onClose={() => setPaletteOpen(false)}
items={[...clientHits, ...staticItems]}
onSelect={onPaletteSelect}
onQuery={onPaletteQuery}
placeholder="Search clients, pages, actions…"
/>
</div>
)
}
- Step 4: Rewrite
apps/hq-web/src/app.css
/* HQ shell — warm ops console (spec 2026-07-17 §3). */
.hq-shell { display: grid; grid-template-columns: 224px 1fr; height: 100vh; }
.hq-side {
background: var(--bg-raised); border-right: 1px solid var(--border);
overflow-y: auto; padding: 12px 0 0; display: flex; flex-direction: column;
}
.hq-brand { font-weight: 700; font-size: 16px; padding: 4px 16px 14px; letter-spacing: -0.01em; }
.hq-brand small { display: block; color: var(--text-dim); font-weight: 400; font-size: 11.5px; }
.hq-group { margin-bottom: 10px; }
.hq-eyebrow {
font-size: 10.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--text-dim); padding: 4px 16px;
}
.hq-side a {
display: flex; align-items: center; gap: 9px; padding: 7px 16px 7px 14px;
color: var(--text); text-decoration: none; font-size: 13.5px;
border-left: 2px solid transparent;
}
.hq-side a .lbl { flex: 1; }
.hq-side a:hover { background: var(--bg-hover); }
.hq-side a.active {
background: var(--accent-soft); border-left-color: var(--accent); font-weight: 600;
}
.hq-count {
font-family: var(--mono); font-size: 10.5px; font-weight: 600;
background: var(--warn-soft); color: var(--warn);
border-radius: 999px; padding: 1px 7px;
}
.hq-count.err { background: var(--err-soft); color: var(--err); }
.hq-side-foot {
margin-top: auto; padding: 10px 16px; border-top: 1px solid var(--border);
font-size: 11px; color: var(--text-dim); font-family: var(--mono);
display: flex; align-items: center; gap: 7px;
}
.hq-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; background: var(--text-dim); }
.hq-dot.ok { background: var(--ok); }
.hq-dot.warn { background: var(--warn); }
.hq-dot.err { background: var(--err); }
.hq-main { display: flex; flex-direction: column; min-width: 0; }
.hq-banner {
padding: 8px 20px; font-size: 13px; color: var(--warn); background: var(--warn-soft);
border-bottom: 1px solid color-mix(in srgb, var(--warn) 35%, transparent);
}
.hq-top {
display: flex; gap: 10px; align-items: center; padding: 8px 20px;
border-bottom: 1px solid var(--border); background: var(--bg-raised); min-height: 48px;
}
.hq-burger { display: none; padding: 6px 9px; }
.hq-search {
display: flex; align-items: center; gap: 8px; min-width: 260px;
background: var(--bg-inset); color: var(--text-dim);
border: 1px solid var(--border); border-radius: 999px;
padding: 6px 12px; font: 400 13px var(--font); cursor: pointer;
transition: border-color var(--speed);
}
.hq-search:hover { border-color: var(--border-strong); }
.hq-search kbd {
margin-left: auto; font-family: var(--mono); font-size: 10px;
border: 1px solid var(--border-strong); border-radius: 3px; padding: 0 5px;
}
.hq-pill {
display: inline-flex; align-items: center; gap: 6px;
border-radius: 999px; padding: 3px 11px; font-size: 12px; font-weight: 500;
border: 1px solid var(--border); color: var(--text-dim);
}
.hq-pill.ok { color: var(--ok); background: var(--ok-soft); border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.hq-pill.warn { color: var(--warn); background: var(--warn-soft); border-color: color-mix(in srgb, var(--warn) 25%, transparent); }
.hq-user { display: inline-flex; align-items: center; gap: 8px; }
.hq-user-av {
width: 26px; height: 26px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600;
}
.hq-user-name { font-size: 13px; font-weight: 500; }
.hq-content { overflow-y: auto; flex: 1; padding: 4px 20px 20px; }
.hq-scrim { display: none; }
/* Dashboard section grid (Task 7). */
.dash-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 24px; align-items: start; }
.dash-grid > section { min-width: 0; }
@media (max-width: 1100px) { .dash-grid { grid-template-columns: 1fr; } }
/* Login — split panel (Task 13). */
.login-wrap { height: 100vh; display: flex; align-items: stretch; justify-content: center; }
.login-brand {
flex: 1; background: #1a1a17; color: #eae8e2;
display: flex; flex-direction: column; justify-content: center; padding: 48px 56px;
}
.login-brand h1 { font-size: 34px; margin: 0 0 8px; letter-spacing: -0.02em; }
.login-brand p { color: #a8a69c; max-width: 44ch; }
.login-form { flex: 0 0 440px; display: flex; align-items: center; justify-content: center; padding: 24px; }
.login-card {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 28px; width: 100%; max-width: 380px;
}
@media (max-width: 800px) {
.login-brand { display: none; }
.login-form { flex: 1; }
}
/* Responsive-lite: drawer sidebar under 900px (APEX checklist item h). */
@media (max-width: 900px) {
.hq-shell { grid-template-columns: 1fr; }
.hq-side {
position: fixed; inset: 0 auto 0 0; width: 240px; z-index: 40;
transform: translateX(-100%); transition: transform var(--speed) ease;
box-shadow: var(--shadow-lg);
}
.hq-side.open { transform: translateX(0); }
.hq-scrim { display: block; position: fixed; inset: 0; z-index: 39; background: rgba(15, 14, 11, 0.4); }
.hq-burger { display: inline-flex; }
.hq-search { min-width: 0; flex: 1; }
.hq-search span { display: none; }
.hq-user-name { display: none; }
.hq-content { padding: 4px 12px 16px; }
.wf-page { padding: 16px 4px 24px; }
}
/* Tables scroll inside their page rather than breaking the layout. */
.hq-content { overflow-x: hidden; }
table.wf { display: block; overflow-x: auto; }
@media (min-width: 901px) { table.wf { display: table; } }
- Step 5:
?new=1support in Clients
In apps/hq-web/src/pages/Clients.tsx: change the react-router import (line 2) to:
import { useNavigate, useSearchParams } from 'react-router-dom'
and inside Clients() replace const [creating, setCreating] = useState(false) with:
const [sp] = useSearchParams()
const [creating, setCreating] = useState(sp.get('new') === '1')
-
Step 6: 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
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-gridCSS (Task 6). Data calls unchanged. -
Step 1: Apply these edits to
apps/hq-web/src/pages/Dashboard.tsx
- Extend the ui import (line 4) to:
import { Badge, Button, DataTable, EmptyState, ErrorState, Notice, PageHeader, Skeleton, StatCard, Stats } from '@sims/ui'
- Replace the loading/error block (lines 28–30) with:
{dash.error !== undefined && <ErrorState message={dash.error} onRetry={dash.reload} />}
{err !== '' && <Notice tone="err">{err}</Notice>}
{v === undefined && dash.error === undefined ? <Skeleton rows={5} /> : v === undefined ? null : (
(and keep the matching closing )} at the end of the JSX — the body below replaces the current <>…</> content).
- Give the stat cards toned hints — replace the
<Stats>block (lines 32–37) with:
<Stats>
<StatCard label="Overdue" value={inr(v.totals.overduePaise)} hint={`${v.overdue.length} invoice(s)`} />
<StatCard label="Queued reminders" value={String(v.totals.queued)} />
<StatCard label="Failed sends" value={String(v.totals.failed)} {...(v.totals.failed > 0 ? { hint: 'needs attention' } : {})} />
<StatCard label="Follow-ups today" value={String(v.followUpsToday.length)} />
</Stats>
Note: StatCard has no toned hint prop; add one now in packages/ui/src/components.tsx — change StatCard to:
export function StatCard(props: { label: string; value: ReactNode; hint?: string; hintTone?: 'ok' | 'warn' | 'err' }) {
return (
<div className="wf-stat">
<div className="label">{props.label}</div>
<div className="value num">{props.value}</div>
{props.hint !== undefined && <div className={`hint${props.hintTone !== undefined ? ` ${props.hintTone}` : ''}`}>{props.hint}</div>}
</div>
)
}
then use hintTone="err" on the Failed card: {...(v.totals.failed > 0 ? { hint: 'needs attention', hintTone: 'err' as const } : {})}.
- Row-tint the reminder queue — add to the queue
DataTable(after thecolumnsprop):
rowTone={(i) => {
const s = v.queue[i]?.status
return s === 'failed' ? 'err' : undefined
}}
- Two-column layout for the secondary sections — wrap the four
<Section …/>calls for "Due this week", "Renewals this month", "Follow-ups today", "Recent payments" in:
<div className="dash-grid">
<section>…Due this week…</section>
<section>…Renewals this month…</section>
<section>…Follow-ups today…</section>
<section>…Recent payments…</section>
</div>
("Reminder queue" and "Overdue" stay full-width above the grid; each <Section> moves inside its own <section> element unchanged.)
-
Step 2: 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
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_STATUSESfrom../api. -
Step 1: Edits to
apps/hq-web/src/pages/Clients.tsx
- Import changes:
import { Badge, Button, DataTable, EmptyState, ErrorState, Field, FilterChips, Notice, PageHeader, Skeleton, Toolbar } from '@sims/ui'
import { createClient, getClients, CLIENT_STATUSES, type ClientStatus, type DocStatus } from '../api'
- Inside
Clients()add status-filter state and a filtered view (after theuseDataline):
const [statusFilter, setStatusFilter] = useState('all')
const counts = new Map<string, number>()
for (const c of data ?? []) counts.set(c.status, (counts.get(c.status) ?? 0) + 1)
const shown = (data ?? []).filter((c) => statusFilter === 'all' || c.status === statusFilter)
- Replace the
<Toolbar>block with:
<Toolbar>
<input
className="wf" style={{ maxWidth: 280 }} placeholder="Search name / code / GSTIN…"
value={q} onChange={(e) => setQ(e.target.value)}
/>
<FilterChips
active={statusFilter}
onChange={setStatusFilter}
chips={[
{ key: 'all', label: 'All', count: data?.length ?? 0 },
...CLIENT_STATUSES.map((s) => ({ key: s, label: s, count: counts.get(s) ?? 0 })),
]}
/>
<span className="spacer" />
<Badge>{shown.length} shown</Badge>
</Toolbar>
- Replace the loading/empty/table block: loading →
<Skeleton rows={6} />; error →<ErrorState message={error} onRetry={reload} />; the table maps overshowninstead ofdata(row click usesshown[i]!.id); mark the code column mono:
{error !== undefined && <ErrorState message={error} onRetry={reload} />}
{data === undefined && error === undefined ? <Skeleton rows={6} />
: shown.length === 0 ? <EmptyState>No clients{q !== '' ? ` matching “${q}”` : statusFilter !== 'all' ? ` with status ${statusFilter}` : ' yet — add the first one'}.</EmptyState> : (
<DataTable
columns={[
{ key: 'code', label: 'Code', mono: true }, { key: 'name', label: 'Name' },
{ key: 'status', label: 'Status' }, { key: 'state', label: 'State' },
{ key: 'contact', label: 'Contact' },
]}
onRowClick={(_row, i) => nav(`/clients/${shown[i]!.id}`)}
rows={shown.map((c) => ({
code: c.code, name: c.name,
status: <Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>,
state: c.stateCode,
contact: c.contacts[0]?.email ?? c.contacts[0]?.phone ?? '—',
}))}
/>
)}
(The old <Notice tone="err"> for error is replaced by the ErrorState above.)
-
Step 2: 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
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,Badgefrom@sims/ui; all existing api calls. -
Produces:
client-forms.tsxexports (used by ClientDetail and DocumentView):PaymentForm,NewRecurringForm,NewAmcForm,LogInteractionForm,AssignModuleForm,ClientModuleStatusSelect,RowDate,AccountOwnerSelect— signatures identical to today'sClientDetail.tsx:324-597. ClientDetail renders tabs keyedoverview | modules | documents | payments | amc | interactions | awswith the tab in the?tab=search param (Task 10 adds the ribbon intooverview). -
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:
import { useState } from 'react'
import { fromRupees } from '@sims/domain'
import { Button, Field, Notice, Toolbar } from '@sims/ui'
import {
createAmc, createInteraction, createRecurringPlan, patchClientModule, recordPayment,
CLIENT_MODULE_STATUSES, KIND_LABEL, PAYMENT_MODES,
type ClientModule, type ClientModuleStatus, type Employee, type InteractionType,
type Kind, type Module, type PaymentMode,
} from '../api'
const today = () => new Date().toISOString().slice(0, 10)
Wrap each form's outer <div style={{ border: '1px solid var(--border)' … }}> container class as className="wf-card" with style removed (same for all five form containers) — the only styling change.
- Step 2: Point DocumentView at the new module
apps/hq-web/src/pages/DocumentView.tsx line 10:
import { PaymentForm } from './client-forms'
- Step 3: Rewrite
apps/hq-web/src/pages/ClientDetail.tsx
import { useState } from 'react'
import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'
import { formatINR } from '@sims/domain'
import {
Badge, Button, DataTable, EmptyState, ErrorState, RecordHeader, Skeleton,
StatCard, Stats, Tabs, Toolbar,
} from '@sims/ui'
import {
assignClientModule, getClient, getClientModules, getLedger, getModules,
patchClient, role, isManagerial, getEmployees, setClientOwner,
getRecurringPlans, deactivateRecurringPlan,
getAmc, deactivateAmc, generateAmcRenewalInvoice,
getInteractions, getInteractionTypes, updateInteraction,
getClientAwsUsage,
CLIENT_STATUSES, KIND_LABEL,
type AmcPaidStatus, type ClientStatus, type Outcome,
} from '../api'
import { CLIENT_TONE, DOC_TONE, useData } from './Clients'
import {
AccountOwnerSelect, AssignModuleForm, ClientModuleStatusSelect,
LogInteractionForm, NewAmcForm, NewRecurringForm, PaymentForm, RowDate,
} from './client-forms'
const inr = (p: number) => formatINR(p, { symbol: false })
const AMC_TONE: Record<AmcPaidStatus, 'ok' | 'warn' | 'err'> = { paid: 'ok', unpaid: 'err', unbilled: 'warn' }
const OUTCOME_TONE: Record<Outcome, 'ok' | 'warn' | 'err'> = { positive: 'ok', neutral: 'warn', negative: 'err' }
const TAB_KEYS = ['overview', 'modules', 'documents', 'payments', 'amc', 'interactions', 'aws'] as const
type TabKey = (typeof TAB_KEYS)[number]
/** Client 360° — record header, KPIs, pulse ribbon (Task 10), tabbed sections. */
export function ClientDetail() {
const id = useParams()['id'] ?? ''
const nav = useNavigate()
const [sp, setSp] = useSearchParams()
const client = useData(() => getClient(id), [id])
const modules = useData(getModules, [])
const cms = useData(() => getClientModules(id), [id])
const ledger = useData(() => getLedger(id), [id])
const plans = useData(() => getRecurringPlans(id), [id])
const amc = useData(() => getAmc(id), [id])
const interactions = useData(() => getInteractions(id), [id])
const types = useData(getInteractionTypes, [])
const awsUsage = useData(() => getClientAwsUsage(id), [id])
const employees = useData(() => getEmployees(), [])
const isOwner = role() === 'owner'
const canRoute = isManagerial()
const [actionErr, setActionErr] = useState<string | undefined>()
const rawTab = sp.get('tab') ?? 'overview'
const tab: TabKey = (TAB_KEYS as readonly string[]).includes(rawTab) ? (rawTab as TabKey) : 'overview'
const setTab = (k: string) => setSp(k === 'overview' ? {} : { tab: k }, { replace: true })
const c = client.data
if (client.error !== undefined) return <ErrorState message={client.error} onRetry={client.reload} />
if (c === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
const moduleName = (moduleId: string) =>
modules.data?.find((m) => m.id === moduleId)?.name ?? moduleId
const docs = ledger.data?.documents ?? []
const openDocs = docs.filter((d) => d.status === 'draft' || d.status === 'sent' || d.status === 'part_paid')
const activeModules = (cms.data ?? []).filter((m) => m.active)
const lastInteraction = interactions.data?.[0]?.onDate
return (
<div className="wf-page">
<div className="wf-crumbs"><Link to="/clients">Clients</Link><span>/</span><span>{c.code}</span></div>
<RecordHeader
name={c.name}
status={<Badge tone={CLIENT_TONE[c.status]}>{c.status}</Badge>}
meta={
<>
<span className="mono">{c.code}</span>
<span>· State {c.stateCode}</span>
{c.gstin !== undefined && c.gstin !== '' && <span className="mono">· {c.gstin}</span>}
{c.contacts[0] !== undefined && (
<span>· {[c.contacts[0].name, c.contacts[0].email, c.contacts[0].phone].filter((x) => x !== undefined && x !== '').join(' · ')}</span>
)}
</>
}
actions={
<>
<select
className="wf" style={{ width: 'auto' }} value={c.status}
onChange={(e) => {
setActionErr(undefined)
patchClient(id, { status: e.target.value as ClientStatus })
.then(client.reload).catch((err: Error) => setActionErr(err.message))
}}
>
{CLIENT_STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<Button tone="primary" onClick={() => nav('/documents/new')}>New document</Button>
</>
}
/>
{canRoute && employees.data !== undefined && (
<Toolbar>
<AccountOwnerSelect
ownerId={c.ownerId}
employees={employees.data.employees}
onChange={(ownerId) => {
setActionErr(undefined)
setClientOwner(id, ownerId)
.then(client.reload).catch((err: Error) => setActionErr(err.message))
}}
/>
</Toolbar>
)}
{actionErr !== undefined && <ErrorState message={actionErr} />}
<Tabs
active={tab}
onChange={setTab}
tabs={[
{ key: 'overview', label: 'Overview' },
{ key: 'modules', label: 'Modules', count: (cms.data ?? []).length },
{ key: 'documents', label: 'Documents', count: docs.length },
{ key: 'payments', label: 'Payments & plans', count: (ledger.data?.payments ?? []).length },
{ key: 'amc', label: 'AMC', count: (amc.data ?? []).length },
{ key: 'interactions', label: 'Interactions', count: (interactions.data ?? []).length },
{ key: 'aws', label: 'AWS', count: (awsUsage.data ?? []).length },
]}
/>
{tab === 'overview' && (
<>
<Stats>
<StatCard label="Active modules" value={String(activeModules.length)} />
<StatCard label="Advance on account" value={ledger.data !== undefined ? formatINR(ledger.data.advancePaise) : '…'} hint="unallocated payments" />
<StatCard label="Open documents" value={String(openDocs.length)} hint={`${docs.length} total`} />
<StatCard label="Last interaction" value={lastInteraction ?? '—'} />
</Stats>
{/* Pulse ribbon lands here in the next task. */}
<h3 style={{ marginTop: 18 }}>Recent documents</h3>
{docs.length === 0 ? <EmptyState>No documents yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' },
]}
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.slice(0, 5).map((d) => ({
no: d.docNo ?? 'draft', type: d.docType, date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
{docs.length > 5 && (
<Toolbar><Button onClick={() => setTab('documents')}>All {docs.length} documents →</Button></Toolbar>
)}
</>
)}
{tab === 'modules' && (
<>
{modules.data !== undefined && (
<AssignModuleForm
modules={modules.data}
onAssign={(moduleId, kind) => {
setActionErr(undefined)
assignClientModule(id, { moduleId, kind })
.then(() => { cms.reload(); ledger.reload() })
.catch((err: Error) => setActionErr(err.message))
}}
/>
)}
{cms.error !== undefined && <ErrorState message={cms.error} onRetry={cms.reload} />}
{cms.data === undefined && cms.error === undefined ? <Skeleton />
: (cms.data ?? []).length === 0 ? <EmptyState>No modules assigned yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'module', label: 'Module' }, { key: 'kind', label: 'Kind' },
{ key: 'status', label: 'Status' },
{ key: 'installed', label: 'Installed' }, { key: 'completed', label: 'Completed' },
{ key: 'trained', label: 'Trained' },
{ key: 'billed', label: 'Billed', numeric: true },
{ key: 'settled', label: 'Settled', numeric: true },
]}
rows={(cms.data ?? []).map((cm) => {
const paid = ledger.data?.modulePaid.find((mp) => mp.moduleId === cm.moduleId)
return {
module: moduleName(cm.moduleId),
kind: KIND_LABEL[cm.kind],
status: <ClientModuleStatusSelect cm={cm} onPatched={() => cms.reload()} onError={setActionErr} />,
installed: <RowDate cm={cm} field="installedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
completed: <RowDate cm={cm} field="completedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
trained: <RowDate cm={cm} field="trainedOn" onPatched={() => cms.reload()} onError={setActionErr} />,
billed: paid !== undefined ? inr(paid.billedPaise) : '—',
settled: paid !== undefined ? inr(paid.settledPaise) : '—',
}
})}
/>
)}
</>
)}
{tab === 'documents' && (
<>
{ledger.error !== undefined && <ErrorState message={ledger.error} onRetry={ledger.reload} />}
{ledger.data === undefined && ledger.error === undefined ? <Skeleton />
: docs.length === 0 ? <EmptyState>No documents yet — compose one from New Document.</EmptyState> : (
<DataTable
columns={[
{ key: 'no', label: 'No', mono: true }, { key: 'type', label: 'Type' },
{ key: 'date', label: 'Date' }, { key: 'payable', label: 'Payable', numeric: true },
{ key: 'status', label: 'Status' },
]}
onRowClick={(_row, i) => nav(`/documents/${docs[i]!.id}`)}
rows={docs.map((d) => ({
no: d.docNo ?? <Badge tone="warn">draft</Badge>,
type: d.docType, date: d.docDate,
payable: inr(d.payablePaise),
status: <Badge tone={DOC_TONE[d.status]}>{d.status}</Badge>,
}))}
/>
)}
</>
)}
{tab === 'payments' && (
<>
{ledger.data !== undefined && (
<Stats>
<StatCard label="Advance on account" value={formatINR(ledger.data.advancePaise)} hint="unallocated payments" />
</Stats>
)}
<PaymentForm clientId={id} onDone={() => { ledger.reload(); cms.reload(); amc.reload() }} />
{ledger.data !== undefined && ledger.data.payments.length > 0 && (
<DataTable
columns={[
{ key: 'on', label: 'Received' }, { key: 'mode', label: 'Mode' },
{ key: 'ref', label: 'Reference', mono: true },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'tds', label: 'TDS', numeric: true },
]}
rows={ledger.data.payments.map((p) => ({
on: p.receivedOn, mode: p.mode, ref: p.reference !== '' ? p.reference : '—',
amount: inr(p.amountPaise), tds: p.tdsPaise > 0 ? inr(p.tdsPaise) : '—',
}))}
/>
)}
<h3 style={{ marginTop: 20 }}>Recurring plans</h3>
{isOwner && cms.data !== undefined && (
<NewRecurringForm
clientId={id}
options={(cms.data ?? []).filter((cm) => cm.active).map((cm) => ({
id: cm.id, label: `${moduleName(cm.moduleId)} · ${KIND_LABEL[cm.kind]}`,
}))}
onDone={() => plans.reload()}
/>
)}
{plans.error !== undefined && <ErrorState message={plans.error} onRetry={plans.reload} />}
{plans.data === undefined && plans.error === undefined ? <Skeleton />
: (plans.data ?? []).length === 0 ? <EmptyState>No recurring plans.</EmptyState> : (
<DataTable
columns={[
{ key: 'module', label: 'Module' }, { key: 'cadence', label: 'Cadence' },
{ key: 'amount', label: 'Amount', numeric: true }, { key: 'next', label: 'Next run' },
{ key: 'policy', label: 'Policy' }, { key: 'active', label: 'Active' }, { key: 'act', label: '' },
]}
rows={(plans.data ?? []).map((plan) => {
const cm = cms.data?.find((x) => x.id === plan.clientModuleId)
return {
module: cm !== undefined ? moduleName(cm.moduleId) : '—',
cadence: plan.cadence,
amount: plan.amountPaise !== null ? inr(plan.amountPaise) : 'price book',
next: plan.nextRun,
policy: <Badge tone={plan.policy === 'auto' ? 'accent' : 'warn'}>{plan.policy}</Badge>,
active: plan.active ? 'yes' : 'no',
act: plan.active ? (
<Button onClick={() => {
setActionErr(undefined)
deactivateRecurringPlan(plan.id)
.then(() => plans.reload()).catch((err: Error) => setActionErr(err.message))
}}>Deactivate</Button>
) : '—',
}
})}
/>
)}
</>
)}
{tab === 'amc' && (
<>
{isOwner && <NewAmcForm clientId={id} onDone={() => amc.reload()} />}
{amc.error !== undefined && <ErrorState message={amc.error} onRetry={amc.reload} />}
{amc.data === undefined && amc.error === undefined ? <Skeleton />
: (amc.data ?? []).length === 0 ? <EmptyState>No AMC contracts.</EmptyState> : (
<DataTable
columns={[
{ key: 'coverage', label: 'Coverage' }, { key: 'period', label: 'Period' },
{ key: 'amount', label: 'Amount', numeric: true },
{ key: 'days', label: 'Reminder days', numeric: true },
{ key: 'paid', label: 'Paid' }, { key: 'act', label: '' },
]}
rows={(amc.data ?? []).map((a) => ({
coverage: a.coverage !== '' ? a.coverage : '—',
period: `${a.periodFrom} → ${a.periodTo}`,
amount: inr(a.amountPaise),
days: a.renewalReminderDays,
paid: <Badge tone={AMC_TONE[a.paidStatus]}>{a.paidStatus}</Badge>,
act: (
<div style={{ display: 'flex', gap: 6 }}>
{a.paidStatus !== 'unpaid' && (
<Button tone="primary" onClick={() => {
setActionErr(undefined)
generateAmcRenewalInvoice(a.id)
.then((doc) => nav(`/documents/${doc.id}`)).catch((err: Error) => setActionErr(err.message))
}}>Generate renewal invoice</Button>
)}
{a.active && (
<Button onClick={() => {
setActionErr(undefined)
deactivateAmc(a.id)
.then(() => amc.reload()).catch((err: Error) => setActionErr(err.message))
}}>Deactivate</Button>
)}
</div>
),
}))}
/>
)}
</>
)}
{tab === 'interactions' && (
<>
{types.data !== undefined && (
<LogInteractionForm clientId={id} types={types.data} onDone={() => interactions.reload()} />
)}
{interactions.error !== undefined && <ErrorState message={interactions.error} onRetry={interactions.reload} />}
{interactions.data === undefined && interactions.error === undefined ? <Skeleton />
: (interactions.data ?? []).length === 0 ? <EmptyState>No interactions logged yet.</EmptyState> : (
<DataTable
columns={[
{ key: 'on', label: 'Date' }, { key: 'type', label: 'Type' },
{ key: 'notes', label: 'Notes' }, { key: 'outcome', label: 'Outcome' },
{ key: 'follow', label: 'Follow-up' },
]}
rows={(interactions.data ?? []).map((i) => ({
on: i.onDate,
type: types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode,
notes: i.notes !== '' ? i.notes : '—',
outcome: i.outcome !== null ? <Badge tone={OUTCOME_TONE[i.outcome]}>{i.outcome}</Badge> : '—',
follow: (
<input
type="date" className="wf" style={{ width: 140 }}
value={i.followUpOn ?? ''}
onChange={(e) => {
setActionErr(undefined)
updateInteraction(i.id, { followUpOn: e.target.value !== '' ? e.target.value : null })
.then(() => interactions.reload()).catch((err: Error) => setActionErr(err.message))
}}
/>
),
}))}
/>
)}
</>
)}
{tab === 'aws' && (
<>
{awsUsage.error !== undefined && <ErrorState message={awsUsage.error} onRetry={awsUsage.reload} />}
{awsUsage.data === undefined && awsUsage.error === undefined ? <Skeleton />
: (awsUsage.data ?? []).length === 0 ? <EmptyState>No AWS usage recorded for this client.</EmptyState> : (
<DataTable
columns={[
{ key: 'month', label: 'Month', mono: true }, { key: 'storage', label: 'Storage GB', numeric: true },
{ key: 'transfer', label: 'Transfer GB', numeric: true }, { key: 'cost', label: 'Cost', numeric: true },
{ key: 'source', label: 'Source' },
]}
rows={(awsUsage.data ?? []).map((u) => ({
month: u.month, storage: u.storageGb, transfer: u.transferGb, cost: inr(u.costPaise), source: u.source,
}))}
/>
)}
</>
)}
</div>
)
}
Notes for the implementer:
-
The "Recent documents" overview table shows 5 with an explicit "All N documents →" button — count visible, so no silent cap.
-
useDatainClients.tsxreturnsreload— everyErrorStategets it. -
formatINRimport stays;fromRupeesmoved 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=documentsdeep-links; all CRUD forms still work in their tabs (assign module, record payment, log interaction, AMC, recurring); DocumentView "Record payment" still works. -
Step 5: Commit
git add apps/hq-web/src/pages/client-forms.tsx apps/hq-web/src/pages/ClientDetail.tsx apps/hq-web/src/pages/DocumentView.tsx
git commit -m "feat(hq-web): Client 360 — record header, KPI row, tabbed sections, forms extracted"
Task 10: Relationship-pulse ribbon (TDD on bucketing)
Files:
- Create:
apps/hq-web/src/pulse.ts - Create:
apps/hq-web/src/components/PulseRibbon.tsx - Modify:
apps/hq-web/src/pages/ClientDetail.tsx(overview tab) - Test:
apps/hq-web/test/pulse.test.ts
Interfaces:
-
Consumes: ledger/interactions/amc/cms data already loaded by ClientDetail (Task 9).
-
Produces:
PulseEvent { id: string; lane: number; date: string; label: string; tone: 'accent' | 'ok' | 'warn' | 'err' }LANES: readonly string[](['Documents', 'Payments', 'Interactions', 'AMC & renewals'])lastMonths(todayIso: string, n?: number): string[]— n 'YYYY-MM' keys ending at today's monthgroupPulse(events: PulseEvent[], months: string[]): Map<string, PulseEvent[]>— key${laneIndex}:${monthIndex}; out-of-window events droppedPulseRibbon(props: { events: PulseEvent[]; todayIso: string; onOpen: (ev: PulseEvent) => void })
-
Step 1: Write the failing test —
apps/hq-web/test/pulse.test.ts:
import { describe, expect, it } from 'vitest'
import { groupPulse, lastMonths, type PulseEvent } from '../src/pulse'
describe('lastMonths', () => {
it('returns 12 months ending at the given date, oldest first', () => {
const m = lastMonths('2026-07-17')
expect(m).toHaveLength(12)
expect(m[0]).toBe('2025-08')
expect(m[11]).toBe('2026-07')
})
it('crosses year boundaries correctly', () => {
expect(lastMonths('2026-01-05', 3)).toEqual(['2025-11', '2025-12', '2026-01'])
})
})
describe('groupPulse', () => {
const ev = (lane: number, date: string, id: string): PulseEvent =>
({ id, lane, date, label: id, tone: 'accent' })
const months = lastMonths('2026-07-17')
it('buckets events by lane and month', () => {
const g = groupPulse([ev(0, '2026-07-01', 'a'), ev(0, '2026-07-30', 'b'), ev(2, '2025-08-09', 'c')], months)
expect(g.get('0:11')?.map((e) => e.id)).toEqual(['a', 'b'])
expect(g.get('2:0')?.map((e) => e.id)).toEqual(['c'])
})
it('drops events outside the window', () => {
const g = groupPulse([ev(1, '2024-01-01', 'old'), ev(1, '2027-01-01', 'future')], months)
expect(g.size).toBe(0)
})
})
-
Step 2: Run it — expect FAIL —
npm test -- pulse→ module not found. -
Step 3: Implement
apps/hq-web/src/pulse.ts
/** Pure math for the relationship-pulse ribbon (Client 360 overview). */
export interface PulseEvent {
id: string
lane: number
date: string // ISO yyyy-mm-dd
label: string
tone: 'accent' | 'ok' | 'warn' | 'err'
}
export const LANES = ['Documents', 'Payments', 'Interactions', 'AMC & renewals'] as const
/** n month keys ('YYYY-MM') ending at todayIso's month, oldest first. */
export function lastMonths(todayIso: string, n = 12): string[] {
const year = Number(todayIso.slice(0, 4))
const month = Number(todayIso.slice(5, 7)) // 1-based
const out: string[] = []
for (let i = n - 1; i >= 0; i--) {
const total = year * 12 + (month - 1) - i
const y = Math.floor(total / 12)
const m = total % 12 + 1
out.push(`${y}-${String(m).padStart(2, '0')}`)
}
return out
}
/** Bucket events into `${lane}:${monthIndex}` cells; out-of-window events are dropped. */
export function groupPulse(events: PulseEvent[], months: string[]): Map<string, PulseEvent[]> {
const index = new Map<string, number>()
months.forEach((m, i) => index.set(m, i))
const out = new Map<string, PulseEvent[]>()
for (const ev of events) {
const mi = index.get(ev.date.slice(0, 7))
if (mi === undefined) continue
const key = `${ev.lane}:${mi}`
const cell = out.get(key)
if (cell !== undefined) cell.push(ev)
else out.set(key, [ev])
}
return out
}
-
Step 4: Run tests — expect PASS —
npm test -- pulse→ 4 pass. -
Step 5: Create
apps/hq-web/src/components/PulseRibbon.tsx
import { useState } from 'react'
import { groupPulse, LANES, lastMonths, type PulseEvent } from '../pulse'
const TONE_VAR: Record<PulseEvent['tone'], string> = {
accent: 'var(--accent)', ok: 'var(--ok)', warn: 'var(--warn)', err: 'var(--err)',
}
/**
* Relationship pulse — last 12 months (H-04's timeline ribbon).
* Four lanes on a shared month axis; hover fills the readout bar; click opens
* the record. Plain DOM dots — no chart library.
*/
export function PulseRibbon(props: {
events: PulseEvent[]
todayIso: string
onOpen: (ev: PulseEvent) => void
}) {
const months = lastMonths(props.todayIso)
const cells = groupPulse(props.events, months)
const [focus, setFocus] = useState<PulseEvent | undefined>()
return (
<div className="pulse">
<div className="pulse-head">
<h3>Relationship pulse — last 12 months</h3>
<div className="pulse-legend">
{LANES.map((lane, i) => (
<span key={lane}><span className="dot" style={{ background: TONE_VAR[i === 0 ? 'accent' : i === 1 ? 'ok' : i === 2 ? 'warn' : 'err'] }} />{lane.toUpperCase()}</span>
))}
</div>
</div>
<div className="pulse-grid" style={{ gridTemplateColumns: `110px repeat(${months.length}, 1fr)` }}>
{LANES.map((lane, li) => (
<div key={lane} style={{ display: 'contents' }}>
<div className="pulse-lane">{lane}</div>
{months.map((m, mi) => {
const evs = cells.get(`${li}:${mi}`) ?? []
return (
<div key={m} className="pulse-cell">
{evs.slice(0, 3).map((ev) => (
<button
key={ev.id}
type="button"
className="pulse-dot"
style={{ background: TONE_VAR[ev.tone] }}
title={ev.label}
onMouseEnter={() => setFocus(ev)}
onFocus={() => setFocus(ev)}
onClick={() => props.onOpen(ev)}
aria-label={ev.label}
/>
))}
{evs.length > 3 && <span className="pulse-more">+{evs.length - 3}</span>}
</div>
)
})}
</div>
))}
<div className="pulse-lane" />
{months.map((m) => (
<div key={m} className="pulse-month">{m.slice(5)}/{m.slice(2, 4)}</div>
))}
</div>
<div className="pulse-readout">
{focus !== undefined
? <><span className="mono">{focus.date}</span> · {focus.label} <span className="pulse-open">open →</span></>
: 'Hover an event · click to open it'}
</div>
</div>
)
}
- Step 6: Ribbon CSS — append to
apps/hq-web/src/app.css:
/* Relationship-pulse ribbon (Client 360 overview). */
.pulse {
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 14px 16px; margin: 16px 0;
}
.pulse-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
.pulse-head h3 { margin: 0; font-size: 15px; }
.pulse-legend { margin-left: auto; display: flex; gap: 12px; font-size: 9.5px; letter-spacing: 0.06em; color: var(--text-dim); }
.pulse-legend .dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 4px; }
.pulse-grid { display: grid; gap: 2px 0; margin-top: 12px; }
.pulse-lane {
font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-dim); padding: 6px 8px 6px 0; white-space: nowrap;
}
.pulse-cell {
border-left: 1px solid var(--border);
min-height: 26px; display: flex; align-items: center; gap: 3px; padding: 2px 3px;
flex-wrap: wrap;
}
.pulse-dot {
width: 9px; height: 9px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
transition: transform 80ms;
}
.pulse-dot:hover { transform: scale(1.5); }
.pulse-more { font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); }
.pulse-month {
border-left: 1px solid var(--border);
font-size: 9.5px; color: var(--text-dim); font-family: var(--mono); padding: 3px;
}
.pulse-readout {
margin-top: 10px; border-left: 2px solid var(--accent);
background: var(--bg-inset); border-radius: var(--radius-sm);
padding: 7px 10px; font-size: 12.5px; color: var(--text);
min-height: 32px; display: flex; align-items: center; gap: 6px;
}
.pulse-open { color: var(--accent-strong); margin-left: auto; font-size: 11.5px; }
@media (max-width: 900px) { .pulse-grid { display: none; } .pulse-readout { display: none; }
.pulse::after { content: 'Timeline hidden on small screens.'; color: var(--text-dim); font-size: 12px; } }
- Step 7: Wire it into ClientDetail's overview tab
In apps/hq-web/src/pages/ClientDetail.tsx:
Add imports:
import { PulseRibbon } from '../components/PulseRibbon'
import { type PulseEvent } from '../pulse'
Inside ClientDetail() (after lastInteraction), assemble the events:
const todayIso = new Date().toISOString().slice(0, 10)
const DOC_PULSE_TONE: Record<string, PulseEvent['tone']> = {
paid: 'ok', part_paid: 'warn', lost: 'err', cancelled: 'err',
}
const pulseEvents: PulseEvent[] = [
...docs.map((d): PulseEvent => ({
id: `doc:${d.id}`, lane: 0, date: d.docDate,
label: `${d.docType} ${d.docNo ?? '(draft)'} — ${formatINR(d.payablePaise)} · ${d.status}`,
tone: DOC_PULSE_TONE[d.status] ?? 'accent',
})),
...(ledger.data?.payments ?? []).map((p): PulseEvent => ({
id: `pay:${p.id}`, lane: 1, date: p.receivedOn,
label: `Payment ${formatINR(p.amountPaise)} · ${p.mode}`, tone: 'ok',
})),
...(interactions.data ?? []).map((i): PulseEvent => ({
id: `int:${i.id}`, lane: 2, date: i.onDate,
label: `${types.data?.find((t) => t.code === i.typeCode)?.label ?? i.typeCode}${i.notes !== '' ? ` — ${i.notes.slice(0, 60)}` : ''}`,
tone: i.outcome === 'negative' ? 'err' : i.outcome === 'positive' ? 'ok' : 'warn',
})),
...(amc.data ?? []).flatMap((a): PulseEvent[] => [
{ id: `amc-from:${a.id}`, lane: 3, date: a.periodFrom, label: `AMC starts — ${a.coverage !== '' ? a.coverage : 'contract'}`, tone: 'accent' },
{ id: `amc-to:${a.id}`, lane: 3, date: a.periodTo, label: `AMC renewal — ${a.paidStatus}`, tone: a.paidStatus === 'unpaid' ? 'err' : 'warn' },
]),
...activeModules.filter((cm) => cm.nextRenewal !== null).map((cm): PulseEvent => ({
id: `ren:${cm.id}`, lane: 3, date: cm.nextRenewal!,
label: `${moduleName(cm.moduleId)} renews`, tone: 'warn',
})),
]
const openPulse = (ev: PulseEvent) => {
if (ev.id.startsWith('doc:')) nav(`/documents/${ev.id.slice(4)}`)
else if (ev.id.startsWith('pay:')) setTab('payments')
else if (ev.id.startsWith('int:')) setTab('interactions')
else setTab('amc')
}
Replace the placeholder comment {/* Pulse ribbon lands here in the next task. */} with:
<PulseRibbon events={pulseEvents} todayIso={todayIso} onOpen={openPulse} />
-
Step 8: 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
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
- Import
ErrorState, RecordHeader, Skeletonfrom@sims/ui(extend line 4) andLinkfrom react-router-dom (extend line 2). - Replace the loading/error early-returns (lines 59–60) with:
if (full.error !== undefined) return <div className="wf-page"><ErrorState message={full.error} onRetry={full.reload} /></div>
if (doc === undefined || full.data === undefined) return <div className="wf-page"><Skeleton rows={6} /></div>
- Replace the
<PageHeader …/>call (lines 90–95) with a breadcrumb + RecordHeader:
<div className="wf-crumbs">
<Link to="/clients">Clients</Link><span>/</span>
{client.data !== undefined ? <Link to={`/clients/${doc.clientId}`}>{client.data.name}</Link> : <span>…</span>}
<span>/</span><span className="mono">{doc.docNo ?? 'draft'}</span>
</div>
<RecordHeader
name={TITLE[doc.docType]}
avatarSeed={client.data?.name ?? doc.clientId}
status={<Badge tone={DOC_TONE[doc.status]}>{doc.status}</Badge>}
meta={
<>
<span className="mono">{doc.docNo ?? '(draft)'}</span>
<span>· {doc.fy}</span>
<span>· {doc.docDate}</span>
<span>· Payable <strong className="num">{formatINR(doc.payablePaise)}</strong></span>
</>
}
/>
- Timeline + Email log side by side — wrap those two sections (from
<h3 …>Timeline</h3>through the emailDataTable's closing)}) in:
<div className="dash-grid">
<section>…Timeline block…</section>
<section>…Email log block…</section>
</div>
- Step 2: NewDocument edits
- Wrap each line-item row in a card: in the
lines.mapJSX (line 203) change the outer div toclassName="wf-card"keepingkey={i}and replacing itsstylewithstyle={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'flex-end' }}. - Swap the client search dropdown suggestion container (line 172)
borderRadius: 8→borderRadius: 'var(--radius)', boxShadow: 'var(--shadow-lg)'. - 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
git add apps/hq-web/src/pages/DocumentView.tsx apps/hq-web/src/pages/NewDocument.tsx
git commit -m "feat(hq-web): document view record header + composer line cards"
Task 12: Reports, Modules, Employees, DocumentTemplate polish
Files:
- Modify:
apps/hq-web/src/pages/Reports.tsx(restructure below) - Modify:
apps/hq-web/src/pages/Modules.tsx(small diffs) - Modify:
apps/hq-web/src/pages/Employees.tsx(small diffs) - Modify:
apps/hq-web/src/pages/DocumentTemplate.tsx(small diffs)
Interfaces:
-
Consumes:
FilterChips,Skeleton,ErrorState,Column.mono,DataTable.rowTone. -
Step 1: Reports — one report at a time behind FilterChips
Rework Reports() so a report state picks which section renders (all four useData hooks stay as they are — they are cheap and keep the code simple):
const [report, setReport] = useState<'dues' | 'revenue' | 'profit' | 'aws'>('dues')
After <PageHeader …/> render:
<FilterChips
active={report}
onChange={(k) => setReport(k as typeof report)}
chips={[
{ key: 'dues', label: 'Dues aging' },
{ key: 'revenue', label: 'Module revenue' },
{ key: 'profit', label: 'Profitability' },
{ key: 'aws', label: 'AWS costs' },
]}
/>
then wrap each existing section in {report === 'dues' && (…)} etc., dropping the <h3> titles (the chip is the title). Add per-section states: loading → <Skeleton rows={5} />, error → <ErrorState message={…} onRetry={….reload} />. On the dues table add:
rowTone={(i) => ((dues.data![i]!.b90p > 0) ? 'err' : dues.data![i]!.b61_90 > 0 ? 'warn' : undefined)}
Import ErrorState, FilterChips, Skeleton from @sims/ui; drop the now-unused Notice/EmptyState only if genuinely unused (empty lists still use EmptyState).
-
Step 2: Modules — swap
Loading…EmptyStates for<Skeleton rows={5} />(two places: module list line 37, price list line 202), errors for<ErrorState message={…} onRetry={….reload}/>; mark columns mono: module listcode, price book none; form containers (NewModuleForm outer div line 136) →className="wf-card"(drop border/radius/padding from style). Extend the ui import accordingly. -
Step 3: Employees — same treatment:
Loading…→<Skeleton rows={4} />,list.error→ErrorStatewithlist.reload;emailcolumnmono: 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-sunkenwhich is undefined → falls back to#e9e9ee; change the twobackground: 'var(--bg-sunken, #e9e9ee)'occurrences (lines 113 in DocumentTemplate.tsx; also NewDocument.tsx lines 286/299 and Modules.tsx line 93) tobackground: '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
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-cardCSS (Task 6). -
Step 1: Rewrite the Login JSX (state + submit logic unchanged) — replace the
returnblock:
return (
<div className="login-wrap">
<div className="login-brand">
<h1>SiMS HQ</h1>
<p>The ops console — clients, documents, payments, AMC and reminders in one place.</p>
</div>
<div className="login-form">
<div className="login-card">
<h2 style={{ marginTop: 0 }}>Sign in</h2>
<Field label="Email">
<input
className="wf" type="email" value={email} autoFocus
onChange={(e) => setEmail(e.target.value)}
/>
</Field>
<Field label="Password">
<input
className="wf" type="password" value={pw}
onChange={(e) => setPw(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && void submit()}
/>
</Field>
<Button tone="primary" onClick={() => void submit()}>{busy ? 'Signing in…' : 'Sign in'}</Button>
{error !== undefined && <Notice tone="err">{error}</Notice>}
</div>
</div>
</div>
)
-
Step 2: Verify —
npm run typecheck→ exit 0. Visual: dark warm brand panel left, form right; brand panel hides below 800px; login still works. -
Step 3: Commit
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:
- Theme: toggle dark/light; click every accent swatch; teal is default on a fresh profile (clear localStorage
ui.accent). - 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.
- Dashboard: skeleton flash on load; queue actions (Preview/Send/Dismiss) still work; failed rows tinted.
- Clients: chips filter; counts correct; search still server-side; row → Client 360.
- 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. - Documents: open a document — breadcrumb, mono number, actions, share links, PDF iframe; New Document composer: pick client, add line, live preview + totals, Save Draft.
- Reports: all four chips; AWS month picker; dues row tinting.
- Modules / Employees / Document Template: load, create/edit flows, template live preview.
- Responsive: ≤900px — drawer nav opens/closes, tables scroll horizontally, composer single-column, login brand panel hidden.
- Dark mode: repeat a spot-check of 3–5 screens in dark.
-
Step 3: Update docs —
README.md/STATUS.mdif they describe the old UI (checkgrep -l "left nav" README.md STATUS.md docs/ -r); update the verified-state line inCLAUDE.md(test count) if the number changed. -
Step 4: Final commit
git add -A
git commit -m "docs: record redesign verification (typecheck + tests + e2e walk)"
Self-review checklist (ran at plan-writing time)
- Spec coverage: tokens §2 → Task 1; shell/palette/responsive §3 → Tasks 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.tscomment. Employees-in-nav gap: Task 6. - Type consistency:
PaletteItemdefined once (Task 5), consumed in Task 6;PulseEvent/lastMonths/groupPulsedefined Task 10 and used only there;client-forms.tsxexports named identically to today's functions;rowTone/monosignatures 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.