# Mangalord design system

One screen. This is the contract the implementation reads — every value in code comes from a token defined here.

## Tokens

All tokens live on `:root` in [frontend/src/lib/styles/tokens.css](../frontend/src/lib/styles/tokens.css). Dark values override under `:root[data-theme="dark"]`. Components consume tokens only — no raw hex, no raw px in scoped styles.

### Color

Semantic, not literal. Tested with [WebAIM contrast checker](https://webaim.org/resources/contrastchecker/).

| Token | Light | Dark | Use |
|---|---|---|---|
| `--bg` | `#ffffff` | `#0f1115` | page background |
| `--surface` | `#f6f7f9` | `#161a21` | cards, inputs, drop zones |
| `--surface-elevated` | `#ffffff` | `#1c2129` | hovered cards, focused inputs |
| `--border` | `#e3e5ea` | `#2a2f37` | hairlines |
| `--border-strong` | `#c8ccd4` | `#3a414d` | input borders |
| `--text` | `#16181d` | `#e8eaed` | body |
| `--text-muted` | `#5b6168` | `#9aa0a6` | meta, hints |
| `--primary` | `#2563eb` | `#60a5fa` | links, primary button, focus |
| `--primary-hover` | `#1d4ed8` | `#3b82f6` | primary hover |
| `--primary-contrast` | `#ffffff` | `#0f1115` | text on primary fill |
| `--danger` | `#b00020` | `#f87171` | errors, destructive |
| `--danger-soft-bg` | `#fff5f5` | `#3a1620` | invalid page row |
| `--success` | `#0a7d2c` | `#4ade80` | success text |
| `--warning-soft-bg` | `#fff5d6` | `#3a2e10` | active bookmark fill |
| `--warning-border` | `#d6a800` | `#a37800` | active bookmark border |
| `--focus-ring` | `#2563eb` | `#60a5fa` | `:focus-visible` outline |
| `--focus-ring-soft` | `rgb(37 99 235 / 0.25)` | `rgb(96 165 250 / 0.3)` | input focus halo (`box-shadow`) |
| `--primary-soft-bg` | `rgb(37 99 235 / 0.08)` | `rgb(96 165 250 / 0.12)` | drop-zone drag-over, selected theme radio |

Verified ratios (light theme, against `--bg`): `--text` 16.5:1 (AAA), `--text-muted` 6.4:1 (AA), `--primary` 5.2:1 (AA), `--danger` 7.6:1 (AAA). Dark theme: `--text` 13.8:1, `--text-muted` 4.7:1, `--primary` 5.3:1, `--danger` 5.1:1. All interactive elements clear 4.5:1; large UI clears 3:1.

### Typography

System stack, zero FOUT cost: `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`. Monospace stack (for code-like elements, currently unused but reserved): `ui-monospace, "SF Mono", Menlo, monospace`.

| Token | Value | Where |
|---|---|---|
| `--font-xs` | `0.75rem` / `1.4` | micro-meta (file sizes, "Bookmarked {date}") |
| `--font-sm` | `0.875rem` / `1.5` | card meta, hints, inline errors |
| `--font-base` | `1rem` / `1.55` | body, inputs, buttons |
| `--font-lg` | `1.125rem` / `1.45` | card titles, h3 |
| `--font-xl` | `1.5rem` / `1.3` | h2 |
| `--font-2xl` | `2rem` / `1.2` | h1 page titles |

Weights: `--weight-regular: 400`, `--weight-medium: 500`, `--weight-semibold: 600`. Headings use semibold. Body and inputs use regular. Card titles use semibold at `--font-sm` or `--font-lg`.

### Spacing

One ladder (4 px base):

`--space-1 0.25rem`, `--space-2 0.5rem`, `--space-3 0.75rem`, `--space-4 1rem`, `--space-5 1.25rem`, `--space-6 1.5rem`, `--space-8 2rem`.

### Radii, shadows, transitions

`--radius-sm 4px`, `--radius-md 6px`, `--radius-lg 10px`, `--radius-pill 999px`.

`--shadow-sm` (light: `0 1px 2px rgb(0 0 0 / 0.06)`; dark: `0 1px 2px rgb(0 0 0 / 0.4)`), `--shadow-md` (light: `0 2px 8px rgb(0 0 0 / 0.08)`; dark: `0 2px 8px rgb(0 0 0 / 0.5)`).

`--transition: 120ms ease-out`. Disabled under `@media (prefers-reduced-motion: reduce)`.

### Z-index

`--z-dropdown 10`, `--z-sticky 50`, `--z-modal 100`, `--z-toast 1000`. No raw `z-index` numbers in component CSS.

## Components

All shapes are token-driven. Implementation lives in route-scoped `<style>` blocks consuming tokens; we do not extract a component library on this branch.

**Button** — 36 px height, `--radius-md`, `--font-base`, weight 500, `--space-3` horizontal padding, `--transition` on bg/border/color. Variants:
- **Primary**: `background: var(--primary)`, `color: var(--primary-contrast)`, hover → `--primary-hover`.
- **Secondary**: `background: transparent`, `color: var(--text)`, `border: 1px solid var(--border-strong)`, hover → `background: var(--surface)`.
- **Icon-only (muted)**: 36 px square, `background: transparent`, `color: var(--text-muted)`, hover → `background: var(--surface-elevated)` and `color: var(--text)`. For low-emphasis controls (Logout, page row move/remove). Always carries `aria-label`.
- **Icon-only (primary)**: 36 px square, fills the **Primary** variant (`background: var(--primary)`, `color: var(--primary-contrast)`, hover → `--primary-hover`). For CTAs that happen to be icon-shaped — the home Search button. Always carries `aria-label`.
- **Icon-only (compact)**: 32 px square, `--radius-sm`, 16 px SVG. Inline row controls only (upload page-list move/remove). Otherwise tracks the muted variant's color treatment — transparent fill, `--text-muted` foreground, `--surface-elevated` hover; the Remove button picks up a `--danger` hover tint.
- **Destructive**: `background: var(--danger)`, `color: #fff`. Reserved — no submit button uses this variant today; the upload page's Remove uses the compact icon-only variant with a `--danger` hover tint.

Icon-only SVG sizing: standard 18 px (`--icon-md`); reader nav 22 px (`--icon-lg`); compact row controls 16 px.

All variants: `:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px }`. Disabled: `opacity: 0.5; cursor: not-allowed`. No dedicated `:active` (pressed) rule today — the hover state carries through; revisit if a control needs a stronger pressed affordance.

**Input / textarea / select** — 36 px height (textarea grows), `padding: 0 var(--space-3)`, `border: 1px solid var(--border-strong)`, `background: var(--surface)`, `color: var(--text)`, `--radius-md`. Focus: `border-color: var(--primary)`, `background: var(--surface-elevated)`, `outline: none` (the colored border *is* the focus signal, plus a `box-shadow: 0 0 0 3px var(--focus-ring-soft)` halo). `aria-invalid="true"` → border `--danger`. Disabled: `opacity: 0.5`. File inputs visually hidden — use the drop-zone pattern.

**Card** — `background: var(--surface)`, `border: 1px solid var(--border)`, `--radius-md`, `padding: var(--space-4)`. Manga card and bookmark card are non-bordered (transparent), only the cover thumbnail gets surface treatment.

**Alert / inline status** — plain text in `--danger` or `--success`. The form-error / field-error / success blocks are inline, not banners.

**Link** — `color: var(--primary)`, underline on hover only. Visited unchanged.

**Manga card** (home grid, bookmarks): cover 2:3 with `--surface` placeholder + lucide `BookImage` icon when no cover; title `--font-sm`/semibold/`-webkit-line-clamp:2`; author `--font-xs`/`--text-muted`/ellipsis.

**Reader nav**: icon-only buttons (`ChevronLeft`/`ChevronRight`), 44 px square (tap-target sized — these are primary controls), `--radius-md`, surface fill, 1 px `--border-strong` border. Hover: `--surface-elevated` background and `--primary` border (it's the main affordance per page). Disabled fades to 0.4. Back-link uses `ArrowLeft` icon plus cover thumbnail and manga title.

**Theme picker** (settings only): native `<fieldset>` with `<legend>Theme</legend>` and three `<label>`s wrapping `<input type="radio" name="theme">` plus a lucide icon (`Monitor` / `Sun` / `Moon`) and text. Idle: `background: var(--surface)`. Hover: `background: var(--surface-elevated)`. Selected: `background: var(--primary-soft-bg)`, `border-color: var(--primary)`. Keyboard focus reaches the visually-hidden radio via `:has(input:focus-visible)`. Persists via `theme.set()`.

## Icons

**[@lucide/svelte](https://www.npmjs.com/package/@lucide/svelte)** (MIT). The legacy `lucide-svelte` package is deprecated; `@lucide/svelte` is the supported successor. Reason: native Svelte 5 components, tree-shaken per-icon import, ~600 icons covering everything we need. Phosphor is a close second; Heroicons trails on Svelte ergonomics. Lucide wins.

Sizes: `--icon-sm 14px`, `--icon-md 18px`, `--icon-lg 22px`. Default stroke width 2.

| Where | Icon | Treatment |
|---|---|---|
| Header search | `Search` | icon-only (primary) button, `aria-label="Search"` |
| Header logout | `LogOut` | icon-only (muted) button, `aria-label="Logout"` |
| Header upload link | `Upload` | inline icon + text "Upload" |
| Header bookmarks link | `Bookmark` | inline icon + text "Bookmarks" |
| Header settings link | `Settings` | inline icon + text "Settings" |
| Header login/register | (none) | text only — these are anonymous-only links |
| Reader prev / next | `ChevronLeft` / `ChevronRight` | icon-only, aria-labels preserved |
| Reader back link | `ArrowLeft` | inline icon + cover + title |
| Upload page row: move up / down | `ArrowUp` / `ArrowDown` | icon-only, aria-labels |
| Upload page row: remove | `Trash2` | icon-only, `aria-label="Remove page"`, danger hover |
| Drop-zone hint | `UploadCloud` | decorative icon above the "Drop pages here, or browse" text |
| Cover placeholder | `BookImage` | shown in manga card and bookmark card when no cover |
| Bookmark toggle | unicode `☆` / `★` | kept as-is — tests assert literal text, and the unicode is icon-ish enough |
| Theme picker | `Monitor` / `Sun` / `Moon` | inline next to each radio label |

**Where text stays unchanged**: page headings, form labels, form submit buttons ("Create manga", "Upload chapter", "Update password" — unfamiliar actions, icon-only would harm clarity), inline cross-page links ("No account? Register"), the "Mangalord" wordmark.

## Theme switching

**State machine**: `Theme = 'system' | 'light' | 'dark'`. Stored value lives in `localStorage` under key `mangalord-theme` (absent → `system`). The *resolved* attribute on `<html>` is always `light` or `dark`; `system` is resolved at runtime via `matchMedia('(prefers-color-scheme: dark)')`.

**At first paint** — inline script in [frontend/src/app.html](../frontend/src/app.html) `<head>` runs synchronously before SvelteKit hydrates:

```js
(function () {
  try {
    var stored = localStorage.getItem('mangalord-theme');
    var pref = stored === 'light' || stored === 'dark' ? stored : (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', pref);
  } catch (_) {
    document.documentElement.setAttribute('data-theme', 'light');
  }
})();
```

**At runtime** — [frontend/src/lib/theme.svelte.ts](../frontend/src/lib/theme.svelte.ts) exposes a Svelte 5 runes store with `value` (the stored preference) and `resolved` (effective light/dark), `set(value)`, and a `matchMedia` listener that re-resolves when the user changes their OS theme while in `system` mode.

**Toggle** — three radios in `/settings`, persisted on change.

## Accessibility

- `:focus-visible` ring on every interactive element. Never `outline: none` without a replacement.
- `@media (prefers-reduced-motion: reduce)` zeroes out `--transition` and disables non-essential animations.
- Every icon-only button has `aria-label`. Every decorative SVG has `aria-hidden="true"`.
- Existing image `alt` text (cover = manga title) is preserved on every restyle.
- Theme picker uses a real `<fieldset>` + `<legend>` and three radios; `aria-labelledby` not needed because `<legend>` is the accessible name.
- Bookmark toggle keeps `aria-pressed`.
- Color is never the sole conveyor of state (success/error text comes with a glyph or descriptive copy where present; bookmark active state has both color and visible text).

## Reserved tokens (declared, not yet consumed)

`tokens.css` declares a handful of forward-looking tokens that no component uses today. They exist so that the first consumer doesn't have to bikeshed values: `--weight-regular`, `--space-5`, `--space-8`, `--radius-lg`, `--radius-pill`, `--shadow-sm`, `--shadow-md`, `--icon-sm` / `--icon-md` / `--icon-lg` (icon sizing is currently passed via lucide's `size` prop literally), and all four `--z-*` levels. A consumer that adopts any of these should also update this section.

## Out of scope

No web font, no CSS framework, no toast/modal/dropdown patterns, no component library extraction, no notification system. Single-file token sheet + scoped styles consuming tokens.
