# CareerKit Design Constitution

**Version 1.0** · Last updated 2026-05-30

This document is the source of truth for how CareerKit looks and behaves. Every PR is reviewed against it. If a rule here is wrong, propose a change to this document — don't ship around it.

---

## Philosophy

CareerKit is a tool people use under pressure. They're job-searching, applying, interviewing — high stakes, often anxious. The interface should remove friction, never add to it.

**We're not Spotify.** This isn't entertainment. It's a workbench. Beauty serves the task.

**We're not Linear.** Linear's audience is power users in flow. Our users are first-timers, returners, career-changers. Discoverability matters as much as efficiency.

**We are something between Stripe Dashboard and Things.** Restrained, fast, predictable. Surprising in the small details, never in the big ones.

---

## The 7 Principles

When in doubt, fall back to these:

1. **Don't make me think.** Every screen should have one obvious thing to do next.
2. **Always let me back out.** Every flow has an exit. Browser back never loses work.
3. **Never lose my work.** Auto-save by default. Drafts persist. Undo destructive actions.
4. **Touch first, then desktop.** Design at 375px. Desktop is the enhancement.
5. **Show me what's happening.** Loading, saving, error, success — all visible, none silent.
6. **Don't surprise me.** Same action = same result, everywhere. No hidden affordances.
7. **Earn trust through detail.** A thousand small correctnesses beat one big gesture.

---

## Tokens

All tokens live in `src/design/tokens.ts` and are mirrored to CSS custom properties in `globals.css`. **Never** hard-code values in components.

### Colors (StudySmarter brand-locked)

```ts
brand: {
  primary:   '#1300FF',  // Smarter Blue   — CTAs, links, active states, selected
  accent:    '#C6FA02',  // Neon Lime      — high-priority moments, "now" indicators
  hotRod:    '#FF5C41',  // Hot-Rod         — destructive, urgent, alerts
  stone200:  '#E7E4E0',  // Stone           — warm neutral surface
}

surface: {
  bg:        '#ffffff',   // page bg
  card:      '#ffffff',   // card bg
  muted:     '#fafafa',   // input/passive bg
  inverse:   '#0a0a0a',   // dark hero (CV Review)
}

ink: {
  default:   '#0a0a0a',
  muted:     '#6b7280',
  hairline:  '#e4e4e7',
}

state: {
  success:   '#16a34a',
  warn:      '#d97706',
  destructive: var(brand.hotRod),  // Hot-Rod IS destructive — brand-correct
}
```

**Banned:** `red`, `green`, `blue` Tailwind utilities. Use `primary`, `accent`, `destructive`, `success`, `warn`. The off-brand → on-brand remap in `globals.css` redirects legacy classes; new code uses the semantic tokens.

### Typography

| Role | Font | Weight | Size range |
|---|---|---|---|
| Heading | Geist | 700–800 | 18–36px |
| Body | Geist | 400–500 | 13–15px |
| Mono / label | Geist Mono | 400–500 | 10–12px |

Type scale: `11 · 12.5 · 14 · 16 · 20 · 24 · 28 · 36`. Ratio ~1.25 between consecutive steps. Use scale tokens, not raw px.

Letter-spacing: -0.02em on body, -0.03em on display (28px+). DM Mono labels: +0.08em letter-spacing, uppercase.

### Spacing

`0 · 2 · 4 · 6 · 8 · 10 · 12 · 14 · 16 · 20 · 24 · 28 · 32 · 40 · 48 · 64 · 80`

Multiples of 2 only. Use `gap` and CSS Grid `gap` over margins. Never absolute positioning for layout.

### Radius

`sm: 8` (chips, inputs, badges) · `md: 12` (cards, panels) · `lg: 16` (hero cards, primary CTAs) · `pill: 999`

Never invent intermediate values. If you need 14, you mean 12.

### Motion

| Token | Duration | Easing | Use case |
|---|---|---|---|
| `fast` | 150ms | `ease-out` | Press feedback, hover color |
| `base` | 200ms | `ease-out` | Modals, drawers, tabs |
| `slow` | 300ms | `ease-out` | Page-level transitions |
| `spring-soft` | — | custom | Drag interactions only |

**Banned:** `transition: all`, `ease-in` on UI, durations >300ms outside of decorative marketing, animation on keyboard-initiated actions (Raycast rule).

