Files
Mangalord/docs/design-system.md
MechaCat02 567d56bfa1 feat: design system with light/dark themes and icon-first UI
Adds a real design system to replace the per-route ad-hoc styling:

- docs/design-system.md is the contract. Semantic CSS custom-property
  tokens (color/type/spacing/radii/shadows/z-index) with verified WCAG
  AA/AAA contrast ratios for both themes.
- frontend/src/lib/styles/tokens.css defines :root tokens + a
  [data-theme="dark"] override + base element resets, a .form-field
  helper, and a global prefers-reduced-motion rule.
- frontend/src/lib/theme.svelte.ts is a Svelte 5 runes store backing
  the theme state machine (system | light | dark). localStorage key
  'mangalord-theme'; matchMedia subscription that re-resolves on OS
  theme change while in 'system' mode; init() / destroy() lifecycle
  wired from +layout.svelte.
- frontend/src/app.html runs a synchronous inline script before
  %sveltekit.head% to set [data-theme] before first paint. No FOUC.
- /settings gains a System / Light / Dark radiogroup (real fieldset +
  legend + radios with lucide icons).
- Every route's <style> block is rewritten to consume tokens — home,
  auth, upload (drop-zone + page list), bookmarks, manga overview,
  reader.
- @lucide/svelte icons replace ad-hoc text controls per the spec:
  Search (icon-only primary), LogOut (icon-only muted), Upload /
  Bookmarks / Settings nav inline icons, ChevronLeft/Right for the
  reader, ArrowUp/Down/Trash2 for the upload page list. The bookmark
  toggle keeps its '☆ Bookmark' / '★ Bookmarked' text verbatim.
- Home search controls split into two rows: input + Search CTA on
  row 1, Sort (and future filters) on row 2.

Accessibility: every icon-only button carries aria-label, every
decorative SVG aria-hidden; existing image alt text preserved;
focus-visible rings reach every interactive element including the
visually-hidden theme radios; color is never the sole conveyor.

Version bump 0.12.0 → 0.13.0 across backend/Cargo.toml and
frontend/package.json (feat: → minor per CLAUDE.md).

Bars: svelte-check 0/0, vitest 51/51, playwright 18/18, cargo test
88/88, clippy -D warnings clean. Two rounds of independent review;
verdict ship-ready.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:52:58 +02:00

12 KiB

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

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.

Cardbackground: 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.

Linkcolor: 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 (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 <head> runs synchronously before SvelteKit hydrates:

(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 runtimefrontend/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.