@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 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). Enforces industrial-design principles (Dieter Rams: honest, thorough to the last detail, as little design as possible) so AI-generated interfaces are defensible systems, not statistical-default slop (purple glow, shadow-as-texture, missing states).
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 ("make it modern/clean"), models reach for high-frequency statistical patterns. Slop is an **ownership problem**: "the design has no owner at the system level." A skill alone is ignorable; a `before_agent_start` hook is not.
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** — tokens (colour/type/spacing/radius), 3–5 named elevation levels, state contract. If absent, no generation yet — use the `ux-presets` skill to reuse shadcn/ui, Material, or Radix, or copy a compact token set.
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, else tighten the brief.
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): token coverage ≥8/10, ≤3 shadow recipes, WCAG AA contrast, full state coverage.
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** | WCAG 2.x relative-luminance ratio for each fg/bg pair (4.5:1 body, 3:1 large/UI) |
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
- ## Skills
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
- - **`ux-design`** — the Constraint-First method + model routing (auto-injected by the hook when active).
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 (tokens, elevation) | `agy_execute mode=plan pro-high` (Gemini) or reuse shadcn/Material |
76
- | Per-screen brief | **GLM-5.2** (200K ctx) |
77
- | Generate variants | `agy_execute mode=accept-edits flash-high` (Gemini) |
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
- | Final review/polish | `agy_execute mode=plan sonnet` (Claude) |
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
- **The inversion rule:** the cheaper/weaker the model, the MORE you must externalise constraints. DeepSeek/GLM are safe for design only inside a fully-specified system.
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
 
@@ -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 contrast (fg + bg as #hex, a label, and a min ratio defaulting to 4.5).",
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: "Minimum contrast ratio. 4.5 for body, 3.0 for large/UI. Defaults to 4.5." },
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 contrast, off-system token values (hardcoded hex / ad-hoc shadows), and missing interaction states (:focus-visible / :disabled). No model needed — all gates are computable. In strict mode, block handoff until this passes.",
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 and token-coverage are computable, not judgement — use this tool instead of eyeballing.",
156
- "Pass fg/bg colour pairs to check WCAG AA contrast (4.5:1 body, 3:1 large/UI).",
157
- "Pass the CSS string to scan for hardcoded hex and ad-hoc box-shadow not built from var() tokens.",
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
- // Three gates, all computable:
4
- // 1. Contrast — WCAG 2.x relative-luminance ratio for colour pairs.
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: WCAG math is ~10 lines; token scan is regex; state scan is a
9
- // selector-set check. No wcag-contrast lib, no css parser, no AST. This is
10
- // mechanical linting, not judgement that is the whole point of the gate.
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
- // --- WCAG contrast --------------------------------------------------------
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
- // Accept 3/4/6/8-digit hex; strip alpha channel from 4/8-digit forms.
22
- const m = /^#?([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})$/i.exec(String(hex).trim());
23
- if (!m) return null;
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
- // ponytail: :root variants (:root.dark, :root[data-theme], grouped :root, .x)
50
- // are standard dark-mode patterns (shadcn, Tailwind, MUI). \b after :root avoids
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
- // Hardcoded hex colours not referencing a token.
73
- const RAW_HEX_RE = /#(?:[0-9a-f]{3}|[0-9a-f]{6})\b/gi;
74
-
75
- // box-shadow declarations (we only flag these if they DON'T reference a var)
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. Substring tests are enough to
103
- // catch the common slop (missing :focus-visible / :disabled). Stateful /g
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; // no interactive selectors => nothing to flag
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: ratio === null ? false : ratio >= (p.min ?? 4.5),
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.0",
4
- "description": "Anti-slop UI/UX design discipline for your Pi agent — enforces industrial-design principles, runs deterministic slop-audit gates, and ships reference design-system presets.",
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, write a 5-field brief,
9
- generate inside constraints, normalise, pass a measurable slop-audit gate.
10
- Includes model routing: Gemini/Claude via agy_execute for generation and
11
- review, DeepSeek-v4 / GLM-5.2 as the main Pi models for briefs and
12
- normalisation. Use when designing or building any UI — web, mobile, or
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
- If these don't exist, don't generate screens yet — keep the model in ideation mode.
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
- - Reuse an existing system (Material, shadcn/ui, Radix) — YAGNI applies to design systems too.
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 | WCAG AA: 4.5:1 body, 3:1 large/UI | Block handoff until fixed |
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) OR reuse shadcn/Material | Gemini = strongest visual reasoning (~80% UI similarity). Or human-owned preset. |
76
- | **Per-screen brief** | Main Pi model: **GLM-5.2** | 200K ctx holds the whole system doc while scoping one screen |
77
- | **Generate variants** | `agy_execute mode=accept-edits flash-high` (Gemini) | Visual king, safe inside constraints |
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
- | **Final review/polish** | `agy_execute mode=plan sonnet` (Claude) or `opus` | Cross-family review; Claude = structured, conservative |
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 producesClaude-class reviews. Don't spend both quota groups on trivial tasks.
121
+ **Cross-family rule:** Gemini/Claude producedeterministic 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 it with Gemini via `agy` first, or reuse a preset.
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
- - Purple/indigo glow gradients unless the brand explicitly calls for them.
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 tokens — the single biggest slop trigger.
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 copy a compact
7
- token set into the project. Use when starting a new UI project with no
8
- existing design system, when Step 0 (Own the system) of the ux-design method
9
- has nothing to reuse, or when asked for a starter token set. These are
10
- REFERENCES only pi-ux never bundles CSS.
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 generate screens yet either
19
- **reuse an existing system** or **copy a compact token set**. This skill is the
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
- ## Option B minimal token set (when no framework, or CSS-only)
36
+ ### Named styles (Claymorphism, Brutalism, Bento, Art Deco, )
36
37
 
37
- When there's nothing to reuse, drop this compact baseline into the project's
38
- `:root` and point the guardrail at it. It satisfies all three audit gates
39
- (tokens, elevation, states) with ~20 lines.
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** (reuse shadcn/Material/Radix) first. This is the lazy, correct path.
89
- 3. Only if nothing fits, copy **Option B** into the project and adapt `--accent` to the brand.
90
- 4. Generate screens with `agy flash-high` INSIDE these constraints.
91
- 5. Run `ux_audit` with the CSS + the 5 most common text colour pairs before handoff.
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 for the minimal set
338
+ ## Audit-ready pairs
94
339
 
95
340
  ```jsonc
96
- // Pass these to ux_audit.pairs to check the baseline itself.
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", "min": 4.5 },
99
- { "fg": "#595959", "bg": "#ffffff", "label": "muted", "min": 4.5 },
100
- { "fg": "#0066ff", "bg": "#ffffff", "label": "accent-link","min": 4.5 },
101
- { "fg": "#ffffff", "bg": "#0066ff", "label": "button", "min": 4.5 },
102
- { "fg": "#c2261b", "bg": "#ffffff", "label": "danger", "min": 4.5 }
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 — `--accent` is the ONE knob you turn for brand.
356
+ - Not opinionated about the accent — `accent` / `{colors.accent}` is the ONE knob you turn for brand.