All motion respects `prefers-reduced-motion: reduce` — strips transforms, keeps opacity fades.

### Breakpoints

```
mobile (default):   0     – 767px
tablet:           768px   – 1023px
desktop:         1024px   – ∞
```

Three breakpoints. Mobile is the default, desktop is `min-width: 768/1024`. No `sm`, `xl`, `2xl` opt-ins. Five breakpoints is where consistency dies.

---

## Component Primitives

Every UI element is built from one of these. Inline `<button>` / `<div className="bg-blue-500 ...">` is forbidden in production code.

### Inventory

| Primitive | Variants | Required props |
|---|---|---|
| `Button` | primary / secondary / ghost / destructive | `size` (sm 32 / md 36 / lg 44), `loading` |
| `IconButton` | same as Button | `label` (a11y), enforces 44×44 touch |
| `Input` | text / email / password / number | `label`, `helperText`, `error` |
| `Textarea` | — | same as Input |
| `Select` | — | `label`, `options` |
| `Checkbox` `Radio` `Switch` | — | `label` (always) |
| `Card` | default / hero / muted | — |
| `Panel` | default / outlined | — |
| `ListRow` | — | `href` or `onClick`, min-h 56 mobile |
| `Badge` | applied / interview / offer / rejected / neutral / warn / destructive | — |
| `Tag` | brand / neutral / hot | — |
| `Tabs` `TabList` `Tab` | — | `aria-label` on TabList |
| `Modal` | default / sheet (mobile) | `title`, `trigger`, no `autoOpen` |
| `Toast` | success / error / info / destructive-undo | `dismissAfter` (default 5000) |
| `Avatar` | square / round | `name` (hash-colored), `size` |
| `Icon` | — | `label` OR `decorative={true}` |
| `ProgressBar` `ProgressRing` `ProgressDots` | — | `value`, `max`, `label` |
| `Stepper` | linear / non-linear | `current`, `steps`, `onBack` |
| `EmptyState` | first-time / no-results / error | `title`, `description`, `action` |
| `SaveIndicator` | — | reflects `useAutoSave` state |
| `Kbd` | — | text content |

### Component contract

Every primitive must implement:
- All interactive states: `default · hover · focus · active · disabled · loading · error`
- Keyboard interaction: tab order, enter/space activation, escape dismissal where applicable
- Touch target ≥44×44 if interactive (via padding, not min-width on visual)
- Visible focus ring (`box-shadow: 0 0 0 3px var(--focus)`)
- `aria-*` attributes baked in
- Reduced-motion behavior

A component that ships without these is incomplete. Storybook stories cover every state.

---

## Layout Primitives

Don't write `display: grid; grid-template-columns: ...` in pages. Use:

| Primitive | Behavior |
|---|---|
| `Shell` | App frame: sidebar (≥1024) / bottom nav (<1024) / top bar / safe areas / main scroll |
| `Stack gap="md"` | Vertical flex, token-driven gap |
| `Cluster gap="sm"` | Horizontal flex, wraps on overflow |
| `Grid cols={[1, 2, 3]}` | Responsive grid — array maps to [mobile, tablet, desktop] |
| `Section` | Header + content slot, standardized vertical rhythm |
| `Sticky` | Sticky top/bottom with backdrop-blur + safe-area awareness |

**Responsive is not a media query — it's a primitive.** Pages declare intent (`cols={[1, 2, 3]}`), primitives handle the rest.

---

## The Rules

### 1. Navigation & user control

| Rule | Why | How |
|---|---|---|
| Every non-root route has a visible back path | Don't dead-end users | Lint: route exports require `backLink` OR `breadcrumb` |
| Browser back always works without losing data | Universal expectation | `next/navigation` patterns + `beforeunload` guard on unsaved forms |
| `Esc` dismisses any overlay (modal, sheet, popover, dropdown) | Standard since the 80s | Built into `Modal`/`Sheet`/`Popover` primitives |
| Destructive actions require explicit confirm OR provide 5s undo | Errors cost real money/time | `Button variant="destructive"` requires `confirm` OR `undo` prop |
| Multi-step flows allow back to prior steps without re-validation | Trust + recovery | `Stepper` primitive — `onBack` required |
| Long flows have "Save and exit" — never just "Cancel" | Don't punish interruption | Convention in flow surface templates |

