Skip to main content

h.work Branding β€” Authoritative Reference

Source of truth for porting any sibling app (h.work web, marketing, etc.) to match this codebase's visual system. Every claim is backed by a file path and line number. Do not invent tokens that are not in this document.

This document was generated by auditing the recruiter app on main at commit 3f0705c. Stack: Next.js 16, React 19, Tailwind CSS v4, shadcn/ui (style: "radix-nova" in components.json:3), Radix UI primitives, lucide-react, next-themes, sonner, tw-animate-css.


1. Brand voice and principles​

  1. Editorial, not SaaS. Source Serif 4 + Manrope, warm parchment background, a single confident claret accent. No purple gradients, no neon, no consumer playfulness. See app/globals.css:54-61.
  2. Lowercase h, always. The wordmark is h.work. The dot is the brand's only accent color. The "work" tail is italic and 55% opacity. components/brand/Logo.tsx:50-82.
  3. Restrained color. Semantic states (amber/emerald/red/orange) only carry meaning. Everything else is neutral foreground on parchment. Never apply a brand wash to a non-brand surface.
  4. Typography does the heavy lifting. Page hierarchy is established with font-display serif headlines and tracked-out uppercase eyebrows, not with colored backgrounds or shadows.
  5. Trust over delight. Sentence case microcopy, questions ending in ?, em-dashes for explanatory clauses, consequence-led destructive confirmations. No emojis in UI. No // decorative slashes.

2. Design tokens​

2.1 Source of truth: app/globals.css​

This project uses Tailwind CSS v4. There is no tailwind.config.ts β€” tokens are declared in app/globals.css with the @theme inline directive plus a :root block of CSS variables (postcss.config.mjs:1-7 shows only @tailwindcss/postcss is wired). Light is the default theme. Dark mode is provided for completeness β€” the product targets light (app/globals.css:114-116).

Colors are in OKLCH (not hex). The brand claret is documented as the hex #7E1D1D (β‰ˆ oklch(0.36 0.13 25)) in app/globals.css:60 and rendered as that hex inside the SVG logo at components/brand/Logo.tsx:29,47,67.

Copy-paste: app/globals.css (verbatim from app/globals.css:1-151)​

@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";

@custom-variant dark (&:is(.dark *));

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-brand: var(--brand);
--color-brand-soft: var(--brand-soft);
--font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
--font-mono: var(--font-mono), ui-monospace, "SFMono-Regular", monospace;
--font-display: var(--font-display), ui-serif, Georgia, serif;
--font-heading: var(--font-display), ui-serif, Georgia, serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}

:root {
--background: oklch(0.985 0.006 85);
--foreground: oklch(0.18 0.008 60);

--card: oklch(0.992 0.004 85);
--card-foreground: oklch(0.18 0.008 60);

--popover: oklch(0.995 0.003 85);
--popover-foreground: oklch(0.18 0.008 60);

--primary: oklch(0.18 0.008 60);
--primary-foreground: oklch(0.985 0.006 85);

--secondary: oklch(0.945 0.008 85);
--secondary-foreground: oklch(0.22 0.008 60);

--muted: oklch(0.95 0.008 85);
--muted-foreground: oklch(0.5 0.012 75);

--accent: oklch(0.94 0.025 25);
--accent-foreground: oklch(0.36 0.13 25);

--brand: oklch(0.36 0.13 25);
--brand-soft: oklch(0.94 0.025 25);

--destructive: oklch(0.42 0.15 25);

--border: oklch(0.9 0.012 85);
--input: oklch(0.91 0.012 85);
--ring: oklch(0.55 0.06 25);

--chart-1: oklch(0.42 0.15 25);
--chart-2: oklch(0.55 0.08 35);
--chart-3: oklch(0.65 0.05 55);
--chart-4: oklch(0.5 0.04 80);
--chart-5: oklch(0.4 0.02 240);

--radius: 0.5rem;

--sidebar: oklch(0.97 0.01 85);
--sidebar-foreground: oklch(0.22 0.008 60);
--sidebar-primary: oklch(0.18 0.008 60);
--sidebar-primary-foreground: oklch(0.985 0.006 85);
--sidebar-accent: oklch(0.94 0.018 25);
--sidebar-accent-foreground: oklch(0.36 0.13 25);
--sidebar-border: oklch(0.9 0.012 85);
--sidebar-ring: oklch(0.55 0.06 25);
}

.dark {
--background: oklch(0.16 0.008 80);
--foreground: oklch(0.95 0.005 85);
--card: oklch(0.2 0.01 80);
--card-foreground: oklch(0.95 0.005 85);
--popover: oklch(0.2 0.01 80);
--popover-foreground: oklch(0.95 0.005 85);
--primary: oklch(0.95 0.005 85);
--primary-foreground: oklch(0.16 0.008 80);
--secondary: oklch(0.26 0.012 80);
--secondary-foreground: oklch(0.95 0.005 85);
--muted: oklch(0.26 0.012 80);
--muted-foreground: oklch(0.7 0.01 80);
--accent: oklch(0.3 0.08 25);
--accent-foreground: oklch(0.85 0.06 25);
--brand: oklch(0.65 0.15 25);
--brand-soft: oklch(0.3 0.08 25);
--destructive: oklch(0.65 0.18 25);
--border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.55 0.08 25);
--chart-1: oklch(0.65 0.15 25);
--chart-2: oklch(0.6 0.08 35);
--chart-3: oklch(0.7 0.05 55);
--chart-4: oklch(0.5 0.04 80);
--chart-5: oklch(0.5 0.04 240);
--sidebar: oklch(0.18 0.008 80);
--sidebar-foreground: oklch(0.92 0.005 85);
--sidebar-primary: oklch(0.95 0.005 85);
--sidebar-primary-foreground: oklch(0.16 0.008 80);
--sidebar-accent: oklch(0.3 0.08 25);
--sidebar-accent-foreground: oklch(0.85 0.06 25);
--sidebar-border: oklch(1 0 0 / 9%);
--sidebar-ring: oklch(0.55 0.08 25);
}

2.2 Body background β€” required (app/globals.css:167-194)​

The parchment effect is two radial gradients over --background. Apply on <body>:

body {
@apply bg-background text-foreground;
background-image:
radial-gradient(ellipse 1200px 800px at 8% -10%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 60%),
radial-gradient(ellipse 900px 700px at 100% 0%, color-mix(in oklab, var(--secondary) 60%, transparent), transparent 65%),
var(--background);
background-attachment: fixed;
}

::selection {
background: color-mix(in oklab, var(--accent) 60%, transparent);
color: var(--accent-foreground);
}

2.3 Optional decorative utilities (app/globals.css:198-229)​

@layer utilities {
.ruled-top { position: relative; }
.ruled-top::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 1px;
background: linear-gradient(90deg,
transparent,
color-mix(in oklab, var(--foreground) 18%, transparent) 20%,
color-mix(in oklab, var(--foreground) 18%, transparent) 80%,
transparent);
}
.grain { position: relative; }
.grain::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background-image: url("data:image/svg+xml;utf8,<svg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.06 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
opacity: 0.5; mix-blend-mode: multiply;
}
}

Unused-token note. .ruled-top and .grain are declared but I found zero call sites in app/ or components/ at audit time. They are documented here because they exist in the design system and may be picked up by future surfaces.

2.4 Token table β€” what to actually use​

