Skip to content

Visual System

Karthik P edited this page Oct 15, 2026 · 1 revision

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.

The landing page

Principles

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.

Typography

Family and loading

The app uses Schibsted Grotesk for everything, matching the brand guide ("one family throughout").

  • app/layout.tsx loads it with next/font/google (Schibsted_Grotesk({ variable: "--font-schibsted", subsets: ["latin"] })). next/font downloads 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.css maps both --font-sans and --font-heading to var(--font-schibsted), and the base layer applies font-sans to <html>. font-heading is used by CardTitle and AlertDialogTitle; it resolves to the same family.
  • <html> carries antialiased.
  • 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).

Scale in use

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:

  • h1 and h2 use text-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-relaxed and, on the landing page, max-w-prose.
  • Inputs are text-base below md and text-sm from md up, which avoids iOS zoom on focus.

Colour usage

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.

Spacing, radius and elevation

Spacing

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

Radius

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

Elevation

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%)

Layout

App shell

┌─────────────────────── 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 with min-h-full, and the container is flex-1.
  • Header nav. The logo links to /home inside the app and / outside it. The primary nav is ghost buttons, visible from sm (640 px); the current page gets bg-muted text-foreground through aria-[current=page]. Below sm a 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 below sm.
  • Page width. AppPage limits content to max-w-2xl and 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 column sticky beside the example run, then a ruled three-column step list from sm.
  • Auth pages. The logo, then a single max-w-sm form column.
Analyze Matches
The Analyze page with a completed run The Matches page with pending matches
Settings

The Settings page

States

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

Dark mode

The app has no dark theme. This was checked in the code rather than assumed:

  • globals.css defines tokens only on :root. There is no .dark block, no @media (prefers-color-scheme: dark) block and no @custom-variant dark.
  • No ThemeProvider from next-themes is mounted (providers.tsx provides only TanStack Query), and nothing toggles a theme.
  • The manifest's theme_color and background_color are both Paper.

Two leftovers from the shadcn templates interact with the operating system's setting anyway:

  1. dark: utilities still fire. In Tailwind CSS 4 the built-in dark variant is @media (prefers-color-scheme: dark), and the project does not override it. The generated primitives carry dark: classes (for example dark:bg-input/30 on outline buttons and inputs, dark:data-unchecked:bg-foreground on the switch thumb, dark:bg-destructive/20 on destructive buttons and badges, and dark:bg-input/30 on 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.
  2. Toasts follow the OS. Toaster calls useTheme(). Without a provider, next-themes returns no theme, so the component passes theme="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.

Iconography

  • Library: lucide-react, declared as the shadcn iconLibrary in components.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.5 in the small sizes) and make them pointer-events-none shrink-0. Badge icons are size-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 an aria-label ("Open navigation", "Dismiss run guide").

Motion

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.

Accessibility

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.

Contrast

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).

Clone this wiki locally