### 2. Mobile-first (non-negotiable)

| Rule | Why | How |
|---|---|---|
| Design at 375×667 first | Worst mainstream phone | Storybook default viewport = iPhone SE |
| Primary CTA reachable in thumb zone (lower half) | Casey persona, one-handed | Flow surfaces use sticky bottom action bar |
| Touch targets ≥44×44 (with padding, not visual hit area) | WCAG 2.5.5 + iOS HIG | Primitives enforce min-h |
| Hover behaviors gated behind `@media (hover: hover) and (pointer: fine)` | Touch triggers false hover | Convention in component CSS |
| Bottom nav on mobile, sidebar on desktop | Discoverability + thumb | `Shell` auto-swaps at 1024 |
| Safe-area insets respected on every fixed/sticky element | Notch, home indicator | `env(safe-area-inset-*)` baked in |
| Works at 320px width | Old phones, Android landscape | CI snapshot at 320 |
| Page-level pull-to-refresh disabled on overview surfaces | Conflicts with content scroll | `overscroll-behavior: contain` on `body` |

### 3. Feedback & state

| Rule | Why | How |
|---|---|---|
| 0–100ms async → no indicator | Avoid flicker | Built-in |
| 100–1000ms async → spinner inside Button | Visibility | `Button loading` state |
| >1000ms async → skeleton + progress | Perceived speed | `Skeleton` primitive |
| Auto-save with visible state ("Gespeichert · vor 3s") | Trust + recovery | `SaveIndicator` + `useAutoSave` hook |
| Optimistic UI when action is reversible | Speed | Mutation patterns doc'd |
| Success → 3s toast, never full-page banner | Don't interrupt flow | `Toast` primitive |
| Error → explains problem + suggests fix + preserves input | Nielsen #9 | Error boundary template |
| Forbidden: `.catch(() => {})` | Silent failures | ESLint rule |

### 4. Hierarchy & focus

| Rule | Why | How |
|---|---|---|
| ≤1 primary action per surface (max 2) | Hick's law | PR check: count `variant="primary"` |
| ≤4 decision points per step | Working memory | `Stepper` splits at 4 |
| Type ratio ≥1.25 between consecutive sizes | Hierarchy | Token scale enforces |
| Active/selected state unmistakable | "What did I click?" | All primitives ship an active state |
| Progressive disclosure: hide complexity until needed | Reduce cognitive load | Accordion/Tabs/`<details>` |

### 5. Forms & input

| Rule | Why | How |
|---|---|---|
| Inline validation on blur (not on every keystroke) | Less noise | `Input` primitive |
| Errors below the field they relate to | Locality | `Input.error` slot |
| Field labels above input (never placeholder-only) | A11y, memory | `Input` requires `label` |
| Submit disabled until valid + reason on hover | Nielsen #5 | `Form` primitive |
| Smart defaults from profile/context | Casey types one-handed | Documented per flow |
| Autocomplete attributes set correctly (email/tel/name) | Browser/iOS autofill | `Input` variants |
| Forms preserve user input on error | Riley stress test | Banned: `setState('')` in error handler |
| Drafts saved + restorable on return | Don't lose work | `useDraft` hook |

### 6. Accessibility (WCAG AA minimum)

| Rule | Why | How |
|---|---|---|
| Body text ≥4.5:1 contrast, large text ≥3:1 | WCAG 1.4.3 | axe-core in CI |
| Visible focus ring on every interactive element | WCAG 2.4.7 | Token `--focus-ring` |
| Every flow keyboard-completable | WCAG 2.1.1 | Cypress test per primary flow |
| Screen reader announces state changes | Sam persona | `aria-live` regions |
| Icons either labeled OR `aria-hidden` | Don't over-announce | `Icon` requires `label` unless `decorative` |
| Heading hierarchy unbroken (h1→h2→h3) | Screen readers | ESLint rule |
| `prefers-reduced-motion` strips transforms | Vestibular disorders | Motion tokens wrap in @media query |
| Form errors associated with input (`aria-describedby`) | A11y | `Input` primitive |
| Color isn't the only signal (icon + text always) | Color-blind users | Status badges have icon + text |

### 7. Empty & error states