Token (CSS var)Tailwind classLight hex (β‰ˆ)PurposeEvidence
--backgroundbg-background#fbf9f5App canvas. Parchment.app/globals.css:63, :168
--foregroundtext-foreground#272220Ink. Body text, headlines.app/globals.css:64
--cardbg-card#fcfbf8Card surface β€” slightly lighter than canvas.app/globals.css:66, components/ui/card.tsx:15
--popoverbg-popover#fcfcf9Floating surfaces (dialog, sheet, dropdown, tooltip target).app/globals.css:69, components/ui/dialog.tsx:64
--primarybg-primary / text-primary#2e2925Default button bg; primary ink.app/globals.css:72, components/ui/button.tsx:12
--primary-foregroundtext-primary-foreground#fbf9f5Text on primary.app/globals.css:73
--secondarybg-secondary#f1eee7Secondary button bg, soft fill.app/globals.css:75, components/ui/button.tsx:15
--mutedbg-muted#f2efe7Muted blocks (skeletons, code inline, empty-state icon bg).app/globals.css:78
--muted-foregroundtext-muted-foreground#7d7670Secondary text, helper copy, eyebrows.app/globals.css:79
--accentbg-accent#f1e3dfHover/focus selection accent β€” claret-tinted wash.app/globals.css:81
--accent-foregroundtext-accent-foreground#7e1d1dInk-on-accent; also the claret used for ::selection text.app/globals.css:82, :194
--brandbg-brand / text-brand#7e1d1dClaret. Logo dot, decorative strokes. Use sparingly.app/globals.css:86, components/brand/Logo.tsx:29
--brand-softbg-brand-soft#f1e3dfClaret wash for backgrounds.app/globals.css:87
--destructivetext-destructive / bg-destructive/10#9d2a26Destructive surfaces (always at 10–20% opacity for bg).app/globals.css:89, components/ui/button.tsx:20
--borderborder-border#e4dfd6Standard 1px borders. Often border-border/60 for soft top bars.app/globals.css:91, app/(dashboard)/layout.tsx:41
--inputborder-input#e7e1d8Input control border.app/globals.css:92, components/ui/input.tsx:11
--ringring-ring / focus-visible:ring-ring/50#a07a73Focus ring color (claret-derived).app/globals.css:93
--sidebar (+ family)bg-sidebar, text-sidebar-foreground#f4f1eaSidebar surface; slightly darker than canvas.app/globals.css:103-110, components/ui/sidebar.tsx:171

Hex column is illustrative β€” the source of truth is OKLCH. Do not convert and bake hex into code; reference the CSS variables.

Chart palette (--chart-1 … --chart-5, app/globals.css:95-99) β€” used by future analytics surfaces. Currently I found no consumers in the recruiter app; document them but do not invent uses.

2.5 Radius scale​

TokenComputedUsed forEvidence
--radius-sm0.3rem(declared, no direct consumers found)app/globals.css:45
--radius-md0.4remxs/sm button corner (capped at 10–12px)app/globals.css:46, components/ui/button.tsx:26-27
--radius-lg0.5rem (= --radius)Default buttons, inputs, dropdown content, select triggercomponents/ui/button.tsx:8, components/ui/input.tsx:11, components/ui/dropdown-menu.tsx:46
--radius-xl0.7remCards, dialog contentcomponents/ui/card.tsx:15, components/ui/dialog.tsx:64
--radius-2xl0.9rem(declared, no direct consumers found)app/globals.css:49
--radius-4xl1.3remBadge / chip (renders as fully pill at h-5)components/ui/badge.tsx:8

Tailwind classes map: rounded-sm/rounded-md/rounded-lg/rounded-xl/rounded-2xl/rounded-4xl follow the --radius-* variables.

Per-component radius cheat sheet:

SurfaceClass
Button (default)rounded-lg
Button (xs/sm)rounded-[min(var(--radius-md),10px)] / rounded-[min(var(--radius-md),12px)]
Input / Textarea / Select triggerrounded-lg
Card / Dialog / Sheet contentrounded-xl
Dropdown content / Tooltip / Menu itemrounded-md / rounded-lg
Badge / Chiprounded-4xl (pill)
Avatarrounded-full
Empty-state icon tilerounded-lg
Toast icon containerrounded-full
Progress barrounded-full

2.6 Elevation philosophy​

This codebase does not use shadows for elevation. Instead it uses ring-1 ring-foreground/10 to differentiate floating and card surfaces, with the card surface itself being slightly lighter than the canvas (--card vs --background). Shadows appear only on Radix Portal content (dropdown, select, floating sidebar variant) and even then it's shadow-md or shadow-sm, never larger.

SurfaceTreatmentEvidence
Cardring-1 ring-foreground/10 + lighter bg-cardcomponents/ui/card.tsx:15
Dialog contentring-1 ring-foreground/10, no shadowcomponents/ui/dialog.tsx:64
Dropdown contentring-1 ring-foreground/10 shadow-mdcomponents/ui/dropdown-menu.tsx:46
Dropdown subcontentring-1 ring-foreground/10 shadow-lgcomponents/ui/dropdown-menu.tsx:247
Select contentring-1 ring-foreground/10 shadow-mdcomponents/ui/select.tsx:72
Sheet contentshadow-lg (overlay panel)components/ui/sheet.tsx:65
Sidebar (floating variant)shadow-sm ring-1 ring-sidebar-bordercomponents/ui/sidebar.tsx:244
Dialog/Sheet overlaybg-black/10 supports-backdrop-filter:backdrop-blur-xscomponents/ui/dialog.tsx:42, components/ui/sheet.tsx:40
TooltipSolid dark pill: bg-foreground text-backgroundcomponents/ui/tooltip.tsx:45
Top bar (sticky header)border-b border-border/60 bg-background/70 backdrop-blur-mdapp/(dashboard)/layout.tsx:41

3. Typography​

3.1 Font loading (app/layout.tsx:2-27)​

import { Manrope, Source_Serif_4, JetBrains_Mono } from "next/font/google";

const manrope = Manrope({
variable: "--font-sans",
subsets: ["latin"],
display: "swap",
});

const sourceSerif = Source_Serif_4({
variable: "--font-display",
subsets: ["latin"],
weight: ["300", "400", "500", "600"],
style: ["normal", "italic"],
display: "swap",
});

const jetbrainsMono = JetBrains_Mono({
variable: "--font-mono",
subsets: ["latin"],
display: "swap",
});

Then on <html>:

<html
lang="en"
className={`${manrope.variable} ${sourceSerif.variable} ${jetbrainsMono.variable} h-full antialiased`}
suppressHydrationWarning
>

html rules from app/globals.css:158-165:

