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>
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)andcolor: var(--text). For low-emphasis controls (Logout, page row move/remove). Always carriesaria-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 carriesaria-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-mutedforeground,--surface-elevatedhover; the Remove button picks up a--dangerhover 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--dangerhover 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 (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 runtime — 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-visiblering on every interactive element. Neveroutline: nonewithout a replacement.@media (prefers-reduced-motion: reduce)zeroes out--transitionand disables non-essential animations.- Every icon-only button has
aria-label. Every decorative SVG hasaria-hidden="true". - Existing image
alttext (cover = manga title) is preserved on every restyle. - Theme picker uses a real
<fieldset>+<legend>and three radios;aria-labelledbynot 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.