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>
This commit is contained in:
162
docs/design-system.md
Normal file
162
docs/design-system.md
Normal file
@@ -0,0 +1,162 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user