DESIGN_PRINCIPLES.md โ h.work
The design decisions behind the platform UI. Read this before adding a screen. Cross-reference: GLOSSARY.md, INFORMATION_ARCHITECTURE.md, VIEWS_BY_ROLE.md, COMPONENT_INVENTORY.md,
frontend/src/app/globals.css.Client portal: CLIENT_PORTAL_UI_RULES.md is binding for anything inside
.hw-client-portaland is partly machine-enforced. This document stays platform-wide; where the two overlap, the portal rules win inside the portal. Its section 6 (runtime authority) is the one part that is not portal-scoped โ it binds every client surface, this document included.Last updated: 2026-08-10 (ยง4 turn progress, ยง5 runtime nouns, ยง6 append-only replies and the review-gate correction, ยง9 โ all consequences of ADR-045 / ADR-046).
1. Light/dark mode strategyโ
Both modes are first-class. Light is the default.โ
- Light mode (Intercom-inspired): white surfaces, warm gray canvas. The platform's home aesthetic โ what new users see.
- Dark mode (Linear-inspired): warm near-black, lighter desaturated claret accent. The aesthetic for power users who live in the queue.
Mechanismโ
A .dark class on <html> flips the entire token system, applied by next-themes
(ThemeProvider in frontend/src/components/providers.tsx): attribute="class",
defaultTheme="light", enableSystem, disableTransitionOnChange. Preference persists
under the theme key and falls back to prefers-color-scheme.
Read the theme with useTheme().resolvedTheme. Never branch on it to pick a colour โ
that is what the token pairs are for.
Token system (truncated โ see globals.css)โ
The base scale is generated, not hand-edited: frontend/src/app/design-tokens.css
comes from frontend/src/design-tokens.ts via npm run generate:tokens, and holds a
:root / .dark pair for each of --background, --foreground, --card, --border,
--brand, --muted, --sidebar, --hover, --destructive, --popover, --ring,
--primary, โฆ
:root { --background: oklch(0.985 0.006 85); --foreground: oklch(0.18 0.008 60); --brand: oklch(0.36 0.13 25); }
.dark { --background: oklch(0.15 0 0); --foreground: oklch(0.95 0 0); --brand: oklch(0.65 0.15 25); }
Then semantic aliases sit on top in globals.css: --bg-canvas, --bg-surface,
--bg-sidebar, --bg-hover, --text-primary, --text-secondary, --text-muted,
--border, --accent, --accent-subtle, --accent-hover, --accent-fg.
Components consume the aliases, never the raw scale.
โ ๏ธ A var() inside a custom property is substituted on the element that declares it.
An alias defined at :root therefore keeps :root's value and will not follow a scoped
override โ a scope that moves --background must re-declare its --bg-* aliases too.
This shipped inert once already; it is now gated
(frontend/src/__tests__/portal-ui-rules.test.ts).
Key ruleโ
- Hex colors don't appear in components. Every color is a token. Exception: brand SVG icons that need a literal brand color (e.g. Slack purple) โ but even then, prefer brand tokens defined once. Ratcheted for client surfaces by the gate above.
- Every token holding a literal colour needs a
.darkcounterpart. A palette sampled off a light-only design is a dark-mode bug until paired โ see CLIENT_PORTAL_UI_RULES.md ยง1.1. - Risk badges and status pills have their own theme-aware token pairs (
--risk-critical-bg/text, etc.). They flip automatically. Noif (theme === 'dark')checks in components.
2. Color usage rulesโ
Claret โ used sparinglyโ
The accent (#7E1D1D light / #C94B4B dark) is the brand color. It is reserved for:
- Primary CTAs โ "Send", "Send Code", "+ New Thread", "Approve", "Send invite".
- Active navigation โ left border on sidebar items, bottom border on top-nav tabs, active text color.
- Specialist persona avatars โ the workspace identity (slug avatar, Specialist persona, user avatars where the user belongs to that workspace).
- Selected-state affordances โ selected ticket row, selected thread, focus ring (3px
--accent-subtlehalo). - Branding tile โ the H logo square.
It is not used for:
- Generic icons (use
--text-muted). - Body text (use
--text-primary). - Generic borders (use
--border). - Errors (use
--danger). - Hover affordances on neutral elements (use
--bg-hover).
A useful rule: if you can replace the claret with a neutral and the meaning is preserved, you should. Reserve the color for moments where it carries semantic weight.
Status semanticsโ
| Token | Use |
|---|---|
--success (green) | Connected channels, resolved tickets, "Online now", high SLA |
--warning (amber) | Trial banner, medium-risk SLA, pending agentic step |
--danger (red) | Critical risk badge, broken channel, destructive action affirmation |
--info (blue) | "AI" handling badge, "You" assignee pill |
--teal-0 | "Assigned" pill (other expert), low-risk badge |
Risk levels are not red-amber-yellow-green:
- critical โ danger (red)
- high โ warning (orange/amber)
- medium โ warning (lighter)
- low โ teal (intentionally not green; green is reserved for "good" states like online/connected, and a green "low risk" badge would mistakenly read as positive when it's actually about uncertainty)
Light vs dark variationโ
The accent shifts (#7E1D1D โ #C94B4B) for legibility on dark surfaces. Status colors also shift slightly:
| Status | Light | Dark |
|---|---|---|
| success | #16A34A | #22C55E |
| warning | #D97706 | #F59E0B |
| danger | #DC2626 | #EF4444 |
Designers don't think about this โ tokens handle it.
3. Typographyโ
Font stack โ do not changeโ
Manrope, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif
Mono: 'JetBrains Mono', ui-monospace, monospace (used for code, IDs, audit log entries).
Display serif (page headers, editorial moments โ --font-display / --font-heading): 'Source Serif 4', ui-serif, Georgia, serif. Used in <PageHeader>, dashboard hero, login page.
Sizes (px)โ
| Size | Use |
|---|---|
| 10 | Section labels (uppercase 0.08em tracking 700 weight), tiny status badges |
| 11 | Metadata (timestamps, captions), button labels, footer links |
| 12 | Secondary body, badges, sidebar nav, inputs in dense panels |
| 13 | Default body, primary table rows, primary nav |
| 14 | Message bubbles (chat), card titles, primary inputs in forms |
| 16โ18 | Section headings (h2/h3) |
| 20โ24 | Page titles (h1), stat values |
Default body is 13px. This is intentionally compact โ power users (experts especially) value information density. Chat messages bump to 14px because conversation tone benefits from a more comfortable reading size.
Weightsโ
- 400 โ body text
- 500 โ sidebar items, semi-emphasized rows
- 600 โ headings, labels, primary nav active state
- 700 โ uppercase section labels, big numbers (24px stat values), badges
Line heightโ
- Body: 1.5
- Bubbles: 1.55 (slightly more breathing room)
- Headings: 1.2โ1.3
Tabular numeralsโ
Use font-variant-numeric: tabular-nums for any column of numbers (queue counts, timestamps, SLA percentages). Prevents column wobble.
4. Component patternsโ
Cardsโ
--bg-surfacebackground,--border1px border, 12px border-radius (Linear/Intercom convention).- 16โ20px internal padding for compact cards, 24โ28px for hero cards.
- Shadows: avoid by default. Use only for floating elements (modals, popovers).
Tablesโ
- 13px body,
--text-primary. - Header row:
--bg-surface+ 11/600 uppercase--text-mutedlabels +--borderbottom. - Row hover:
--bg-hover. - Row selected:
--accent-subtle+--accentleft bar (3px). - Sticky header on scroll.
Inputsโ
--bg-surfacebackground,--border1px border, 8px border-radius (slightly tighter than cards).- Focus:
--accentborder + 3px--accent-subtlehalo (box-shadow). - 14px font for primary inputs, 13px for dense.
- 10/12 vertical padding ร 12/14 horizontal.
Buttonsโ
- See
ButtoninCOMPONENT_INVENTORY.mdfor variants. - Heights: sm 28 / md 36 / lg 44. Touch targets โฅ44px for any tap-able primary action.
- Primary always uses
--accentbackground; never use--successor--infoas a button background.
Badgesโ
- 11px / 600 weight / uppercase / 0.04em tracking / 4px radius.
- Each variant pairs
--{semantic}-subtlebg with--{semantic}`` text. Never raw hex.
Bubbles (chat)โ
- 14px body / 1.55 line-height / 9px vertical 13px horizontal padding / 18px radius with directional 4px tail.
- Outgoing (user/customer):
--accentbg + white text. Tail in bottom-right (18px 18px 4px 18px). - Incoming (Specialist/Expert):
--bg-surfacebg + 1px border +--text-primary. Tail in top-left (4px 18px 18px 18px). - Internal note: dashed amber border + ๐ icon, never visible to clients (gated by
EXPERT_ROLES).
Active navโ
Two patterns, depending on orientation:
| Layout | Active state |
|---|---|
| Vertical sidebar (AM, Superadmin, Portal admin, Portal chat threads) | 3px --accent left border + --accent text + --accent-subtle bg |
| Horizontal top tabs (Workspace, status tabs in queue, Org tabs in queue) | 2px --accent bottom border + --accent text |
These are the only two active-nav patterns. Don't invent a third.
Empty statesโ
- Centered,
--text-mutedcolor, soft tone. - Eyebrow label (10/700 uppercase muted) + h2 (20/600 primary) + caption (13/muted) + primary CTA.
- For the chat empty state: a single Specialist card with green online dot โ the entire visual weight is on the next action, not on the absence of content.
Loading statesโ
- Skeleton placeholders matching real content shape โ never spinners on content surfaces.
- Spinner only on buttons during async actions.
A Specialist turn is not a page load. A skeleton promises imminent content โ true of a read, false of a turn. A warm turn answers in seconds; a cold admission can take 60โ95s (#5288). Design the wait rather than disguise it: the phases are real, and ADR-046 ยง8 requires them to be measured separately โ API receipt โ ACP attach, cold admission, first model request, model/tool loop, canonical persistence and delivery.
- Name the phase when you can. "Waking up" during a cold admission and "Working" during the model/tool loop tell the client different things, and both are true.
- A wait needs a failure state. A warm session can go silent after 15 minutes idle (#5578), and an indicator with no end state turns that into a client sitting in front of nothing.
- Raising a timeout is not a performance fix (ADR-046 ยง8). Neither is a longer animation.
Modalsโ
- Centered,
--bg-surface, 12px radius, 1px border, 24โ32px padding. - Backdrop:
rgba(0,0,0,0.5)light /rgba(0,0,0,0.7)dark. - Close icon top-right (16px, muted, hover lifts).
- Primary action right, secondary left.
5. Voice and copy guidelinesโ
These come from GLOSSARY.md and from product values. They apply to UI copy, error messages, empty states, and email templates.
The platform nameโ
- Always lowercase:
h.workโ including at the start of sentences ("h.work helps youโฆ"). - The dot is part of the name. Do not write "human work" or "Human Work".
Words to useโ
- "humans" โ when referring to the people on the team. Not "users", not "members" (unless specifically referring to org membership).
- "workspace" โ user-facing term for an org. Use this in UI, URLs (
acme.h.work), emails. "Org" is internal/code-only. - "Specialist" (capitalized as a role/persona name like "Bob is your Specialist") โ the named client-facing identity.
- "Expert" โ internal humans behind the Specialist. Never shown to clients.
- "AI Agent" / "agent" โ automated component. Internal-only language. Clients see "AI" badge inline; they never see the word "agent" in copy.
- "Send" โ the verb for outbound messages, not "submit".
- "Resolve" โ the verb for closing a ticket, not "close".
- "Defer" โ for snoozing (24h default), not "snooze".
Words to avoidโ
- โ "User" โ replace with "human", "team member", or the specific role.
- โ "Submit" โ replace with "Send" or the specific action verb ("Save", "Invite", "Resolve").
- โ "Ticket" in client-facing copy where conversation works ("thread" is fine in chat sidebar, "ticket" is okay for trackable work items).
- โ Capitalized "User", "Customer" in body copy. Exception: official entity names (Specialist, Expert).
- โ Marketing speak: "amazing", "powerful", "revolutionary". This is a B2B SaaS โ the audience knows what's amazing.
- โ Runtime nouns โ "Hermes", "AgentFS", "SessionDB", "session", "locator", raw tool names. A thread has a subject, not an id; a file is "in this thread", not at a
file_uri. Code addresses a message by its locator; a human is never shown one.
Toneโ
- Direct, calm, helpful. Treat the reader as a capable colleague.
- Short sentences. Three short ones beat one compound sentence.
- No emoji in product UI. Reserve emoji for status chips that already have semantic meaning (โ for confirmation, ๐ for internal note). Never decorative.
- Errors describe what went wrong + what to do next. "We couldn't reach the agent service. Please try again in a moment." โ not "Error: 500 Internal Server Error".
- Copy is sentence case. Never Title Case for body text or buttons. Exceptions: page titles, official names.
Inclusive defaultsโ
- Singular "they" by default. No gendered pronouns in templates.
- No assumptions about the reader's role or business size.
Examplesโ
| โ Don't | โ Do |
|---|---|
| "Submit Form" | "Save changes" |
| "Welcome User!" | "Welcome to your workspace" |
| "Click here to message your AI Agent" | "Message Bob to get started" |
| "An Error Occurred" | "We couldn't load your threads. Try refreshing." |
| "Successfully Sent!" | "Sent" (in a toast โ the toast color carries the semantics) |
6. What NOT to doโ
These are anti-patterns we have explicitly rejected. If a design proposal contains any of these, push back.
โ Red on blackโ
The previous design used #7E1D1D claret on #0A0A0B near-black backgrounds. This reads as harsh, amateur, and visually fatiguing. Dark mode now uses warm #0F0F10 with the lighter #C94B4B accent โ calibrated for contrast and comfort.
โ Inline hex colors in componentsโ
Every color goes through a token. If you need a new color, define it in globals.css first and then reference the alias. A grep for #[0-9a-f]{6} in TSX should turn up zero matches in component code (only in globals.css).
โ All-caps headlines or labels in body copyโ
Reserve uppercase for section eyebrows (10px, 700 weight, 0.08em tracking) and badges/pills. Page titles, button labels, and body text are sentence case.
โ Editorial serif used deliberately, not gratuitouslyโ
The product uses Manrope for body + UI text (--font-sans) and Source Serif 4 as an editorial display serif (--font-display / --font-heading โ used in page headers, dashboards, login). JetBrains Mono is the only mono font. The serif is deliberate โ page headers and editorial moments use it. Don't add a third sans family (no Inter โ superseded 2026-Q2 โ and no Geist Mono either, despite older comments). The previous all-sans claim was wrong; both font swaps (#1010 Inter + #1104 Test Tiempos) were reverted in #1234 / #1223, restoring Manrope + Source Serif 4 as the canonical stack.
โ Multiple primary buttons in one viewโ
One primary CTA per screen (or per section). If there are competing actions, the secondary action gets the secondary variant. The eye should know where to land.
โ Modal-trapped destructive actionsโ
Destructive actions (delete workspace, archive org, remove member) should:
- Use the danger variant explicitly.
- Require typed confirmation for irreversible actions ("type ACME to confirm").
- Never auto-focus the destructive button.
โ Confidence scores in client UIโ
Clients never see a numeric confidence (e.g. "82%"), and since ADR-046 there is none to see: the message and Specialist confidence/review columns were deleted from the schema, so nothing produces the number. The rule stands against anything that would reintroduce one โ it would dilute the Specialist persona and imply a control the client does not have.
What clients do see is handling โ an "AI" badge where it is relevant, or that a human has taken over. Escalation is real; a review gate is not (see below).
โ A reply that can be replaced, hidden, or regenerated in placeโ
The transcript appends. ADR-046 ยง9: a new reply may not replace, hide, or regenerate the previous Specialist reply, and ยง6 requires conflicting prose to surface as a conflict rather than be masked client-side. So there is no inline "regenerate this answer" on something the client has already read, and no quiet de-duplication of a double-send โ a client-side mask hides a write defect instead of fixing it.
"Regenerate" is legitimate on an unsent draft, expert-side, before release
(/conversations/drafts/:messageId/regenerate). Once released, the next answer is a new
message underneath the previous one.