Repository navigation
Visual System
Job Matcher looks like a marked-up sheet of paper: a pale green-grey page, near-white cards, dark green-black ink, and one yellow highlighter reserved for the parts of a job ad that fit your resume. The interface is quiet and typographic. One typeface does all the work, hierarchy comes from weight and size rather than colour, and colour carries meaning (green for a match, red for a problem, yellow for evidence). This page describes the design language as the code implements it. Exact values are in Design Tokens, the building blocks in Components, and the logo and palette rationale in Brand.

| Principle | How the code applies it |
|---|---|
| Highlighter on paper. | The brand mark is a check drawn as two highlighter strokes; where they cross the yellow deepens. In the UI the same yellow (--highlight, #f3de6e) marks matched requirements inside a reason: the landing page's example run wraps them in <mark className="bg-highlight/70 …">. The marketing site animates a highlighter stroke across matched titles. |
| Ink, not colour, for hierarchy. | Text is Ink (--foreground) or muted Ink (--muted-foreground). Headings differ by weight and size only. Primary buttons are Ink on light text. |
| Colour means something. | Match green (--match) appears on match badges, progress bars, done checks and focus rings. Destructive red (--destructive) appears only on errors, failed verdicts and the destructive button. Yellow is evidence. |
| Rules over boxes. | Lists are separated by hairlines (divide-y border-y) rather than nested cards. Cards are used for the few summary objects (a run, a setup checklist, the home cards) and are outlined with a 1 px ring rather than a shadow. |
| Say what happens next. | Empty and error states are a sentence plus the next action, never an illustration. See States. |
The app uses Schibsted Grotesk for everything, matching the brand guide ("one family throughout").
-
app/layout.tsxloads it withnext/font/google(Schibsted_Grotesk({ variable: "--font-schibsted", subsets: ["latin"] })).next/fontdownloads the font at build time and serves it from the app's own origin, so the browser makes no request to Google. No weights are listed, so the variable font is used. -
globals.cssmaps both--font-sansand--font-headingtovar(--font-schibsted), and the base layer appliesfont-sansto<html>.font-headingis used byCardTitleandAlertDialogTitle; it resolves to the same family. -
<html>carriesantialiased. - Monospace (
font-mono) is Tailwind's default stack. It appears only for example URLs and inline code in the setup wizard and job sources.
The marketing site self-hosts the same font from site/assets/fonts/ (see Marketing Site).
The app uses Tailwind's default type scale; no font-size tokens are customised.
| Role | Classes | Size | Weight | Tracking | Where |
|---|---|---|---|---|---|
| Landing hero | text-5xl sm:text-6xl font-extrabold leading-[0.95] tracking-[-0.035em] |
3rem → 3.75rem | 800 | −0.035em | app/page.tsx |
| Page title | text-4xl font-extrabold tracking-[-0.03em] |
2.25rem | 800 | −0.03em |
AppPage, AuthForm
|
| Landing section | text-3xl font-bold tracking-tight |
1.875rem | 700 | −0.025em | "How a run works" |
| Section heading | text-2xl font-bold tracking-tight |
1.5rem | 700 | −0.025em | Settings sections, wizard step, "Latest run", "Past runs", fieldset legends |
| Run statistic | text-2xl font-semibold tabular-nums |
1.5rem | 600 | — | Found / Eligible / Checked / Failed |
| Card title (primary) | text-lg font-semibold |
1.125rem | 600 | — | Home primary cards, setup checklist |
| Wordmark | text-lg font-bold tracking-tight |
1.125rem | 700 | −0.025em | Logo |
| Card title (default) | font-heading text-base font-medium |
1rem | 500 | — | CardTitle |
| Body |
text-base (inherited) |
1rem | 400 | — | Paragraphs |
| Secondary text | text-sm text-muted-foreground |
0.875rem | 400 | — | Reasons, descriptions, metadata |
| Buttons, labels | text-sm font-medium |
0.875rem | 500 | — |
Button, Label
|
| Badges | text-xs font-medium |
0.75rem | 500 | — | Badge |
Other typographic details:
-
h1andh2usetext-wrap: balance(set in the base layer). - Numbers that change or align use
tabular-nums: run statistics, step numbers, past-run counts, progress values. - Long reading text uses
leading-relaxedand, on the landing page,max-w-prose. - Inputs are
text-basebelowmdandtext-smfrommdup, which avoids iOS zoom on focus.
The palette is the brand palette plus a few neutrals derived from it. All colours are flat hex values on :root.
| Colour | Token | Used for |
|---|---|---|
Paper #eef1ec
|
--background |
Page background; manifest theme and background colour |
Sheet #fafbf8
|
--card, --popover
|
Cards, dialogs, menus, toasts |
Ink #1c2a23
|
--foreground, --primary
|
Text, primary buttons, the logo tile |
Muted ink #56655c
|
--muted-foreground |
Secondary text |
Match #2f6b4f
|
--match, --ring
|
Match badge, progress indicator, done icons, checkbox accent, step numbers on the landing page, focus rings |
Marker #f3de6e
|
--highlight |
Highlighted evidence in reasons; the logo check |
Marker, twice #e2b92a
|
--highlight-deep |
Only the logo overlap |
Destructive #b4432f
|
--destructive |
Error text, "Couldn't check" badge, destructive button, invalid fields |
Rule #d3dacf
|
--border |
Hairlines, separators, outline buttons |
Opacity modifiers do much of the tinting: ring-foreground/10 outlines cards and popovers, bg-muted/50 for hover rows and card footers, bg-destructive/10 for the destructive button and badge, bg-highlight/70 for marks, ring-ring/50 for focus.
--match-soft (#dcebdf) is defined and exposed as bg-match-soft, but no component uses it today.
Tailwind's default 0.25rem spacing scale is unchanged. Recurring values:
| Context | Value |
|---|---|
| Page gutter |
px-5 (1.25rem), sm:px-8 (2rem) |
| Header | py-6 |
AppPage vertical rhythm |
gap-10 between title and content; pt-10 lg:pt-16 pb-24; Settings uses gap-16 between sections |
| Card padding |
--card-spacing: --spacing(4) (1rem) by default, --spacing(3) for size="sm"; app cards override it to --spacing(5) (1.25rem) |
| List rows |
py-3 (runs, sources) to py-4 (results, matches) |
| Form stacks |
gap-6 between form blocks, gap-5 inside a FieldGroup, gap-2 inside a Field
|
One base radius, --radius: 0.375rem (6 px), with derived steps (see Design Tokens).
| Element | Class | Resolved |
|---|---|---|
| Buttons, inputs, menus, notices | rounded-lg |
6 px |
| Small buttons | rounded-[min(var(--radius-md),12px)] |
4.8 px |
| Menu items, skeletons | rounded-md |
4.8 px |
| Cards, dialogs, home skeletons | rounded-xl |
8.4 px |
| Badges | rounded-4xl |
15.6 px (a pill at 20 px tall) |
| Highlight marks | rounded-[2px] |
2 px |
The app is nearly flat.
| Surface | Treatment |
|---|---|
| Card |
ring-1 ring-foreground/10, no shadow |
| Alert dialog |
ring-1 ring-foreground/10 over a bg-black/10 backdrop with backdrop-blur-xs where supported |
| Dropdown menu | shadow-md ring-1 ring-foreground/10 |
| Landing example run | A custom lift: shadow-[0_1px_0_var(--border),0_12px_32px_-12px_rgb(28_42_35/0.25)] (the shadow colour is Ink at 25%) |
┌─────────────────────── max-w-6xl (72rem), px-5 / sm:px-8 ────────────────────────┐
│ [mark] Job Matcher Home Analyze Matches Settings [menu] [user v] │ header py-6
│ │
│ Page title (text-4xl, extrabold) │
│ ┌─────────── AppPage: max-w-2xl (42rem) ───────────┐ │
│ │ content, gap-10 │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
-
Container. Every page (landing, auth, app) is
mx-auto w-full max-w-6xl px-5 sm:px-8. The body is a flex column withmin-h-full, and the container isflex-1. -
Header nav. The logo links to
/homeinside the app and/outside it. The primary nav is ghost buttons, visible fromsm(640 px); the current page getsbg-muted text-foregroundthrougharia-[current=page]. Belowsma ghost icon button opens a dropdown with the same links. The account menu is an outline button with a user icon; the name is hidden belowsm. -
Page width.
AppPagelimits content tomax-w-2xland aligns it left, leaving the right side of wide screens empty. The marketing site crops the screenshots to this column for that reason. -
Landing page. A two-column hero from
lg(minmax(0,5fr) minmax(0,6fr)), with the text columnstickybeside the example run, then a ruled three-column step list fromsm. -
Auth pages. The logo, then a single
max-w-smform column.
| Analyze | Matches |
|---|---|
![]() |
![]() |
Settings

| State | Pattern | Implementation |
|---|---|---|
| Loading | Grey blocks pulsing in the shape of the content, inside a container with aria-busy="true" and a visually hidden "Loading" |
Skeleton (animate-pulse rounded-md bg-muted); AppPage's three lines, HomeSkeleton, WizardSkeleton, SettingsLoader; a size-8 skeleton stands in for the account button |
| Empty | One muted sentence, often between border-y rules, plus an outline or primary button to the next step |
No pending matches, no runs, no sources (with example board URLs), missing resume or source on Analyze |
| Error (page or section) | A text-destructive sentence; where a refetch can help, an outline "Try again" button |
Home, wizard, AppPage session failure |
| Error (field) |
FieldError (role="alert", text-sm text-destructive) under the input; the Field gets data-invalid and turns its label red; inputs with aria-invalid get a red border and ring |
Settings sections, job sources, auth form |
| Toast | Sonner, bottom-right by default, with lucide icons per type (check circle, info, triangle, octagon, spinning loader) and colours from --popover, --popover-foreground, --border, --radius
|
components/ui/sonner.tsx, mounted once in the root layout |
| In progress | Progress bar recoloured to Match ([&_[data-slot=progress-indicator]]:bg-match), an indeterminate bar when the total is unknown, and a pulsing "Checking" badge |
Run card, home active-run card |
| Notice |
rounded-lg bg-muted px-4 py-3 text-sm panel |
Run outcome, "This page embeds a … board" suggestion |
| Confirmation | Alert dialog with Cancel (outline) and a destructive action | Clear saved scrape data |
The app has no dark theme. This was checked in the code rather than assumed:
-
globals.cssdefines tokens only on:root. There is no.darkblock, no@media (prefers-color-scheme: dark)block and no@custom-variant dark. - No
ThemeProviderfromnext-themesis mounted (providers.tsxprovides only TanStack Query), and nothing toggles a theme. - The manifest's
theme_colorandbackground_colorare both Paper.
Two leftovers from the shadcn templates interact with the operating system's setting anyway:
-
dark:utilities still fire. In Tailwind CSS 4 the built-indarkvariant is@media (prefers-color-scheme: dark), and the project does not override it. The generated primitives carrydark:classes (for exampledark:bg-input/30on outline buttons and inputs,dark:data-unchecked:bg-foregroundon the switch thumb,dark:bg-destructive/20on destructive buttons and badges, anddark:bg-input/30on the job source<select>). On a device set to dark mode these apply on top of the light palette, so a few controls render slightly differently. -
Toasts follow the OS.
ToastercallsuseTheme(). Without a provider,next-themesreturns no theme, so the component passestheme="system"to Sonner. Its base colours are set inline from the light tokens.
Adding a dark theme would mean defining dark values for every token in Design Tokens, choosing a dark variant strategy, and mounting a theme provider. The brand guide's dark-background artwork (lockup-dark.svg, mark-check.svg) already exists for it.
-
Library: lucide-react, declared as the shadcn
iconLibraryincomponents.json. -
Icons in use:
ListChecks,LogOut,MenuIcon,Settings,UserRound(app shell);CheckCircle2,Circle(setup checklist);Check(wizard step nav);X(dismiss first-run guide);CircleCheckIcon,InfoIcon,TriangleAlertIcon,OctagonXIcon,Loader2Icon(toasts). -
Sizing: buttons and menu items size unsized SVGs to
size-4(size-3/size-3.5in the small sizes) and make thempointer-events-none shrink-0. Badge icons aresize-3. - Brand icons: lucide no longer ships brand marks, so the landing page and marketing site draw the GitHub mark as an inline SVG.
-
Decorative icons carry
aria-hidden; icon-only buttons carry anaria-label("Open navigation", "Dismiss run guide").
Motion is minimal and functional.
| Motion | Where | Detail |
|---|---|---|
| Dialog and menu enter/exit |
AlertDialog, DropdownMenu
|
tw-animate-css fade plus zoom-in-95 / zoom-out-95, duration-100
|
| Button press | Button |
active:not-aria-[haspopup]:translate-y-px (a 1 px nudge) |
| Colour transitions | Buttons, badges, inputs, nav links |
transition-all / transition-colors with Tailwind's default duration |
| Switch thumb | Switch |
transition-transform |
| Progress |
Progress indicator |
transition-all as the value changes |
| Skeleton pulse | Skeleton |
animate-pulse |
| Checking badge | VerdictBadge |
motion-safe:animate-pulse, so it stays still when the user prefers reduced motion |
| Toast loader | Toaster |
animate-spin on the loading icon |
The marketing site's example-run animation is described in Marketing Site; it is skipped entirely under prefers-reduced-motion: reduce.
Practices verified in the code:
| Area | Practice |
|---|---|
| Focus | Every element gets outline-ring/50 in the base layer. Buttons, inputs, badges and switches show a 3 px ring-ring/50 plus a border-ring on :focus-visible; the ring colour is Match green. Settings headings and wizard links show the same ring. |
| Landmarks and labels |
<main> per page; <nav aria-label="Primary">, "Settings sections" and "Setup steps"; card regions labelled with aria-labelledby. |
| Current location |
aria-current="page" on the active nav item, aria-current="step" on the active wizard step. |
| Focus management | Opening /settings#<section> focuses that section's heading (tabIndex={-1}), without pulling back a user who has already interacted. Changing wizard step focuses the new step heading. |
| Toggle state | Match status tabs and the site's OS switch are buttons with aria-pressed. |
| Live and status | Field errors use role="alert"; loading containers use aria-busy. |
| Hidden text |
sr-only text completes visual-only cues: each verdict badge's explanation, "(done)" on completed wizard steps, "Source code on GitHub" on the icon-only GitHub link, "Loading" in skeletons. |
| Progress |
Progress bars have aria-labels ("Run progress", "Setup progress"); the wizard bar sets getAriaValueText to "Step N of 5". |
| Forms | Every input has a <label> (FieldLabel htmlFor); auth inputs set autoComplete; the password field sets minLength={8}. |
| Reduced motion |
motion-safe: on the only looping app animation besides skeletons. |
| Colour not alone | Verdicts are badges with text ("Match", "No match", "Couldn't check"); on the marketing site a matched title also gets a check glyph. |
Ratios computed from the token values (WCAG 2.x relative luminance):
| Foreground | Background | Ratio |
|---|---|---|
Ink #1c2a23
|
Paper #eef1ec
|
13.1 : 1 |
Ink #1c2a23
|
Sheet #fafbf8
|
14.4 : 1 |
Muted ink #56655c
|
Paper #eef1ec
|
5.4 : 1 |
Muted ink #56655c
|
Sheet #fafbf8
|
5.9 : 1 |
Muted ink #56655c
|
Muted #e3e8e0
|
4.95 : 1 |
Light text #f4f6f1
|
Match #2f6b4f (match badge) |
5.8 : 1 |
Destructive #b4432f
|
Paper #eef1ec
|
4.9 : 1 |
Ink #1c2a23
|
Marker #f3de6e (highlight) |
11.0 : 1 |
All body-text pairs meet WCAG AA (4.5 : 1).
Job Matcher · Website · Report an issue · MIT license
Using Job Matcher
System design
Matching engine
Interface and brand
Engineering
Paper
Sheet
Ink
Muted ink
Match
Marker
Marker, twice
Destructive
Rule 