| Rule | Why | How |
|---|---|---|
| Empty states teach, never apologize | "Bisher keine Bewerbungen" + CTA, not "Keine Daten" | `EmptyState` primitive requires `action` prop |
| First-time vs. you-have-nothing states differ | Different intent | `EmptyState firstTime` variant |
| 404/500 pages offer paths forward (home, search, support) | Don't dead-end | Error route templates |
| Offline state surfaces (banner + cached read-only) | Train commute reality | `useOnline` hook + offline banner |
| Session-expired preserves draft + re-auths without loss | Returning users | `useSession` + draft persistence |

### 8. Trust & no-surprises

| Rule | Why | How |
|---|---|---|
| No autoplaying audio/video | Universally hated | Code review |
| No modals without explicit user trigger | Trust killer | `Modal` primitive — no `autoOpen` prop |
| No dark patterns (hidden unsubscribe, fake countdown, manipulated defaults) | Reputation | Manual PR review |
| External links open in same tab unless explicit reason | User control | `Link` primitive — `external` opt-in |
| Pricing shown before commitment | Trust | Flow design convention |
| No surprise nav on tap — same action = same place | Predictability | Component testing |

### 9. Performance

| Metric | Target | How |
|---|---|---|
| LCP (mobile) | ≤2.5s | Lighthouse CI |
| LCP (desktop) | ≤1.5s | Lighthouse CI |
| CLS | ≤0.1 | Lighthouse CI |
| TBT (mobile) | ≤300ms | Lighthouse CI |
| JS bundle per route (gzipped) | ≤200KB | bundle-analyzer |
| Initial CSS (gzipped) | ≤50KB | bundle-analyzer |
| Skeleton, not spinner, on initial load | Perceived speed | Loading components |
| Images lazy-loaded below fold | Slow networks | `Image` primitive |
| Fonts preloaded with `display=swap` | Avoid FOUT | `next/font` |

### 10. Brand voice

| Rule | Why | How |
|---|---|---|
| German first, English second (DACH-priority) | Audience | i18n key convention |
| Du-form everywhere (not Sie) | Audience age + brand | Translation review |
| No em-dashes in user-facing copy | Brand voice | Lint rule on i18n files |
| Plain language, no jargon (no "ATS", "OKR" without context) | Jordan persona | Editorial review |
| Confident, not chirpy ("3 Threads laufen" not "Yay, 3 applications!") | Brand personality | Copy review |
| Energy: optimistic + practical, never juvenile | Brand guidelines | Copy review |
| Numbers always tabular (font-variant-numeric: tabular-nums) | Data-trustable | Primitive default |

---

## Enforcement Mechanisms

### Automated (CI/lint — catches mechanical violations)

```js
// .eslintrc.js — custom rules
'studysmarter/no-hex-color': 'error',           // Hex literals banned outside tokens.ts
'studysmarter/no-px-spacing': 'error',          // Raw px in margin/padding/gap banned
'studysmarter/no-font-family-inline': 'error',  // font-[family-name:...] banned
'studysmarter/no-transition-all': 'error',      // transition: all banned
'studysmarter/no-empty-catch': 'error',         // .catch(() => {}) banned
'studysmarter/icon-needs-label': 'error',       // <Icon /> without label or decorative
'studysmarter/heading-order': 'error',          // h1→h2→h3, no skipping
```

**CI pipeline:**
- ESLint (with custom rules above)
- TypeScript strict
- axe-core (a11y) on every Storybook story
- Chromatic (visual regression) on every PR
- Lighthouse CI mobile + desktop budgets
- Bundle size budget per route
- 320px / 768px / 1280px snapshot tests for every page

### PR template (forces human checks)

```markdown
## Design checklist
- [ ] Uses only design tokens (no hex/px/font-family inline)
- [ ] Tested at 375 / 768 / 1280
- [ ] Touch targets ≥44px verified
- [ ] Keyboard-only walkthrough passes
- [ ] Tested with VoiceOver (Mac) or NVDA (Win) at least once
- [ ] Loading / empty / error states implemented
- [ ] Back/cancel/escape paths exist on every screen
- [ ] If destructive: confirm OR undo
- [ ] `prefers-reduced-motion` tested
- [ ] German copy reviewed (du-form, no em-dash, plain language)
- [ ] No Sparkles icon
- [ ] No `<Highlight>` in product copy
```

