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β
- 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. - Lowercase
h, always. The wordmark ish.work. The dot is the brand's only accent color. The "work" tail is italic and 55% opacity.components/brand/Logo.tsx:50-82. - 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.
- Typography does the heavy lifting. Page hierarchy is established with
font-displayserif headlines and tracked-out uppercase eyebrows, not with colored backgrounds or shadows. - 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 class | Light hex (β) | Purpose | Evidence |
|---|---|---|---|---|
--background | bg-background | #fbf9f5 | App canvas. Parchment. | app/globals.css:63, :168 |
--foreground | text-foreground | #272220 | Ink. Body text, headlines. | app/globals.css:64 |
--card | bg-card | #fcfbf8 | Card surface β slightly lighter than canvas. | app/globals.css:66, components/ui/card.tsx:15 |
--popover | bg-popover | #fcfcf9 | Floating surfaces (dialog, sheet, dropdown, tooltip target). | app/globals.css:69, components/ui/dialog.tsx:64 |
--primary | bg-primary / text-primary | #2e2925 | Default button bg; primary ink. | app/globals.css:72, components/ui/button.tsx:12 |
--primary-foreground | text-primary-foreground | #fbf9f5 | Text on primary. | app/globals.css:73 |
--secondary | bg-secondary | #f1eee7 | Secondary button bg, soft fill. | app/globals.css:75, components/ui/button.tsx:15 |
--muted | bg-muted | #f2efe7 | Muted blocks (skeletons, code inline, empty-state icon bg). | app/globals.css:78 |
--muted-foreground | text-muted-foreground | #7d7670 | Secondary text, helper copy, eyebrows. | app/globals.css:79 |
--accent | bg-accent | #f1e3df | Hover/focus selection accent β claret-tinted wash. | app/globals.css:81 |
--accent-foreground | text-accent-foreground | #7e1d1d | Ink-on-accent; also the claret used for ::selection text. | app/globals.css:82, :194 |
--brand | bg-brand / text-brand | #7e1d1d | Claret. Logo dot, decorative strokes. Use sparingly. | app/globals.css:86, components/brand/Logo.tsx:29 |
--brand-soft | bg-brand-soft | #f1e3df | Claret wash for backgrounds. | app/globals.css:87 |
--destructive | text-destructive / bg-destructive/10 | #9d2a26 | Destructive surfaces (always at 10β20% opacity for bg). | app/globals.css:89, components/ui/button.tsx:20 |
--border | border-border | #e4dfd6 | Standard 1px borders. Often border-border/60 for soft top bars. | app/globals.css:91, app/(dashboard)/layout.tsx:41 |
--input | border-input | #e7e1d8 | Input control border. | app/globals.css:92, components/ui/input.tsx:11 |
--ring | ring-ring / focus-visible:ring-ring/50 | #a07a73 | Focus ring color (claret-derived). | app/globals.css:93 |
--sidebar (+ family) | bg-sidebar, text-sidebar-foreground | #f4f1ea | Sidebar 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β
| Token | Computed | Used for | Evidence |
|---|---|---|---|
--radius-sm | 0.3rem | (declared, no direct consumers found) | app/globals.css:45 |
--radius-md | 0.4rem | xs/sm button corner (capped at 10β12px) | app/globals.css:46, components/ui/button.tsx:26-27 |
--radius-lg | 0.5rem (= --radius) | Default buttons, inputs, dropdown content, select trigger | components/ui/button.tsx:8, components/ui/input.tsx:11, components/ui/dropdown-menu.tsx:46 |
--radius-xl | 0.7rem | Cards, dialog content | components/ui/card.tsx:15, components/ui/dialog.tsx:64 |
--radius-2xl | 0.9rem | (declared, no direct consumers found) | app/globals.css:49 |
--radius-4xl | 1.3rem | Badge / 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:
| Surface | Class |
|---|---|
| Button (default) | rounded-lg |
| Button (xs/sm) | rounded-[min(var(--radius-md),10px)] / rounded-[min(var(--radius-md),12px)] |
| Input / Textarea / Select trigger | rounded-lg |
| Card / Dialog / Sheet content | rounded-xl |
| Dropdown content / Tooltip / Menu item | rounded-md / rounded-lg |
| Badge / Chip | rounded-4xl (pill) |
| Avatar | rounded-full |
| Empty-state icon tile | rounded-lg |
| Toast icon container | rounded-full |
| Progress bar | rounded-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.
| Surface | Treatment | Evidence |
|---|---|---|
| Card | ring-1 ring-foreground/10 + lighter bg-card | components/ui/card.tsx:15 |
| Dialog content | ring-1 ring-foreground/10, no shadow | components/ui/dialog.tsx:64 |
| Dropdown content | ring-1 ring-foreground/10 shadow-md | components/ui/dropdown-menu.tsx:46 |
| Dropdown subcontent | ring-1 ring-foreground/10 shadow-lg | components/ui/dropdown-menu.tsx:247 |
| Select content | ring-1 ring-foreground/10 shadow-md | components/ui/select.tsx:72 |
| Sheet content | shadow-lg (overlay panel) | components/ui/sheet.tsx:65 |
| Sidebar (floating variant) | shadow-sm ring-1 ring-sidebar-border | components/ui/sidebar.tsx:244 |
| Dialog/Sheet overlay | bg-black/10 supports-backdrop-filter:backdrop-blur-xs | components/ui/dialog.tsx:42, components/ui/sheet.tsx:40 |
| Tooltip | Solid dark pill: bg-foreground text-background | components/ui/tooltip.tsx:45 |
| Top bar (sticky header) | border-b border-border/60 bg-background/70 backdrop-blur-md | app/(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)β
| Step | Class combo | Use case | Evidence |
|---|---|---|---|
| Hero H1 | font-display text-4xl tracking-tight text-foreground | Top-level dashboard, settings root, public report | app/(dashboard)/page.tsx:129, app/(dashboard)/settings/page.tsx:43, app/report/[token]/page.tsx:135 |
| Page H1 | font-display text-3xl tracking-tight | Most index/detail pages, login | app/(dashboard)/billing/page.tsx:104, app/(dashboard)/company/page.tsx:16, app/(dashboard)/roles/page.tsx:118, app/(auth)/login/page.tsx:122 |
| Error H1 | font-display text-3xl tracking-tight | Global error, 404 | app/error.tsx:25, app/not-found.tsx:10 |
| Section H2 | font-display text-2xl tracking-tight | Major section dividers inside a page | app/suspended/page.tsx:45 (text-2xl) |
| Editorial italic | font-display italic text-3xl leading-[1.15] text-foreground/85 | Marketing copy (login left panel) | app/(auth)/login/page.tsx:101 |
| Editorial body | font-display italic text-base leading-relaxed text-foreground/70 | Marketing prose | app/(auth)/login/page.tsx:104 |
| In-context H1 | text-2xl font-semibold tracking-tight truncate | Role-detail header (non-display) | components/roles/RoleHeader.tsx |
| Card title | font-heading text-base leading-snug font-medium (default size); text-sm at data-size=sm | Card title slot | components/ui/card.tsx:41 |
| Dialog title | font-heading text-base leading-none font-medium | Dialog title | components/ui/dialog.tsx:133 |
| Sheet title | font-heading text-base font-medium text-foreground | Sheet title | components/ui/sheet.tsx:117 |
| Body | (inherits text-sm from card/dialog content; html default sans) | Default running text | components/ui/card.tsx:15 |
| Small / caption | text-xs text-muted-foreground | Helper copy, captions | components/ui/card.tsx:53, components/ui/dropdown-menu.tsx:173 |
| Eyebrow (large) | text-[0.7rem] uppercase tracking-[0.22em] text-muted-foreground font-medium | Above hero H1, sidebar inset header | app/(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-medium | Sidebar wordmark subtitle, report header | components/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-medium | Form labels in editorial contexts | app/(auth)/login/page.tsx:51,66 |
| Footer rule | text-[0.65rem] uppercase tracking-[0.22em] text-muted-foreground | Public report footer | app/report/[token]/page.tsx:162 |
| Code inline | rounded bg-muted px-1 py-0.5 text-[0.85em] font-mono | Inline code in markdown | components/ui/Markdown.tsx:34 |
| Code block | my-4 rounded-md bg-muted p-3 text-xs overflow-x-auto | Fenced code in markdown | components/ui/Markdown.tsx:28 |
| System mono | font-mono text-xs (often paired with text-foreground/80) | Tavus IDs, invoice numbers, env keys, error digests, type-to-confirm | components/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-uipackage, 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/80outline: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/50secondary:bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foregroundghost:hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50destructive: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 filllink: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-2xs: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-3sm: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.5lg:h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2icon:size-8icon-xs:size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3icon-sm:size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lgicon-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-foregroundsecondary:bg-secondary text-secondary-foregrounddestructive:bg-destructive/10 text-destructiveoutline:border-border text-foregroundghost:hover:bg-muted hover:text-muted-foregroundlink: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>
| Slot | Class | Source |
|---|---|---|
<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'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-foregrounddestructive: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-tightetc. - 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 case | Wrapper | Evidence |
|---|---|---|
| Top-level dashboard (most generous) | mx-auto w-full max-w-5xl space-y-10 | app/(dashboard)/page.tsx:123 |
| List / detail page | mx-auto w-full max-w-5xl space-y-6 | app/(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-8 | app/(dashboard)/settings/page.tsx:38, app/(dashboard)/company/page.tsx:14 |
| Billing / secondary form | mx-auto w-full max-w-3xl space-y-6 | app/(dashboard)/billing/page.tsx:70 |
| Form / wizard | mx-auto w-full max-w-2xl space-y-6 | app/(dashboard)/roles/new/page.tsx |
| Single-purpose flow | mx-auto w-full max-w-xl ... | app/(dashboard)/billing/setup/page.tsx |
| Public report | mx-auto w-full max-w-3xl px-6 py-12 | app/report/[token]/page.tsx:120 |
| Suspended page | mx-auto flex min-h-screen w-full max-w-xl items-center justify-center p-6 | app/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-pxgradient from transparent βvia-foreground/10β transparent, absolute-positioned atleft-1/2. - Form column:
max-w-sm,space-y-8, tallerh-11inputs 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β
| Context | Size class | Example |
|---|---|---|
| Default in-button / icon-in-button | h-4 w-4 | <Plus className="mr-1 h-4 w-4" /> (app/(dashboard)/roles/page.tsx:125) |
| Card title trailing icon | h-4 w-4 text-muted-foreground | app/(dashboard)/page.tsx:204 |
Empty-state hero icon (inside a h-10 w-10 tile) | h-5 w-5 text-muted-foreground | app/(dashboard)/roles/page.tsx:134 |
| Inline meta / staleness indicator | h-3.5 w-3.5 | app/(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" button | components/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-foregroundfor 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-squareinAvatarImage). - Treatment:
rounded-full+ the pseudo-element hairline (see Section 4.12). Do not addring-*to avatars; the hairline is the design. - Sidebar user avatar is
h-7 w-7(not the defaultsize-8) β seecomponents/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 fullh.workwordmark with a circular orbit mark + claret dot + ink satellite. Default sizeh-7 w-auto. The mark sits on the left attransform="translate(28, 28)".mark: just the orbit + dot, viewBox0 0 56 56. Defaulth-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", fillrgba(26, 22, 18, 0.55), same negative letter-spacing
Logo placements found in this app:
| Location | Size | Pairing | Source |
|---|---|---|---|
| Sidebar expanded | h-9 w-auto (lockup) | text-[0.6rem] "Recruiter" eyebrow below | components/dashboard/AppSidebar.tsx:49-52 |
| Sidebar collapsed | h-7 w-7 (mark) | none | components/dashboard/AppSidebar.tsx:56 |
| Top bar (post-sidebar) | h-5 w-5 opacity-80 (mark) | text-[0.7rem] "Recruiter" eyebrow inline | app/(dashboard)/layout.tsx:44-47 |
| Login left panel | h-12 w-auto (lockup) | text-[0.65rem] "Recruiter" eyebrow below | app/(auth)/login/page.tsx:94-97 |
| Login right (mobile) | h-9 w-auto (lockup) | none | app/(auth)/login/page.tsx:117 |
| Public report header | h-9 w-auto (lockup) | text-[0.6rem] "Recruiter" eyebrow below | app/report/[token]/page.tsx:124-127 |
| Suspended card | h-8 w-auto (lockup) | text-[0.6rem] "Recruiter" eyebrow below | app/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:
- CSS
transition-*utilities on hover/focus states. Most have no explicit duration β they use Tailwind's default150ms ease-in-out. tw-animate-cssfor Radix Portal enter/exit (data-open:animate-in,data-closed:animate-outetc.). Imported atapp/globals.css:2.
| Token / pattern | Where | Notes |
|---|---|---|
transition-colors | <Card hover>, links, nav, ThemeSwitcher, RolesFilterTabs, TableRow, dropdown items | Default β no duration set |
transition-all | Button, Badge (from CVA base) | Default duration |
transition-[width,height,padding] | Sidebar menu buttons | components/ui/sidebar.tsx:469 |
transition-[width] duration-200 ease-linear | Sidebar gap | components/ui/sidebar.tsx:220 |
transition-[left,right,width] duration-200 ease-linear | Sidebar container | components/ui/sidebar.tsx:232 |
transition-[margin,opacity] duration-200 ease-linear | Sidebar group label | components/ui/sidebar.tsx:404 |
duration-100 | Dialog, Sheet, Dropdown, Select content/overlay | Faster than default for portals |
duration-200 | Sheet transition | components/ui/sheet.tsx:65 |
data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 | Dialog, Dropdown, Select content | tw-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, Select | Direction-aware slide |
data-[side=β¦]:data-open:slide-in-from-β¦-10 | Sheet | Larger 10-unit slide for drawer |
animate-pulse | Skeleton, AI generation Sparkles icon | tailwindcss built-in |
animate-spin | Loader2Icon in toast/loading | tailwindcss built-in |
active:not-aria-[haspopup]:translate-y-px | Button base | 1px 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-100for portal enter/exit. - Default to
duration-200 ease-linearfor 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β
- 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.
- Button labels: sentence case verb phrase. "Save changes", "Create role", "Open customer portal", "End impersonation", "Export to ATS", "Add candidate", "Choose PDF".
h.workalways. Lowercaseh. Never "h.work", "H Work", or "Hwork". The dot is part of the wordmark.- No emojis in UI. None found in any user-facing string (verified). Use a lucide icon if you need an icon.
- No
//decorative slashes. No "// SECTION" headers, no "//FAQ" navigation. Use the uppercase eyebrow + serif headline pattern instead. - Pending labels use U+2026 ellipsis. "Signing inβ¦", "Creatingβ¦", "Savingβ¦", "Closingβ¦", "Deletingβ¦", "Generatingβ¦". Three-dot string
...is wrong. - Questions end with
?in destructive confirmations. "Delete this draft?", "Close this role?", "Clone this role into a new draft?", "Delete this candidate?". - 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. - Eyebrow product-name caption is
Recruiter. Always paired with the logo. Alwaysuppercase tracking-[0.22em] text-muted-foreground font-medium. - Bullet separator is
Β·(middle dot U+00B7). Used inRead-only evaluation Β· Generated by h.work Recruiter(app/report/[token]/page.tsx:163). - Currency / numeric placeholders use the en-dash
βin ranges. Example:"$160k β $200k + equity"(components/roles/CreateRoleForm.tsx:222). Not a hyphen. - 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.
- 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) - No emojis in UI. Zero in
app/orcomponents/. Use lucide icons instead. - No
//decorative slashes. No "// SETTINGS" pseudo-headers, no slash-prefixed navigation. - No
rounded-fullon buttons. Buttons are alwaysrounded-lg. Pills are reserved for badges (rounded-4xl) and avatars (rounded-full). - 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) - No box-shadow elevation on cards. Cards use
ring-1 ring-foreground/10+ a slightly-lighterbg-card. Shadows are reserved for portals. - No framer-motion. Stay with CSS transitions and
tw-animate-css. Adding framer-motion crosses a deliberate line. - No three-dot
...ellipsis. Always U+2026β¦. - No "Loading..." spinner state for buttons. Swap the label: "Savingβ¦". Spinners only appear in toasts and the AI-generation
Sparklesindicator. - No
@tailwindcss/typographyplugin. Markdown styling is hand-rolled incomponents/ui/Markdown.tsx. Do not add the plugin. - No third icon library. lucide only. No mixing heroicons, phosphor, react-icons.
- No purple, no neon, no "AI" gradients. The brand is editorial. Claret + neutrals. AI surfaces use a
Sparklesicon withanimate-pulse, nothing more. - No "Welcome back, John! π" tone. Conversational, not effusive: "Welcome back. Enter your credentials to continue."
- 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. - No "Try again later" / "Oops!" / "Whoops!" microcopy. Errors state what failed: "Generation failed", "Couldn't copy link", "Failed to end impersonation".
- No brand color on functional surfaces.
--brandis 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. - 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.
- Admin layout uses a hand-rolled sidebar, not the
Sidebarprimitive.app/(admin)/layout.tsx:23-56declares<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'sSidebarprimitive is16rem(256px) and uses sidebar-specific tokens; admin isw-56(224px) onbg-muted/30. Pick one. If admin is meant to feel distinct (different audience), document the difference; otherwise unify onSidebarProvider + Sidebar. - Audit-log page has a raw
<input>instead of<Input>.app/(admin)/admin/audit-log/page.tsx:196hand-rolls an input withh-9 ... shadow-sm focus-visible:ring-1β none of which match the shadcnInputrecipe (h-8, no shadow,ring-3). active_rolesProgress threshold colors use literal Tailwind palette colors (bg-red-500,bg-orange-500) instead of tokens. This is by design (seecomponents/dashboard/BillingWidget.tsx:96-105), but it's the only documented use β keep it scoped to threshold indicators.- Two eyebrow sizes coexist (
tracking-[0.22em]andtracking-[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. --radius-smand--radius-2xlare declared in@theme inlinebut unused. Not a bug, but tokens without consumers tend to drift. Mark them experimental or remove..ruled-topand.grainutilities are declared but unused (no call sites inapp/orcomponents/). Same caveat β keep, but mark as reserved.--font-headingis declared asvar(--font-display)in@theme inline(app/globals.css:15) but onlyfont-displayand the implicitfont-heading(used incomponents/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.- Sidebar
AppSidebaricon-collapsed mark usesh-7 w-7, top-bar mark usesh-5 w-5 opacity-80, suspended usesh-8 w-autofor 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.
- 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-referencepackage.json. - Set up
postcss.config.mjswith{"@tailwindcss/postcss": {}}only. Delete anytailwind.config.tsβ tokens live in CSS in v4. - Set up
components.jsonwith"style": "radix-nova","baseColor": "neutral","cssVariables": true,"iconLibrary": "lucide". Path aliases matchcomponents.json:14-21. - Replace
app/globals.csswholesale with the block in Section 2.1, includingbodybackground-image,::selection,.font-display,.tabular-nums,.ruled-top,.grain. - Wire fonts in
app/layout.tsxwithnext/font/googlefor Manrope, Source_Serif_4 (weights 300/400/500/600 + italic), and JetBrains_Mono β see Section 3.1. Apply variables on<html>withh-full antialiased suppressHydrationWarning. Body class:min-h-full flex flex-col. - Drop in
components/providers.tsxverbatim β Section 5.2. Confirm<ThemeProvider attribute="class" defaultTheme="light" enableSystem disableTransitionOnChange>and<TooltipProvider delayDuration={150}>. - Add
components/ui/sonner.tsxwith the lucide icon overrides +--normal-bg/--normal-text/--normal-border/--border-radiusCSS-var overrides (Section 4.14). Mount<Toaster richColors closeButton />inside Providers. - 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 usesring-1 ring-foreground/10, dialog content usesring-1 ring-foreground/10 rounded-xl, badge usesrounded-4xl). - Add
components/ui/Markdown.tsxverbatim if the app renders markdown. Do not install@tailwindcss/typography. - Copy
components/brand/Logo.tsxverbatim including the hardcoded hex (#1A1612,#7E1D1D). Update thearia-labeland<title>to your product name if not "Recruiter". - Build the app shell. Sidebar at
16rem/18remmobile /3remicon (Section 5.4). Top barh-14, sticky,border-b border-border/60 bg-background/70 backdrop-blur-md px-4, with mark + eyebrow product name (Section 5.3). Mainp-6 lg:p-8. - Pick a page container width from the table in Section 5.5. Default:
mx-auto w-full max-w-5xl space-y-6for list/detail,max-w-2xl space-y-6for forms. - 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?. - Confirm zero
framer-motion, zero emoji, zero//decorations, zero gradient buttons, zerorounded-fullbuttons, zeroAlertDialog. Grep for each. - 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). - Drop
<Logo />into: (a) sidebar header, expanded lockuph-9 w-auto+ "Recruiter" eyebrow below; collapsed markh-7 w-7; (b) top bar, markh-5 w-5 opacity-80+ inline eyebrow; (c) auth page, lockuph-12 w-autoon editorial panel + lockuph-9 w-autofor mobile. - 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). - Run
grep -rn '#7E1D1D\|#1A1612' components/β the only hits should be insideLogo.tsx. Anywhere else is a leak; route through tokens. - Run
grep -rnE 'π|π|π|β¨' app/ components/β should return zero results. Same for\\/\\/outside of code comments. - 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.mdwith the new recipe + file:line evidence from the recruiter app.