@bacnh85/pi-ux 0.3.0 → 0.4.3
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/CHANGELOG.md +83 -0
- package/README.md +29 -19
- package/extensions/index.js +17 -10
- package/hooks/ux-audit.js +241 -40
- package/package.json +2 -2
- package/skills/ux-design/SKILL.md +68 -22
- package/skills/ux-presets/SKILL.md +271 -25
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,88 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.3
|
|
4
|
+
|
|
5
|
+
- **Named styles via the design.md library.** `ux-presets` now documents a
|
|
6
|
+
reuse path for named aesthetics (Claymorphism, Brutalism, Bento, Art Deco, …):
|
|
7
|
+
fetch the canonical `DESIGN.md` from the [design.md style library](https://designmd.app/library),
|
|
8
|
+
`npx @google/design.md lint` it, then `ux_audit` — block handoff on fail. No
|
|
9
|
+
style is pre-approved: the stock Claymorphism template ships a button that
|
|
10
|
+
fails `ux_audit` at **Lc −15.67** (lilac `#E6E6FA` on peach `#FDBCB4`),
|
|
11
|
+
corrected to ink-on-peach (Lc 75.72 ✓). Glassmorphism and neumorphism remain
|
|
12
|
+
banned slop tells regardless of library entry.
|
|
13
|
+
- Step 2 of "How to use with ux-design" names the library path alongside
|
|
14
|
+
shadcn/Material/Radix as reuse-before-invent options.
|
|
15
|
+
|
|
16
|
+
## 0.4.2
|
|
17
|
+
|
|
18
|
+
- **Review fixes** (7 findings, all pinned with regression tests):
|
|
19
|
+
- **FIX (HIGH)** `parseOklch` silently corrupted percentage lightness: the
|
|
20
|
+
regex placed `%?` outside the capture group, so `oklch(L% C H)` (the
|
|
21
|
+
standard CSS form) never divided L by 100, clamping any `0 < L < 100` to
|
|
22
|
+
white. Captured inside the group now. Affects both APCA and WCAG.
|
|
23
|
+
- **FIX (latent)** `parseOklch` returned linear sRGB without gamma encoding;
|
|
24
|
+
APCA's `sRGBtoY` then double-linearized oklch values, making contrast
|
|
25
|
+
wrong. Added the standard linear→sRGB gamma transfer. oklch contrast now
|
|
26
|
+
matches the equivalent hex.
|
|
27
|
+
- **FIX (MEDIUM, FP)** neon-glow heuristic flagged a normal accent shadow
|
|
28
|
+
(≤12px coloured blur) as slop — the `/0\.d/` alpha fallback was too broad.
|
|
29
|
+
Rewritten to require a blur radius ≥ 20px (the actual glow signature).
|
|
30
|
+
- **FIX (MEDIUM, FN)** neon-glow heuristic missed coloured **hex** glow
|
|
31
|
+
shadows (only inspected `rgba?()`). Now parses hex colours in shadows too.
|
|
32
|
+
- **FIX (LOW)** gradient-orb regex truncated at the first inner `)` (nested
|
|
33
|
+
`rgba`/`hsl` stops). Now captures one level of nested parens.
|
|
34
|
+
- **FIX (LOW)** `apcaThreshold` gave bold body text (14–17px) the relaxed
|
|
35
|
+
Lc 45; per APCA guidance bold body text stays at Lc 60 until ≥18px.
|
|
36
|
+
- **FIX (LOW)** alpha hex (`#RRGGBBAA` / `#RGBA`) was invisible to the
|
|
37
|
+
token gate (`\b` failed between adjacent hex digits). Switched to a
|
|
38
|
+
negative-lookahead regex matching 3/4/6/8-digit hex.
|
|
39
|
+
- 12 new regression tests (64 → 76).
|
|
40
|
+
|
|
41
|
+
## 0.4.1
|
|
42
|
+
|
|
43
|
+
- **Medium-tuned DESIGN.md presets (Web + Mobile).** `ux-presets` Option B is
|
|
44
|
+
now split into **B1 Web** (mouse + keyboard: hover, `:focus-visible`, desktop
|
|
45
|
+
type scale, 65ch measure, responsive container) and **B2 Mobile** (touch-first:
|
|
46
|
+
≥44pt tap targets, `:active`/pressed with NO `:hover`, safe-area insets,
|
|
47
|
+
16px base to avoid iOS zoom, thumb-zone layout, iOS-HIG/Material notes). Each
|
|
48
|
+
is spec-compliant and lintable from day one.
|
|
49
|
+
- **Step 0 unblocks when DESIGN.md is missing.** The resolution order is now:
|
|
50
|
+
(1) use repo-root DESIGN.md if present; (2) reuse an already-wired system
|
|
51
|
+
(shadcn/MUI/Radix/Tailwind); (3) otherwise drop in a medium-tuned preset
|
|
52
|
+
(infer Web vs Mobile from the task; ASK only if genuinely unclear) as the
|
|
53
|
+
implicit system and keep generating; (4) only if no preset fits, generate one
|
|
54
|
+
once with `agy`. The agent no longer stalls in the common no-DESIGN.md case;
|
|
55
|
+
it offers to persist the preset to repo root but does not auto-write.
|
|
56
|
+
|
|
57
|
+
## 0.4.0
|
|
58
|
+
|
|
59
|
+
- **DESIGN.md anchor (shift-left).** Step 0 of the ux-design method now points
|
|
60
|
+
at a repo-root **DESIGN.md** (Google Labs open standard) as the single source
|
|
61
|
+
of truth the agent reads before styling. Lint via shell-out:
|
|
62
|
+
`npx @google/design.md lint DESIGN.md` — pi-ux orchestrates the tool, it is
|
|
63
|
+
NOT a runtime dependency (pi-ux stays zero-dep). The `ux-presets` skill ships
|
|
64
|
+
a spec-compliant DESIGN.md starter alongside the existing `:root` CSS block.
|
|
65
|
+
- **APCA contrast (primary gate) + WCAG 2.x sidecar.** The `ux_audit` contrast
|
|
66
|
+
gate now reports perceptual APCA Lc as the primary pass/fail (Lc ≥75 body,
|
|
67
|
+
≥45 large/bold, ≥30 non-text) with the legacy WCAG ratio shown as a
|
|
68
|
+
compliance sidecar for orgs that must report it. APCA catches dark-theme +
|
|
69
|
+
thin-type slop that WCAG 2.x misses — the exact text-only-model failure
|
|
70
|
+
mode. `pairs` gain optional `weight`/`size` to set the APCA threshold.
|
|
71
|
+
- **oklch() support.** Colour parsing now accepts `oklch(L C H)` in addition
|
|
72
|
+
to hex (DESIGN.md allows oklch values).
|
|
73
|
+
- **Slop-tell gate (4th gate).** New deterministic gate flagging named AI
|
|
74
|
+
signatures: glassmorphism (`backdrop-filter`), gradient orbs, neon glow,
|
|
75
|
+
the untouched shadcn default-card reflex (`rounded-2xl`+`shadow-lg`+`p-6`),
|
|
76
|
+
and 1px gray card borders. Co-occurrence heuristics avoid false positives on
|
|
77
|
+
legitimate token-based elevation.
|
|
78
|
+
- **Deterministic-first model routing.** The skill's model-routing table is
|
|
79
|
+
rewritten: text-only models (DeepSeek-v4, GLM-5.2, Kimi K3) now do
|
|
80
|
+
generation + normalisation inside a locked system; `agy`/Gemini/Claude is
|
|
81
|
+
demoted to optional one-time DESIGN.md generation + optional polish — never
|
|
82
|
+
the review gate. Text-only models now lead frontend (Kimi K3 is #1 on the
|
|
83
|
+
Arena.ai Frontend Code Arena, ahead of Claude Fable 5).
|
|
84
|
+
- Zero dependencies retained. Plain JS, `node --test` (64 tests).
|
|
85
|
+
|
|
3
86
|
## 0.3.0
|
|
4
87
|
|
|
5
88
|
- **`ux_audit` tool (deterministic slop-audit gate).** New LLM-callable tool
|
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# pi-ux
|
|
2
2
|
|
|
3
|
-
Anti-slop UI/UX design discipline for the [Pi coding agent](https://pi.dev).
|
|
4
|
-
|
|
5
|
-
This is a **discipline enforcer**, not a generator. Generation stays with `agy_execute` (Gemini/Claude) and the main Pi models (DeepSeek-v4, GLM-5.2); pi-ux makes sure their output lands inside a defensible design system.
|
|
3
|
+
Anti-slop UI/UX design discipline for the [Pi coding agent](https://pi.dev). Anchors a lintable **DESIGN.md**, runs **deterministic** slop-audit gates (APCA contrast + tokens + states + slop tells), and works **with text-only models** (DeepSeek-v4, GLM-5.2, Kimi K3) — `agy`/Gemini/Claude is optional polish, never the review gate.
|
|
6
4
|
|
|
7
5
|
## Why
|
|
8
6
|
|
|
9
|
-
AI-generated UI converges on the same defaults — purple/indigo glow, shadow-heavy cards, missing focus/disabled/error states — because under vague direction
|
|
7
|
+
AI-generated UI converges on the same defaults — purple/indigo glow, shadow-heavy cards, missing focus/disabled/error states — because under vague direction, models reach for high-frequency statistical patterns. Slop is an **ownership problem**: "the design has no owner at the system level." The fix is shift-left: own the system in a DESIGN.md, then gate deterministically.
|
|
8
|
+
|
|
9
|
+
**Text-only models now lead frontend** (Kimi K3, an open MIT model, is #1 on the Arena.ai Frontend Code Arena, ahead of Claude Fable 5). Inside a fully-specified system they produce non-slop UI — which means the review gate can be mechanical, not a vision-LLM call.
|
|
10
10
|
|
|
11
11
|
## Install
|
|
12
12
|
|
|
@@ -41,45 +41,55 @@ The guardrail is injected into the system prompt on every agent start while acti
|
|
|
41
41
|
|
|
42
42
|
The injected skill enforces **Constraint-First Design Generation**:
|
|
43
43
|
|
|
44
|
-
0. **Own the system** —
|
|
44
|
+
0. **Own the system via DESIGN.md** — a repo-root `DESIGN.md` (Google Labs open standard: YAML token frontmatter + rationale) is the single source of truth. Lint it before generating:
|
|
45
|
+
```bash
|
|
46
|
+
npx @google/design.md lint DESIGN.md
|
|
47
|
+
```
|
|
48
|
+
If absent, reuse a preset (`ux-presets` skill: shadcn/Material/Radix) or scaffold one once. pi-ux orchestrates `@google/design.md` via shell-out — **not** a runtime dependency.
|
|
45
49
|
1. **5-field brief per screen** — user job, inventory, token constraints, required states, one reference.
|
|
46
|
-
2. **Generate fast, converge early** — 2-loop convergence trigger
|
|
50
|
+
2. **Generate fast, converge early** — text-only models inside the locked system; 2-loop convergence trigger.
|
|
47
51
|
3. **Normalise** the draft back into tokens/elevation/spacing.
|
|
48
|
-
4. **Slop-audit gate** — run `ux_audit` (measurable):
|
|
52
|
+
4. **Slop-audit gate** — run `ux_audit` (measurable): APCA contrast, token coverage, state coverage, slop tells.
|
|
49
53
|
|
|
50
54
|
## The `ux_audit` tool
|
|
51
55
|
|
|
52
56
|
Deterministic slop-audit — no model needed, all gates are computable:
|
|
53
57
|
|
|
54
58
|
```
|
|
55
|
-
ux_audit css="..." pairs=[{fg:"#111",bg:"#fff",label:"body",min:4.5}]
|
|
59
|
+
ux_audit css="..." pairs=[{fg:"#111",bg:"#fff",label:"body",weight:400,size:16,min:4.5}]
|
|
56
60
|
```
|
|
57
61
|
|
|
58
62
|
| Gate | What it checks |
|
|
59
63
|
|------|----------------|
|
|
60
|
-
| **Contrast** |
|
|
64
|
+
| **Contrast (APCA)** | Perceptual APCA Lc per fg/bg pair (Lc ≥75 body, ≥45 large-bold, ≥30 non-text). hex or `oklch()`. Optional `weight`/`size` set the threshold. WCAG 2.x ratio shown as a compliance sidecar. |
|
|
61
65
|
| **Tokens** | Hardcoded hex outside `:root` token defs; `box-shadow` not built from `var(--…)` tokens |
|
|
62
66
|
| **States** | Interactive selectors (`button`/`a`/`input`/…/`[role=button]`) missing `:focus-visible` or `:disabled` |
|
|
67
|
+
| **Slop tells** | Named AI signatures: glassmorphism (`backdrop-filter`), gradient orbs, neon glow, the shadcn default-card reflex (`rounded-2xl`+`shadow-lg`+`p-6`), 1px gray card borders |
|
|
63
68
|
|
|
64
69
|
Returns pass/fail per gate + a formatted report. In `strict` mode this is the gate that blocks handoff.
|
|
65
70
|
|
|
66
|
-
|
|
71
|
+
**Why APCA over WCAG 2.x:** APCA is perceptual and accounts for font weight/size; it catches dark-theme + thin-type slop that the legacy WCAG ratio misses. Example: `#aaa` on `#1e1e1e` scores APCA Lc -54.4 (fails ≥75) but WCAG 7.18:1 (passes ≥4.5) — APCA catches what WCAG can't.
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
- **`ux-presets`** — reference design-system presets for Step 0: reuse shadcn/ui / Material 3 / Radix, or copy a compact token set. Reference only — no bundled CSS.
|
|
73
|
+
## Model routing (deterministic-first)
|
|
70
74
|
|
|
71
|
-
The skill tells the agent which model to use for each step:
|
|
75
|
+
The skill tells the agent which model to use for each step. The gate is mechanical, not a vision-LLM call:
|
|
72
76
|
|
|
73
77
|
| Step | Best tool/model |
|
|
74
78
|
|------|-----------------|
|
|
75
|
-
| Define system (
|
|
76
|
-
|
|
|
77
|
-
|
|
|
79
|
+
| Define system (DESIGN.md) | Reuse a preset OR `agy_execute mode=plan pro-high` (Gemini) **once** |
|
|
80
|
+
| Lint system | `npx @google/design.md lint DESIGN.md` (shell-out) |
|
|
81
|
+
| Per-screen brief | **GLM-5.2** (1M ctx) |
|
|
82
|
+
| Generate variants | **DeepSeek-v4**, **GLM-5.2**, or **Kimi K3** (text-only, inside constraints) |
|
|
78
83
|
| Normalise into system | **DeepSeek-v4** or **GLM-5.2** |
|
|
79
|
-
| Slop audit | `ux_audit` tool (deterministic) |
|
|
80
|
-
|
|
|
84
|
+
| Slop audit | `ux_audit` tool (deterministic) + DESIGN.md lint |
|
|
85
|
+
| Optional polish (never a gate) | `agy_execute mode=accept-edits sonnet` (Claude) |
|
|
86
|
+
|
|
87
|
+
**The inversion rule:** the cheaper/weaker the model, the MORE you must externalise constraints. **The deterministic-first principle:** don't spend vision-model quota on what `ux_audit` computes for free.
|
|
88
|
+
|
|
89
|
+
## Skills
|
|
81
90
|
|
|
82
|
-
|
|
91
|
+
- **`ux-design`** — the Constraint-First method + deterministic-first model routing (auto-injected by the hook when active).
|
|
92
|
+
- **`ux-presets`** — reference design-system presets for Step 0: a lintable DESIGN.md starter, the shadcn/Material/Radix reuse table, and a CSS-only `:root` fallback. Reference only — no bundled CSS.
|
|
83
93
|
|
|
84
94
|
## Configuration
|
|
85
95
|
|
package/extensions/index.js
CHANGED
|
@@ -71,15 +71,17 @@ function auditParametersSchema() {
|
|
|
71
71
|
},
|
|
72
72
|
pairs: {
|
|
73
73
|
type: "array",
|
|
74
|
-
description: "Foreground/background colour pairs to check for WCAG
|
|
74
|
+
description: "Foreground/background colour pairs to check for contrast (APCA primary + WCAG sidecar). fg + bg as #hex or oklch(); optional weight/size set the APCA threshold; min is the WCAG compliance floor.",
|
|
75
75
|
items: {
|
|
76
76
|
type: "object",
|
|
77
77
|
additionalProperties: false,
|
|
78
78
|
properties: {
|
|
79
|
-
fg: { type: "string", description: "Foreground colour, e.g. '#111111'." },
|
|
79
|
+
fg: { type: "string", description: "Foreground colour, e.g. '#111111' or 'oklch(60% 0.18 250)'." },
|
|
80
80
|
bg: { type: "string", description: "Background colour, e.g. '#ffffff'." },
|
|
81
81
|
label: { type: "string", description: "Human label for this text style (e.g. 'body')." },
|
|
82
|
-
min: { type: "number", description: "
|
|
82
|
+
min: { type: "number", description: "WCAG compliance floor (4.5 body, 3.0 large/UI). Shown as a sidecar; the primary gate is APCA. Defaults to 4.5." },
|
|
83
|
+
weight: { type: "number", description: "Font weight (400/500/700). With size, sets the APCA threshold. Defaults to 400." },
|
|
84
|
+
size: { type: "number", description: "Font size in px. With weight, sets the APCA threshold. Defaults to 16." },
|
|
83
85
|
},
|
|
84
86
|
required: ["fg", "bg"],
|
|
85
87
|
},
|
|
@@ -94,10 +96,11 @@ function formatAuditResult(result) {
|
|
|
94
96
|
lines.push("");
|
|
95
97
|
|
|
96
98
|
const c = result.gates.contrast;
|
|
97
|
-
lines.push(c.pass ? "✓ Contrast" : "✗ Contrast");
|
|
99
|
+
lines.push(c.pass ? "✓ Contrast (APCA)" : "✗ Contrast (APCA)");
|
|
98
100
|
for (const r of c.results) {
|
|
101
|
+
const lc = r.apca === null ? "n/a" : `Lc ${r.apca}`;
|
|
99
102
|
const ratio = r.ratio === null ? "n/a" : `${r.ratio.toFixed(2)}:1`;
|
|
100
|
-
lines.push(` ${r.pass ? "✓" : "✗"} ${r.label || `${r.fg}/${r.bg}`}: ${ratio} (min ${r.min ?? 4.5})`);
|
|
103
|
+
lines.push(` ${r.pass ? "✓" : "✗"} ${r.label || `${r.fg}/${r.bg}`}: ${lc} (min Lc ${r.apcaMin}) · WCAG ${ratio} (min ${r.min ?? 4.5})`);
|
|
101
104
|
}
|
|
102
105
|
|
|
103
106
|
const t = result.gates.tokens;
|
|
@@ -110,6 +113,10 @@ function formatAuditResult(result) {
|
|
|
110
113
|
for (const m of s.missingFocusVisible) lines.push(` ✗ ${m}`);
|
|
111
114
|
for (const m of s.missingDisabled) lines.push(` ✗ ${m}`);
|
|
112
115
|
|
|
116
|
+
const st = result.gates.slopTells;
|
|
117
|
+
lines.push(st.pass ? "✓ Slop tells" : "✗ Slop tells");
|
|
118
|
+
for (const tell of st.tells) lines.push(` ✗ ${tell}`);
|
|
119
|
+
|
|
113
120
|
return lines.join("\n");
|
|
114
121
|
}
|
|
115
122
|
|
|
@@ -149,12 +156,12 @@ export default function uxExtension(pi) {
|
|
|
149
156
|
name: "ux_audit",
|
|
150
157
|
label: "UX Slop Audit",
|
|
151
158
|
description:
|
|
152
|
-
"Run deterministic slop-audit gates on CSS: WCAG
|
|
153
|
-
promptSnippet: "Run deterministic UX slop-audit (contrast + tokens + states)",
|
|
159
|
+
"Run deterministic slop-audit gates on CSS: APCA contrast (perceptual; WCAG sidecar), off-system token values (hardcoded hex / ad-hoc shadows), missing interaction states (:focus-visible / :disabled), and named AI slop tells (glassmorphism, gradient orbs, neon glow, default-card). No model needed — all gates are computable. In strict mode, block handoff until this passes.",
|
|
160
|
+
promptSnippet: "Run deterministic UX slop-audit (APCA contrast + tokens + states + slop tells)",
|
|
154
161
|
promptGuidelines: [
|
|
155
|
-
"Contrast
|
|
156
|
-
"Pass fg/bg colour pairs to
|
|
157
|
-
"Pass the CSS string to scan for hardcoded hex
|
|
162
|
+
"Contrast, token-coverage, and slop-tells are computable, not judgement — use this tool instead of eyeballing or calling a vision model.",
|
|
163
|
+
"Pass fg/bg colour pairs (hex or oklch()) + optional weight/size to set the APCA threshold; the WCAG ratio is shown as a compliance sidecar.",
|
|
164
|
+
"Pass the CSS string to scan for hardcoded hex, ad-hoc box-shadow, and named AI tells (glassmorphism, gradient orbs, neon glow, the shadcn default-card reflex, 1px gray borders).",
|
|
158
165
|
"State coverage flags interactive elements (button/a/input/...) missing :focus-visible or :disabled rules.",
|
|
159
166
|
],
|
|
160
167
|
parameters: auditParametersSchema(),
|
package/hooks/ux-audit.js
CHANGED
|
@@ -1,35 +1,139 @@
|
|
|
1
1
|
// pi-ux — deterministic slop-audit engine (no model, no deps).
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// 1. Contrast — WCAG 2.x
|
|
3
|
+
// Four gates, all computable:
|
|
4
|
+
// 1. Contrast — APCA Lc (primary, perceptual) + WCAG 2.x ratio (sidecar).
|
|
5
5
|
// 2. Tokens — off-system values (raw hex, magic px, ad-hoc shadows).
|
|
6
6
|
// 3. States — interactive elements missing focus-visible / disabled.
|
|
7
|
+
// 4. SlopTells — named AI signatures (glassmorphism, orbs, glow, default-card).
|
|
7
8
|
//
|
|
8
|
-
// Ponytail:
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Ponytail: all gates are mechanical linting, not judgement. APCA math is
|
|
10
|
+
// ~25 lines; WCAG is ~10; token scan is regex; state scan is substring; tell
|
|
11
|
+
// scan is co-occurrence heuristics. No wcag-contrast lib, no css parser, no AST.
|
|
11
12
|
|
|
12
|
-
// ---
|
|
13
|
+
// --- colour parsing -------------------------------------------------------
|
|
14
|
+
// Accepts hex (3/4/6/8-digit) and oklch(L C H) / oklch(L C H / a). Returns
|
|
15
|
+
// [r,g,b] 0-255, or null. oklch support matters because DESIGN.md (Google's
|
|
16
|
+
// open standard) allows oklch() values.
|
|
17
|
+
|
|
18
|
+
function parseHex(hex) {
|
|
19
|
+
const m = /^#?([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})$/i.exec(String(hex).trim());
|
|
20
|
+
if (!m) return null;
|
|
21
|
+
let h = m[1];
|
|
22
|
+
if (h.length === 8) h = h.slice(0, 6); // drop alpha
|
|
23
|
+
if (h.length === 4) h = h.slice(0, 3);
|
|
24
|
+
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
25
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ponytail: standard oklch->linear-sRGB->sRGB. ~20 lines, no dep. Handles the
|
|
29
|
+
// cases DESIGN.md allows; guards NaN/overflow.
|
|
30
|
+
function parseOklch(str) {
|
|
31
|
+
const m = /^oklch\(\s*([0-9.]+%?)\s+([0-9.]+)\s+([0-9.]+)(?:deg)?\s*(?:\/\s*([0-9.]+))?%?\s*\)$/i.exec(String(str).trim());
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
const L = Math.min(Math.max(parseFloat(m[1]) / (String(m[1]).endsWith('%') ? 100 : 1), 0), 1);
|
|
34
|
+
const C = parseFloat(m[2]);
|
|
35
|
+
const Hdeg = parseFloat(m[3]);
|
|
36
|
+
if ([L, C, Hdeg].some(Number.isNaN)) return null;
|
|
37
|
+
const h = (Hdeg * Math.PI) / 180;
|
|
38
|
+
const a = C * Math.cos(h);
|
|
39
|
+
const b = C * Math.sin(h);
|
|
40
|
+
// OKLab -> linear sRGB
|
|
41
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
42
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
43
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
44
|
+
const l3 = l_ ** 3, m3 = m_ ** 3, s3 = s_ ** 3;
|
|
45
|
+
let r = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
|
|
46
|
+
let g = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
|
|
47
|
+
let bl = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
|
|
48
|
+
// linear -> sRGB gamma encoding, then to 0-255. APCA's sRGBtoY expects
|
|
49
|
+
// gamma-encoded sRGB (same as hex produces); without this, oklch values
|
|
50
|
+
// double-linearize and contrast is wrong.
|
|
51
|
+
const enc = (c) => {
|
|
52
|
+
c = Math.min(Math.max(c, 0), 1);
|
|
53
|
+
return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055;
|
|
54
|
+
};
|
|
55
|
+
const to255 = (c) => Math.round(enc(c) * 255);
|
|
56
|
+
return [to255(r), to255(g), to255(bl)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function parseColor(input) {
|
|
60
|
+
const s = String(input).trim();
|
|
61
|
+
if (s.startsWith('#') || /^[0-9a-f]{3,8}$/i.test(s)) return parseHex(s);
|
|
62
|
+
if (s.toLowerCase().startsWith('oklch')) return parseOklch(s);
|
|
63
|
+
// ponytail: named/hsl/rgb not supported — DESIGN.md tokens use hex/oklch.
|
|
64
|
+
// Tokens with other formats are just not contrast-checkable here; the gate
|
|
65
|
+
// still runs on the pairs that ARE parseable.
|
|
66
|
+
return parseHex(s); // falls back to hex parser (returns null on garbage)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// --- APCA contrast (primary gate) -----------------------------------------
|
|
70
|
+
// Canonical APCA 0.0.98G-4g (W3, constants fixed since Feb 2021). Source:
|
|
71
|
+
// Myndex/apca-w3 master src/apca-w3.js (fetched 2024). Returns signed Lc:
|
|
72
|
+
// positive = dark text on light bg (BoW); negative = light text on dark (WoB).
|
|
73
|
+
// Range ≈ ±106. Polarity matters — text is the FIRST arg, bg the SECOND.
|
|
74
|
+
|
|
75
|
+
const APCA = {
|
|
76
|
+
mainTRC: 2.4, sRco: 0.2126729, sGco: 0.7151522, sBco: 0.0721750,
|
|
77
|
+
normBG: 0.56, normTXT: 0.57, revTXT: 0.62, revBG: 0.65,
|
|
78
|
+
blkThrs: 0.022, blkClmp: 1.414, scaleBoW: 1.14, scaleWoB: 1.14,
|
|
79
|
+
loBoWoffset: 0.027, loWoBoffset: 0.027, deltaYmin: 0.0005, loClip: 0.1,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function sRGBtoY(rgb) {
|
|
83
|
+
const [r, g, b] = rgb;
|
|
84
|
+
const exp = (chan) => Math.pow(chan / 255.0, APCA.mainTRC);
|
|
85
|
+
return APCA.sRco * exp(r) + APCA.sGco * exp(g) + APCA.sBco * exp(b);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function apcaContrastLc(textColor, bgColor) {
|
|
89
|
+
const txtRgb = parseColor(textColor);
|
|
90
|
+
const bgRgb = parseColor(bgColor);
|
|
91
|
+
if (!txtRgb || !bgRgb) return null;
|
|
92
|
+
let txtY = sRGBtoY(txtRgb);
|
|
93
|
+
let bgY = sRGBtoY(bgRgb);
|
|
94
|
+
if (isNaN(txtY) || isNaN(bgY) || Math.min(txtY, bgY) < 0 || Math.max(txtY, bgY) > 1.1) return 0.0;
|
|
95
|
+
// soft black clamp
|
|
96
|
+
txtY = txtY > APCA.blkThrs ? txtY : txtY + Math.pow(APCA.blkThrs - txtY, APCA.blkClmp);
|
|
97
|
+
bgY = bgY > APCA.blkThrs ? bgY : bgY + Math.pow(APCA.blkThrs - bgY, APCA.blkClmp);
|
|
98
|
+
if (Math.abs(bgY - txtY) < APCA.deltaYmin) return 0.0;
|
|
99
|
+
let SAPC, out;
|
|
100
|
+
if (bgY > txtY) { // BoW: dark text on light
|
|
101
|
+
SAPC = (Math.pow(bgY, APCA.normBG) - Math.pow(txtY, APCA.normTXT)) * APCA.scaleBoW;
|
|
102
|
+
out = SAPC < APCA.loClip ? 0.0 : SAPC - APCA.loBoWoffset;
|
|
103
|
+
} else { // WoB: light text on dark — negative
|
|
104
|
+
SAPC = (Math.pow(bgY, APCA.revBG) - Math.pow(txtY, APCA.revTXT)) * APCA.scaleWoB;
|
|
105
|
+
out = SAPC > -APCA.loClip ? 0.0 : SAPC + APCA.loWoBoffset;
|
|
106
|
+
}
|
|
107
|
+
return out * 100.0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// APCA threshold for a text/bg pair by font weight + size (px).
|
|
111
|
+
// Spec guidance: Lc 75 body, 60 for 400@18px+, 45 large/bold, 30 non-text.
|
|
112
|
+
function apcaThreshold(weight, size) {
|
|
113
|
+
const w = typeof weight === 'number' ? weight : 400;
|
|
114
|
+
const s = typeof size === 'number' ? size : 16;
|
|
115
|
+
const bold = w >= 700;
|
|
116
|
+
// APCA font-Lc lookup: Lc 45 = ≥24px regular or ≥18px bold; Lc 60 = ≥18px
|
|
117
|
+
// regular or bold body (14–17px); Lc 75 = small body. Bold body text is
|
|
118
|
+
// NOT exempt from readability — it stays at Lc 60 until it's also large.
|
|
119
|
+
if (s >= 24 || (bold && s >= 18)) return 45;
|
|
120
|
+
if (s >= 18 || bold) return 60;
|
|
121
|
+
return 75; // body text — strictest
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- WCAG 2.x contrast (compliance sidecar) -------------------------------
|
|
125
|
+
// Kept verbatim from v0.3.0 — some orgs must report the WCAG ratio. APCA is
|
|
126
|
+
// the primary gate; WCAG is shown alongside for procurement/legal.
|
|
13
127
|
|
|
14
128
|
function channelLuminance(c) {
|
|
15
|
-
// sRGB channel (0-255) -> linear luminance component
|
|
16
129
|
const s = c / 255;
|
|
17
130
|
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
18
131
|
}
|
|
19
132
|
|
|
20
133
|
function relativeLuminance(hex) {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
let h = m[1];
|
|
25
|
-
// 4/8-digit: drop the trailing alpha channel (last 1/2 digits).
|
|
26
|
-
if (h.length === 8) h = h.slice(0, 6);
|
|
27
|
-
if (h.length === 4) h = h.slice(0, 3);
|
|
28
|
-
if (h.length === 3) h = h.split('').map((c) => c + c).join('');
|
|
29
|
-
const r = parseInt(h.slice(0, 2), 16);
|
|
30
|
-
const g = parseInt(h.slice(2, 4), 16);
|
|
31
|
-
const b = parseInt(h.slice(4, 6), 16);
|
|
32
|
-
return 0.2126 * channelLuminance(r) + 0.7152 * channelLuminance(g) + 0.0722 * channelLuminance(b);
|
|
134
|
+
const rgb = parseColor(hex);
|
|
135
|
+
if (!rgb) return null;
|
|
136
|
+
return 0.2126 * channelLuminance(rgb[0]) + 0.7152 * channelLuminance(rgb[1]) + 0.0722 * channelLuminance(rgb[2]);
|
|
33
137
|
}
|
|
34
138
|
|
|
35
139
|
function contrastRatio(fg, bg) {
|
|
@@ -45,10 +149,9 @@ function contrastRatio(fg, bg) {
|
|
|
45
149
|
|
|
46
150
|
// ponytail: a real CSS parser is overkill. Extract token *names* and *values*
|
|
47
151
|
// from :root { --token: value; } so we can tell generated code apart from the
|
|
48
|
-
// system.
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
// false matches like :rootCause. Matches any combinator/selector up to '{'.
|
|
152
|
+
// system. :root variants (:root.dark, :root[data-theme], grouped :root, .x)
|
|
153
|
+
// are standard dark-mode patterns (shadcn, Tailwind, MUI). \b after :root
|
|
154
|
+
// avoids false matches like :rootCause.
|
|
52
155
|
function extractTokens(css) {
|
|
53
156
|
const tokens = new Map(); // value -> name(s)
|
|
54
157
|
const rootBlock = /:root\b[^{]*\{([^}]*)\}/g;
|
|
@@ -60,7 +163,6 @@ function extractTokens(css) {
|
|
|
60
163
|
while ((decl = declRe.exec(decls)) !== null) {
|
|
61
164
|
const name = decl[1].trim();
|
|
62
165
|
const value = decl[2].trim();
|
|
63
|
-
// index by normalised value so we can match usage in generated css
|
|
64
166
|
const key = value.toLowerCase().replace(/\s+/g, ' ');
|
|
65
167
|
const existing = tokens.get(key);
|
|
66
168
|
tokens.set(key, existing ? `${existing},--${name}` : `--${name}`);
|
|
@@ -69,16 +171,15 @@ function extractTokens(css) {
|
|
|
69
171
|
return tokens;
|
|
70
172
|
}
|
|
71
173
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
174
|
+
// ponytail: negative lookahead (?![0-9a-f]) instead of \b — \b fails between
|
|
175
|
+
// adjacent hex digits so 8-digit alpha hex (#RRGGBBAA) was invisible to this
|
|
176
|
+
// gate. Now matches 3/4/6/8-digit hex without partial-matching longer values.
|
|
177
|
+
const RAW_HEX_RE = /#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})(?![0-9a-f])/gi;
|
|
76
178
|
const BOX_SHADOW_RE = /box-shadow\s*:\s*([^;}]+)/gi;
|
|
77
179
|
|
|
78
180
|
function scanOffSystem(css, tokens) {
|
|
79
181
|
const findings = { hardcodedHex: [], adhocShadow: [] };
|
|
80
182
|
|
|
81
|
-
// 1. hardcoded hex outside :root token defs (and :root.dark / grouped variants)
|
|
82
183
|
const rootFree = css.replace(/:root\b[^{]*\{[^}]*\}/g, '');
|
|
83
184
|
let hexMatch;
|
|
84
185
|
while ((hexMatch = RAW_HEX_RE.exec(rootFree)) !== null) {
|
|
@@ -87,7 +188,6 @@ function scanOffSystem(css, tokens) {
|
|
|
87
188
|
if (!tokens.has(key)) findings.hardcodedHex.push(hex);
|
|
88
189
|
}
|
|
89
190
|
|
|
90
|
-
// 2. box-shadow not built from tokens
|
|
91
191
|
let shadowMatch;
|
|
92
192
|
while ((shadowMatch = BOX_SHADOW_RE.exec(rootFree)) !== null) {
|
|
93
193
|
const val = shadowMatch[1];
|
|
@@ -99,15 +199,14 @@ function scanOffSystem(css, tokens) {
|
|
|
99
199
|
|
|
100
200
|
// --- state coverage -------------------------------------------------------
|
|
101
201
|
|
|
102
|
-
// ponytail: presence check, not a CSS parser.
|
|
103
|
-
//
|
|
104
|
-
// regexes with .test() flake across calls, so use plain includes().
|
|
202
|
+
// ponytail: presence check, not a CSS parser. Stateful /g regexes with
|
|
203
|
+
// .test() flake across calls, so use plain includes().
|
|
105
204
|
function scanStates(css) {
|
|
106
205
|
const findings = { missingFocusVisible: [], missingDisabled: [] };
|
|
107
206
|
|
|
108
207
|
const hasInteractive =
|
|
109
208
|
/\b(?:button|a|input|select|textarea)\b/i.test(css) || /\[role\s*=\s*"?button"?\]/i.test(css);
|
|
110
|
-
if (!hasInteractive) return findings;
|
|
209
|
+
if (!hasInteractive) return findings;
|
|
111
210
|
|
|
112
211
|
if (!css.includes(':focus-visible')) findings.missingFocusVisible.push('no :focus-visible rule for interactive elements');
|
|
113
212
|
if (!css.includes(':disabled')) findings.missingDisabled.push('no :disabled rule for interactive elements');
|
|
@@ -115,25 +214,118 @@ function scanStates(css) {
|
|
|
115
214
|
return findings;
|
|
116
215
|
}
|
|
117
216
|
|
|
217
|
+
// --- slop tells (named AI signatures) -------------------------------------
|
|
218
|
+
// Co-occurrence heuristics, not single-keyword flags, to avoid false positives
|
|
219
|
+
// on legitimate token-based elevation. Each tell is documented with the slop it
|
|
220
|
+
// catches and the conservative pattern that must all match.
|
|
221
|
+
|
|
222
|
+
function scanSlopTells(css) {
|
|
223
|
+
const tells = [];
|
|
224
|
+
const lower = css.toLowerCase();
|
|
225
|
+
|
|
226
|
+
// 1. Glassmorphism — backdrop-filter anywhere is the tell.
|
|
227
|
+
if (lower.includes('backdrop-filter')) {
|
|
228
|
+
tells.push('glassmorphism (backdrop-filter) — faux depth implying capability the feature lacks');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 2. Gradient orbs — radial-gradient with a violet/purple/indigo hue +
|
|
232
|
+
// large blur. The orb signature is a big diffuse coloured blob: check
|
|
233
|
+
// for a blue-dominant saturated hex (B > R+20 AND B > G+20) and a large
|
|
234
|
+
// blur (filter:blur(NNpx) in the CSS or ≥100px size in the gradient).
|
|
235
|
+
// ponytail: allow one level of nested parens (rgba/hsl stops) so the
|
|
236
|
+
// full gradient value is captured, not truncated at the first inner ).
|
|
237
|
+
const hasBigFilterBlur = /blur\(\s*\d{2,}\s*px/i.test(css);
|
|
238
|
+
const radial = /radial-gradient\(([^()]*(?:\([^()]*\)[^()]*)*)\)/gi;
|
|
239
|
+
let rm;
|
|
240
|
+
while ((rm = radial.exec(css)) !== null) {
|
|
241
|
+
const v = rm[0];
|
|
242
|
+
const hasVioletHue = [...v.matchAll(/#(?:[0-9a-f]{3}|[0-9a-f]{6})\b/gi)].some((hm) => {
|
|
243
|
+
const rgb = parseHex(hm[0]);
|
|
244
|
+
if (!rgb) return false;
|
|
245
|
+
return rgb[2] > rgb[0] + 20 && rgb[2] > rgb[1] + 20; // B-dominant
|
|
246
|
+
});
|
|
247
|
+
const bigBlur = hasBigFilterBlur || /\b\d{3,}px\b/.test(v);
|
|
248
|
+
if (hasVioletHue && bigBlur) {
|
|
249
|
+
tells.push('gradient orb (violet/purple radial-gradient with large blur) — the #1 AI-slop signature');
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// 3. Neon glow — coloured box-shadow with large blur (≥20px). Checks BOTH
|
|
255
|
+
// rgba() and hex colours. A normal accent shadow (≤12px blur) is NOT a
|
|
256
|
+
// glow; a large coloured blur is the v0/Cursor tell.
|
|
257
|
+
const shadowRe = /box-shadow\s*:\s*([^;}]+)/gi;
|
|
258
|
+
let sm;
|
|
259
|
+
while ((sm = shadowRe.exec(css)) !== null) {
|
|
260
|
+
const v = sm[1];
|
|
261
|
+
const pxVals = [...v.matchAll(/(\d+(?:\.\d+)?)px/gi)].map((mm) => parseFloat(mm[1]));
|
|
262
|
+
const maxPx = pxVals.length ? Math.max(...pxVals) : 0;
|
|
263
|
+
if (maxPx < 20) continue; // small blur = normal shadow, not glow
|
|
264
|
+
|
|
265
|
+
const colours = [];
|
|
266
|
+
for (const cm of v.matchAll(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([0-9.]+))?\s*\)/gi)) {
|
|
267
|
+
colours.push([+cm[1], +cm[2], +cm[3], cm[4] !== undefined ? parseFloat(cm[4]) : 1]);
|
|
268
|
+
}
|
|
269
|
+
for (const hm of v.matchAll(/#(?:[0-9a-f]{3}|[0-9a-f]{6})\b/gi)) {
|
|
270
|
+
const rgb = parseHex(hm[0]);
|
|
271
|
+
if (rgb) colours.push([rgb[0], rgb[1], rgb[2], 1]);
|
|
272
|
+
}
|
|
273
|
+
let foundGlow = false;
|
|
274
|
+
for (const [r, g, b, a] of colours) {
|
|
275
|
+
const greyish = Math.abs(r - g) < 15 && Math.abs(g - b) < 15;
|
|
276
|
+
if (!greyish && a >= 0.3) { foundGlow = true; break; }
|
|
277
|
+
}
|
|
278
|
+
if (foundGlow) {
|
|
279
|
+
tells.push('neon glow (coloured large-blur box-shadow) — v0/Cursor signature');
|
|
280
|
+
break;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 4. The default card — untouched shadcn reflex. Require rounded-2xl/3xl AND
|
|
285
|
+
// shadow-lg/xl AND p-6/p-8 to co-occur (the actual slop reflex).
|
|
286
|
+
if (/(rounded-(2xl|3xl))/.test(lower) && /(shadow-(lg|xl))/.test(lower) && /\bp-(6|8)\b/.test(lower)) {
|
|
287
|
+
tells.push('default card (rounded-2xl + shadow-lg + p-6 reflex) — separate with whitespace → bg shift → elevation first');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 5. 1px gray card border — the most reliable single AI tell. Tailwind
|
|
291
|
+
// border-zinc/gray defaults OR a literal 1px solid near-gray hex.
|
|
292
|
+
const tailwindGray = /\bborder-(zinc|gray|slate|neutral)-(?:100|200)\b/.test(lower);
|
|
293
|
+
const litGrayBorder = /border[^;}]*:\s*1px\s+solid\s+(#(?:e5e7eb|e4e4e7|d4d4d8|f1f5f9|e2e8f0))\b/i.test(css);
|
|
294
|
+
if (tailwindGray || litGrayBorder) {
|
|
295
|
+
tells.push('1px gray card border (border-zinc/gray default or near-gray 1px solid) — the most reliable AI tell');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return { tells };
|
|
299
|
+
}
|
|
300
|
+
|
|
118
301
|
// --- aggregate gate -------------------------------------------------------
|
|
119
302
|
|
|
120
303
|
function audit({ css = '', pairs = [] }) {
|
|
121
|
-
// pairs: [{ fg: '#000', bg: '#fff', label: 'body', min: 4.5 }]
|
|
122
|
-
// ponytail: destructure defaults don't cover null — guard explicitly so a
|
|
123
|
-
// malformed { pairs: null } or { css: null } call behaves like the empty
|
|
124
|
-
// value, not a throw.
|
|
125
304
|
const safeCss = typeof css === 'string' ? css : '';
|
|
126
305
|
const safePairs = Array.isArray(pairs) ? pairs : [];
|
|
127
306
|
const tokens = extractTokens(safeCss);
|
|
128
307
|
const off = scanOffSystem(safeCss, tokens);
|
|
129
308
|
const states = scanStates(safeCss);
|
|
309
|
+
const tells = scanSlopTells(safeCss);
|
|
130
310
|
|
|
131
311
|
const contrastResults = safePairs.map((p) => {
|
|
312
|
+
const lc = apcaContrastLc(p.fg, p.bg);
|
|
132
313
|
const ratio = contrastRatio(p.fg, p.bg);
|
|
314
|
+
const apcaMin = apcaThreshold(p.weight, p.size);
|
|
315
|
+
// pass follows APCA (primary). A pair passes if APCA is present and meets
|
|
316
|
+
// its threshold; if APCA is null (unparseable colour), fail on WCAG.
|
|
317
|
+
let pass;
|
|
318
|
+
if (lc === null) {
|
|
319
|
+
pass = ratio === null ? false : ratio >= (p.min ?? 4.5);
|
|
320
|
+
} else {
|
|
321
|
+
pass = Math.abs(lc) >= apcaMin;
|
|
322
|
+
}
|
|
133
323
|
return {
|
|
134
324
|
...p,
|
|
325
|
+
apca: lc === null ? null : Math.round(lc * 100) / 100,
|
|
326
|
+
apcaMin,
|
|
135
327
|
ratio: ratio === null ? null : Math.round(ratio * 100) / 100,
|
|
136
|
-
pass
|
|
328
|
+
pass,
|
|
137
329
|
};
|
|
138
330
|
});
|
|
139
331
|
|
|
@@ -144,19 +336,28 @@ function audit({ css = '', pairs = [] }) {
|
|
|
144
336
|
contrast: { pass: contrastPass, results: contrastResults },
|
|
145
337
|
tokens: { pass: off.hardcodedHex.length === 0 && off.adhocShadow.length === 0, ...off },
|
|
146
338
|
states: { pass: states.missingFocusVisible.length === 0 && states.missingDisabled.length === 0, ...states },
|
|
339
|
+
slopTells: { pass: tells.tells.length === 0, ...tells },
|
|
147
340
|
},
|
|
148
341
|
pass: contrastPass
|
|
149
342
|
&& off.hardcodedHex.length === 0 && off.adhocShadow.length === 0
|
|
150
|
-
&& states.missingFocusVisible.length === 0 && states.missingDisabled.length === 0
|
|
343
|
+
&& states.missingFocusVisible.length === 0 && states.missingDisabled.length === 0
|
|
344
|
+
&& tells.tells.length === 0,
|
|
151
345
|
};
|
|
152
346
|
}
|
|
153
347
|
|
|
154
348
|
module.exports = {
|
|
349
|
+
parseHex,
|
|
350
|
+
parseOklch,
|
|
351
|
+
parseColor,
|
|
352
|
+
sRGBtoY,
|
|
353
|
+
apcaContrastLc,
|
|
354
|
+
apcaThreshold,
|
|
155
355
|
channelLuminance,
|
|
156
356
|
relativeLuminance,
|
|
157
357
|
contrastRatio,
|
|
158
358
|
extractTokens,
|
|
159
359
|
scanOffSystem,
|
|
160
360
|
scanStates,
|
|
361
|
+
scanSlopTells,
|
|
161
362
|
audit,
|
|
162
363
|
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bacnh85/pi-ux",
|
|
3
|
-
"version": "0.3
|
|
4
|
-
"description": "Anti-slop UI/UX design discipline for your Pi agent —
|
|
3
|
+
"version": "0.4.3",
|
|
4
|
+
"description": "Anti-slop UI/UX design discipline for your Pi agent — anchors a lintable DESIGN.md, runs deterministic slop-audit gates (APCA contrast + tokens + states + slop tells), works with text-only models, ships reference design-system presets.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -5,11 +5,11 @@ description: >
|
|
|
5
5
|
industrial-design principles (Dieter Rams: honest, thorough to the last
|
|
6
6
|
detail, as little design as possible) so output is a defensible system, not
|
|
7
7
|
statistical-default slop (purple glow, shadow-as-texture, missing states).
|
|
8
|
-
Covers the Constraint-First method: own the system
|
|
9
|
-
generate inside constraints, normalise, pass a
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
Covers the Constraint-First method: own the system via a lintable DESIGN.md,
|
|
9
|
+
write a 5-field brief, generate inside constraints, normalise, pass a
|
|
10
|
+
measurable slop-audit gate. Works deterministically with text-only models
|
|
11
|
+
(DeepSeek-v4, GLM-5.2, Kimi K3); agy/Gemini/Claude is optional polish, never
|
|
12
|
+
the review gate. Use when designing or building any UI — web, mobile, or
|
|
13
13
|
desktop. Active via /ux lite|strict|off.
|
|
14
14
|
argument-hint: ""
|
|
15
15
|
license: MIT
|
|
@@ -18,7 +18,8 @@ license: MIT
|
|
|
18
18
|
# UX Design Discipline — Anti-Slop, Industrial-Design Method
|
|
19
19
|
|
|
20
20
|
You implement UI INSIDE an existing design system. You do NOT invent visual
|
|
21
|
-
language. Slop fills the gaps you leave — so you stop leaving gaps.
|
|
21
|
+
language. Slop fills the gaps you leave — so you stop leaving gaps. The system
|
|
22
|
+
is anchored by a repo-root **DESIGN.md** that every generation reads.
|
|
22
23
|
|
|
23
24
|
## The anti-slop guardrail (hard rules)
|
|
24
25
|
|
|
@@ -33,18 +34,42 @@ language. Slop fills the gaps you leave — so you stop leaving gaps.
|
|
|
33
34
|
|
|
34
35
|
## The Constraint-First method
|
|
35
36
|
|
|
36
|
-
### Step 0 — Own the system (the model must NEVER invent this)
|
|
37
|
-
|
|
37
|
+
### Step 0 — Own the system via DESIGN.md (the model must NEVER invent this)
|
|
38
|
+
|
|
39
|
+
**A repo-root `DESIGN.md` is the single source of truth the agent reads before
|
|
40
|
+
styling anything.** Without it, every generation drifts back to the
|
|
41
|
+
statistical average. So own the system BEFORE generating — but you do NOT have
|
|
42
|
+
to block: when DESIGN.md is missing, drop in a medium-tuned preset as the
|
|
43
|
+
implicit system and keep going (see below).
|
|
44
|
+
|
|
45
|
+
A DESIGN.md (Google Labs open standard) combines machine-readable design tokens
|
|
46
|
+
(YAML frontmatter: `colors`, `typography`, `rounded`, `spacing`, `components`)
|
|
47
|
+
with human-readable rationale (Overview → Colors → Typography → Layout →
|
|
48
|
+
Elevation → Shapes → Components → Do's & Don'ts). This is the anchor the
|
|
49
|
+
deterministic gate and the agent both read.
|
|
50
|
+
|
|
51
|
+
**Resolution order (stop at the first that holds):**
|
|
52
|
+
1. **Repo-root `DESIGN.md` exists** → read it; use its tokens verbatim. This is the happy path.
|
|
53
|
+
2. **An existing system is already wired in** (shadcn/MUI/Radix theme, Tailwind config) → reuse it — YAGNI applies to design systems too.
|
|
54
|
+
3. **Nothing exists** → do NOT block. Pick a **medium-tuned preset** from the `ux-presets` skill (B1 Web vs B2 Mobile — infer from the task; **ASK only if web-vs-mobile is genuinely unclear**, because it changes touch targets, hover, and safe areas). Use the preset **in-context as the implicit system** for this generation, then offer to persist it to repo-root `DESIGN.md` so future sessions reuse it (don't auto-write — that's an unrequested file mutation).
|
|
55
|
+
4. **No preset fits** → generate a DESIGN.md once with `agy_execute mode=plan pro-high` (Gemini); thereafter text-only models are sufficient.
|
|
56
|
+
|
|
57
|
+
The non-negotiables a preset/system must define before any screen is written:
|
|
38
58
|
- **Tokens:** colour (one accent + neutrals), type scale, spacing (8px grid), radius, elevation.
|
|
39
59
|
- **Elevation scale:** 3–5 named levels only. Map every shadow to one.
|
|
40
|
-
- **State contract:** every interactive component declares all states.
|
|
41
|
-
|
|
60
|
+
- **State contract:** every interactive component declares all states (web: + `:hover`/`:focus-visible`; mobile: + `:active`/pressed, NO hover).
|
|
61
|
+
|
|
62
|
+
**Lint the system (when persisted to disk):**
|
|
63
|
+
```bash
|
|
64
|
+
npx @google/design.md lint DESIGN.md
|
|
65
|
+
```
|
|
66
|
+
This validates token references, contrast, section order, and structure as structured JSON. pi-ux **orchestrates** `@google/design.md` via shell-out — it is NOT a runtime dependency (pi-ux stays zero-dep).
|
|
42
67
|
|
|
43
68
|
### Step 1 — The 5-field brief (one per screen)
|
|
44
69
|
All five are required. If you can't produce inventory + states with confidence, the work is under-specified — more generation will mostly manufacture cleanup debt.
|
|
45
70
|
1. **User job + success state** — one sentence.
|
|
46
71
|
2. **Screen inventory** — required components and actions.
|
|
47
|
-
3. **Token constraints** — palette, type, spacing, elevation (paste the tokens).
|
|
72
|
+
3. **Token constraints** — palette, type, spacing, elevation (paste the DESIGN.md tokens).
|
|
48
73
|
4. **Required interaction states** per interactive component.
|
|
49
74
|
5. **One reference** screen/component that already feels like the product (brand tone).
|
|
50
75
|
|
|
@@ -58,36 +83,57 @@ Replace ad-hoc colours with tokens, remap shadows to the elevation scale, snap s
|
|
|
58
83
|
|
|
59
84
|
### Step 4 — Slop-audit gate (blocks handoff on fail)
|
|
60
85
|
|
|
86
|
+
Run `ux_audit` on the generated CSS. The contrast gate reports **APCA Lc**
|
|
87
|
+
(perceptual, primary — Lc ≥75 body / ≥45 large-bold / ≥30 non-text) with a
|
|
88
|
+
WCAG 2.x ratio sidecar for compliance reporting. APCA catches dark-theme +
|
|
89
|
+
thin-type slop that the legacy WCAG ratio misses. A 4th gate flags named AI
|
|
90
|
+
tells (glassmorphism, gradient orbs, neon glow, default-card).
|
|
91
|
+
|
|
61
92
|
| Gate | Pass | Fail action |
|
|
62
93
|
|---|---|---|
|
|
63
94
|
| Token coverage | ≥8/10 components map to tokens | Pause, repair baseline |
|
|
64
95
|
| Shadow recipes | ≤3 named recipes on core surfaces | Collapse to named elevations |
|
|
65
|
-
| Contrast |
|
|
96
|
+
| Contrast (APCA) | Lc ≥75 body, ≥45 large-bold, ≥30 non-text (WCAG sidecar shown) | Block handoff until fixed |
|
|
66
97
|
| State coverage | all interactive elements have focus + disabled | Keep in draft |
|
|
67
98
|
| Component hygiene | no duplicates, no frame-pile | Refactor before handoff |
|
|
99
|
+
| Slop tells | no glassmorphism / orbs / glow / default-card / 1px-gray-border | Refactor: space → bg shift → elevation before a border |
|
|
68
100
|
|
|
69
101
|
## Model routing (who does what)
|
|
70
102
|
|
|
71
103
|
Split the work along each model's strength. **The inversion rule:** the cheaper/weaker the model, the MORE you must externalise constraints. Taste lives in the brief, not the weights.
|
|
72
104
|
|
|
105
|
+
**The deterministic-first principle:** the gate is mechanical (DESIGN.md lint +
|
|
106
|
+
`ux_audit`), not a vision-LLM call. Text-only models now lead frontend
|
|
107
|
+
(Kimi K3 is #1 on the Arena.ai Frontend Code Arena, ahead of Claude Fable 5) —
|
|
108
|
+
inside a locked system they produce non-slop. agy/Gemini/Claude is optional,
|
|
109
|
+
never the review gate.
|
|
110
|
+
|
|
73
111
|
| Step | Best tool/model | Why |
|
|
74
112
|
|---|---|---|
|
|
75
|
-
| **Define system** (tokens, elevation, type) | `agy_execute mode=plan pro-high` (Gemini)
|
|
76
|
-
| **
|
|
77
|
-
| **
|
|
113
|
+
| **Define system** (DESIGN.md: tokens, elevation, type) | Reuse a preset (ux-presets) OR `agy_execute mode=plan pro-high` (Gemini) **once** | Preset is cheapest. Gemini = strongest visual reasoning for the one-time scaffold. |
|
|
114
|
+
| **Lint system** | `npx @google/design.md lint DESIGN.md` (shell-out) | Deterministic token-ref + contrast + structure validation. |
|
|
115
|
+
| **Per-screen brief** | Main Pi model: **GLM-5.2** | 1M ctx holds the whole DESIGN.md while scoping one screen |
|
|
116
|
+
| **Generate variants** | Main Pi model: **DeepSeek-v4**, **GLM-5.2**, or **Kimi K3** | Text-only models lead frontend inside a locked system; cheaper than vision calls. |
|
|
78
117
|
| **Normalise into system** | Main Pi model: **DeepSeek-v4** or **GLM-5.2** | Long context, token remapping, mechanical precision |
|
|
79
|
-
| **Slop audit** | `ux_audit` tool (deterministic) | Contrast + token coverage are computable, not judgement |
|
|
80
|
-
| **
|
|
118
|
+
| **Slop audit** | `ux_audit` tool (deterministic) + DESIGN.md lint | Contrast (APCA) + token coverage + slop tells are computable, not judgement |
|
|
119
|
+
| **Optional polish** (never a gate) | `agy_execute mode=accept-edits sonnet` (Claude) or `opus` | Only if brand-fit is uncertain after the deterministic gate passes. NOT required. |
|
|
81
120
|
|
|
82
|
-
**Cross-family rule:** Gemini
|
|
121
|
+
**Cross-family rule:** Gemini/Claude produce → deterministic gate reviews. Don't spend vision-model quota on what `ux_audit` computes for free. agy review is a fallback for aesthetic uncertainty, never the gate.
|
|
83
122
|
|
|
84
|
-
DeepSeek/GLM are safe for design **only inside a fully-specified system**. If no system exists yet, generate
|
|
123
|
+
DeepSeek/GLM/Kimi K3 are safe for design **only inside a fully-specified system**. If no system exists yet, generate a DESIGN.md once (preset or agy), then text-only models are sufficient for every generation thereafter.
|
|
85
124
|
|
|
86
125
|
## Banned anti-patterns
|
|
87
126
|
|
|
88
|
-
-
|
|
127
|
+
These are auto-detected by `ux_audit`'s slop-tell gate — fail the gate and the
|
|
128
|
+
screen stays in draft until refactored.
|
|
129
|
+
|
|
130
|
+
- **Glassmorphism** (`backdrop-filter`) — faux depth that implies capability the feature lacks (violates "honest").
|
|
131
|
+
- **Gradient orbs / purple-indigo glow** (large-blur `radial-gradient`, coloured high-opacity `box-shadow`) — the #1 AI-slop signature, unless the brand explicitly calls for it.
|
|
132
|
+
- **Neon-on-dark** (cyan/violet glowing card borders) — the v0/Cursor signature.
|
|
133
|
+
- **The default card** (`rounded-2xl shadow-lg p-6` untouched shadcn reflex) — separate with whitespace → background shift → elevation, in that order; a border is the last resort.
|
|
134
|
+
- **1px gray card border** (`border-zinc`/`border-gray` defaults) — the most reliable AI tell.
|
|
135
|
+
- **Permanent dark mode** as the default reflex — the most common AI tell.
|
|
89
136
|
- Shadows as texture (drifting blur/opacity per component). Shadows = named elevation only.
|
|
90
|
-
- Glassmorphism / faux-3D that implies capability the feature lacks (violates "honest").
|
|
91
137
|
- Magic pixel values; off-scale font sizes; ad-hoc accent colours.
|
|
92
138
|
- Shipping a component without `focus-visible` + `disabled` states.
|
|
93
|
-
- Prompting "make it modern/clean" with no
|
|
139
|
+
- Prompting "make it modern/clean" with no DESIGN.md — the single biggest slop trigger.
|
|
@@ -3,11 +3,12 @@ name: ux-presets
|
|
|
3
3
|
description: >
|
|
4
4
|
Reference design-system presets for ux-design. Anti-slop shortcuts: instead of
|
|
5
5
|
inventing tokens, elevation, and state contracts from scratch, reuse one of
|
|
6
|
-
these battle-tested systems (shadcn/ui, Material 3, Radix) or
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
6
|
+
these battle-tested systems (shadcn/ui, Material 3, Radix) or drop in a
|
|
7
|
+
medium-tuned DESIGN.md starter (Web or Mobile) as the implicit system when no
|
|
8
|
+
DESIGN.md exists. Use when starting a new UI project with no existing design
|
|
9
|
+
system, when Step 0 (Own the system) of the ux-design method has nothing to
|
|
10
|
+
reuse, or when asked for a starter token set. These are REFERENCES only —
|
|
11
|
+
pi-ux never bundles CSS.
|
|
11
12
|
argument-hint: ""
|
|
12
13
|
license: MIT
|
|
13
14
|
---
|
|
@@ -15,9 +16,9 @@ license: MIT
|
|
|
15
16
|
# UX System Presets — anti-slop shortcuts for Step 0
|
|
16
17
|
|
|
17
18
|
The Constraint-First method starts at "Own the system." If the project has no
|
|
18
|
-
tokens / elevation scale / state contract, do NOT
|
|
19
|
-
**reuse an existing system** or **
|
|
20
|
-
shortcut drawer for both.
|
|
19
|
+
tokens / elevation scale / state contract, you do NOT have to block generation —
|
|
20
|
+
**reuse an existing system** or **drop in a medium-tuned DESIGN.md starter** as
|
|
21
|
+
the implicit system. This skill is the shortcut drawer for both.
|
|
21
22
|
|
|
22
23
|
## Option A — reuse an existing system (preferred, YAGNI)
|
|
23
24
|
|
|
@@ -32,11 +33,254 @@ These already define tokens, 3–5 elevation levels, and full state contracts
|
|
|
32
33
|
which is exactly what the anti-slop guardrail demands. **Reusing beats
|
|
33
34
|
redefining.** YAGNI applies to design systems too.
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
### Named styles (Claymorphism, Brutalism, Bento, Art Deco, …)
|
|
36
37
|
|
|
37
|
-
When
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
When the brief calls for a *named aesthetic* (a kids app wants Claymorphism;
|
|
39
|
+
an agency site wants Brutalism), **do not invent one and do not hand-copy a
|
|
40
|
+
template blindly.** Pull the canonical, lintable `DESIGN.md` from the
|
|
41
|
+
**[design.md style library](https://designmd.app/library)** (30+ styles,
|
|
42
|
+
same `DESIGN.md` standard this skill uses) and run it through the gate:
|
|
43
|
+
|
|
44
|
+
1. Fetch the style's `DESIGN.md` from `designmd.app/library/<style>`.
|
|
45
|
+
2. `npx @google/design.md lint DESIGN.md` (token-ref + structure validation).
|
|
46
|
+
3. `ux_audit` on the CSS + the style's common text pairs — **block handoff on fail.**
|
|
47
|
+
|
|
48
|
+
> **No style is pre-approved.** Named-style templates are aesthetic
|
|
49
|
+
> starting points, not vetted systems. The stock **Claymorphism** template
|
|
50
|
+
> ships a button in lilac (`#E6E6FA`) text on peach (`#FDBCB4`) — that pair
|
|
51
|
+
> fails `ux_audit` at **Lc −15.67** (WCAG 1.31:1). Correct it to ink-on-peach
|
|
52
|
+
> (Lc 75.72 ✓) before trusting it. The gate, not the template, makes a style
|
|
53
|
+
> non-slop. Note: glassmorphism and neumorphism are **banned** slop tells
|
|
54
|
+
> here regardless of any library entry.
|
|
55
|
+
|
|
56
|
+
## Option B — medium-tuned DESIGN.md starters (lintable, drop-in)
|
|
57
|
+
|
|
58
|
+
When there's nothing to reuse AND no repo-root DESIGN.md, pick the starter
|
|
59
|
+
matching the medium and use it **in-context as the implicit system** — then
|
|
60
|
+
keep generating. This is how the agent stays unblocked and non-slop when
|
|
61
|
+
DESIGN.md is absent. Lint it (and optionally persist it to repo root) when the
|
|
62
|
+
user wants to keep it:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @google/design.md lint DESIGN.md
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Which medium? (infer, then ask only if genuinely unclear)
|
|
69
|
+
|
|
70
|
+
- **Web** — landing page, dashboard, marketing site, admin panel, "responsive", docs, anything mouse + keyboard. Default when the target is a browser at desktop/tablet width.
|
|
71
|
+
- **Mobile** — "iOS/Android app", native (SwiftUI/Kotlin), React Native/Flutter, mobile-first PWA, "screen" for a phone, anything touch-first.
|
|
72
|
+
- Ambiguous (e.g. "a screen", no platform) → **ASK** which medium; do not guess. This is the one decision worth a question, because it changes touch targets, hover, and safe areas.
|
|
73
|
+
|
|
74
|
+
### B1 — Web preset (mouse + keyboard, responsive)
|
|
75
|
+
|
|
76
|
+
```markdown
|
|
77
|
+
---
|
|
78
|
+
name: Web Baseline
|
|
79
|
+
description: Anti-slop web system — one accent + neutrals, hover + focus-visible, desktop type scale, 65ch measure.
|
|
80
|
+
colors:
|
|
81
|
+
primary: "#111111"
|
|
82
|
+
accent: "#0066ff"
|
|
83
|
+
accent-hover: "#0052cc"
|
|
84
|
+
text: "#111111"
|
|
85
|
+
text-muted: "#595959"
|
|
86
|
+
bg: "#ffffff"
|
|
87
|
+
surface: "#f7f7f8"
|
|
88
|
+
border: "#e4e4e7"
|
|
89
|
+
danger: "#c2261b"
|
|
90
|
+
success: "#15803d"
|
|
91
|
+
typography:
|
|
92
|
+
body:
|
|
93
|
+
fontFamily: Inter
|
|
94
|
+
fontSize: 1rem
|
|
95
|
+
lineHeight: 1.6
|
|
96
|
+
h1:
|
|
97
|
+
fontFamily: Inter
|
|
98
|
+
fontSize: 2.441rem
|
|
99
|
+
fontWeight: 700
|
|
100
|
+
lineHeight: 1.15
|
|
101
|
+
h2:
|
|
102
|
+
fontFamily: Inter
|
|
103
|
+
fontSize: 1.953rem
|
|
104
|
+
fontWeight: 700
|
|
105
|
+
label:
|
|
106
|
+
fontFamily: Inter
|
|
107
|
+
fontSize: 0.8rem
|
|
108
|
+
fontWeight: 600
|
|
109
|
+
letterSpacing: 0.02em
|
|
110
|
+
rounded:
|
|
111
|
+
sm: 6px
|
|
112
|
+
md: 10px
|
|
113
|
+
lg: 14px
|
|
114
|
+
spacing:
|
|
115
|
+
sm: 8px
|
|
116
|
+
md: 16px
|
|
117
|
+
lg: 24px
|
|
118
|
+
xl: 32px
|
|
119
|
+
2xl: 48px
|
|
120
|
+
components:
|
|
121
|
+
button-primary:
|
|
122
|
+
backgroundColor: "{colors.accent}"
|
|
123
|
+
textColor: "#ffffff"
|
|
124
|
+
rounded: "{rounded.md}"
|
|
125
|
+
padding: "{spacing.sm} {spacing.md}"
|
|
126
|
+
button-primary-hover:
|
|
127
|
+
backgroundColor: "{colors.accent-hover}"
|
|
128
|
+
card:
|
|
129
|
+
backgroundColor: "{colors.surface}"
|
|
130
|
+
rounded: "{rounded.lg}"
|
|
131
|
+
padding: "{spacing.lg}"
|
|
132
|
+
input:
|
|
133
|
+
backgroundColor: "{colors.bg}"
|
|
134
|
+
textColor: "{colors.text}"
|
|
135
|
+
rounded: "{rounded.sm}"
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Overview
|
|
139
|
+
|
|
140
|
+
Desktop/responsive web. One accent, neutral surfaces, hierarchy by weight.
|
|
141
|
+
Separation by whitespace → background shift → elevation, in that order; a border
|
|
142
|
+
is the last resort.
|
|
143
|
+
|
|
144
|
+
## Colors
|
|
145
|
+
|
|
146
|
+
- **primary (#111111):** deep ink for headlines and primary text.
|
|
147
|
+
- **accent (#0066ff):** the sole driver for interaction. The ONE knob to swap for brand.
|
|
148
|
+
- **text-muted (#595959):** secondary copy, captions, metadata.
|
|
149
|
+
- **bg/surface:** warm-tinted off-whites, softer than pure white. Tint pure black/white warm or cool for depth.
|
|
150
|
+
- **danger/success:** reserved for semantic state, never decoration.
|
|
151
|
+
|
|
152
|
+
## Typography
|
|
153
|
+
|
|
154
|
+
Inter, hierarchy by weight on a 1.25 modular scale. Body at 1rem / 1.6 line-height.
|
|
155
|
+
Hold the measure (line length) to 60–80 characters for readability.
|
|
156
|
+
|
|
157
|
+
## Layout
|
|
158
|
+
|
|
159
|
+
- 8px spacing grid; no magic pixel values. Section padding varies so hero/content/CTA don't feel the same weight.
|
|
160
|
+
- Responsive: max-width container (e.g. 1152px), 12-column awareness, breakpoints at 640/768/1024/1280.
|
|
161
|
+
|
|
162
|
+
## Elevation & Depth
|
|
163
|
+
|
|
164
|
+
Map EVERY shadow to a named level — never invent blur/opacity per component:
|
|
165
|
+
`sm` resting card · `md` raised/dropdown · `lg` popover · `xl` modal.
|
|
166
|
+
|
|
167
|
+
## Do's and Don'ts
|
|
168
|
+
|
|
169
|
+
- DO declare `default` / `hover` / `focus-visible` / `active` / `disabled` for every interactive element — keyboard nav is primary on web.
|
|
170
|
+
- DON'T use `backdrop-filter`, gradient orbs, coloured glow, or a 1px gray card border — the AI-slop signatures.
|
|
171
|
+
- DON'T ship pure `#fff`/`#000`; tint them.
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### B2 — Mobile preset (touch-first, native / RN / mobile web)
|
|
175
|
+
|
|
176
|
+
```markdown
|
|
177
|
+
---
|
|
178
|
+
name: Mobile Baseline
|
|
179
|
+
description: Anti-slop mobile system — 44pt touch targets, no hover (tap/active), safe-area insets, 16px base (no zoom).
|
|
180
|
+
colors:
|
|
181
|
+
primary: "#111111"
|
|
182
|
+
accent: "#0066ff"
|
|
183
|
+
accent-pressed: "#0052cc"
|
|
184
|
+
text: "#111111"
|
|
185
|
+
text-muted: "#595959"
|
|
186
|
+
bg: "#ffffff"
|
|
187
|
+
surface: "#f7f7f8"
|
|
188
|
+
border: "#e4e4e7"
|
|
189
|
+
danger: "#c2261b"
|
|
190
|
+
typography:
|
|
191
|
+
body:
|
|
192
|
+
fontFamily: Inter
|
|
193
|
+
fontSize: 1rem
|
|
194
|
+
lineHeight: 1.5
|
|
195
|
+
h1:
|
|
196
|
+
fontFamily: Inter
|
|
197
|
+
fontSize: 1.953rem
|
|
198
|
+
fontWeight: 700
|
|
199
|
+
lineHeight: 1.2
|
|
200
|
+
h2:
|
|
201
|
+
fontFamily: Inter
|
|
202
|
+
fontSize: 1.563rem
|
|
203
|
+
fontWeight: 700
|
|
204
|
+
label:
|
|
205
|
+
fontFamily: Inter
|
|
206
|
+
fontSize: 0.875rem
|
|
207
|
+
fontWeight: 600
|
|
208
|
+
rounded:
|
|
209
|
+
sm: 8px
|
|
210
|
+
md: 12px
|
|
211
|
+
lg: 16px
|
|
212
|
+
spacing:
|
|
213
|
+
sm: 8px
|
|
214
|
+
md: 16px
|
|
215
|
+
lg: 24px
|
|
216
|
+
xl: 32px
|
|
217
|
+
components:
|
|
218
|
+
button-primary:
|
|
219
|
+
backgroundColor: "{colors.accent}"
|
|
220
|
+
textColor: "#ffffff"
|
|
221
|
+
rounded: "{rounded.md}"
|
|
222
|
+
height: 48px
|
|
223
|
+
padding: "{spacing.md} {spacing.lg}"
|
|
224
|
+
button-primary-pressed:
|
|
225
|
+
backgroundColor: "{colors.accent-pressed}"
|
|
226
|
+
tap-target:
|
|
227
|
+
height: 44px
|
|
228
|
+
width: 44px
|
|
229
|
+
list-row:
|
|
230
|
+
backgroundColor: "{colors.bg}"
|
|
231
|
+
textColor: "{colors.text}"
|
|
232
|
+
padding: "{spacing.md} {spacing.lg}"
|
|
233
|
+
tab-bar:
|
|
234
|
+
backgroundColor: "{colors.bg}"
|
|
235
|
+
height: 56px
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Overview
|
|
239
|
+
|
|
240
|
+
Touch-first mobile (iOS / Android / React Native / Flutter / mobile web). One
|
|
241
|
+
accent, generous gutters, thumb-reach layout. Interaction is TAP, not hover.
|
|
242
|
+
|
|
243
|
+
## Colors
|
|
244
|
+
|
|
245
|
+
- **primary (#111111):** ink for headings and primary text.
|
|
246
|
+
- **accent (#0066ff):** the sole driver for interaction. The ONE knob to swap for brand.
|
|
247
|
+
- **accent-pressed:** replaces hover — the feedback state for a tap.
|
|
248
|
+
- **bg/surface:** layered to separate content; tint pure black/white warm or cool.
|
|
249
|
+
|
|
250
|
+
## Typography
|
|
251
|
+
|
|
252
|
+
Inter on a 1.2 scale. **Body at 16px minimum** — below 16px, iOS Safari
|
|
253
|
+
auto-zooms on input focus, which is slop. Hierarchy by weight, not by shrinking
|
|
254
|
+
the body.
|
|
255
|
+
|
|
256
|
+
## Layout
|
|
257
|
+
|
|
258
|
+
- 16px gutters, 24px section padding (thumb ergonomics).
|
|
259
|
+
- Thumb zone: primary actions reachable from the bottom third; place destructive actions away from the thumb arc.
|
|
260
|
+
- Safe-area insets for notch and home indicator: `env(safe-area-inset-top/bottom)` (web), or platform safe-area guides (native).
|
|
261
|
+
- Bottom-anchored patterns: tab bar, bottom sheet, FAB.
|
|
262
|
+
|
|
263
|
+
## Elevation & Depth
|
|
264
|
+
|
|
265
|
+
Named levels only: `sm` resting · `md` raised sheet · `lg` modal. A bottom sheet
|
|
266
|
+
is nearer than the page — keep it that way.
|
|
267
|
+
|
|
268
|
+
## Do's and Don'ts
|
|
269
|
+
|
|
270
|
+
- DO make every interactive element ≥ 44pt (iOS HIG) / 48dp (Material) — the #1 thing models get wrong.
|
|
271
|
+
- DO use `:active` / pressed states for tap feedback — there is NO hover on touch. Do not ship `:hover`-only feedback.
|
|
272
|
+
- DO respect safe areas (notch, home indicator, keyboard).
|
|
273
|
+
- DON'T use `backdrop-filter`, gradient orbs, coloured glow, or 1px gray borders — AI-slop signatures on mobile too.
|
|
274
|
+
- DON'T set body text below 16px (triggers iOS zoom).
|
|
275
|
+
- iOS vs Android: follow the host platform — iOS HIG (SF symbols, larger corner radius, sheet) vs Material 3 (FAB, ripple, top app bar).
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
## Option C — minimal `:root` token set (CSS-only, no DESIGN.md)
|
|
279
|
+
|
|
280
|
+
For CSS-only projects that do not adopt DESIGN.md, drop this compact baseline
|
|
281
|
+
into the project's `:root`. It satisfies the token + elevation + state gates of
|
|
282
|
+
`ux_audit` with ~20 lines. Prefer Option B when the project can adopt DESIGN.md
|
|
283
|
+
— it is lintable, diffable, and medium-tuned; this is the plain fallback.
|
|
40
284
|
|
|
41
285
|
```css
|
|
42
286
|
:root {
|
|
@@ -84,22 +328,24 @@ When there's nothing to reuse, drop this compact baseline into the project's
|
|
|
84
328
|
|
|
85
329
|
## How to use with ux-design
|
|
86
330
|
|
|
87
|
-
1. Run **Step 0** of ux-design. If the project already has tokens/elevation/states → use them.
|
|
88
|
-
2. If not, pick **Option A**
|
|
89
|
-
3.
|
|
90
|
-
4.
|
|
91
|
-
5.
|
|
331
|
+
1. Run **Step 0** of ux-design. If the project already has a DESIGN.md / tokens / elevation / states → use them.
|
|
332
|
+
2. If not, pick **Option A** — reuse an installed system (shadcn/Material/Radix) when the stack matches, OR pull a named style (Claymorphism, Brutalism, …) from the design.md library. Both reuse-before-invent; both still must pass `ux_audit`.
|
|
333
|
+
3. Otherwise pick **Option B** — infer the medium (Web B1 vs Mobile B2) from the task, use the starter in-context as the implicit system, and keep generating. Only ASK if web-vs-mobile is unclear.
|
|
334
|
+
4. Only for CSS-only projects that won't adopt DESIGN.md, use **Option C** (`:root` block).
|
|
335
|
+
5. Generate screens with any text-only model (DeepSeek-v4 / GLM-5.2 / Kimi K3) INSIDE these constraints.
|
|
336
|
+
6. Run `ux_audit` with the CSS + the most common text colour pairs before handoff.
|
|
92
337
|
|
|
93
|
-
## Audit-ready pairs
|
|
338
|
+
## Audit-ready pairs
|
|
94
339
|
|
|
95
340
|
```jsonc
|
|
96
|
-
// Pass these to ux_audit.pairs to check
|
|
341
|
+
// Pass these to ux_audit.pairs to check a baseline itself.
|
|
342
|
+
// weight/size enable the APCA thresholds; min is the WCAG compliance sidecar.
|
|
97
343
|
[
|
|
98
|
-
{ "fg": "#111111", "bg": "#ffffff", "label": "body",
|
|
99
|
-
{ "fg": "#595959", "bg": "#ffffff", "label": "muted",
|
|
100
|
-
{ "fg": "#0066ff", "bg": "#ffffff", "label": "accent-link","min": 4.5 },
|
|
101
|
-
{ "fg": "#ffffff", "bg": "#0066ff", "label": "button",
|
|
102
|
-
{ "fg": "#c2261b", "bg": "#ffffff", "label": "danger",
|
|
344
|
+
{ "fg": "#111111", "bg": "#ffffff", "label": "body", "min": 4.5, "weight": 400, "size": 16 },
|
|
345
|
+
{ "fg": "#595959", "bg": "#ffffff", "label": "muted", "min": 4.5, "weight": 400, "size": 16 },
|
|
346
|
+
{ "fg": "#0066ff", "bg": "#ffffff", "label": "accent-link", "min": 4.5, "weight": 400, "size": 16 },
|
|
347
|
+
{ "fg": "#ffffff", "bg": "#0066ff", "label": "button", "min": 4.5, "weight": 600, "size": 16 },
|
|
348
|
+
{ "fg": "#c2261b", "bg": "#ffffff", "label": "danger", "min": 4.5, "weight": 400, "size": 16 }
|
|
103
349
|
]
|
|
104
350
|
```
|
|
105
351
|
|
|
@@ -107,4 +353,4 @@ When there's nothing to reuse, drop this compact baseline into the project's
|
|
|
107
353
|
|
|
108
354
|
- Not a CSS framework — reference only. Don't bundle it; copy what you need.
|
|
109
355
|
- Not themeable at runtime — it's a baseline to extend, not a product.
|
|
110
|
-
- Not opinionated about the accent —
|
|
356
|
+
- Not opinionated about the accent — `accent` / `{colors.accent}` is the ONE knob you turn for brand.
|