### Component primitives (catches everything else by default)

Most rules become unviolatable because the primitive enforces them. Examples:
- Can't ship a 32px button — `<Button>` has `min-h-[44px]` on the mobile primitive
- Can't forget `aria-label` on icon-only button — `<IconButton>` requires `label`
- Can't ship a modal without escape-to-close — `<Modal>` binds the listener
- Can't forget the focus ring — primitives include it by default

### Manual rituals

- **Weekly real-device dogfooding:** 30 min, team rotates. Catches more than tools do.
- **Quarterly design audit week:** Whole team reviews every screen against this doc. Files cleanup tasks.
- **Per-feature design review:** Before PR, Figma or mock walkthrough with 1+ designer.
- **Per-PR code review:** At least one reviewer with this doc open.

---

## Exception process

Sometimes a rule should be broken. The rule isn't "never deviate" — it's "deviate intentionally and visibly."

**To log an exception:**
1. Add a comment in code explaining what rule is broken and why
2. Open a docs PR proposing either an update to this DESIGN.md OR a permanent exception listed in `DESIGN_EXCEPTIONS.md`
3. Get one designer + one engineer sign-off
4. Merge

Exception examples we've already taken:
- **CV Review's dark ink-black hero** breaks the "white surface" default — intentional for "verdict moment" weight.
- **Career Quiz's centered focused-flow** breaks the sidebar shell — intentional for distraction-free Q&A.
- **CV Builder + Career Quiz sticky backdrop-blur top** breaks overview-surface "flat top" — intentional for in-flow chrome.

Document these explicitly. Don't let exceptions multiply silently.

---

## Migration guide

Existing CareerKit3 components don't follow this constitution yet. Migrate incrementally:

1. **Foundation week:** Extract tokens into `tokens.ts`. Configure Tailwind to read them. Build top-6 primitives (Button, Input, Card, Badge, Avatar, Icon). Storybook running.
2. **Per route migration:**
   - Replace inline-styled buttons with `<Button>`
   - Replace bespoke cards with `<Card>` / `<Panel>`
   - Replace ad-hoc grids with `<Grid>` / `<Stack>`
   - Add loading / empty / error states
   - Verify against PR checklist
   - Feature-flag behind `useRedesign` to ship gradually
3. **Delete old components** once all routes migrated.

Order: Dashboard → Tracker → CV Builder → CV Review → Career Quiz → Auth flows → Settings.

Don't do a big-bang rewrite branch. Ship primitives one at a time, migrate routes one at a time.

---

## Persona red-flag checklist

Before shipping any new surface, walk through it as:

- **Casey (mobile, one-handed, distracted):** Can I do the primary thing in the thumb zone? Does state persist if I switch apps mid-flow?
- **Sam (screen reader, keyboard-only):** Can I tab through everything? Does each action announce its outcome?
- **Jordan (first-time, low confidence):** Is the next action obvious within 5 seconds? Are icons labeled?
- **Alex (power user):** Are there keyboard shortcuts? Can I bulk-action? Are there modals I can't skip?
- **Riley (stress tester):** What happens with 0 items? 1000 items? An emoji in a field? A 60-character company name?

If any persona fails, file a bug before shipping.

---

## Anti-patterns we've already burned on

Don't reintroduce these without strong reason:

- ❌ `Sparkles` icon as decoration ("AI lives here" = 2023 SaaS landing page)
- ❌ `<Highlight>` in product copy (reserve for brand surfaces)
- ❌ 6+ feature pills in a row (SEO keyword stuffing aesthetic)
- ❌ Three different card "schemes" in one row (use one scheme, vary icon color)
- ❌ Big-number hero stats with no narrative ("14 im Tracker" alone is not a hero)
- ❌ Inline `font-[family-name:var(--font-heading)]` (set globally on h1-h6)
- ❌ Funnel bars with minimum-width-when-nonzero (lies about the data)
- ❌ `transition-all duration-700` (too slow, animates the wrong properties)
- ❌ Silent `.catch(() => {})` (production bug discovery dies)
- ❌ Modal as first thought (exhaust inline / progressive alternatives first)

---

## Changelog

- **2026-05-30 v1.0** — Initial constitution. Adopted Geist + Hot-Rod color across mocks. Established primitive contract.
