Skip to main content

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-portal and 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 .dark counterpart. 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. No if (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:

  1. Primary CTAs โ€” "Send", "Send Code", "+ New Thread", "Approve", "Send invite".
  2. Active navigation โ€” left border on sidebar items, bottom border on top-nav tabs, active text color.
  3. Specialist persona avatars โ€” the workspace identity (slug avatar, Specialist persona, user avatars where the user belongs to that workspace).
  4. Selected-state affordances โ€” selected ticket row, selected thread, focus ring (3px --accent-subtle halo).
  5. 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โ€‹

TokenUse
--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:

StatusLightDark
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)โ€‹

SizeUse
10Section labels (uppercase 0.08em tracking 700 weight), tiny status badges
11Metadata (timestamps, captions), button labels, footer links
12Secondary body, badges, sidebar nav, inputs in dense panels
13Default body, primary table rows, primary nav
14Message bubbles (chat), card titles, primary inputs in forms
16โ€“18Section headings (h2/h3)
20โ€“24Page 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-surface background, --border 1px 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-muted labels + --border bottom.
  • Row hover: --bg-hover.
  • Row selected: --accent-subtle + --accent left bar (3px).
  • Sticky header on scroll.

Inputsโ€‹

  • --bg-surface background, --border 1px border, 8px border-radius (slightly tighter than cards).
  • Focus: --accent border + 3px --accent-subtle halo (box-shadow).
  • 14px font for primary inputs, 13px for dense.
  • 10/12 vertical padding ร— 12/14 horizontal.

Buttonsโ€‹

  • See Button in COMPONENT_INVENTORY.md for variants.
  • Heights: sm 28 / md 36 / lg 44. Touch targets โ‰ฅ44px for any tap-able primary action.
  • Primary always uses --accent background; never use --success or --info as a button background.

Badgesโ€‹

  • 11px / 600 weight / uppercase / 0.04em tracking / 4px radius.
  • Each variant pairs --{semantic}-subtle bg 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): --accent bg + white text. Tail in bottom-right (18px 18px 4px 18px).
  • Incoming (Specialist/Expert): --bg-surface bg + 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:

LayoutActive 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-muted color, 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:

  1. Use the danger variant explicitly.
  2. Require typed confirmation for irreversible actions ("type ACME to confirm").
  3. 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.

โŒ Review chrome on the reply pathโ€‹

There is no Humanwork delivery gate. held_for_review is hard-false โ€” "this session model has no held-for-review governance gate" (sessions.service.ts) โ€” and ADR-046 ยง9 removed the columns that could recreate one. Never draw "awaiting review", "pending approval" or a held-back reply on a Specialist answer: it tells the client that someone internal is standing between them and their answer, which is not true.

The approval surface that does exist runs the other way โ€” the runtime asks the client (user-input-request, requestKind: "client_approval") before it acts. Draw that as the Specialist asking you: "Create these 5 tickets?" with Approve and Decline. Never as internal moderation. The two look alike and mean opposite things about who is waiting.

โŒ Exposing the teamโ€‹

Clients never see:

  • The word "Expert" in attribution (only "Specialist name" or the specialist's persona).
  • The internal note panel.
  • The AI agent's internal name or instance ID. They see "AI" if relevant to the handling.
  • Multiple senders in a single thread (other than themselves and the Specialist).

If a client asks "who answered this?" the answer is always "Bob" (the Specialist).

โŒ Yellow/amber for Debug Mode panelsโ€‹

Make development affordances (debug mode, demo login panels) subtle โ€” --surface-3 background, single-pixel separators, low chroma. They shouldn't shout for attention or be confused with warnings.

โŒ Random border-radiusโ€‹

Stick to the scale: 4px (badges), 8px (inputs, small buttons), 12px (cards), 18px (chat bubbles), 50% (avatars). No border-radius: 7px ad-hoc.

โŒ Mixed icon stylesโ€‹

We use line-style icons (Lucide-like, 1.5โ€“2px stroke, currentColor) throughout. No mixing line and filled. Exception: the H logo tile is filled by design.

โŒ Toasts with raw Error objectsโ€‹

toast.error(err instanceof Error ? err : "Friendly fallback") โ€” never let raw stack traces or "Internal Server Error" reach users. The error is logged separately.


7. Accessibility baselinesโ€‹

  • All interactive elements have โ‰ฅ44ร—44px touch targets (use .tap-target utility).
  • Text contrast โ‰ฅ4.5:1 for body, โ‰ฅ3:1 for large text. Verify both light and dark modes.
  • Focus ring visible on every focusable element โ€” --accent 2px outline + 2px offset (set globally on :focus-visible).
  • Form labels are programmatic โ€” <label> for inputs, aria-label for icon-only buttons.
  • Color is never the only signal โ€” risk badges have text labels; status pills do too.
  • Animations respect prefers-reduced-motion (skip slide/pulse if requested).

8. Performance baselinesโ€‹

  • Default body 13px is faster to scan and lets us pack more meaningful content above the fold.
  • Skeletons over spinners โ€” perceived speed.
  • Queue/chat freshness is event-driven. Explicit open, reload, refresh, or socket reconnect may perform one bounded authoritative read; idle time never polls.
  • No layout shift after data load โ€” reserve space (placeholder cards, fixed-height rows).

9. When in doubtโ€‹

  1. Reach for tokens, not values. If you're typing a hex, stop and check whether a token already exists or should be added.
  2. Prefer existing components over new ones. If you need a card, use the same pattern other cards use. Variation should be intentional.
  3. Match the role's mental model. A client wants conversation; an expert wants a queue; an AM wants a dashboard. Don't import the wrong metaphor.
  4. Cut, then cut again. The more you remove, the better B2B SaaS gets. If a label, divider, or icon doesn't earn its place, delete it.
  5. Ask what owns it. Before drawing a surface: is this view state, product state, session identity, transcript, or agent configuration โ€” and who is authoritative (AgentFS, SessionDB, Hermes, or the API)? The UI adds a workflow, an operator experience, or an integration. Never a store. See CLIENT_PORTAL_UI_RULES.md section 6.

Owner: Design + Platform team