html {
font-family: var(--font-sans);
font-feature-settings: "ss01", "cv11";
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.font-display {
font-family: var(--font-display);
font-optical-sizing: auto;
letter-spacing: -0.01em;
}
.tabular-nums { font-variant-numeric: tabular-nums; }

3.2 Type scale (only what is actually used in this repo)​

StepClass comboUse caseEvidence
Hero H1font-display text-4xl tracking-tight text-foregroundTop-level dashboard, settings root, public reportapp/(dashboard)/page.tsx:129, app/(dashboard)/settings/page.tsx:43, app/report/[token]/page.tsx:135
Page H1font-display text-3xl tracking-tightMost index/detail pages, loginapp/(dashboard)/billing/page.tsx:104, app/(dashboard)/company/page.tsx:16, app/(dashboard)/roles/page.tsx:118, app/(auth)/login/page.tsx:122
Error H1font-display text-3xl tracking-tightGlobal error, 404app/error.tsx:25, app/not-found.tsx:10
Section H2font-display text-2xl tracking-tightMajor section dividers inside a pageapp/suspended/page.tsx:45 (text-2xl)
Editorial italicfont-display italic text-3xl leading-[1.15] text-foreground/85Marketing copy (login left panel)app/(auth)/login/page.tsx:101
Editorial bodyfont-display italic text-base leading-relaxed text-foreground/70Marketing proseapp/(auth)/login/page.tsx:104
In-context H1text-2xl font-semibold tracking-tight truncateRole-detail header (non-display)components/roles/RoleHeader.tsx
Card titlefont-heading text-base leading-snug font-medium (default size); text-sm at data-size=smCard title slotcomponents/ui/card.tsx:41
Dialog titlefont-heading text-base leading-none font-mediumDialog titlecomponents/ui/dialog.tsx:133
Sheet titlefont-heading text-base font-medium text-foregroundSheet titlecomponents/ui/sheet.tsx:117
Body(inherits text-sm from card/dialog content; html default sans)Default running textcomponents/ui/card.tsx:15
Small / captiontext-xs text-muted-foregroundHelper copy, captionscomponents/ui/card.tsx:53, components/ui/dropdown-menu.tsx:173
Eyebrow (large)text-[0.7rem] uppercase tracking-[0.22em] text-muted-foreground font-mediumAbove hero H1, sidebar inset headerapp/(dashboard)/page.tsx:126, app/(dashboard)/settings/page.tsx:40, app/(dashboard)/layout.tsx:45
Eyebrow (small)text-[0.6rem] uppercase tracking-[0.22em] text-muted-foreground font-mediumSidebar wordmark subtitle, report headercomponents/dashboard/AppSidebar.tsx:50, app/report/[token]/page.tsx:125, app/suspended/page.tsx:38
Eyebrow (form)text-xs uppercase tracking-[0.18em] text-muted-foreground font-mediumForm labels in editorial contextsapp/(auth)/login/page.tsx:51,66
Footer ruletext-[0.65rem] uppercase tracking-[0.22em] text-muted-foregroundPublic report footerapp/report/[token]/page.tsx:162
Code inlinerounded bg-muted px-1 py-0.5 text-[0.85em] font-monoInline code in markdowncomponents/ui/Markdown.tsx:34
Code blockmy-4 rounded-md bg-muted p-3 text-xs overflow-x-autoFenced code in markdowncomponents/ui/Markdown.tsx:28
System monofont-mono text-xs (often paired with text-foreground/80)Tavus IDs, invoice numbers, env keys, error digests, type-to-confirmcomponents/dashboard/AppSidebar.tsx, app/error.tsx:30, see Section 8

Markdown headings inside <Markdown> are scoped β€” they use sans-serif text-2xl/text-xl/text-base with font-semibold tracking-tight (components/ui/Markdown.tsx:16-18). Do not confuse markdown headings with page chrome headings.

3.3 Italic as a rhetorical device​

Italic is used to make a name read like an object, not for emphasis. Example: dashboard greeting (app/(dashboard)/page.tsx:130):

<>Recruiting for <em className="font-light">{company.name}</em>.</>

And inverted on the suspended screen (app/suspended/page.tsx:49):

<em className="not-italic font-medium text-foreground">{org.name}</em>

Use the italic-name pattern when referencing the company or workspace name in headline copy. Don't use italic for general emphasis β€” that's what weight is for.


4. Component recipes​

Each section gives the canonical className string a porter can copy. All recipes are taken verbatim from components/ui/* (shadcn style: "radix-nova").

Import note. Radix is imported as the consolidated radix-ui package, not @radix-ui/react-*:

import { Dialog as DialogPrimitive } from "radix-ui"

See components/ui/dialog.tsx:4, components/ui/select.tsx:4, etc. Keep this convention in the sibling app.

4.1 Button (components/ui/button.tsx)​

Base (:8):

group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4

Variants:

  • default: bg-primary text-primary-foreground [a]:hover:bg-primary/80
  • outline: border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50
  • secondary: bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground
  • ghost: hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50
  • destructive: bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40 β€” note: destructive is a tinted background, never a saturated red fill
  • link: text-primary underline-offset-4 hover:underline

Sizes (height + padding only β€” base sets rounded-lg):

  • default: h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2
  • xs: h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3
  • sm: h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5
  • lg: h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2
  • icon: size-8
  • icon-xs: size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3
  • icon-sm: size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg
  • icon-lg: size-9

Minimal example:

<Button>Save changes</Button>
<Button variant="outline">Cancel</Button>
<Button variant="ghost" size="sm">View billing β†’</Button>
<Button variant="destructive">Delete draft</Button>
<Button size="icon-sm" variant="ghost" aria-label="Close"><XIcon /></Button>

Leading icon convention: <Plus className="mr-1 h-4 w-4" /> New role (app/(dashboard)/roles/page.tsx:125).

Pending state: swap the label and append an ellipsis: {pending ? "Signing in…" : "Sign in"} (app/(auth)/login/page.tsx:80). Use the U+2026 ellipsis character, not three dots.

4.2 Input (components/ui/input.tsx:11)​

h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40
<Input id="email" type="email" placeholder="you@company.com" />

Tall variant for editorial forms β€” append className="h-11" on login (app/(auth)/login/page.tsx:62).

4.3 Textarea (components/ui/textarea.tsx:10)​

flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40

For free-form markdown editing, append font-mono text-sm (see components/roles/JobDescriptionEditor.tsx:138).

4.4 Label (components/ui/label.tsx:15)​

flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50

Editorial-form labels use the eyebrow recipe instead (app/(auth)/login/page.tsx:51):

<Label htmlFor="email" className="text-xs uppercase tracking-[0.18em] text-muted-foreground font-medium">
Email
</Label>

4.5 Select (components/ui/select.tsx)​

Trigger (:47):

flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4

Content (:72):

relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 …

Item (:115):

relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground …

Placeholder convention is sentence case + ellipsis: "Select…" (components/roles/CreateRoleForm.tsx:185).

4.6 Checkbox / Radio​

Not present as a shadcn primitive in components/ui/. Where the app needs choice, it uses DropdownMenuRadioItem/DropdownMenuRadioGroup (see theme switcher at components/dashboard/AppSidebar.tsx:121-129) or a custom segmented radiogroup styled as a pill (components/dashboard/ThemeSwitcher.tsx:23-46):

<div role="radiogroup" aria-label="Theme" className="inline-flex rounded-md border bg-background p-0.5">
<button
type="button" role="radio" aria-checked={active === value}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-xs font-medium transition-colors",
active === value ? "bg-secondary text-secondary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<Icon className="h-3.5 w-3.5" /> {label}
</button>
</div>

If you need standalone Checkbox/Radio, add them from shadcn with style: "radix-nova" β€” do not introduce custom recipes.

4.7 Card (components/ui/card.tsx)​

Root (:15):

group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl

Header (:28):

group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3

Title (:41): font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm

Description (:53): text-sm text-muted-foreground

Content (:76): px-4 group-data-[size=sm]/card:px-3

Footer (:87): flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3

Hover-affordance variant (app/(dashboard)/page.tsx:199): <Card className="transition-colors hover:border-foreground/20"> β€” note hover treatment is a border tint, not a shadow.

<Card>
<CardHeader>
<CardTitle>Current plan</CardTitle>
<CardDescription>Active monthly subscription.</CardDescription>
</CardHeader>
<CardContent>…</CardContent>
</Card>

4.8 Dialog / Modal (components/ui/dialog.tsx)​

Overlay (:42):

fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0

Content (:64):

fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95

Footer (:110):

-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end

Title (:133): font-heading text-base leading-none font-medium

Description (:148): text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground

Close-button is <Button variant="ghost" size="icon-sm" className="absolute top-2 right-2"> with <XIcon /> (:72-80).

<Dialog>
<DialogTrigger asChild><Button variant="destructive">Delete</Button></DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete this draft?</DialogTitle>
<DialogDescription>
Deletes the role and any work-in-progress JD, questions, and rubric. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild><Button variant="ghost">Cancel</Button></DialogClose>
<Button variant="destructive">Delete draft</Button>
</DialogFooter>
</DialogContent>
</Dialog>

4.9 Sheet / Drawer (components/ui/sheet.tsx)​

Content (:65) β€” width capped at sm:max-w-sm, slides in via data-open:slide-in-from-{side}-10. Default side is right. Mobile sidebar uses Sheet with side left (components/ui/sidebar.tsx:181-203).

Title: font-heading text-base font-medium text-foreground.

4.10 Tabs (components/ui/tabs.tsx)​

Two variants β€” default (segmented muted pill) and line (underlined). Default is used for in-page tabbing; line is used inline on filter rows. The roles index uses a hand-rolled underlined tabbing instead, see components/roles/RolesFilterTabs.tsx:14-28:

<div className="flex items-center gap-1 border-b">
{FILTERS.map((f) => (
<Link
href={...}
className={cn(
"px-3 py-2 text-sm border-b-2 -mb-px transition-colors",
current === f.value
? "border-foreground font-medium"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{f.label}
</Link>
))}
</div>

Prefer this hand-rolled pattern for in-page filter tabs (URL-driven). Use the shadcn Tabs primitive (variant="default" or variant="line") when the tab state is local.

4.11 Badge / Chip (components/ui/badge.tsx)​

Base (:8):

group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!

Variants:

  • default: bg-primary text-primary-foreground
  • secondary: bg-secondary text-secondary-foreground
  • destructive: bg-destructive/10 text-destructive
  • outline: border-border text-foreground
  • ghost: hover:bg-muted hover:text-muted-foreground
  • link: text-primary underline-offset-4 hover:underline

Status mapping convention (app/(dashboard)/roles/page.tsx:30-34):

const STATUS_VARIANT = { draft: "secondary", active: "default", closed: "outline" } as const;

4.12 Avatar (components/ui/avatar.tsx)​

Root (:20):

group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten

The hairline ring is implemented as an ::after pseudo-element with border + mix-blend-darken β€” not a Tailwind ring-*. Preserve this if you reskin: it's what makes avatars feel like coins instead of stickers.

Fallback (:52): flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs

Image (:37): aspect-square size-full rounded-full object-cover

<Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">{(email ?? "U")[0].toUpperCase()}</AvatarFallback>
</Avatar>

(components/dashboard/AppSidebar.tsx:88-92. Note: avatars in this app are always identicon-style fallbacks β€” there is no uploaded photo path. If the sibling app uploads photos, use <AvatarImage /> inside <Avatar> with the same root recipe.)

4.13 Tooltip (components/ui/tooltip.tsx)​

Provider delay is 150ms (set in components/providers.tsx:24, overriding the component's default 0).

Content (:45):

z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background …

Arrow: z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground

Tooltips are dark pills on light canvas β€” solid bg-foreground, white text-background. Do not invert.

4.14 Toast / Sonner (components/ui/sonner.tsx)​

Mounted in components/providers.tsx:26 with <Toaster richColors closeButton />. The component overrides Sonner's CSS vars to match the popover palette:

style={{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
}}

Icons (all lucide-react, size-4):

  • success β†’ CircleCheckIcon
  • info β†’ InfoIcon
  • warning β†’ TriangleAlertIcon
  • error β†’ OctagonXIcon
  • loading β†’ Loader2Icon + animate-spin

Toast API conventions (from Section 8):

toast.success("Role created");
toast.error("Couldn't copy link");
toast.success("Persona prompt generated β€” review and edit before creating");

4.15 Dropdown menu (components/ui/dropdown-menu.tsx)​

Content (:46):

z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 …

Item (:76):

group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive …

Label (:173): px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7 β€” note labels in dropdowns use the eyebrow-ish style.

Separator (:188): -mx-1 my-1 h-px bg-border.

4.16 Table (components/ui/table.tsx)​

<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table className="w-full caption-bottom text-sm">…</table>
</div>
SlotClassSource
<thead>[&_tr]:border-b:26
<tbody>[&_tr:last-child]:border-0:36
<tfoot>border-t bg-muted/50 font-medium [&>tr]:last:border-b-0:47
<tr>border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted:60
<th>h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0:73
<td>p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0:86
<caption>mt-4 text-sm text-muted-foreground:101

Tables are wrapped in a Card to inherit the ring + radius (app/(dashboard)/roles/page.tsx:151):

<Card className="overflow-x-auto">
<Table>
<TableHeader><TableRow>…</TableRow></TableHeader>
<TableBody>…</TableBody>
</Table>
</Card>

Numeric cells: className="tabular-nums" and often text-muted-foreground (app/(dashboard)/roles/page.tsx:204).

4.17 Empty state pattern​

Always a Card with a CardHeader containing a h-10 w-10 muted rounded square icon tile + title + description, then a CardContent with the primary CTA. From app/(dashboard)/roles/page.tsx:131-147:

<Card>
<CardHeader>
<div className="mb-2 flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<Briefcase className="h-5 w-5 text-muted-foreground" />
</div>
<CardTitle>No roles yet</CardTitle>
<CardDescription>
Create your first role to start hiring. We&apos;ll help you draft a
job description, design interview questions, and a scoring rubric.
</CardDescription>
</CardHeader>
<CardContent>
<Button asChild>
<Link href="/roles/new">Create your first role</Link>
</Button>
</CardContent>
</Card>

Inline empty state inside a table row (app/(dashboard)/roles/page.tsx:221-230):

<TableRow>
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground py-8">
No {filter === "all" ? "" : filter} roles to show.
</TableCell>
</TableRow>

4.18 Loading / Skeleton​

Skeleton primitive (components/ui/skeleton.tsx:7):

animate-pulse rounded-md bg-muted

For loading buttons / pending actions, the convention is label swap, not spinner overlay: {pending ? "Signing in…" : "Sign in"} (app/(auth)/login/page.tsx:80). Spinners (Loader2Icon animate-spin) appear only in the toast-loading icon and inline AI generation indicators (<Sparkles className="animate-pulse" />).

4.19 Progress (components/ui/progress.tsx)​

<Progress
value={pct}
className={pct >= 100 ? "[&>div]:bg-red-500" : pct >= 90 ? "[&>div]:bg-orange-500" : ""}
/>

The threshold-colored progress bar is the only documented use of literal Tailwind palette colors on a non-semantic surface β€” and it's a deliberate three-tier warning (components/dashboard/BillingWidget.tsx:96-105,114-123). Keep this exact convention; don't extend it.

Base bar (:17): relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted. Bar height is 1px (h-1) β€” slimmer than the Tailwind default.

4.20 Alert (components/ui/alert.tsx)​

Base (:7):

group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4

Variants:

  • default: bg-card text-card-foreground
  • destructive: bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current
<Alert variant="destructive">
<AlertDescription>Invalid email or password.</AlertDescription>
</Alert>

4.21 Separator (components/ui/separator.tsx:19)​

shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch

4.22 Markdown renderer (components/ui/Markdown.tsx)​

Self-contained β€” does not depend on @tailwindcss/typography. The recipe for each element is in the file's components prop (:16-43). Notable choices:

  • Headings use sans (not display): text-2xl font-semibold tracking-tight etc.
  • Links: underline underline-offset-2 hover:text-primary
  • Inline code: rounded bg-muted px-1 py-0.5 text-[0.85em] font-mono
  • Code blocks: my-4 rounded-md bg-muted p-3 text-xs overflow-x-auto
  • Blockquote: my-3 border-l-2 border-border pl-3 italic text-muted-foreground
  • <hr>: my-6 border-border

5. Layout recipes​

5.1 Root HTML + body (app/layout.tsx:39-49)​

<html lang="en" className={`${manrope.variable} ${sourceSerif.variable} ${jetbrainsMono.variable} h-full antialiased`} suppressHydrationWarning>
<body className="min-h-full flex flex-col">
<Providers>{children}</Providers>
</body>
</html>

5.2 Providers stack (components/providers.tsx)​

Order matters: SessionProvider β†’ ThemeProvider β†’ QueryClientProvider β†’ TooltipProvider β†’ Toaster:

<SessionProvider>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>
<QueryClientProvider client={queryClient}>
<TooltipProvider delayDuration={150}>
{children}
<Toaster richColors closeButton />
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</SessionProvider>

Light is the default theme. defaultTheme="light" with enableSystem and disableTransitionOnChange. QueryClient: staleTime: 30_000, refetchOnWindowFocus: false.

5.3 Authenticated app shell (app/(dashboard)/layout.tsx)​

<SidebarProvider>
<AppSidebar email={user.email} role={...} />
<SidebarInset>
{isImpersonating ? <ImpersonationBanner ... /> : null}
<header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-border/60 bg-background/70 backdrop-blur-md px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mx-1 h-4" />
<Logo variant="mark" className="h-5 w-5 opacity-80" />
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-muted-foreground">Recruiter</span>
</header>
<main className="flex-1 p-6 lg:p-8">{children}</main>
</SidebarInset>
</SidebarProvider>

Top bar specs:

  • Height h-14 (56px)
  • Sticky, z-30
  • Border: border-b border-border/60 (60% opacity for a soft rule)
  • Background: bg-background/70 backdrop-blur-md
  • Contents in order: sidebar trigger, vertical separator, mark-only logo at h-5 w-5 opacity-80, eyebrow product name
  • Padding: px-4

Main padding: p-6 lg:p-8 (not p-4, not p-8 at all sizes).

5.4 Sidebar (components/ui/sidebar.tsx)​

Constants (:29-32):

  • SIDEBAR_WIDTH = "16rem" (256px)
  • SIDEBAR_WIDTH_MOBILE = "18rem" (288px β€” Sheet panel)
  • SIDEBAR_WIDTH_ICON = "3rem" (48px, collapsed)
  • Keyboard shortcut: Cmd/Ctrl + B (:32,99-100)
  • State persists via cookie sidebar_state (:27)

The AppSidebar (components/dashboard/AppSidebar.tsx) is collapsible="icon". Expanded shows full lockup + "Recruiter" eyebrow; collapsed shows mark only at h-7 w-7:

<SidebarHeader>
<Link href="/" className="block px-2 py-1 group/wordmark">
<div className="group-data-[collapsible=icon]:hidden space-y-1">
<Logo className="h-9 w-auto" />
<span className="block pl-px text-[0.6rem] uppercase tracking-[0.22em] text-muted-foreground font-medium">
Recruiter
</span>
</div>
<div className="hidden group-data-[collapsible=icon]:flex items-center justify-center">
<Logo variant="mark" className="h-7 w-7" />
</div>
</Link>
</SidebarHeader>

Nav items use <SidebarMenuButton asChild isActive={active} tooltip={label}> with a <Link> containing an icon and a <span> label. Active match logic β€” pathname === href || pathname.startsWith(\${href}/`) (:65-66`).

Footer holds a user-menu DropdownMenu trigger that renders a size="lg" SidebarMenuButton with avatar fallback + truncated email + role:

<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg" tooltip={email ?? "Account"}>
<Avatar className="h-7 w-7">
<AvatarFallback className="text-xs">{(email ?? "U")[0].toUpperCase()}</AvatarFallback>
</Avatar>
<div className="flex flex-col items-start leading-tight overflow-hidden">
<span className="text-sm truncate w-full">{email ?? "User"}</span>
<span className="text-xs text-muted-foreground capitalize">{role ?? "user"}</span>
</div>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent side="right" align="end" className="w-52">
<ThemeMenuItems />
<DropdownMenuSeparator />
<SignOutButton />
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
</SidebarFooter>

5.5 Page-level containers​

Use caseWrapperEvidence
Top-level dashboard (most generous)mx-auto w-full max-w-5xl space-y-10app/(dashboard)/page.tsx:123
List / detail pagemx-auto w-full max-w-5xl space-y-6app/(dashboard)/roles/page.tsx:115
Detail page (medium)mx-auto w-full max-w-4xl space-y-6(candidate detail)
Settings / company (denser sections)mx-auto w-full max-w-3xl space-y-8app/(dashboard)/settings/page.tsx:38, app/(dashboard)/company/page.tsx:14
Billing / secondary formmx-auto w-full max-w-3xl space-y-6app/(dashboard)/billing/page.tsx:70
Form / wizardmx-auto w-full max-w-2xl space-y-6app/(dashboard)/roles/new/page.tsx
Single-purpose flowmx-auto w-full max-w-xl ...app/(dashboard)/billing/setup/page.tsx
Public reportmx-auto w-full max-w-3xl px-6 py-12app/report/[token]/page.tsx:120
Suspended pagemx-auto flex min-h-screen w-full max-w-xl items-center justify-center p-6app/suspended/page.tsx:33

Use space-y-6 as the default vertical rhythm. Increase to space-y-8 when sections are short, space-y-10 only on the top-level dashboard. Headline + subhead pairs nest at space-y-1 (tight) or space-y-2 (with an eyebrow above).

5.6 Auth / public shell (login, app/(auth)/login/page.tsx:88-135)​

Two-column on lg+, single column below. Left column is editorial marketing:

<main className="flex-1 grid min-h-screen lg:grid-cols-[1.1fr_1fr]">
<section className="relative hidden lg:flex flex-col p-12 overflow-hidden bg-[color-mix(in_oklab,var(--accent)_45%,var(--background))]">
<div className="absolute top-12 bottom-12 left-1/2 w-px bg-gradient-to-b from-transparent via-foreground/10 to-transparent pointer-events-none" />
<header className="space-y-2">
<Logo className="h-12 w-auto" />
<span className="block pl-px text-[0.65rem] uppercase tracking-[0.22em] text-muted-foreground font-medium">Recruiter</span>
</header>
<div className="flex-1 flex items-center">
<div className="space-y-6 max-w-md">
<h2 className="font-display italic text-3xl leading-[1.15] text-foreground/85">First-round interviews, handled.</h2>
<p className="font-display italic text-base leading-relaxed text-foreground/70">…</p>
</div>
</div>
</section>
<section className="flex items-center justify-center p-6 lg:p-12 relative">
<div className="absolute top-6 left-6 lg:hidden"><Logo className="h-9 w-auto" /></div>
<div className="w-full max-w-sm space-y-8">
<div className="space-y-2">
<h1 className="font-display text-3xl tracking-tight text-foreground">Sign in</h1>
<p className="text-sm text-muted-foreground">Welcome back. Enter your credentials to continue.</p>
</div>
<Suspense><LoginForm /></Suspense>
<p className="text-xs text-muted-foreground">Need an account? Ask your administrator to provision one for you.</p>
</div>
</section>
</main>

Key choices:

  • Editorial panel background: color-mix(in oklab, var(--accent) 45%, var(--background)) β€” a 45/55 blend of the claret-tinted accent over canvas.
  • Decorative vertical rule: w-px gradient from transparent β†’ via-foreground/10 β†’ transparent, absolute-positioned at left-1/2.
  • Form column: max-w-sm, space-y-8, taller h-11 inputs and button.
  • Marketing copy is font-display italic.

5.7 Mobile nav​

The dashboard sidebar swaps to a Sheet on mobile breakpoints. The breakpoint is whatever useIsMobile() returns (see hooks/use-mobile.ts). On mobile the Sheet is side="left", width --sidebar-width: 18rem, padding p-0, internal close button hidden (components/ui/sidebar.tsx:181-203).

5.8 Suspended / out-of-the-app shell (app/suspended/page.tsx)​

A centered single Card with logo at h-8, eyebrow, amber icon tile, headline, optional reason in <Alert>, then SignOutButton. Use this pattern for any out-of-the-app interstitial (suspended, awaiting verification, plan required).

5.9 Public report shell (app/report/[token]/page.tsx)​

Different from the app shell. Single column max-w-3xl px-6 py-12. Header has logo + eyebrow on the left, optional company name as uppercase tracked-out caption on the right (text-xs uppercase tracking-[0.2em] text-muted-foreground). Hero H1 is font-display text-4xl tracking-tight text-foreground (the larger of the two H1 variants). Footer rule: a centered uppercase caption with the bullet separator (Β·):

<footer className="mt-12 border-t border-border/60 pt-6 text-center text-[0.65rem] uppercase tracking-[0.22em] text-muted-foreground">
Read-only evaluation Β· Generated by h.work Recruiter
</footer>

6. Iconography & imagery​

6.1 Icon library​

lucide-react exclusively. Declared in components.json:13 ("iconLibrary": "lucide") and used throughout. Do not introduce heroicons, phosphor, or custom SVG sprites alongside lucide.

6.2 Default sizes​

ContextSize classExample
Default in-button / icon-in-buttonh-4 w-4<Plus className="mr-1 h-4 w-4" /> (app/(dashboard)/roles/page.tsx:125)
Card title trailing iconh-4 w-4 text-muted-foregroundapp/(dashboard)/page.tsx:204
Empty-state hero icon (inside a h-10 w-10 tile)h-5 w-5 text-muted-foregroundapp/(dashboard)/roles/page.tsx:134
Inline meta / staleness indicatorh-3.5 w-3.5app/(dashboard)/roles/page.tsx:179
Compact meta (timestamps, badges)h-3 w-3(candidate detail)
Sidebar item icon(inherited from sidebar; size-4 via [&_svg]:size-4)components/ui/sidebar.tsx:469
Dialog/Sheet close<XIcon /> inside size="icon-sm" buttoncomponents/ui/dialog.tsx:77

Stroke width. Lucide default (1.5px). The codebase does not override strokeWidth anywhere. Do not.

6.3 Default color​

  • Functional icons: text-muted-foreground for secondary, inherited foreground for primary.
  • Semantic icons: text-emerald-600 (success), text-amber-500/text-amber-600 (warning), text-destructive (error), text-red-600 / text-orange-600 (usage thresholds).

The brand claret (text-brand) is not used on functional icons. It is reserved for the logo dot, ::selection, and the focus ring.

6.4 Imagery / avatars​

  • No photo uploads in this app β€” every avatar uses <AvatarFallback> (a single uppercase initial).
  • Aspect ratio: always 1:1 (aspect-square in AvatarImage).
  • Treatment: rounded-full + the pseudo-element hairline (see Section 4.12). Do not add ring-* to avatars; the hairline is the design.
  • Sidebar user avatar is h-7 w-7 (not the default size-8) β€” see components/dashboard/AppSidebar.tsx:88.

If the sibling app uploads photos, keep <AvatarImage> with object-cover and reuse the same root. Do not add overlays, filters, or vignettes β€” the parchment background already supplies tonal warmth.

6.5 Logo (components/brand/Logo.tsx)​

Two variants:

  • lockup (default): the full h.work wordmark with a circular orbit mark + claret dot + ink satellite. Default size h-7 w-auto. The mark sits on the left at transform="translate(28, 28)".
  • mark: just the orbit + dot, viewBox 0 0 56 56. Default h-7 w-7.

Logo is rendered as inline SVG (no PNG/SVG file import) so it's crisp at any size and adds zero HTTP requests.

Fixed colors β€” do not theme. The SVG uses the literal hex strings #1A1612 (ink) and #7E1D1D (claret) regardless of theme (components/brand/Logo.tsx:28-30,46-48,57,67,78). Even in dark mode the logo stays the same. The "work" tail uses rgba(26, 22, 18, 0.55) for the italic.

Typography in the SVG:

  • fontFamily="var(--font-display), 'Source Serif 4', Georgia, 'Times New Roman', serif"
  • fontSize="34", fontWeight="400"
  • "h": letterSpacing="-0.012em", fill #1A1612
  • "." (dot): fill #7E1D1D β€” the only saturated color anywhere in the system
  • "work": fontStyle="italic", fill rgba(26, 22, 18, 0.55), same negative letter-spacing

Logo placements found in this app:

LocationSizePairingSource
Sidebar expandedh-9 w-auto (lockup)text-[0.6rem] "Recruiter" eyebrow belowcomponents/dashboard/AppSidebar.tsx:49-52
Sidebar collapsedh-7 w-7 (mark)nonecomponents/dashboard/AppSidebar.tsx:56
Top bar (post-sidebar)h-5 w-5 opacity-80 (mark)text-[0.7rem] "Recruiter" eyebrow inlineapp/(dashboard)/layout.tsx:44-47
Login left panelh-12 w-auto (lockup)text-[0.65rem] "Recruiter" eyebrow belowapp/(auth)/login/page.tsx:94-97
Login right (mobile)h-9 w-auto (lockup)noneapp/(auth)/login/page.tsx:117
Public report headerh-9 w-auto (lockup)text-[0.6rem] "Recruiter" eyebrow belowapp/report/[token]/page.tsx:124-127
Suspended cardh-8 w-auto (lockup)text-[0.6rem] "Recruiter" eyebrow belowapp/suspended/page.tsx:37-40

The "Recruiter" eyebrow is always in text-muted-foreground font-medium uppercase tracking-[0.22em] and appears below or beside the lockup. Always. This is the product-name lockup convention.


7. Motion tokens​

There is no framer-motion in this codebase (verified by grep -r "framer-motion" --include="*.tsx" --include="*.ts" β†’ 0 matches). Motion is delivered by:

  1. CSS transition-* utilities on hover/focus states. Most have no explicit duration β€” they use Tailwind's default 150ms ease-in-out.
  2. tw-animate-css for Radix Portal enter/exit (data-open:animate-in, data-closed:animate-out etc.). Imported at app/globals.css:2.
Token / patternWhereNotes
transition-colors<Card hover>, links, nav, ThemeSwitcher, RolesFilterTabs, TableRow, dropdown itemsDefault β€” no duration set
transition-allButton, Badge (from CVA base)Default duration
transition-[width,height,padding]Sidebar menu buttonscomponents/ui/sidebar.tsx:469
transition-[width] duration-200 ease-linearSidebar gapcomponents/ui/sidebar.tsx:220
transition-[left,right,width] duration-200 ease-linearSidebar containercomponents/ui/sidebar.tsx:232
transition-[margin,opacity] duration-200 ease-linearSidebar group labelcomponents/ui/sidebar.tsx:404
duration-100Dialog, Sheet, Dropdown, Select content/overlayFaster than default for portals
duration-200Sheet transitioncomponents/ui/sheet.tsx:65
data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95Dialog, Dropdown, Select contenttw-animate-css keyframes
data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95(same)(same)
data-[side=bottom]:slide-in-from-top-2 (etc.)Dropdown, SelectDirection-aware slide
data-[side=…]:data-open:slide-in-from-…-10SheetLarger 10-unit slide for drawer
animate-pulseSkeleton, AI generation Sparkles icontailwindcss built-in
animate-spinLoader2Icon in toast/loadingtailwindcss built-in
active:not-aria-[haspopup]:translate-y-pxButton base1px down on press β€” a touch of physicality

Motion guidance for porters:

  • Default to transition-colors (no duration) for hover/focus tone changes.
  • Default to duration-100 for portal enter/exit.
  • Default to duration-200 ease-linear for layout (width/position) changes.
  • Press affordance: active:translate-y-px (already on <Button>).
  • Do not add framer-motion unless a surface needs choreography that CSS cannot express. If you do, document it in this file.

8. Microcopy & capitalization rules​

8.1 Rules​

  1. Headings: sentence case. "Sign in" β€” not "Sign In". "Open roles and their candidate pipelines." β€” not "Open Roles And Their Candidate Pipelines". Proper nouns and brand terms keep their casing.
  2. Button labels: sentence case verb phrase. "Save changes", "Create role", "Open customer portal", "End impersonation", "Export to ATS", "Add candidate", "Choose PDF".
  3. h.work always. Lowercase h. Never "h.work", "H Work", or "Hwork". The dot is part of the wordmark.
  4. No emojis in UI. None found in any user-facing string (verified). Use a lucide icon if you need an icon.
  5. No // decorative slashes. No "// SECTION" headers, no "//FAQ" navigation. Use the uppercase eyebrow + serif headline pattern instead.
  6. Pending labels use U+2026 ellipsis. "Signing in…", "Creating…", "Saving…", "Closing…", "Deleting…", "Generating…". Three-dot string ... is wrong.
  7. Questions end with ? in destructive confirmations. "Delete this draft?", "Close this role?", "Clone this role into a new draft?", "Delete this candidate?".
  8. Em-dashes for explanatory clauses. β€” (U+2014), no spaces around it when separating a clause; with spaces when it acts like a comma in conversational prose. Both patterns exist; follow the surrounding voice.
  9. Eyebrow product-name caption is Recruiter. Always paired with the logo. Always uppercase tracking-[0.22em] text-muted-foreground font-medium.
  10. Bullet separator is Β· (middle dot U+00B7). Used in Read-only evaluation Β· Generated by h.work Recruiter (app/report/[token]/page.tsx:163).
  11. Currency / numeric placeholders use the en-dash – in ranges. Example: "$160k – $200k + equity" (components/roles/CreateRoleForm.tsx:222). Not a hyphen.
  12. Mono is for system identifiers: invoice numbers, env keys, Tavus IDs, error digests, type-to-confirm tokens, shareable URLs. Not for emphasis.

8.2 Real examples (pulled verbatim with file:line)​

Buttons (verb-led, sentence case):

  • "New role" β€” app/(dashboard)/roles/page.tsx:126
  • "Create your first role" β€” app/(dashboard)/roles/page.tsx:144
  • "Generate with AI" β€” components/roles/JobDescriptionEditor.tsx:114
  • "Save changes" β€” components/roles/JobDescriptionEditor.tsx:118
  • "Sign in" / "Signing in…" β€” app/(auth)/login/page.tsx:80
  • "Sign out" β€” components/dashboard/SignOutButton.tsx:27
  • "End impersonation" β€” components/dashboard/ImpersonationBanner.tsx:38
  • "Open customer portal" β€” app/(dashboard)/billing/page.tsx:240
  • "Export to ATS" β€” components/candidates/AtsExportDialog.tsx:87
  • "Try again" / "Go home" β€” app/error.tsx:33-35
  • "Back to dashboard" β€” app/not-found.tsx:16

Page H1s (sentence case):

  • "Roles" β€” app/(dashboard)/roles/page.tsx:118
  • "Billing" β€” app/(dashboard)/billing/page.tsx:104
  • "Company profile" β€” app/(dashboard)/company/page.tsx:16
  • "Settings" β€” app/(dashboard)/settings/page.tsx:43
  • "New role" β€” app/(dashboard)/roles/new/page.tsx:7
  • "Sign in" β€” app/(auth)/login/page.tsx:122
  • "Page not found" β€” app/not-found.tsx:10
  • "Something went wrong" β€” app/error.tsx:25

Editorial / italic headlines:

  • "First-round interviews, handled." β€” app/(auth)/login/page.tsx:102
  • "h.work writes the job, runs the screen, and surfaces the candidates worth your time. The recruiter you wish you had time to be β€” at the scale you actually need." β€” app/(auth)/login/page.tsx:104-108

Empty states (sentence case, helpful):

  • "No roles yet" + "Create your first role to start hiring. We'll help you draft a job description, design interview questions, and a scoring rubric." β€” app/(dashboard)/roles/page.tsx:136-141
  • "No invoices yet β€” the first one fires at the end of your current billing period." β€” app/(dashboard)/billing/page.tsx:194-196
  • "No questions yet. Generate with AI or add them manually." β€” components/roles/InterviewQuestionsEditor.tsx:265
  • "No rubric yet. Generate with AI or add criteria manually. Weights must sum to 100." β€” components/roles/RubricEditor.tsx:245
  • "No resume uploaded." β€” candidate detail

Toasts β€” success (sentence fragment, past tense):

  • "Role created" β€” components/roles/CreateRoleForm.tsx
  • "Role activated", "Role deleted", "Draft created from clone"
  • "Job description generated", "Saved", "Questions saved", "Generated ${n} questions"
  • "Rubric saved", "Generated ${n} rubric criteria"
  • "Persona prompt generated β€” review and edit before creating"
  • "Avatar created and wired to this role."
  • "Candidate added", "Candidate deleted"
  • "Interview link copied", "Existing link copied", "Link copied", "Public report link copied"
  • "Impersonation ended" β€” components/dashboard/ImpersonationBanner.tsx:20
  • "Company profile saved"

Toasts β€” error (sentence case, no exclamation, no "Oops"):

  • "Couldn't copy link"
  • "Failed to end impersonation" β€” components/dashboard/ImpersonationBanner.tsx:24
  • "Generation failed"
  • "Need a saved JD and interview questions first."

Confirmations (question form, consequence-led):

  • "Delete this draft?" + body: "Deletes the role and any work-in-progress JD, questions, and rubric. This cannot be undone."
  • "Close this role?" + body: "Closing is permanent. Closed roles can't be reactivated β€” you'll need to clone the role to hire again."
  • "Clone this role into a new draft?"
  • "Delete this candidate?"

Helper text (sentence case, explanatory):

  • "Your plan, this period's usage, and invoice history." β€” app/(dashboard)/billing/page.tsx:106
  • "Used as context for every AI generation in this app." β€” app/(dashboard)/company/page.tsx:18
  • "Open roles and their candidate pipelines." β€” app/(dashboard)/roles/page.tsx:120
  • "Welcome back. Enter your credentials to continue." β€” app/(auth)/login/page.tsx:124
  • "Need an account? Ask your administrator to provision one for you." β€” app/(auth)/login/page.tsx:131
  • "Need a different plan? Contact your administrator." β€” app/(dashboard)/billing/page.tsx:139

Eyebrow captions (uppercase, tracked-out):

  • "Recruiter" (product subtitle) β€” many; canonical at app/(dashboard)/layout.tsx:46
  • "Overview" (above hero H1) β€” app/(dashboard)/page.tsx:127
  • "Workspace" (above "Settings") β€” app/(dashboard)/settings/page.tsx:40-41
  • "Read-only evaluation Β· Generated by h.work Recruiter" β€” app/report/[token]/page.tsx:163

Form labels styled as eyebrows (tracking-[0.18em]):

  • "Email", "Password" β€” app/(auth)/login/page.tsx:51,66

8.3 Italic-name and inverted-italic patterns​

Use italic for names that you're treating like the subject of a sentence:

  • Recruiting for <em className="font-light">{company.name}</em>. β€” app/(dashboard)/page.tsx:130

Use not-italic font-medium to emphasize a proper noun inside a serif italic context (inverted-italic):

  • <em className="not-italic font-medium text-foreground">{org.name}</em> can't access h.work Recruiter right now. β€” app/suspended/page.tsx:49

9. Anti-patterns​

Things this codebase does not do. Do not introduce them when porting.

  1. No gradients on content surfaces. The only gradients in the app are (a) the body parchment radial gradients, (b) the vertical hairline rule on the login left panel. No gradient buttons, no gradient cards, no gradient text. (app/globals.css:172-175, app/(auth)/login/page.tsx:92)
  2. No emojis in UI. Zero in app/ or components/. Use lucide icons instead.
  3. No // decorative slashes. No "// SETTINGS" pseudo-headers, no slash-prefixed navigation.
  4. No rounded-full on buttons. Buttons are always rounded-lg. Pills are reserved for badges (rounded-4xl) and avatars (rounded-full).
  5. No saturated red destructive backgrounds. Destructive buttons are bg-destructive/10 text-destructive β€” a tinted background, never a full red fill. (components/ui/button.tsx:20)
  6. No box-shadow elevation on cards. Cards use ring-1 ring-foreground/10 + a slightly-lighter bg-card. Shadows are reserved for portals.
  7. No framer-motion. Stay with CSS transitions and tw-animate-css. Adding framer-motion crosses a deliberate line.
  8. No three-dot ... ellipsis. Always U+2026 ….
  9. No "Loading..." spinner state for buttons. Swap the label: "Saving…". Spinners only appear in toasts and the AI-generation Sparkles indicator.
  10. No @tailwindcss/typography plugin. Markdown styling is hand-rolled in components/ui/Markdown.tsx. Do not add the plugin.
  11. No third icon library. lucide only. No mixing heroicons, phosphor, react-icons.
  12. No purple, no neon, no "AI" gradients. The brand is editorial. Claret + neutrals. AI surfaces use a Sparkles icon with animate-pulse, nothing more.
  13. No "Welcome back, John! πŸ‘‹" tone. Conversational, not effusive: "Welcome back. Enter your credentials to continue."
  14. No <AlertDialog>. All confirmations use <Dialog> with a question title and a destructive Button. Type-to-confirm is the pattern for high-stakes actions, not a second modal.
  15. No "Try again later" / "Oops!" / "Whoops!" microcopy. Errors state what failed: "Generation failed", "Couldn't copy link", "Failed to end impersonation".
  16. No brand color on functional surfaces. --brand is used in the SVG logo, in ::selection, and in the --ring. It is not the default button color, not the default link color, not the default badge color.
  17. No theming the logo. Logo SVG uses literal hex even in dark mode. Don't pipe it through CSS vars.

10. Appendix β€” Inconsistencies found​

These were noticed during the audit. They should be resolved before porting so the sibling app inherits one canonical pattern, not two.

  1. Admin layout uses a hand-rolled sidebar, not the Sidebar primitive. app/(admin)/layout.tsx:23-56 declares <aside className="w-56 shrink-0 border-r bg-muted/30 p-4 flex flex-col gap-6"> and a raw <nav> with <Link> items. The dashboard's Sidebar primitive is 16rem (256px) and uses sidebar-specific tokens; admin is w-56 (224px) on bg-muted/30. Pick one. If admin is meant to feel distinct (different audience), document the difference; otherwise unify on SidebarProvider + Sidebar.
  2. Audit-log page has a raw <input> instead of <Input>. app/(admin)/admin/audit-log/page.tsx:196 hand-rolls an input with h-9 ... shadow-sm focus-visible:ring-1 β€” none of which match the shadcn Input recipe (h-8, no shadow, ring-3).
  3. active_roles Progress threshold colors use literal Tailwind palette colors (bg-red-500, bg-orange-500) instead of tokens. This is by design (see components/dashboard/BillingWidget.tsx:96-105), but it's the only documented use β€” keep it scoped to threshold indicators.
  4. Two eyebrow sizes coexist (tracking-[0.22em] and tracking-[0.18em]) with the smaller used for form labels and the larger for chrome. Both are deliberate, but porters often pick one and lose the form-label variant. Document both: tracking-[0.22em] for chrome eyebrows; tracking-[0.18em] for form labels.
  5. --radius-sm and --radius-2xl are declared in @theme inline but unused. Not a bug, but tokens without consumers tend to drift. Mark them experimental or remove.
  6. .ruled-top and .grain utilities are declared but unused (no call sites in app/ or components/). Same caveat β€” keep, but mark as reserved.
  7. --font-heading is declared as var(--font-display) in @theme inline (app/globals.css:15) but only font-display and the implicit font-heading (used in components/ui/card.tsx:41, components/ui/dialog.tsx:133, components/ui/sheet.tsx:117) consume it. Both currently resolve to Source Serif 4. Leave it pointing at display unless you have a reason to split them.
  8. Sidebar AppSidebar icon-collapsed mark uses h-7 w-7, top-bar mark uses h-5 w-5 opacity-80, suspended uses h-8 w-auto for the lockup. All logical, but it's worth deciding canonical lockup sizes per surface: 12 (auth marketing), 9 (sidebar / report / mobile login), 8 (interstitial card), 7 (sidebar collapsed mark), 5 (top-bar mark).

11. Porting checklist​

Use this in order. Each step is verifiable.

  1. Install runtime deps to match this repo's stack. tailwindcss@4, @tailwindcss/postcss, tw-animate-css, shadcn@4 (CLI), radix-ui, lucide-react, class-variance-authority, clsx, tailwind-merge, next-themes, sonner. Versions: cross-reference package.json.
  2. Set up postcss.config.mjs with {"@tailwindcss/postcss": {}} only. Delete any tailwind.config.ts β€” tokens live in CSS in v4.
  3. Set up components.json with "style": "radix-nova", "baseColor": "neutral", "cssVariables": true, "iconLibrary": "lucide". Path aliases match components.json:14-21.
  4. Replace app/globals.css wholesale with the block in Section 2.1, including body background-image, ::selection, .font-display, .tabular-nums, .ruled-top, .grain.
  5. Wire fonts in app/layout.tsx with next/font/google for Manrope, Source_Serif_4 (weights 300/400/500/600 + italic), and JetBrains_Mono β€” see Section 3.1. Apply variables on <html> with h-full antialiased suppressHydrationWarning. Body class: min-h-full flex flex-col.
  6. Drop in components/providers.tsx verbatim β€” Section 5.2. Confirm <ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange> and <TooltipProvider delayDuration={150}>.
  7. Add components/ui/sonner.tsx with the lucide icon overrides + --normal-bg/--normal-text/--normal-border/--border-radius CSS-var overrides (Section 4.14). Mount <Toaster richColors closeButton /> inside Providers.
  8. Install shadcn primitives via the CLI (shadcn add button card dialog dropdown-menu input label select separator sheet skeleton table tabs textarea tooltip avatar badge alert progress). After install, diff each generated file against Section 4 of this doc and patch to match β€” radix-nova differs slightly per release, and our recipes are the source of truth (e.g. card uses ring-1 ring-foreground/10, dialog content uses ring-1 ring-foreground/10 rounded-xl, badge uses rounded-4xl).
  9. Add components/ui/Markdown.tsx verbatim if the app renders markdown. Do not install @tailwindcss/typography.
  10. Copy components/brand/Logo.tsx verbatim including the hardcoded hex (#1A1612, #7E1D1D). Update the aria-label and <title> to your product name if not "Recruiter".
  11. Build the app shell. Sidebar at 16rem / 18rem mobile / 3rem icon (Section 5.4). Top bar h-14, sticky, border-b border-border/60 bg-background/70 backdrop-blur-md px-4, with mark + eyebrow product name (Section 5.3). Main p-6 lg:p-8.
  12. Pick a page container width from the table in Section 5.5. Default: mx-auto w-full max-w-5xl space-y-6 for list/detail, max-w-2xl space-y-6 for forms.
  13. Audit your microcopy. Every button, headline, empty state, and error against Section 8 rules. The four highest-leverage rules: sentence case; lowercase h.work; pending labels swap to "…ing…"; questions end with ?.
  14. Confirm zero framer-motion, zero emoji, zero // decorations, zero gradient buttons, zero rounded-full buttons, zero AlertDialog. Grep for each.
  15. Replace your destructive confirmation pattern. Convert to <Dialog> with question title + present-tense consequence description + ghost Cancel + destructive Button. Add type-to-confirm for high-stakes actions (Section 8 / 4.8).
  16. Drop <Logo /> into: (a) sidebar header, expanded lockup h-9 w-auto + "Recruiter" eyebrow below; collapsed mark h-7 w-7; (b) top bar, mark h-5 w-5 opacity-80 + inline eyebrow; (c) auth page, lockup h-12 w-auto on editorial panel + lockup h-9 w-auto for mobile.
  17. Visually verify. Compare side-by-side with the recruiter app: parchment background tone, claret dot on logo, eyebrow caps, card ring (not shadow), button height (h-8), card radius (rounded-xl).
  18. Run grep -rn '#7E1D1D\|#1A1612' components/ β€” the only hits should be inside Logo.tsx. Anywhere else is a leak; route through tokens.
  19. Run grep -rnE 'πŸš€|πŸŽ‰|πŸ‘‹|✨' app/ components/ β€” should return zero results. Same for \\/\\/ outside of code comments.
  20. Cross-check this document. If you discover a recipe you needed and it wasn't here, the document is wrong β€” open a PR against docs/branding.md with the new recipe + file:line evidence from the recruiter app.