ux-ui-agent-skills 2.0.0 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/apply-aesthetic/SKILL.md +1 -1
- package/.claude/skills/design-code/SKILL.md +3 -0
- package/.claude/skills/design-qa/SKILL.md +1 -1
- package/.claude/skills/ux-writing/SKILL.md +1 -0
- package/CLAUDE.md +6 -0
- package/README.md +7 -2
- package/components/organisms.md +6 -0
- package/examples/apple-demo/index.html +86 -0
- package/examples/apple-home/index.html +221 -0
- package/examples/golden/theme.css +4 -0
- package/examples/sample-app/BRIEF.md +14 -0
- package/examples/sample-app/Dashboard.tsx +32 -0
- package/examples/sample-app/Settings.tsx +50 -0
- package/examples/sample-app/StatCard.tsx +16 -0
- package/examples/sample-app/preview.html +158 -0
- package/examples/sample-app/styles.css +67 -0
- package/package.json +7 -2
- package/scripts/accuracy_report.mjs +54 -0
- package/scripts/check_no_emoji.py +67 -0
- package/scripts/lint_hardcodes.py +18 -5
- package/scripts/measure_render.mjs +95 -0
- package/taste/design-taste.md +42 -13
- package/taste/motion-choreography.md +6 -6
- package/workflows/design-qa.md +1 -0
|
@@ -8,7 +8,7 @@ description: Apply a visual direction — an archetype (high-end agency, editori
|
|
|
8
8
|
Choose and apply a design direction without breaking accessibility.
|
|
9
9
|
|
|
10
10
|
## Steps
|
|
11
|
-
1.
|
|
11
|
+
1. **Brief Inference first (mandatory)** — before any tokens, name it: industry/domain, audience & tone, the one mood adjective the result must earn, motion depth, and the layout-family sequence (`taste/design-taste.md` → Brief Inference + Variance Mandate). Generating before deciding = slop.
|
|
12
12
|
2. Pick a direction in `taste/aesthetic-systems.md`:
|
|
13
13
|
- An **archetype** (recipe mapped to our tokens), or
|
|
14
14
|
- A **named library system** — browse with `python3 scripts/design_systems.py list` (or `search <term>` / `show <name>`); specs live in `design-systems/library/<name>/DESIGN.md`.
|
|
@@ -30,3 +30,6 @@ Code is the highest-stakes output — self-check every time:
|
|
|
30
30
|
6. **Single-theme consistency** — consume the project's ONE shared token theme (the root CSS-var layer); never define a per-page palette or new colors. Across multiple pages/screens, the same semantic tokens must drive every surface so the whole product stays visually identical and themeable from one place (CLAUDE.md → Single-Theme Consistency).
|
|
31
31
|
7. **One shared primitive layer** — build ONE reusable component per atom (`Button`, `Input`, `Modal`, `Badge` via `cva`/equivalent); never repeat utility-class clusters inline across files or hand-roll a div-as-modal per screen. Overlays reuse the single `Modal` primitive: focus trap, `role="dialog"`, `aria-modal="true"`, `aria-labelledby`, Escape, **return focus on close**, backdrop (WCAG 2.4.3 + 2.1.2). See `examples/golden/Button.tsx` + `examples/golden/Modal.tsx`.
|
|
32
32
|
8. **Font loading** — NEVER `@import` web fonts in CSS (render-blocking). Use a framework loader (`next/font`) or `<link rel="preconnect">` + `<link rel="preload">` with `font-display: swap`; self-host when possible. Font family comes from a token (`--font-sans`), never a literal.
|
|
33
|
+
9. **Semantic token BY INTENT + consistency** — the token's *meaning* must match the action: destructive (Delete/Remove) → `action.destructive` (danger), **never** `action.primary`; secondary = neutral outline/transparent with dark text (**never a colored fill** → no dark-text-on-blue). The SAME action uses the SAME variant **everywhere** (a trigger button and its confirm button must match — not red in one place and blue in another). Gates don't catch this — you must.
|
|
34
|
+
10. **No emoji as icons** — use a real icon set (default **lucide**) as inline SVG with `currentColor`; never an emoji, including in JS that swaps a label (swap the `<svg>`, not a text/emoji string). Mentally run `scripts/lint_taste.py` (it flags emoji-as-icon).
|
|
35
|
+
11. **Destructive confirmation UX** — irreversible actions (delete account/data, anything stated "cannot be undone") need real friction per WCAG 3.3.4 / 3.3.6: a confirmation dialog whose **confirm button restates the action** ("Delete account", not "Delete"/"OK"/"Yes") and, for high-stakes, a **type-to-confirm** step. See `examples/sample-app/preview.html` and `content/voice-tone.md`.
|
|
@@ -10,7 +10,7 @@ Stand up the automated + manual gates that stop quality from regressing.
|
|
|
10
10
|
## Steps
|
|
11
11
|
1. Read `workflows/design-qa.md` (the QA pyramid: token/lint gates → automated a11y → visual regression → manual a11y).
|
|
12
12
|
2. Wire the **fast gates** first (every commit/PR): `python3 scripts/validate_tokens.py`, `python3 scripts/validate_contrast.py` (batch WCAG over the token pairs), and `python3 scripts/lint_hardcodes.py <src>` (no raw hex/px/timing in component code). The repo's `.github/workflows/ci.yml` runs these.
|
|
13
|
-
3. Add **automated a11y** (axe-core / Pa11y) over each component's states (error/loading/disabled/expanded/selected), zero serious/critical to merge.
|
|
13
|
+
3. Add **automated a11y** (axe-core / Pa11y) over each component's states (error/loading/disabled/expanded/selected), zero serious/critical to merge. Run the **real-render contrast gate** `node scripts/measure_render.mjs <file.html>` (and `--dark`) — it opens the page in headless Chromium, disables transitions, and measures the true computed-style + alpha-composited contrast of every text element (catches what static token checks miss).
|
|
14
14
|
4. Add **visual regression** snapshots across variants × sizes × states × light/dark + key breakpoints + RTL; freeze animations + deterministic data.
|
|
15
15
|
5. Sign off the **manual a11y** checklist per release (keyboard, screen reader, 400% reflow, 200% text-spacing, reduced-motion, forced-colors — `accessibility/*`).
|
|
16
16
|
|
|
@@ -12,6 +12,7 @@ Produce or critique interface copy in the project's voice.
|
|
|
12
12
|
2. Match tone to the user's emotional state (onboarding/success/routine/error/destructive). Higher stress → plainer language.
|
|
13
13
|
3. Apply the formulas:
|
|
14
14
|
- Buttons: frontload the verb, name the outcome.
|
|
15
|
+
- **Confirmation dialogs:** the confirm button **restates the action and object** — "Delete account", not "Delete"/"OK"/"Yes"/"Confirm". The title asks ("Delete account?"), the button answers in matching words. Cancel stays "Cancel". For irreversible/high-stakes actions, require a **type-to-confirm** step (WCAG 3.3.4/3.3.6).
|
|
15
16
|
- Errors: what happened → why → how to fix (no dead ends, no codes/stack traces).
|
|
16
17
|
- Empty states: value → first action.
|
|
17
18
|
4. Enforce mechanics: sentence case, no ALL CAPS, numerals, no blame on the user, labels (not placeholders), no directional/color-only instructions.
|
package/CLAUDE.md
CHANGED
|
@@ -150,6 +150,11 @@ Examples: `semantic.text.primary`, `component.button.primary-bg-hover`, `semanti
|
|
|
150
150
|
3. **Interactive colors** — all clickable elements use `action.primary` or `text.link`
|
|
151
151
|
4. **Limit palette** — 1 primary, 1 destructive, neutrals. Use accent colors sparingly.
|
|
152
152
|
5. **Colored shadows** — only on hover states for emphasis (see `tokens/shadows.json` → `colored`)
|
|
153
|
+
6. **Token BY INTENT (non-negotiable)** — pick the token whose *meaning* matches the action, not just any token that resolves:
|
|
154
|
+
- **Destructive** actions (Delete, Remove, Revoke) → `action.destructive` / `component.button.destructive-bg` — **NEVER** `action.primary`. The same destructive action uses the **same** danger variant **everywhere** (trigger button AND its confirm-modal button — never red in one place and blue in another).
|
|
155
|
+
- **Primary** = the one main affirmative action; **secondary** = neutral (transparent/outline, dark text — **never a colored fill**, so no dark-text-on-blue); **danger** = destructive.
|
|
156
|
+
- Consistency rule: one action role → one variant across all pages. A blue "Delete" is a bug.
|
|
157
|
+
7. **No emoji as UI icons** — emoji are inconsistent across platforms and read as machine-generated slop. Use a real icon set (default: **lucide**) as inline SVG with `currentColor` via the Icon component (`components/icon-system.md`). This includes JS that swaps button labels — swap the `<svg>`/icon, never inject an emoji string.
|
|
153
158
|
|
|
154
159
|
### Color Generation (when creating new palettes)
|
|
155
160
|
Use **OKLCH color space** for perceptually uniform shade scales:
|
|
@@ -515,6 +520,7 @@ frameworks/
|
|
|
515
520
|
scripts/ ← validate_tokens.py · contrast.py · validate_contrast.py (batch WCAG, light+dark)
|
|
516
521
|
· validate_component_spec.py · lint_hardcodes.py (hex/px/ms + Tailwind palette + font)
|
|
517
522
|
· validate_theme_refs.py (every var(--…) resolves to the theme) · lint_taste.py
|
|
523
|
+
· measure_render.mjs (REAL headless-render WCAG gate — true computed contrast, light+dark)
|
|
518
524
|
· design_systems.py · scaffold_component.py
|
|
519
525
|
.github/workflows/ ← ci.yml (quality gates: tokens + contrast + spec + npm test on push/PR)
|
|
520
526
|
· release.yml (auto GitHub Release + npm publish on tag)
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A comprehensive kit of structured instructions, design tokens, runnable skills,
|
|
|
8
8
|
|
|
9
9
|
<br>
|
|
10
10
|
|
|
11
|
-
[](https://github.com/plugin87/ux-ui-agent-skills/releases)
|
|
12
12
|
[](#-license)
|
|
13
13
|
[](#-accessibility-standards)
|
|
14
14
|
|
|
@@ -32,7 +32,7 @@ A comprehensive kit of structured instructions, design tokens, runnable skills,
|
|
|
32
32
|
|
|
33
33
|
## 📌 Version
|
|
34
34
|
|
|
35
|
-
**Current release: `v2.
|
|
35
|
+
**Current release: `v2.1.0`** · See the [Changelog](#-changelog) · [All releases](https://github.com/plugin87/ux-ui-agent-skills/releases)
|
|
36
36
|
|
|
37
37
|
> No build tools, dependencies, or runtime required — this is a pure instruction & knowledge layer for AI agents.
|
|
38
38
|
|
|
@@ -322,6 +322,11 @@ This is a **starter kit** — make it yours:
|
|
|
322
322
|
|
|
323
323
|
## 📝 Changelog
|
|
324
324
|
|
|
325
|
+
### `v2.1.0`
|
|
326
|
+
- ✅ **Accuracy report** — `npm run verify` (`scripts/accuracy_report.mjs`) runs every objective correctness gate as one all-or-nothing, reproducible check: token validity + alias resolution, WCAG contrast (token pairs), component-spec completeness, no hardcoded values (golden + sample), theme-ref resolution, no-emoji, and **real headless-Chrome WCAG measurement** (sample-app, light + dark). Prints `N/N = 100%` or the exact failures.
|
|
327
|
+
- 🧱 **Block-level lint exemption** — `scripts/lint_hardcodes.py` supports `ds-allow-hardcode:start` / `:end` for justified illustration blocks (e.g. CSS product art), keeping the rest of the file strictly token-only.
|
|
328
|
+
|
|
329
|
+
|
|
325
330
|
### `v2.0.0` — Enforcement layer (breaking)
|
|
326
331
|
|
|
327
332
|
> **Breaking:** dark-mode token values changed (link, primary action) and `border.strong` now meets 3:1 — re-verify any snapshots/visual tests. The kit moves from *advisory* guidance to *enforced* gates.
|
package/components/organisms.md
CHANGED
|
@@ -273,6 +273,12 @@ A focused overlay that interrupts workflow for critical information or action.
|
|
|
273
273
|
- Set `inert` attribute on content behind modal (or `aria-hidden="true"`)
|
|
274
274
|
- See `aria-patterns.md` → Dialog
|
|
275
275
|
|
|
276
|
+
**Confirmation variant (destructive):**
|
|
277
|
+
- The confirm button uses the **danger/destructive** token (never primary) and the SAME variant as the trigger that opened it.
|
|
278
|
+
- The confirm label **restates the action** ("Delete account"), matching the title — not "Delete"/"OK"/"Yes" (`content/voice-tone.md`).
|
|
279
|
+
- **Irreversible** actions (cannot be undone) require friction (WCAG 3.3.4/3.3.6): a **type-to-confirm** field (e.g. type "DELETE") that enables the confirm button; focus the field on open. Reference: `examples/sample-app/preview.html`.
|
|
280
|
+
- Order: Cancel (secondary) then the destructive action; default focus goes to the safe path / the confirm field, never auto-focuses the destructive button.
|
|
281
|
+
|
|
276
282
|
---
|
|
277
283
|
|
|
278
284
|
## 6. Drawer
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Apple-brand demo — built from design-systems/library/apple/DESIGN.md</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* === tokens straight from apple/DESIGN.md === */
|
|
9
|
+
:root{
|
|
10
|
+
--black:#000000;--pale:#f5f5f7;--white:#ffffff;--ink:#1d1d1f;
|
|
11
|
+
--graphite:#272729;
|
|
12
|
+
--action:#0071e3;--action-hover:#0077ed;--link:#0066cc;--link-on-dark:#2997ff;
|
|
13
|
+
--text-secondary:#6e6e73;--border-soft:#d2d2d7;--border-mid:#86868b;
|
|
14
|
+
--font-display:"SF Pro Display","SF Pro Icons","Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
15
|
+
--font-text:"SF Pro Text","SF Pro Icons","Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
16
|
+
--radius-pill:980px;--radius-card:18px;
|
|
17
|
+
--space-2:8px;--space-3:12px;--space-4:16px;--space-6:24px;--space-8:32px;--space-12:48px;--space-20:80px;
|
|
18
|
+
}
|
|
19
|
+
*{box-sizing:border-box;margin:0}
|
|
20
|
+
body{font-family:var(--font-text);color:var(--ink);background:var(--white);-webkit-font-smoothing:antialiased}
|
|
21
|
+
.wrap{max-inline-size:980px;margin-inline:auto;padding-inline:var(--space-6)}
|
|
22
|
+
/* pill button — Apple signature capsule */
|
|
23
|
+
.btn{display:inline-flex;align-items:center;gap:var(--space-2);font:600 17px/1 var(--font-text);
|
|
24
|
+
padding:12px 22px;border:0;border-radius:var(--radius-pill);cursor:pointer;
|
|
25
|
+
background:var(--action);color:var(--white);text-decoration:none}
|
|
26
|
+
.btn:hover{background:var(--action-hover)}
|
|
27
|
+
.btn:focus-visible{outline:3px solid var(--action);outline-offset:3px}
|
|
28
|
+
.btn--ghost{background:transparent;color:var(--action);box-shadow:inset 0 0 0 1px var(--border-mid)}
|
|
29
|
+
.btn--ghost:hover{background:var(--pale)}
|
|
30
|
+
.link{color:var(--link);text-decoration:none;font:600 17px/1.4 var(--font-text)}
|
|
31
|
+
.link:hover{text-decoration:underline}
|
|
32
|
+
.link--on-dark{color:var(--link-on-dark)}
|
|
33
|
+
/* nav */
|
|
34
|
+
.nav{background:rgba(0,0,0,.8);backdrop-filter:saturate(180%) blur(20px);color:var(--white);position:sticky;top:0;z-index:9}
|
|
35
|
+
.nav .wrap{display:flex;align-items:center;justify-content:space-between;height:44px;font:400 14px/1 var(--font-text)}
|
|
36
|
+
.nav a{color:var(--white);text-decoration:none;opacity:.85}.nav a:hover{opacity:1}
|
|
37
|
+
/* hero — black cinematic chapter */
|
|
38
|
+
.hero{background:var(--black);color:var(--white);text-align:center;padding-block:var(--space-20)}
|
|
39
|
+
.hero h1{font:600 56px/1.07 var(--font-display);letter-spacing:-.28px}
|
|
40
|
+
.hero p{font:400 24px/1.2 var(--font-display);margin-top:var(--space-3);color:#e8e8ed}
|
|
41
|
+
.hero .actions{display:flex;gap:var(--space-6);justify-content:center;margin-top:var(--space-8)}
|
|
42
|
+
/* pale feature band */
|
|
43
|
+
.band{background:var(--pale);color:var(--ink);text-align:center;padding-block:var(--space-20)}
|
|
44
|
+
.band h2{font:600 48px/1.08 var(--font-display);letter-spacing:-.144px}
|
|
45
|
+
.band p{font:400 21px/1.4 var(--font-text);color:var(--text-secondary);margin-top:var(--space-4);max-inline-size:600px;margin-inline:auto}
|
|
46
|
+
/* product grid (white retail surface) */
|
|
47
|
+
.grid{display:grid;gap:var(--space-6);grid-template-columns:1fr;padding-block:var(--space-12)}
|
|
48
|
+
@media(min-width:734px){.grid{grid-template-columns:repeat(3,1fr)}}
|
|
49
|
+
.card{background:var(--white);border:1px solid var(--border-soft);border-radius:var(--radius-card);padding:var(--space-8);text-align:center}
|
|
50
|
+
.card h3{font:600 28px/1.14 var(--font-display);letter-spacing:.196px}
|
|
51
|
+
.card .price{font:400 17px/1.47 var(--font-text);color:var(--text-secondary);margin-block:var(--space-2) var(--space-6)}
|
|
52
|
+
.foot{background:var(--pale);color:var(--text-secondary);font:400 12px/1.4 var(--font-text);padding-block:var(--space-8);border-top:1px solid var(--border-soft)}
|
|
53
|
+
</style>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<nav class="nav"><div class="wrap"><span>Apple</span><span style="display:flex;gap:var(--space-6)"><a href="#">Store</a><a href="#">Mac</a><a href="#">iPhone</a><a href="#">Support</a></span></div></nav>
|
|
57
|
+
|
|
58
|
+
<header class="hero">
|
|
59
|
+
<div class="wrap">
|
|
60
|
+
<h1>iPhone 17 Pro</h1>
|
|
61
|
+
<p>The most advanced iPhone, engineered to disappear.</p>
|
|
62
|
+
<div class="actions">
|
|
63
|
+
<a class="link link--on-dark" href="#">Learn more</a>
|
|
64
|
+
<a class="btn" href="#">Buy</a>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
</header>
|
|
68
|
+
|
|
69
|
+
<section class="band">
|
|
70
|
+
<div class="wrap">
|
|
71
|
+
<h2>Built to last. Designed to vanish.</h2>
|
|
72
|
+
<p>Aerospace-grade titanium. A precision system where the hardware becomes the narrative and the interface gets out of the way.</p>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
75
|
+
|
|
76
|
+
<section class="wrap">
|
|
77
|
+
<div class="grid">
|
|
78
|
+
<div class="card"><h3>iPhone 17</h3><div class="price">From $799</div><a class="btn" href="#">Buy</a></div>
|
|
79
|
+
<div class="card"><h3>iPhone 17 Pro</h3><div class="price">From $1,099</div><a class="btn" href="#">Buy</a></div>
|
|
80
|
+
<div class="card"><h3>iPhone Air</h3><div class="price">From $999</div><a class="btn btn--ghost" href="#">Learn more</a></div>
|
|
81
|
+
</div>
|
|
82
|
+
</section>
|
|
83
|
+
|
|
84
|
+
<footer class="foot"><div class="wrap">Copyright 2026 Apple-brand demo. Built from design-systems/library/apple/DESIGN.md. Not affiliated with Apple Inc.</div></footer>
|
|
85
|
+
</body>
|
|
86
|
+
</html>
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Apple — homepage (brand demo, built from apple/DESIGN.md)</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* ============ FOUNDATION — tokens from design-systems/library/apple/DESIGN.md ============ */
|
|
9
|
+
:root{
|
|
10
|
+
/* neutral triad + ink (structural foundation) */
|
|
11
|
+
--black:#000000; --pale:#f5f5f7; --white:#ffffff; --ink:#1d1d1f;
|
|
12
|
+
--graphite-1:#272729; --graphite-2:#2a2a2c;
|
|
13
|
+
/* accent — blue, scarce, action/links only */
|
|
14
|
+
--blue:#0071e3; --blue-hover:#0077ed; --link:#0066cc; --link-on-dark:#2997ff;
|
|
15
|
+
/* neutrals / text / border */
|
|
16
|
+
--text-secondary:#6e6e73; --text-on-dark-soft:#a1a1a6; --border-soft:#d2d2d7; --border-mid:#86868b;
|
|
17
|
+
/* type families */
|
|
18
|
+
--font-display:"SF Pro Display","SF Pro Icons","Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
19
|
+
--font-text:"SF Pro Text","SF Pro Icons","Helvetica Neue",Helvetica,Arial,sans-serif;
|
|
20
|
+
/* type scale (px from the DESIGN.md hierarchy table) */
|
|
21
|
+
--fs-hero:56px; --fs-section:48px; --fs-tile:40px; --fs-sub:21px; --fs-body:17px; --fs-label:14px; --fs-mini:12px;
|
|
22
|
+
--lh-tight:1.05; --lh-snug:1.1; --lh-body:1.47; --track-tight:-0.015em;
|
|
23
|
+
/* spacing (8px base + Apple micro-steps) */
|
|
24
|
+
--s-4:4px; --s-6:6px; --s-8:8px; --s-12:12px; --s-14:14px; --s-17:17px; --s-20:20px;
|
|
25
|
+
--s-24:24px; --s-32:32px; --s-44:44px; --s-64:64px; --s-90:90px; --s-120:120px;
|
|
26
|
+
/* radius tiers */
|
|
27
|
+
--r-field:8px; --r-card:18px; --r-module:28px; --r-pill:980px; --r-circle:50%;
|
|
28
|
+
/* depth (restrained) */
|
|
29
|
+
--shadow-2:0 4px 24px rgba(0,0,0,.10);
|
|
30
|
+
/* layout */
|
|
31
|
+
--container:980px; --gap:12px;
|
|
32
|
+
/* device-art geometry (illustration, not UI tokens) */
|
|
33
|
+
--phone-w:230px; --phone-h:470px;
|
|
34
|
+
}
|
|
35
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
36
|
+
html{-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
|
|
37
|
+
body{font-family:var(--font-text);color:var(--ink);background:var(--white)}
|
|
38
|
+
a{color:inherit;text-decoration:none}
|
|
39
|
+
|
|
40
|
+
/* ============ NAV ============ */
|
|
41
|
+
.nav{position:sticky;top:0;z-index:20;background:rgba(22,22,23,.8);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px)}
|
|
42
|
+
.nav .row{max-inline-size:var(--container);margin-inline:auto;height:var(--s-44);display:flex;align-items:center;justify-content:space-between;padding-inline:var(--s-20)}
|
|
43
|
+
.nav ul{display:flex;align-items:center;gap:var(--s-32);list-style:none}
|
|
44
|
+
.nav a,.nav .glyph{color:#f5f5f7;opacity:.82;font:400 var(--fs-mini)/1 var(--font-text)}
|
|
45
|
+
.nav a:hover{opacity:1}
|
|
46
|
+
.nav .icon{inline-size:15px;block-size:15px;stroke:#f5f5f7;fill:none;stroke-width:1.6;opacity:.82}
|
|
47
|
+
.applogo{inline-size:15px;block-size:18px;fill:#f5f5f7;opacity:.9}
|
|
48
|
+
|
|
49
|
+
/* ============ TILE SYSTEM ============ */
|
|
50
|
+
.tile{position:relative;text-align:center;overflow:hidden}
|
|
51
|
+
.tile__inner{position:absolute;top:var(--s-44);inset-inline:0;z-index:2;padding-inline:var(--s-20)}
|
|
52
|
+
.tile h2{font-family:var(--font-display);font-size:var(--fs-section);line-height:var(--lh-snug);letter-spacing:var(--track-tight);font-weight:600}
|
|
53
|
+
.tile .eyebrow{font-family:var(--font-display);font-size:var(--fs-tile);font-weight:600;line-height:var(--lh-snug)}
|
|
54
|
+
.tile .sub{font-family:var(--font-display);font-size:var(--fs-sub);font-weight:400;margin-top:var(--s-6)}
|
|
55
|
+
.tile .links{display:flex;gap:var(--s-24);justify-content:center;margin-top:var(--s-17);font-size:var(--fs-sub)}
|
|
56
|
+
.tile .price{font-size:var(--fs-body);color:var(--text-secondary);margin-top:var(--s-8)}
|
|
57
|
+
.tile--dark{background:var(--black);color:var(--white)}
|
|
58
|
+
.tile--dark .sub{color:var(--text-on-dark-soft)}
|
|
59
|
+
.tile--light{background:var(--white);color:var(--ink)}
|
|
60
|
+
.tile--pale{background:var(--pale);color:var(--ink)}
|
|
61
|
+
.tile--light .sub,.tile--pale .sub{color:var(--ink)}
|
|
62
|
+
|
|
63
|
+
/* chevron link */
|
|
64
|
+
.applink{display:inline-flex;align-items:center;gap:2px;color:var(--link);font-weight:400}
|
|
65
|
+
.applink:hover{text-decoration:underline}
|
|
66
|
+
.tile--dark .applink{color:var(--link-on-dark)}
|
|
67
|
+
.applink .chev{inline-size:.7em;block-size:.7em;stroke:currentColor;fill:none;stroke-width:2.4;margin-top:.1em}
|
|
68
|
+
/* capsule CTA */
|
|
69
|
+
.cta{display:inline-flex;align-items:center;height:var(--s-44);padding-inline:var(--s-20);border-radius:var(--r-pill);background:var(--blue);color:var(--white);font:400 var(--fs-body)/1 var(--font-text)}
|
|
70
|
+
.cta:hover{background:var(--blue-hover)}
|
|
71
|
+
.cta:focus-visible{outline:3px solid var(--blue);outline-offset:3px}
|
|
72
|
+
|
|
73
|
+
/* 2-up row of half tiles */
|
|
74
|
+
.row2{display:grid;grid-template-columns:1fr;gap:var(--gap);background:var(--white)}
|
|
75
|
+
@media(min-width:734px){.row2{grid-template-columns:1fr 1fr}}
|
|
76
|
+
.row2 .tile{padding-block:var(--s-64) 0}
|
|
77
|
+
/* real product imagery (referenced from Apple CDN) — full-bleed, image bg becomes the chapter bg */
|
|
78
|
+
.product{display:block;inline-size:100%;block-size:auto;margin:0}
|
|
79
|
+
|
|
80
|
+
/* ============ STAGE (full-bleed product image) ============ */
|
|
81
|
+
.stage{display:block}
|
|
82
|
+
|
|
83
|
+
/* ============ FOOTER ============ */
|
|
84
|
+
.foot{background:var(--pale);color:var(--text-secondary);font-size:var(--fs-mini);line-height:1.5;padding-block:var(--s-32);margin-top:var(--s-44)}
|
|
85
|
+
.foot .row{max-inline-size:var(--container);margin-inline:auto;padding-inline:var(--s-20)}
|
|
86
|
+
.foot .cols{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--s-24);padding-block:var(--s-20);border-block-end:1px solid var(--border-soft)}
|
|
87
|
+
@media(min-width:734px){.foot .cols{grid-template-columns:repeat(5,1fr)}}
|
|
88
|
+
.foot h4{color:var(--ink);font-size:var(--fs-mini);font-weight:600;margin-bottom:var(--s-8)}
|
|
89
|
+
.foot li{list-style:none;margin-bottom:var(--s-8)}
|
|
90
|
+
.foot a:hover{text-decoration:underline}
|
|
91
|
+
.foot .legal{padding-block:var(--s-17) 0}
|
|
92
|
+
|
|
93
|
+
/* mobile menu panel */
|
|
94
|
+
.nav__menu{display:none}
|
|
95
|
+
.mobilemenu{position:sticky;top:var(--s-44);z-index:19;background:rgba(22,22,23,.94);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);padding:var(--s-8) var(--s-20) var(--s-24)}
|
|
96
|
+
.mobilemenu[hidden]{display:none}
|
|
97
|
+
.mobilemenu ul{list-style:none;max-inline-size:var(--container);margin-inline:auto}
|
|
98
|
+
.mobilemenu a{display:block;color:#f5f5f7;font-family:var(--font-display);font-size:var(--fs-sub);font-weight:500;padding-block:var(--s-12);border-block-end:1px solid rgba(255,255,255,.14)}
|
|
99
|
+
|
|
100
|
+
/* responsive */
|
|
101
|
+
@media(max-width:833px){
|
|
102
|
+
:root{--fs-section:36px;--fs-tile:26px;--fs-sub:18px}
|
|
103
|
+
}
|
|
104
|
+
@media(max-width:734px){
|
|
105
|
+
:root{--fs-section:30px;--fs-tile:24px;--fs-sub:16px;--fs-body:15px}
|
|
106
|
+
/* nav collapses to logo + menu + bag */
|
|
107
|
+
.nav ul{display:none}
|
|
108
|
+
.nav__menu{display:inline-block}
|
|
109
|
+
/* tiles stack: text in normal flow ABOVE the image (no overlay overlap) */
|
|
110
|
+
.tile__inner{position:static;padding-block:var(--s-44) var(--s-24)}
|
|
111
|
+
.tile{text-align:center}
|
|
112
|
+
.tile--dark{background:var(--black)} .tile--light{background:var(--white)} .tile--pale{background:var(--pale)}
|
|
113
|
+
.foot .cols{grid-template-columns:repeat(2,1fr)}
|
|
114
|
+
}
|
|
115
|
+
</style>
|
|
116
|
+
</head>
|
|
117
|
+
<body>
|
|
118
|
+
|
|
119
|
+
<nav class="nav">
|
|
120
|
+
<div class="row">
|
|
121
|
+
<svg class="applogo" viewBox="0 0 14 17" aria-label="Apple"><path d="M11.6 9c0-2 1.6-2.9 1.7-3-1-1.4-2.4-1.6-2.9-1.6-1.2-.1-2.4.7-3 .7-.6 0-1.6-.7-2.6-.7C3.5 4.5 2.3 5.2 1.6 6.4.2 8.8 1.2 12.4 2.6 14.4c.7 1 1.5 2.1 2.5 2 1-.04 1.4-.65 2.6-.65 1.2 0 1.6.65 2.6.63 1.1-.02 1.8-1 2.5-2 .8-1.15 1.1-2.27 1.1-2.33-.02-.01-2.1-.81-2.1-3.05zM9.7 3.2c.55-.67.92-1.6.82-2.53-.79.03-1.75.53-2.32 1.2-.51.59-.96 1.54-.84 2.45.88.07 1.78-.45 2.34-1.12z"/></svg>
|
|
122
|
+
<ul>
|
|
123
|
+
<li><a href="#">Store</a></li><li><a href="#">Mac</a></li><li><a href="#">iPad</a></li>
|
|
124
|
+
<li><a href="#">iPhone</a></li><li><a href="#">Watch</a></li><li><a href="#">Vision</a></li>
|
|
125
|
+
<li><a href="#">AirPods</a></li><li><a href="#">TV & Home</a></li><li><a href="#">Entertainment</a></li>
|
|
126
|
+
<li><a href="#">Accessories</a></li><li><a href="#">Support</a></li>
|
|
127
|
+
</ul>
|
|
128
|
+
<span style="display:flex;align-items:center;gap:var(--s-20)">
|
|
129
|
+
<svg class="icon" viewBox="0 0 24 24" aria-label="Search"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
|
|
130
|
+
<svg class="icon" viewBox="0 0 24 24" aria-label="Bag"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/></svg>
|
|
131
|
+
<button id="menuBtn" class="nav__menu" aria-label="Menu" aria-expanded="false" aria-controls="mobilemenu" style="background:none;border:0;padding:0;cursor:pointer">
|
|
132
|
+
<svg class="icon" viewBox="0 0 24 24"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
|
|
133
|
+
</button>
|
|
134
|
+
</span>
|
|
135
|
+
</div>
|
|
136
|
+
</nav>
|
|
137
|
+
|
|
138
|
+
<div class="mobilemenu" id="mobilemenu" hidden>
|
|
139
|
+
<ul>
|
|
140
|
+
<li><a href="#">Store</a></li><li><a href="#">Mac</a></li><li><a href="#">iPad</a></li>
|
|
141
|
+
<li><a href="#">iPhone</a></li><li><a href="#">Watch</a></li><li><a href="#">Vision</a></li>
|
|
142
|
+
<li><a href="#">AirPods</a></li><li><a href="#">TV & Home</a></li><li><a href="#">Entertainment</a></li>
|
|
143
|
+
<li><a href="#">Accessories</a></li><li><a href="#">Support</a></li>
|
|
144
|
+
</ul>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
<!-- Hero: iPhone (black chapter) -->
|
|
148
|
+
<section class="tile tile--dark">
|
|
149
|
+
<div class="tile__inner">
|
|
150
|
+
<p class="eyebrow">iPhone 17 Pro</p>
|
|
151
|
+
<p class="sub">Titanium. So strong. So light. So Pro.</p>
|
|
152
|
+
<div class="links">
|
|
153
|
+
<a class="applink" href="#">Learn more <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
154
|
+
<a class="applink" href="#">Buy <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
<img class="product" src="https://www.apple.com/v/home/images/iphone-17-pro/a/hero_iphone_17_pro__dbaaq3mt8u2q_large.jpg" alt="iPhone 17 Pro" />
|
|
158
|
+
</section>
|
|
159
|
+
|
|
160
|
+
<!-- MacBook (light chapter) -->
|
|
161
|
+
<section class="tile tile--light">
|
|
162
|
+
<div class="tile__inner">
|
|
163
|
+
<p class="eyebrow">MacBook Air</p>
|
|
164
|
+
<p class="sub">Sky blue color. Sky high performance.</p>
|
|
165
|
+
<div class="links">
|
|
166
|
+
<a class="applink" href="#">Learn more <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
167
|
+
<a class="applink" href="#">Buy <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
<img class="product" src="https://www.apple.com/v/home/images/macbook-air-m5/a/promo_macbook_air_m5__e5xk2yysqiie_large.jpg" alt="MacBook Air" />
|
|
171
|
+
</section>
|
|
172
|
+
|
|
173
|
+
<!-- 2-up row: Watch (pale) + AirPods (dark) -->
|
|
174
|
+
<div class="row2">
|
|
175
|
+
<section class="tile tile--pale">
|
|
176
|
+
<div class="tile__inner">
|
|
177
|
+
<p class="eyebrow">Watch Series 11</p>
|
|
178
|
+
<p class="sub">Smarter. Brighter. Mightier.</p>
|
|
179
|
+
<div class="links">
|
|
180
|
+
<a class="applink" href="#">Learn more <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
181
|
+
<a class="applink" href="#">Buy <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
<img class="product" src="https://www.apple.com/v/home/images/apple-watch-series-11/a/promo_apple_watch_series_11__gnlwqxe1jlu2_large.jpg" alt="Apple Watch Series 11" />
|
|
185
|
+
</section>
|
|
186
|
+
<section class="tile tile--pale">
|
|
187
|
+
<div class="tile__inner">
|
|
188
|
+
<p class="eyebrow">iPhone 17</p>
|
|
189
|
+
<p class="sub">The one to get.</p>
|
|
190
|
+
<div class="links">
|
|
191
|
+
<a class="applink" href="#">Learn more <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
192
|
+
<a class="applink" href="#">Buy <svg class="chev" viewBox="0 0 24 24"><path d="m9 6 6 6-6 6"/></svg></a>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
<img class="product" src="https://www.apple.com/v/home/images/iphone-17/a/hero_iphone_17__ekga3xh1n5aq_large.jpg" alt="iPhone 17" />
|
|
196
|
+
</section>
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<footer class="foot">
|
|
200
|
+
<div class="row">
|
|
201
|
+
<div class="cols">
|
|
202
|
+
<div><h4>Shop and Learn</h4><ul><li><a href="#">Store</a></li><li><a href="#">Mac</a></li><li><a href="#">iPad</a></li><li><a href="#">iPhone</a></li><li><a href="#">Watch</a></li></ul></div>
|
|
203
|
+
<div><h4>Services</h4><ul><li><a href="#">Apple Music</a></li><li><a href="#">Apple TV+</a></li><li><a href="#">iCloud</a></li><li><a href="#">Apple Pay</a></li></ul></div>
|
|
204
|
+
<div><h4>Account</h4><ul><li><a href="#">Manage Your ID</a></li><li><a href="#">Apple Store Account</a></li><li><a href="#">iCloud.com</a></li></ul></div>
|
|
205
|
+
<div><h4>Apple Store</h4><ul><li><a href="#">Find a Store</a></li><li><a href="#">Genius Bar</a></li><li><a href="#">Today at Apple</a></li><li><a href="#">Trade In</a></li></ul></div>
|
|
206
|
+
<div><h4>About Apple</h4><ul><li><a href="#">Newsroom</a></li><li><a href="#">Careers</a></li><li><a href="#">Investors</a></li><li><a href="#">Contact</a></li></ul></div>
|
|
207
|
+
</div>
|
|
208
|
+
<p class="legal">More ways to shop: Find an Apple Store or other retailer near you.<br>Copyright 2026 Apple-brand demo. Built from design-systems/library/apple/DESIGN.md. Not affiliated with Apple Inc.</p>
|
|
209
|
+
</div>
|
|
210
|
+
</footer>
|
|
211
|
+
|
|
212
|
+
<script>
|
|
213
|
+
const menuBtn=document.getElementById('menuBtn'),mobileMenu=document.getElementById('mobilemenu');
|
|
214
|
+
if(menuBtn){menuBtn.addEventListener('click',()=>{
|
|
215
|
+
const open=!mobileMenu.hasAttribute('hidden');
|
|
216
|
+
if(open){mobileMenu.setAttribute('hidden','');menuBtn.setAttribute('aria-expanded','false');}
|
|
217
|
+
else{mobileMenu.removeAttribute('hidden');menuBtn.setAttribute('aria-expanded','true');}
|
|
218
|
+
});}
|
|
219
|
+
</script>
|
|
220
|
+
</body>
|
|
221
|
+
</html>
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
/* action */
|
|
16
16
|
--color-action-primary: #2563eb; /* white on this = 5.2:1 */
|
|
17
17
|
--color-action-primary-hover: #1d4ed8;
|
|
18
|
+
--color-action-danger: #dc2626; /* destructive — white on this = 4.8:1 */
|
|
19
|
+
--color-action-danger-hover: #b91c1c;
|
|
18
20
|
/* borders + focus */
|
|
19
21
|
--color-border-default: #e5e7eb; /* decorative dividers/edges */
|
|
20
22
|
--color-border-strong: #6b7280; /* essential control borders — 4.8:1 */
|
|
@@ -64,6 +66,8 @@
|
|
|
64
66
|
--color-text-on-action: #ffffff;
|
|
65
67
|
--color-action-primary: #2563eb; /* white on this = 5.2:1 (dark-safe) */
|
|
66
68
|
--color-action-primary-hover: #1d4ed8;
|
|
69
|
+
--color-action-danger: #dc2626; /* white = 4.8:1, dark-safe */
|
|
70
|
+
--color-action-danger-hover: #b91c1c;
|
|
67
71
|
--color-border-default: #1f2937;
|
|
68
72
|
--color-border-strong: #6b7280; /* essential borders ≥3:1 in dark too */
|
|
69
73
|
--color-text-link: #60a5fa; /* lightened for ≥4.5:1 on dark */
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Test Brief (the "โจทย์")
|
|
2
|
+
|
|
3
|
+
**Build an Analytics admin with two pages that share one theme:**
|
|
4
|
+
1. **Dashboard** — page title, 4 KPI stat cards (responsive 1→2→4 cols), a primary "Export" action.
|
|
5
|
+
2. **Settings** — a form (name + notifications toggle) and a "Delete account" flow that opens a **confirmation modal**.
|
|
6
|
+
|
|
7
|
+
**Requirements (the bar):**
|
|
8
|
+
- One shared token theme (`../golden/theme.css`); zero hardcoded colors/sizes/fonts; no raw Tailwind palette utilities.
|
|
9
|
+
- Light + dark must both pass WCAG AA.
|
|
10
|
+
- The modal MUST use the shared `Modal` primitive (focus trap, role=dialog, aria-modal, Escape, return focus).
|
|
11
|
+
- Reuse one `Button` primitive; essential input borders use `border-strong` (3:1).
|
|
12
|
+
- Responsive at multiple breakpoints (not a single 768px).
|
|
13
|
+
|
|
14
|
+
This is fed through the gates (`lint_hardcodes`, `validate_theme_refs`, `validate_contrast`) + a design review to prove the loop closes.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Dashboard.tsx — page 1. Reuses Button + StatCard; consumes the shared theme only.
|
|
2
|
+
import { Button } from "../golden/Button";
|
|
3
|
+
import { StatCard } from "./StatCard";
|
|
4
|
+
|
|
5
|
+
const KPIS = [
|
|
6
|
+
{ label: "Revenue", value: "$48.2k", delta: 12 },
|
|
7
|
+
{ label: "Active users", value: "3,910", delta: 4 },
|
|
8
|
+
{ label: "Churn", value: "1.8%", delta: -2 },
|
|
9
|
+
{ label: "NPS", value: "62", delta: 7 },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
export function Dashboard() {
|
|
13
|
+
return (
|
|
14
|
+
<div className="app">
|
|
15
|
+
<main className="container">
|
|
16
|
+
<header style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
|
17
|
+
<div>
|
|
18
|
+
<h1 className="page-title">Analytics</h1>
|
|
19
|
+
<p className="subtle">Last 30 days</p>
|
|
20
|
+
</div>
|
|
21
|
+
<Button>Export</Button>
|
|
22
|
+
</header>
|
|
23
|
+
|
|
24
|
+
<section className="grid" aria-label="Key metrics" style={{ marginBlockStart: "var(--space-6)" }}>
|
|
25
|
+
{KPIS.map((k) => (
|
|
26
|
+
<StatCard key={k.label} {...k} />
|
|
27
|
+
))}
|
|
28
|
+
</section>
|
|
29
|
+
</main>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Settings.tsx — page 2. Reuses the shared Button + Modal primitives.
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { Button } from "../golden/Button";
|
|
4
|
+
import { Modal } from "../golden/Modal";
|
|
5
|
+
|
|
6
|
+
export function Settings() {
|
|
7
|
+
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
8
|
+
const [notify, setNotify] = useState(true);
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="app">
|
|
12
|
+
<main className="container" style={{ display: "grid", gap: "var(--space-6)" }}>
|
|
13
|
+
<h1 className="page-title">Settings</h1>
|
|
14
|
+
|
|
15
|
+
<div className="field">
|
|
16
|
+
<label className="field__label" htmlFor="name">Display name</label>
|
|
17
|
+
<input className="field__input" id="name" defaultValue="Plug" />
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<div className="field" style={{ gridTemplateColumns: "1fr auto", alignItems: "center" }}>
|
|
21
|
+
<label className="field__label" htmlFor="notify">Email notifications</label>
|
|
22
|
+
<button
|
|
23
|
+
id="notify"
|
|
24
|
+
role="switch"
|
|
25
|
+
aria-checked={notify}
|
|
26
|
+
className="btn btn--secondary"
|
|
27
|
+
onClick={() => setNotify((v) => !v)}
|
|
28
|
+
>
|
|
29
|
+
{notify ? "On" : "Off"}
|
|
30
|
+
</button>
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<div>
|
|
34
|
+
<Button onClick={() => setConfirmOpen(true)}>Delete account</Button>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
<Modal open={confirmOpen} onClose={() => setConfirmOpen(false)} titleId="confirm-title">
|
|
38
|
+
<h2 id="confirm-title" className="page-title" style={{ fontSize: "var(--text-xl)" }}>
|
|
39
|
+
Delete account?
|
|
40
|
+
</h2>
|
|
41
|
+
<p className="subtle">This is permanent and cannot be undone.</p>
|
|
42
|
+
<div style={{ display: "flex", gap: "var(--space-2)", marginBlockStart: "var(--space-4)" }}>
|
|
43
|
+
<Button className="btn--secondary" onClick={() => setConfirmOpen(false)}>Cancel</Button>
|
|
44
|
+
<Button onClick={() => setConfirmOpen(false)}>Delete</Button>
|
|
45
|
+
</div>
|
|
46
|
+
</Modal>
|
|
47
|
+
</main>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// StatCard.tsx — KPI card. Token-driven; delta uses color + arrow (never color alone).
|
|
2
|
+
type Props = { label: string; value: string; delta: number };
|
|
3
|
+
|
|
4
|
+
export function StatCard({ label, value, delta }: Props) {
|
|
5
|
+
const up = delta >= 0;
|
|
6
|
+
return (
|
|
7
|
+
<div className="card">
|
|
8
|
+
<div className="card__label">{label}</div>
|
|
9
|
+
<div className="card__value">{value}</div>
|
|
10
|
+
<div className={up ? "card__delta--up" : "card__delta--down"}>
|
|
11
|
+
<span aria-hidden="true">{up ? "▲" : "▼"}</span> {Math.abs(delta)}%
|
|
12
|
+
<span className="sr-only">{up ? "increase" : "decrease"}</span>
|
|
13
|
+
</div>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" data-theme="light">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>Sample App — token-driven, light/dark, WCAG-checked</title>
|
|
7
|
+
<style>
|
|
8
|
+
/* === theme.css (the ONE shared token theme) === */
|
|
9
|
+
:root{
|
|
10
|
+
--color-surface-page:#ffffff;--color-surface-card:#ffffff;--color-surface-raised:#f9fafb;
|
|
11
|
+
--color-text-primary:#111827;--color-text-secondary:#4b5563;--color-text-on-action:#ffffff;
|
|
12
|
+
--color-action-primary:#2563eb;--color-action-primary-hover:#1d4ed8;
|
|
13
|
+
--color-action-danger:#dc2626;--color-action-danger-hover:#b91c1c;
|
|
14
|
+
--color-border-default:#e5e7eb;--color-border-strong:#6b7280;--color-text-link:#2563eb;
|
|
15
|
+
--color-feedback-error:#dc2626;--color-success:#15803d;
|
|
16
|
+
--color-scrim:rgba(2,6,23,.6);--shadow-overlay:0 20px 25px -5px rgba(0,0,0,.15),0 8px 10px -6px rgba(0,0,0,.10);--z-modal:50;
|
|
17
|
+
--shadow-focus-ring:0 0 0 2px var(--color-surface-page),0 0 0 4px var(--color-action-primary);
|
|
18
|
+
--font-sans:Inter,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;
|
|
19
|
+
--text-sm:.875rem;--text-base:1rem;--text-lg:1.125rem;--text-xl:1.25rem;--text-3xl:1.875rem;--text-4xl:2.25rem;
|
|
20
|
+
--leading-tight:1.25;
|
|
21
|
+
--space-1:.25rem;--space-2:.5rem;--space-3:.75rem;--space-4:1rem;--space-5:1.25rem;--space-6:1.5rem;--space-8:2rem;
|
|
22
|
+
--radius-button:.5rem;--radius-lg:.75rem;
|
|
23
|
+
--size-control-md:2.5rem;--opacity-disabled:.5;--transition-micro:150ms ease-out;
|
|
24
|
+
--bp-sm:640px;--bp-lg:1024px;
|
|
25
|
+
}
|
|
26
|
+
:root[data-theme="dark"]{
|
|
27
|
+
--color-surface-page:#030712;--color-surface-card:#111827;--color-surface-raised:#1f2937;
|
|
28
|
+
--color-text-primary:#f9fafb;--color-text-secondary:#9ca3af;--color-text-on-action:#ffffff;
|
|
29
|
+
--color-action-primary:#2563eb;--color-action-primary-hover:#1d4ed8;
|
|
30
|
+
--color-action-danger:#dc2626;--color-action-danger-hover:#b91c1c;
|
|
31
|
+
--color-border-default:#1f2937;--color-border-strong:#6b7280;--color-text-link:#60a5fa;
|
|
32
|
+
--color-feedback-error:#f87171;--color-success:#4ade80;
|
|
33
|
+
--color-scrim:rgba(0,0,0,.7);--shadow-overlay:0 20px 25px -5px rgba(0,0,0,.5),0 8px 10px -6px rgba(0,0,0,.4);
|
|
34
|
+
}
|
|
35
|
+
/* === app styles (tokens only) === */
|
|
36
|
+
*{box-sizing:border-box}
|
|
37
|
+
body{margin:0;font-family:var(--font-sans);background:var(--color-surface-page);color:var(--color-text-primary);transition:background var(--transition-micro),color var(--transition-micro)}
|
|
38
|
+
.container{max-inline-size:var(--bp-lg);margin-inline:auto;padding:var(--space-6)}
|
|
39
|
+
.topbar{display:flex;justify-content:space-between;align-items:center;gap:var(--space-4);border-block-end:1px solid var(--color-border-default);padding-block-end:var(--space-4);margin-block-end:var(--space-6)}
|
|
40
|
+
.page-title{font-size:var(--text-3xl);line-height:var(--leading-tight);font-weight:700;margin:0}
|
|
41
|
+
.subtle{color:var(--color-text-secondary);font-size:var(--text-sm);margin:var(--space-1) 0 0}
|
|
42
|
+
.grid{display:grid;gap:var(--space-4);grid-template-columns:1fr;margin-block:var(--space-6)}
|
|
43
|
+
@media(min-width:640px){.grid{grid-template-columns:repeat(2,1fr)}}
|
|
44
|
+
@media(min-width:1024px){.grid{grid-template-columns:repeat(4,1fr)}}
|
|
45
|
+
.card{background:var(--color-surface-card);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-5)}
|
|
46
|
+
.card__label{color:var(--color-text-secondary);font-size:var(--text-sm)}
|
|
47
|
+
.card__value{font-size:var(--text-4xl);line-height:var(--leading-tight);font-weight:700;margin-block:var(--space-1)}
|
|
48
|
+
.up{color:var(--color-success);font-size:var(--text-sm)}.down{color:var(--color-feedback-error);font-size:var(--text-sm)}
|
|
49
|
+
.btn{display:inline-flex;align-items:center;gap:var(--space-2);block-size:var(--size-control-md);padding-inline:var(--space-4);border-radius:var(--radius-button);border:0;cursor:pointer;font:500 var(--text-sm)/1 var(--font-sans);background:var(--color-action-primary);color:var(--color-text-on-action);transition:background var(--transition-micro)}
|
|
50
|
+
.btn:hover{background:var(--color-action-primary-hover)}
|
|
51
|
+
.btn:focus-visible{outline:none;box-shadow:var(--shadow-focus-ring)}
|
|
52
|
+
.btn:disabled{opacity:var(--opacity-disabled);pointer-events:none}
|
|
53
|
+
/* secondary — transparent fill, dark text; NEVER a colored fill (no dark-on-blue) */
|
|
54
|
+
.btn--secondary{background:transparent;color:var(--color-text-primary);box-shadow:inset 0 0 0 1px var(--color-border-strong)}
|
|
55
|
+
.btn--secondary:hover{background:var(--color-surface-raised)}
|
|
56
|
+
.btn--secondary:focus-visible{box-shadow:inset 0 0 0 1px var(--color-border-strong),var(--shadow-focus-ring)}
|
|
57
|
+
/* danger — destructive token (red), white text 4.8:1; for Delete/Remove only */
|
|
58
|
+
.btn--danger{background:var(--color-action-danger);color:var(--color-text-on-action)}
|
|
59
|
+
.btn--danger:hover{background:var(--color-action-danger-hover)}
|
|
60
|
+
.section{background:var(--color-surface-card);border:1px solid var(--color-border-default);border-radius:var(--radius-lg);padding:var(--space-6);margin-block-end:var(--space-6)}
|
|
61
|
+
.field{display:grid;gap:var(--space-2);margin-block-end:var(--space-5)}
|
|
62
|
+
.field__label{font-size:var(--text-sm);font-weight:500}
|
|
63
|
+
.field__input{block-size:var(--size-control-md);padding-inline:var(--space-3);border:1px solid var(--color-border-strong);border-radius:var(--radius-button);background:var(--color-surface-page);color:var(--color-text-primary);font-size:var(--text-base)}
|
|
64
|
+
.field__input:focus-visible{outline:none;box-shadow:var(--shadow-focus-ring)}
|
|
65
|
+
.row{display:flex;justify-content:space-between;align-items:center;gap:var(--space-4)}
|
|
66
|
+
.backdrop{position:fixed;inset:0;display:grid;place-items:center;background:var(--color-scrim);z-index:var(--z-modal)}
|
|
67
|
+
.modal{background:var(--color-surface-card);color:var(--color-text-primary);border-radius:var(--radius-lg);padding:var(--space-6);max-inline-size:var(--bp-sm);width:min(92vw,420px);box-shadow:var(--shadow-overlay)}
|
|
68
|
+
.hidden{display:none}
|
|
69
|
+
.icon{inline-size:1.05em;block-size:1.05em;flex:none;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.15em}
|
|
70
|
+
.sr-only{position:absolute;inline-size:1px;block-size:1px;padding:0;margin:-1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
71
|
+
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
|
72
|
+
</style>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<!-- lucide icons (MIT) as an inline sprite — no emoji -->
|
|
76
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
77
|
+
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></symbol>
|
|
78
|
+
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/></symbol>
|
|
79
|
+
<symbol id="i-up" viewBox="0 0 24 24"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></symbol>
|
|
80
|
+
<symbol id="i-down" viewBox="0 0 24 24"><polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/></symbol>
|
|
81
|
+
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M10 11v6M14 11v6"/></symbol>
|
|
82
|
+
</svg>
|
|
83
|
+
<div class="container">
|
|
84
|
+
<div class="topbar">
|
|
85
|
+
<div>
|
|
86
|
+
<h1 class="page-title">Analytics</h1>
|
|
87
|
+
<p class="subtle">Last 30 days · one shared theme · light + dark</p>
|
|
88
|
+
</div>
|
|
89
|
+
<div style="display:flex;gap:var(--space-2)">
|
|
90
|
+
<button class="btn btn--secondary" id="themeBtn" aria-pressed="false"><svg class="icon" id="themeIcon"><use href="#i-moon"/></svg> <span id="themeLabel">Dark</span></button>
|
|
91
|
+
<button class="btn">Export</button>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<section class="grid" aria-label="Key metrics">
|
|
96
|
+
<div class="card"><div class="card__label">Revenue</div><div class="card__value">$48.2k</div><div class="up"><svg class="icon" aria-hidden="true"><use href="#i-up"/></svg> 12% <span class="sr-only">increase</span></div></div>
|
|
97
|
+
<div class="card"><div class="card__label">Active users</div><div class="card__value">3,910</div><div class="up"><svg class="icon" aria-hidden="true"><use href="#i-up"/></svg> 4% <span class="sr-only">increase</span></div></div>
|
|
98
|
+
<div class="card"><div class="card__label">Churn</div><div class="card__value">1.8%</div><div class="down"><svg class="icon" aria-hidden="true"><use href="#i-down"/></svg> 2% <span class="sr-only">decrease</span></div></div>
|
|
99
|
+
<div class="card"><div class="card__label">NPS</div><div class="card__value">62</div><div class="up"><svg class="icon" aria-hidden="true"><use href="#i-up"/></svg> 7% <span class="sr-only">increase</span></div></div>
|
|
100
|
+
</section>
|
|
101
|
+
|
|
102
|
+
<section class="section">
|
|
103
|
+
<h2 class="page-title" style="font-size:var(--text-xl)">Settings</h2>
|
|
104
|
+
<p class="subtle" style="margin-bottom:var(--space-5)">Same theme, same primitives, second page.</p>
|
|
105
|
+
<div class="field">
|
|
106
|
+
<label class="field__label" for="name">Display name</label>
|
|
107
|
+
<input class="field__input" id="name" value="Plug" />
|
|
108
|
+
</div>
|
|
109
|
+
<div class="field row">
|
|
110
|
+
<label class="field__label" for="notify">Email notifications</label>
|
|
111
|
+
<button class="btn btn--secondary" id="notify" role="switch" aria-checked="true">On</button>
|
|
112
|
+
</div>
|
|
113
|
+
<button class="btn btn--danger" id="delBtn"><svg class="icon" aria-hidden="true"><use href="#i-trash"/></svg> Delete account</button>
|
|
114
|
+
</section>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
<div class="backdrop hidden" id="backdrop">
|
|
118
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="ctitle" aria-describedby="cdesc">
|
|
119
|
+
<h2 id="ctitle" class="page-title" style="font-size:var(--text-xl)">Delete account?</h2>
|
|
120
|
+
<p id="cdesc" class="subtle">This permanently deletes your account and all data. This cannot be undone.</p>
|
|
121
|
+
<div class="field" style="margin-top:var(--space-4)">
|
|
122
|
+
<label class="field__label" for="confirmInput">Type <strong>DELETE</strong> to confirm</label>
|
|
123
|
+
<input class="field__input" id="confirmInput" autocomplete="off" autocapitalize="characters" placeholder="DELETE" />
|
|
124
|
+
</div>
|
|
125
|
+
<div style="display:flex;gap:var(--space-2);margin-top:var(--space-4)">
|
|
126
|
+
<button class="btn btn--secondary" id="cancelBtn">Cancel</button>
|
|
127
|
+
<button class="btn btn--danger" id="confirmBtn" disabled><svg class="icon" aria-hidden="true"><use href="#i-trash"/></svg> Delete account</button>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<script>
|
|
133
|
+
const root=document.documentElement,tb=document.getElementById('themeBtn');
|
|
134
|
+
const themeUse=document.querySelector('#themeIcon use'),themeLabel=document.getElementById('themeLabel');
|
|
135
|
+
tb.onclick=()=>{const d=root.getAttribute('data-theme')==='dark';root.setAttribute('data-theme',d?'light':'dark');
|
|
136
|
+
themeUse.setAttribute('href',d?'#i-moon':'#i-sun');themeLabel.textContent=d?'Dark':'Light';tb.setAttribute('aria-pressed',String(!d))};
|
|
137
|
+
const nb=document.getElementById('notify');
|
|
138
|
+
nb.onclick=()=>{const on=nb.getAttribute('aria-checked')==='true';nb.setAttribute('aria-checked',String(!on));nb.textContent=on?'Off':'On'};
|
|
139
|
+
const bd=document.getElementById('backdrop'),del=document.getElementById('delBtn');
|
|
140
|
+
const confirmInput=document.getElementById('confirmInput'),confirmBtn=document.getElementById('confirmBtn'),cancelBtn=document.getElementById('cancelBtn');
|
|
141
|
+
let lastFocus=null;
|
|
142
|
+
const FOCUSABLE='button:not([disabled]),input,a[href],select,textarea,[tabindex]:not([tabindex="-1"])';
|
|
143
|
+
function syncConfirm(){confirmBtn.disabled = confirmInput.value.trim().toUpperCase()!=='DELETE';}
|
|
144
|
+
function open(){lastFocus=document.activeElement;confirmInput.value='';confirmBtn.disabled=true;bd.classList.remove('hidden');confirmInput.focus();}
|
|
145
|
+
function close(){bd.classList.add('hidden');lastFocus&&lastFocus.focus();}
|
|
146
|
+
del.onclick=open;cancelBtn.onclick=close;confirmInput.addEventListener('input',syncConfirm);
|
|
147
|
+
confirmBtn.onclick=()=>{ if(!confirmBtn.disabled) close(); }; // would delete here
|
|
148
|
+
bd.onclick=e=>{if(e.target===bd)close()};
|
|
149
|
+
document.addEventListener('keydown',e=>{if(bd.classList.contains('hidden'))return;
|
|
150
|
+
if(e.key==='Escape')return close();
|
|
151
|
+
if(e.key!=='Tab')return;
|
|
152
|
+
const f=[...bd.querySelectorAll(FOCUSABLE)].filter(el=>!el.disabled);
|
|
153
|
+
const first=f[0],last=f[f.length-1];
|
|
154
|
+
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
|
|
155
|
+
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}});
|
|
156
|
+
</script>
|
|
157
|
+
</body>
|
|
158
|
+
</html>
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/* sample-app styles — consumes ONLY the shared theme tokens (../golden/theme.css).
|
|
2
|
+
* Import order in the real app: theme.css first, then this file. No colors defined here. */
|
|
3
|
+
|
|
4
|
+
.app {
|
|
5
|
+
font-family: var(--font-sans);
|
|
6
|
+
background: var(--color-surface-page);
|
|
7
|
+
color: var(--color-text-primary);
|
|
8
|
+
min-block-size: 100vh;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/* layout */
|
|
12
|
+
.container { max-inline-size: var(--bp-lg); margin-inline: auto; padding: var(--space-6); }
|
|
13
|
+
.page-title { font-size: var(--text-3xl); line-height: var(--leading-tight); font-weight: 700; }
|
|
14
|
+
.subtle { color: var(--color-text-secondary); font-size: var(--text-sm); }
|
|
15
|
+
|
|
16
|
+
/* responsive grid — uses multiple breakpoints, not one */
|
|
17
|
+
.grid { display: grid; gap: var(--space-4); grid-template-columns: 1fr; }
|
|
18
|
+
@media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); } }
|
|
19
|
+
@media (min-width: 1024px) { .grid { grid-template-columns: repeat(4, 1fr); } }
|
|
20
|
+
|
|
21
|
+
/* stat card */
|
|
22
|
+
.card {
|
|
23
|
+
background: var(--color-surface-card);
|
|
24
|
+
border: 1px solid var(--color-border-default);
|
|
25
|
+
border-radius: var(--radius-lg);
|
|
26
|
+
padding: var(--space-5);
|
|
27
|
+
}
|
|
28
|
+
.card__value { font-size: var(--text-4xl); line-height: var(--leading-tight); font-weight: 700; }
|
|
29
|
+
.card__label { color: var(--color-text-secondary); font-size: var(--text-sm); }
|
|
30
|
+
.card__delta--up { color: var(--color-action-primary); }
|
|
31
|
+
.card__delta--down { color: var(--color-feedback-error); }
|
|
32
|
+
|
|
33
|
+
/* button (shared primitive) */
|
|
34
|
+
.btn {
|
|
35
|
+
display: inline-flex; align-items: center; gap: var(--space-2);
|
|
36
|
+
block-size: var(--size-control-md); padding-inline: var(--space-4);
|
|
37
|
+
border-radius: var(--radius-button); border: 0; cursor: pointer;
|
|
38
|
+
font-family: var(--font-sans); font-size: var(--text-sm); font-weight: 500;
|
|
39
|
+
background: var(--color-action-primary); color: var(--color-text-on-action);
|
|
40
|
+
transition: background var(--transition-micro);
|
|
41
|
+
}
|
|
42
|
+
.btn:hover { background: var(--color-action-primary-hover); }
|
|
43
|
+
.btn:focus-visible { outline: none; box-shadow: var(--shadow-focus-ring); }
|
|
44
|
+
.btn:disabled { opacity: var(--opacity-disabled); pointer-events: none; }
|
|
45
|
+
.btn--secondary {
|
|
46
|
+
background: transparent; color: var(--color-text-primary);
|
|
47
|
+
box-shadow: inset 0 0 0 1px var(--color-border-strong);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* form field — essential border uses border-strong (3:1) */
|
|
51
|
+
.field { display: grid; gap: var(--space-2); }
|
|
52
|
+
.field__label { font-size: var(--text-sm); font-weight: 500; }
|
|
53
|
+
.field__input {
|
|
54
|
+
block-size: var(--size-control-md); padding-inline: var(--space-3);
|
|
55
|
+
border: 1px solid var(--color-border-strong); border-radius: var(--radius-button);
|
|
56
|
+
background: var(--color-surface-page); color: var(--color-text-primary);
|
|
57
|
+
font-size: var(--text-base);
|
|
58
|
+
}
|
|
59
|
+
.field__input:focus-visible { outline: none; box-shadow: var(--shadow-focus-ring); }
|
|
60
|
+
|
|
61
|
+
/* visually-hidden but available to screen readers */
|
|
62
|
+
.sr-only {
|
|
63
|
+
position: absolute; inline-size: 1px; block-size: 1px;
|
|
64
|
+
padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@media (prefers-reduced-motion: reduce) { .btn { transition: none; } }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ux-ui-agent-skills",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Turn Claude into a Senior Design Architect — DTCG design tokens, 50 components, WCAG 2.2 AA→AAA + i18n/RTL accessibility, enforced single-theme consistency (contrast, hardcode, Tailwind-palette, theme-ref + CI gates), any-framework code, 138 design systems, 15 runnable skills. Install into any project with npx.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,12 @@
|
|
|
8
8
|
"ux-ui-agent-skills": "bin/cli.js"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node bin/cli.js list >/dev/null && python3 scripts/validate_tokens.py && python3 scripts/validate_contrast.py && python3 scripts/validate_component_spec.py && python3 scripts/lint_hardcodes.py examples/golden && python3 scripts/validate_theme_refs.py"
|
|
11
|
+
"test": "node bin/cli.js list >/dev/null && python3 scripts/validate_tokens.py && python3 scripts/validate_contrast.py && python3 scripts/validate_component_spec.py && python3 scripts/lint_hardcodes.py examples/golden && python3 scripts/validate_theme_refs.py && python3 scripts/check_no_emoji.py",
|
|
12
|
+
"test:render": "node scripts/measure_render.mjs examples/apple-demo/index.html examples/sample-app/preview.html && node scripts/measure_render.mjs --dark examples/sample-app/preview.html",
|
|
13
|
+
"verify": "node scripts/accuracy_report.mjs"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"playwright": "^1.40.0"
|
|
12
17
|
},
|
|
13
18
|
"files": [
|
|
14
19
|
"bin/",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ACCURACY REPORT — one command, all-or-nothing, reproducible.
|
|
4
|
+
*
|
|
5
|
+
* Runs every objective correctness gate the kit can prove and prints a single
|
|
6
|
+
* verdict. "100%" here means: of the checks that CAN be measured objectively,
|
|
7
|
+
* every one passes — nothing partial ships. It does NOT claim subjective visual
|
|
8
|
+
* or brand fidelity (no tool can); it claims token-consistency, theme-resolution,
|
|
9
|
+
* WCAG contrast (real headless-Chrome render, light + dark), and no-emoji.
|
|
10
|
+
*
|
|
11
|
+
* Usage: node scripts/accuracy_report.mjs
|
|
12
|
+
* Exit 0 only if 100% of checks pass.
|
|
13
|
+
*/
|
|
14
|
+
import { execSync } from 'node:child_process';
|
|
15
|
+
|
|
16
|
+
const checks = [
|
|
17
|
+
['Token JSON valid + aliases resolve', 'python3 scripts/validate_tokens.py'],
|
|
18
|
+
['WCAG contrast — token pairs (light + dark)', 'python3 scripts/validate_contrast.py'],
|
|
19
|
+
['Component specs complete (anatomy/variants/states/tokens/a11y)', 'python3 scripts/validate_component_spec.py'],
|
|
20
|
+
['No hardcoded values (hex/px/ms/Tailwind/font) — golden', 'python3 scripts/lint_hardcodes.py examples/golden'],
|
|
21
|
+
['No hardcoded values — sample-app', 'python3 scripts/lint_hardcodes.py examples/sample-app'],
|
|
22
|
+
['Every var(--…) resolves to the theme (no floating tokens)', 'python3 scripts/validate_theme_refs.py'],
|
|
23
|
+
['No emoji in UI output or taste files', 'python3 scripts/check_no_emoji.py'],
|
|
24
|
+
['REAL-render WCAG — sample-app (light)', 'node scripts/measure_render.mjs examples/sample-app/preview.html'],
|
|
25
|
+
['REAL-render WCAG — sample-app (dark)', 'node scripts/measure_render.mjs --dark examples/sample-app/preview.html'],
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
console.log('='.repeat(64));
|
|
29
|
+
console.log(' ACCURACY REPORT — objective correctness, reproducible');
|
|
30
|
+
console.log('='.repeat(64));
|
|
31
|
+
|
|
32
|
+
let pass = 0;
|
|
33
|
+
const fails = [];
|
|
34
|
+
for (const [label, cmd] of checks) {
|
|
35
|
+
let ok = true, out = '';
|
|
36
|
+
try { out = execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] }).toString(); }
|
|
37
|
+
catch (e) { ok = false; out = (e.stdout || '').toString() + (e.stderr || '').toString(); }
|
|
38
|
+
console.log(` ${ok ? 'PASS' : 'FAIL'} ${label}`);
|
|
39
|
+
if (ok) pass++; else fails.push([label, out.trim().split('\n').slice(-6).join('\n')]);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pct = Math.round((pass / checks.length) * 100);
|
|
43
|
+
console.log('-'.repeat(64));
|
|
44
|
+
console.log(` RESULT: ${pass}/${checks.length} checks passed = ${pct}%`);
|
|
45
|
+
if (fails.length) {
|
|
46
|
+
console.log('\n FAILURES:');
|
|
47
|
+
for (const [label, detail] of fails) console.log(`\n ✗ ${label}\n${detail.split('\n').map(l => ' ' + l).join('\n')}`);
|
|
48
|
+
console.log('\n NOT 100% — fix the above. Nothing partial ships.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
console.log('\n 100% — every objective correctness check passes. Re-run anytime to reproduce.');
|
|
52
|
+
console.log(' Scope: token-consistency, theme-resolution, WCAG AA (real render, light+dark),');
|
|
53
|
+
console.log(' no hardcodes, no emoji. Subjective visual/brand fidelity is NOT claimed here.');
|
|
54
|
+
process.exit(0);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Fail if any emoji / decorative pictograph appears in UI output or the taste doctrine.
|
|
3
|
+
|
|
4
|
+
The kit forbids emoji in product UI (taste/design-taste.md) — this enforces it so it
|
|
5
|
+
can't drift back. Scans example UI + the taste files by default.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/check_no_emoji.py # examples/ + taste/
|
|
9
|
+
python3 scripts/check_no_emoji.py path/to/src ...
|
|
10
|
+
Exit 0 = clean, 1 = an emoji/pictograph was found.
|
|
11
|
+
"""
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
17
|
+
DEFAULT = [ROOT / "examples", ROOT / "taste"]
|
|
18
|
+
EXTS = {".md", ".mdx", ".html", ".htm", ".tsx", ".jsx", ".ts", ".js",
|
|
19
|
+
".vue", ".svelte", ".css", ".scss", ".astro", ".json"}
|
|
20
|
+
|
|
21
|
+
# Emoji + dingbat pictographs (check marks, stars, etc.). Deliberately EXCLUDES
|
|
22
|
+
# arrows (U+2190–21FF) and box-drawing, which are legitimate typographic notation.
|
|
23
|
+
EMOJI = re.compile(
|
|
24
|
+
"[\U0001F000-\U0001FAFF" # symbols & pictographs, emoticons, transport, supplemental
|
|
25
|
+
"\U00002600-\U000026FF" # misc symbols
|
|
26
|
+
"\U00002700-\U000027BF" # dingbats (✅ ✔ ✗ ✨ ✂ …)
|
|
27
|
+
"\U00002B00-\U00002BFF" # misc symbols & arrows pictographs (⭐ …)
|
|
28
|
+
"\U0001FA70-\U0001FAFF"
|
|
29
|
+
"✅✔✖✨❌❓❔❕️⃣]"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def iter_files(paths):
|
|
34
|
+
for p in paths:
|
|
35
|
+
pp = Path(p)
|
|
36
|
+
if pp.is_dir():
|
|
37
|
+
for f in pp.rglob("*"):
|
|
38
|
+
if f.suffix in EXTS and "node_modules" not in f.parts:
|
|
39
|
+
yield f
|
|
40
|
+
elif pp.is_file():
|
|
41
|
+
yield pp
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main(argv):
|
|
45
|
+
paths = [Path(a) for a in argv] or DEFAULT
|
|
46
|
+
hits = []
|
|
47
|
+
files = list(iter_files(paths))
|
|
48
|
+
for f in files:
|
|
49
|
+
try:
|
|
50
|
+
text = f.read_text(encoding="utf-8")
|
|
51
|
+
except (UnicodeDecodeError, OSError):
|
|
52
|
+
continue
|
|
53
|
+
for n, line in enumerate(text.splitlines(), 1):
|
|
54
|
+
for m in EMOJI.finditer(line):
|
|
55
|
+
hits.append(f"{f}:{n}: emoji/pictograph {m.group(0)!r} — use lucide / plain text")
|
|
56
|
+
print(f"Scanned {len(files)} file(s).")
|
|
57
|
+
if hits:
|
|
58
|
+
print(f"\nFAIL: {len(hits)} emoji/pictograph(s) found:")
|
|
59
|
+
for h in hits:
|
|
60
|
+
print(" x " + h)
|
|
61
|
+
return 1
|
|
62
|
+
print("OK: no emoji in UI output or taste files.")
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
if __name__ == "__main__":
|
|
67
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -57,13 +57,17 @@ def lint_line(line, tailwind=True):
|
|
|
57
57
|
if stripped.startswith(("//", "*", "/*", "#", "<!--")):
|
|
58
58
|
return []
|
|
59
59
|
hits = []
|
|
60
|
+
# @media / @container conditions can't use var() (a CSS limitation) — breakpoint px there
|
|
61
|
+
# is not drift; skip px/ms on those lines (still check hex/tailwind/font).
|
|
62
|
+
media_cond = "@media" in line or "@container" in line
|
|
60
63
|
for m in HEX.finditer(line):
|
|
61
64
|
hits.append(("hex", m.group(0)))
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
if not media_cond:
|
|
66
|
+
for m in PX.finditer(line):
|
|
67
|
+
if m.group(0) not in PX_OK:
|
|
68
|
+
hits.append(("px", m.group(0)))
|
|
69
|
+
for m in MS.finditer(line):
|
|
70
|
+
hits.append(("time", m.group(0)))
|
|
67
71
|
if tailwind:
|
|
68
72
|
for m in TW.finditer(line):
|
|
69
73
|
hits.append(("tailwind-palette", m.group(0)))
|
|
@@ -98,7 +102,16 @@ def main(argv):
|
|
|
98
102
|
text = f.read_text()
|
|
99
103
|
except (UnicodeDecodeError, OSError):
|
|
100
104
|
continue
|
|
105
|
+
in_allow = False
|
|
101
106
|
for n, line in enumerate(text.splitlines(), 1):
|
|
107
|
+
if "ds-allow-hardcode:start" in line:
|
|
108
|
+
in_allow = True
|
|
109
|
+
continue
|
|
110
|
+
if "ds-allow-hardcode:end" in line:
|
|
111
|
+
in_allow = False
|
|
112
|
+
continue
|
|
113
|
+
if in_allow:
|
|
114
|
+
continue
|
|
102
115
|
for kind, val in lint_line(line, tailwind):
|
|
103
116
|
print(f"{f}:{n}: hardcoded {kind} '{val}' — use a token")
|
|
104
117
|
violations += 1
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* REAL-render WCAG gate. Opens HTML in headless Chrome, disables transitions,
|
|
4
|
+
* and measures the contrast of every visible text element against its true
|
|
5
|
+
* (alpha-composited) background. This is ground truth — not hand-typed numbers.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node scripts/measure_render.mjs examples/apple-demo/index.html
|
|
9
|
+
* node scripts/measure_render.mjs --dark examples/sample-app/preview.html
|
|
10
|
+
* node scripts/measure_render.mjs # defaults to examples/ *.html
|
|
11
|
+
*
|
|
12
|
+
* Requires Playwright (`npm i -D playwright` + a Chrome/Chromium). If it isn't
|
|
13
|
+
* installed the script SKIPS (exit 0) so it never blocks users who don't have it.
|
|
14
|
+
* Exit 1 only when a real rendered text pair is below WCAG 2.2 AA.
|
|
15
|
+
*/
|
|
16
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
17
|
+
import { resolve, join } from 'node:path';
|
|
18
|
+
|
|
19
|
+
let chromium;
|
|
20
|
+
try { ({ chromium } = await import('playwright')); }
|
|
21
|
+
catch { console.log('measure_render: playwright not installed — SKIPPED (npm i -D playwright to enable)'); process.exit(0); }
|
|
22
|
+
|
|
23
|
+
const argv = process.argv.slice(2);
|
|
24
|
+
const dark = argv.includes('--dark');
|
|
25
|
+
let files = argv.filter(a => !a.startsWith('--'));
|
|
26
|
+
if (files.length === 0) {
|
|
27
|
+
const root = resolve('examples');
|
|
28
|
+
const walk = d => readdirSync(d).flatMap(n => {
|
|
29
|
+
const p = join(d, n);
|
|
30
|
+
return statSync(p).isDirectory() ? walk(p) : (p.endsWith('.html') ? [p] : []);
|
|
31
|
+
});
|
|
32
|
+
try { files = walk(root); } catch { files = []; }
|
|
33
|
+
}
|
|
34
|
+
if (files.length === 0) { console.log('measure_render: no HTML files to check.'); process.exit(0); }
|
|
35
|
+
|
|
36
|
+
function lin(c) { c /= 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; }
|
|
37
|
+
function L([r, g, b]) { return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b); }
|
|
38
|
+
function ratio(a, b) { const l1 = L(a), l2 = L(b), hi = Math.max(l1, l2), lo = Math.min(l1, l2); return (hi + 0.05) / (lo + 0.05); }
|
|
39
|
+
|
|
40
|
+
let browser;
|
|
41
|
+
try { browser = await chromium.launch({ channel: 'chrome' }); }
|
|
42
|
+
catch { try { browser = await chromium.launch(); } catch (e) { console.log('measure_render: no browser available — SKIPPED'); process.exit(0); } }
|
|
43
|
+
|
|
44
|
+
let totalFail = 0;
|
|
45
|
+
for (const f of files) {
|
|
46
|
+
const page = await browser.newPage();
|
|
47
|
+
await page.goto('file://' + resolve(f));
|
|
48
|
+
await page.addStyleTag({ content: '*{transition:none!important;animation:none!important}' });
|
|
49
|
+
if (dark) await page.evaluate(() => document.documentElement.setAttribute('data-theme', 'dark'));
|
|
50
|
+
|
|
51
|
+
const items = await page.evaluate(() => {
|
|
52
|
+
const toRGBA = s => { const m = s && s.match(/[\d.]+/g); return m ? [+m[0], +m[1], +m[2], m[3] !== undefined ? +m[3] : 1] : null; };
|
|
53
|
+
function bgOf(el) {
|
|
54
|
+
const stack = []; let n = el;
|
|
55
|
+
while (n) { const c = toRGBA(getComputedStyle(n).backgroundColor); if (c && c[3] > 0) { stack.push(c); if (c[3] >= 1) break; } n = n.parentElement; }
|
|
56
|
+
let base = [255, 255, 255];
|
|
57
|
+
for (let i = stack.length - 1; i >= 0; i--) { const [r, g, b, a] = stack[i]; base = [r * a + base[0] * (1 - a), g * a + base[1] * (1 - a), b * a + base[2] * (1 - a)]; }
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
62
|
+
if (['SCRIPT', 'STYLE', 'SVG', 'PATH', 'USE'].includes(el.tagName)) continue;
|
|
63
|
+
const direct = [...el.childNodes].some(n => n.nodeType === 3 && n.textContent.trim().length);
|
|
64
|
+
if (!direct) continue;
|
|
65
|
+
if (!el.offsetParent && getComputedStyle(el).position !== 'fixed') continue; // not visible
|
|
66
|
+
const cs = getComputedStyle(el);
|
|
67
|
+
if (cs.visibility === 'hidden' || +cs.opacity === 0) continue;
|
|
68
|
+
const col = toRGBA(cs.color); if (!col) continue;
|
|
69
|
+
const px = parseFloat(cs.fontSize), bold = parseInt(cs.fontWeight, 10) >= 700;
|
|
70
|
+
out.push({ tag: el.tagName.toLowerCase() + (el.className ? '.' + String(el.className).split(' ')[0] : ''),
|
|
71
|
+
text: el.textContent.trim().slice(0, 24), color: [col[0], col[1], col[2]], bg: bgOf(el), px, bold });
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
});
|
|
75
|
+
await page.close();
|
|
76
|
+
|
|
77
|
+
const fails = [];
|
|
78
|
+
for (const it of items) {
|
|
79
|
+
const need = (it.px >= 24 || (it.px >= 18.66 && it.bold)) ? 3.0 : 4.5;
|
|
80
|
+
const r = ratio(it.color, it.bg);
|
|
81
|
+
if (r < need) fails.push({ ...it, r, need });
|
|
82
|
+
}
|
|
83
|
+
const mode = dark ? ' [dark]' : '';
|
|
84
|
+
if (fails.length) {
|
|
85
|
+
totalFail += fails.length;
|
|
86
|
+
console.log(`\nFAIL ${f}${mode} — ${fails.length} text pair(s) below WCAG AA:`);
|
|
87
|
+
for (const x of fails) console.log(` ✗ <${x.tag}> "${x.text}" ${x.r.toFixed(2)}:1 (need ${x.need}) [rgb(${x.color.map(Math.round)}) on rgb(${x.bg.map(Math.round)})]`);
|
|
88
|
+
} else {
|
|
89
|
+
console.log(`OK ${f}${mode} — all ${items.length} text element(s) meet WCAG AA`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
await browser.close();
|
|
93
|
+
if (totalFail) { console.log(`\n${totalFail} real-rendered contrast failure(s).`); process.exit(1); }
|
|
94
|
+
console.log('\nOK: every rendered text element meets WCAG 2.2 AA.');
|
|
95
|
+
process.exit(0);
|
package/taste/design-taste.md
CHANGED
|
@@ -6,6 +6,21 @@ Tokens, components, and accessibility make a design *correct*. **Taste** makes i
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## Brief Inference — decide before you generate
|
|
10
|
+
|
|
11
|
+
Slop comes from generating before deciding. Do NOT open with default components. First infer the brief from the request (one or two lines is enough) and commit to it:
|
|
12
|
+
|
|
13
|
+
- **Industry / domain** — fintech, editorial, dev-tool, healthcare, consumer hardware… (sets density, restraint, trust cues)
|
|
14
|
+
- **Audience & tone** — expert vs. first-time; calm vs. energetic; premium vs. utilitarian
|
|
15
|
+
- **Mood** — the adjective the result must earn: "expensive", "editorial", "precise", "warm", "brutal"
|
|
16
|
+
- **Motion depth** — none / subtle feedback / expressive choreography
|
|
17
|
+
- **Layout family** — the section-archetype sequence you'll use (see Variance Mandate)
|
|
18
|
+
- **Reference anchor** — an archetype or named system from `taste/aesthetic-systems.md` to aim at
|
|
19
|
+
|
|
20
|
+
Write these down, then generate to them. If you can't name the mood and the layout family, you will regress to the mean. This step is mandatory in `design-code`, `apply-aesthetic`, and `redesign`.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
9
24
|
## The Core Problem: Statistical Slop
|
|
10
25
|
|
|
11
26
|
A model left to its defaults regresses to the mean — and the mean is mediocre. These are the recurring "tells" of machine-generated UI. Treat each as a **defect to actively break**, not a starting point.
|
|
@@ -23,7 +38,7 @@ A model left to its defaults regresses to the mean — and the mean is mediocre.
|
|
|
23
38
|
| Rainbow of accent colors | No discipline | One primary, one accent max; neutrals carry the weight |
|
|
24
39
|
| Cramped vertical spacing | Feels dense and anxious | Generous macro-whitespace between sections (see Spatial Rhythm) |
|
|
25
40
|
| Default system spacing (8px everywhere) | No rhythm | Intentional spacing scale with large jumps at section level |
|
|
26
|
-
| Emoji
|
|
41
|
+
| Emoji anywhere in product UI (icons, labels, toggles) | Reads as a toy; inconsistent across platforms | Real icon set (**lucide**) as inline SVG with `currentColor`, consistent grid and weight — never emoji, including in JS that swaps a label |
|
|
27
42
|
|
|
28
43
|
---
|
|
29
44
|
|
|
@@ -44,6 +59,20 @@ Pick from distinct section archetypes and avoid repeating one back-to-back:
|
|
|
44
59
|
|
|
45
60
|
---
|
|
46
61
|
|
|
62
|
+
## Block Coherence (iterative additions)
|
|
63
|
+
|
|
64
|
+
Pages are built one block at a time, often across turns. Each new block must read as part of **one** system, not a fresh start. Before adding a block, re-read what exists and match its contract:
|
|
65
|
+
|
|
66
|
+
- **Same tokens, same scale** — reuse the established type scale, spacing rhythm, radius language, and the one primary + one accent. A new block never introduces a new color, font, or radius.
|
|
67
|
+
- **Same primitives** — reuse the existing `Button`/`Card`/`Input` components; don't hand-roll a parallel version with different padding or states.
|
|
68
|
+
- **Vary composition, not vocabulary** — the *layout* should differ from the previous block (Variance Mandate), but the *materials* (color, type, depth, motion) stay constant.
|
|
69
|
+
- **Density continuity** — keep the same information density and whitespace hierarchy as adjacent blocks; a sudden dense table after airy hero bands reads as bolted-on.
|
|
70
|
+
- **State + a11y parity** — every interactive block carries the same hover/focus/press/disabled treatment and the same dark-mode behavior as the rest.
|
|
71
|
+
|
|
72
|
+
> Test: drop the new block in isolation next to an existing one. If a stranger could tell they were authored separately, it fails coherence.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
47
76
|
## Typography Taste
|
|
48
77
|
|
|
49
78
|
Type is 90% of taste. Tokens live in `tokens/typography.json`; here is how to *wield* them.
|
|
@@ -103,17 +132,17 @@ Whitespace is not empty — it is the most reliable signal of confidence.
|
|
|
103
132
|
|
|
104
133
|
Before shipping any visual output, confirm:
|
|
105
134
|
|
|
106
|
-
1.
|
|
107
|
-
2.
|
|
108
|
-
3.
|
|
109
|
-
4.
|
|
110
|
-
5.
|
|
111
|
-
6.
|
|
112
|
-
7.
|
|
113
|
-
8.
|
|
114
|
-
9.
|
|
115
|
-
10.
|
|
116
|
-
11.
|
|
117
|
-
12.
|
|
135
|
+
1. No heading wraps past ~3 lines; display type is wide and short
|
|
136
|
+
2. Adjacent sections do **not** share the same layout archetype
|
|
137
|
+
3. Body text constrained to 60–75ch
|
|
138
|
+
4. One primary color + at most one accent; neutrals carry the rest
|
|
139
|
+
5. Off-black text on warm/cool-white surface (no pure `#000`/`#fff`)
|
|
140
|
+
6. Section-level whitespace is generous; spacing hierarchy is outer > inner
|
|
141
|
+
7. Depth comes from layered surfaces/hairlines, not a shadow on every box
|
|
142
|
+
8. Bento/grid has no empty dead cells; alignment is exact
|
|
143
|
+
9. All interactive elements have hover + focus + press states
|
|
144
|
+
10. **No emoji anywhere in the UI** (use a real icon set — lucide — as inline SVG); no "SECTION 01" filler labels; no invisible button text
|
|
145
|
+
11. Reduced-motion fallback present
|
|
146
|
+
12. Every value traces to a token — nothing hardcoded
|
|
118
147
|
|
|
119
148
|
> If the output passes the system checks (tokens, a11y, states) **and** this aesthetic check, it is ready. If taste and a system rule conflict, the system rule wins.
|
|
@@ -55,12 +55,12 @@ Tokens in `tokens/motion.json` define the *vocabulary* (durations, easings, pres
|
|
|
55
55
|
|
|
56
56
|
## Anti-Patterns (the cheap tells)
|
|
57
57
|
|
|
58
|
-
-
|
|
59
|
-
-
|
|
60
|
-
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
58
|
+
- **Avoid:** everything animates on load — nothing feels important.
|
|
59
|
+
- **Avoid:** long durations (> 500ms) on routine UI — feels sluggish.
|
|
60
|
+
- **Avoid:** bounce/spring on every element — toy-like.
|
|
61
|
+
- **Avoid:** animating `box-shadow`/`filter`/`width` — jank.
|
|
62
|
+
- **Avoid:** motion with no reduced-motion fallback — accessibility failure.
|
|
63
|
+
- **Avoid:** scroll-jacking that fights the user's scroll — abandon.
|
|
64
64
|
|
|
65
65
|
---
|
|
66
66
|
|
package/workflows/design-qa.md
CHANGED
|
@@ -19,6 +19,7 @@ Automated gates that keep quality from regressing as the system changes. Manual
|
|
|
19
19
|
- `scripts/validate_tokens.py` — JSON valid + all aliases resolve.
|
|
20
20
|
- **No hard-coded values** — lint/grep for raw hex, px, and timing in component code; every value must trace to a token (CLAUDE.md Output Rules). Fail the build on a raw `#`, `px` in styles outside the token layer, or magic durations.
|
|
21
21
|
- `scripts/contrast.py` — gate color/token changes against WCAG (4.5:1 text, 3:1 UI; 7:1 if targeting AAA).
|
|
22
|
+
- **`scripts/measure_render.mjs` (real-render gate)** — the static checks above verify the token palette; this verifies the **actual rendered result**. It opens the HTML in headless Chromium, disables transitions/animations, walks the DOM, and measures every visible text element's computed-style contrast against its true alpha-composited background, in light and `--dark`. Use this to catch defects static checks can't (wrong variant rendering dark-on-color, a transparent button over the wrong surface, a transition mid-state). Requires Playwright (`npm i -D playwright`); skips cleanly if absent.
|
|
22
23
|
|
|
23
24
|
## 2. Automated accessibility (every PR)
|
|
24
25
|
|