@bacnh85/pi-ux 0.4.5 → 0.4.6

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,57 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.6 (2026-09-06)
4
+
5
+ ### Added
6
+
7
+ - **Step 4 — Render & Inspect** in the ux-design skill: a vision verify loop
8
+ for multimodal models (GLM-5.3, Claude, Gemini). Reference-first
9
+ capture with `web_screenshot` (now returned inline as an image block by
10
+ pi-web 0.6.2), inspect your own rendered build at a daemon-reachable address
11
+ (LAN IP / host.docker.internal; localhost only for a native same-host daemon), visibility
12
+ baseline (judge at 1×–3×, never sub-visible precision), deterministic
13
+ `ux_audit` stays the blocking gate. The single biggest quality lever for
14
+ flash-tier models: eyes on output beat rules in a prompt. Inspired by
15
+ zcode-plugins video2code. Slop-audit gate renumbered to Step 5; text-only
16
+ fallback unchanged. Live-tested end-to-end; hardened with SSRF-blocked
17
+ daemon fallbacks (local headless chrome capture read back inline — fast,
18
+ offline; LAN IP or cloudflared tunnel only when the remote daemon must
19
+ render the page).
20
+ - `ux_audit`: pairs declared `min: 3` **without** size/weight are now
21
+ treated as non-text graphics (APCA Lc ≥ 30, per the documented gate
22
+ table) instead of the body-text Lc ≥ 75 floor. Found auditing a real
23
+ dashboard chart (accent-70% column on white: Lc 57.5 / WCAG 3.07:1 —
24
+ legal for graphics, wrongly failed as body copy). Text pairs with
25
+ size/weight are unchanged.
26
+ - **Taste layer** (from anthropics/skills `frontend-design`): the ux-design
27
+ skill now carries the five named cliché clusters (cream/terracotta "Claude
28
+ look", acid-on-black, broadsheet kit, SaaS-card kit, template chrome),
29
+ taste rules (typography-as-personality, one-orchestrated-motion, design
30
+ writing, spend boldness in one place), the generic-default check before
31
+ building, variant-branching as files (`.ux/drafts/`), BEFORE-state capture
32
+ when redesigning, and design-DNA extraction from reference sites into
33
+ DESIGN.md.
34
+ - `ux_audit` taste tells: flags **tracked-out uppercase eyebrows**
35
+ (uppercase + ≤13px + letter-spacing ≥0.08em in one rule) and **tinted
36
+ near-black backgrounds** (`#0B0B0B`/`#111` standing in for black; pure
37
+ `#000` allowed). The States gate now also fails CSS with
38
+ transitions/animations but no `prefers-reduced-motion` fallback.
39
+ - New **`ux-capture`** skill: the render-and-inspect capture playbook
40
+ (local headless-Chrome vs daemon-rendered `web_screenshot`, daemon
41
+ addressing, SSRF blocks, tunnel last resort) split out of the always-
42
+ injected ux-design body to keep per-turn prompt overhead down.
43
+ - **`ux_audit` auto-extracts contrast pairs** from rules that declare both
44
+ colour and background when no pairs are supplied (rgb-normalised dedupe,
45
+ 24-pair cap) — closing the #1 silent gap where unchecked pairs meant
46
+ unchecked contrast. Fragment input now prints a hint to audit the complete
47
+ stylesheet instead of silently blocking on missing selectors.
48
+ - **Data-viz rules + scale-to-task** in the ux-design skill: chart
49
+ fill-on-track contrast, categorical ramp limits, secondary-series opacity,
50
+ tabular numerics, empty-chart states, dense-table patterns; one-line brief
51
+ + single draft is now legitimate for small internal tools.
52
+ (Feedback implemented from a field review by a glm-5.3-flash agent that
53
+ designed with pi-ux under A2A peer review.)
54
+
3
55
  ## 0.4.5 (2026-08-29)
4
56
 
5
57
  ### Added
package/README.md CHANGED
@@ -49,7 +49,10 @@ The injected skill enforces **Constraint-First Design Generation**:
49
49
  1. **5-field brief per screen** — user job, inventory, token constraints, required states, one reference.
50
50
  2. **Generate fast, converge early** — text-only models inside the locked system; 2-loop convergence trigger.
51
51
  3. **Normalise** the draft back into tokens/elevation/spacing.
52
- 4. **Slop-audit gate** — run `ux_audit` (measurable): APCA contrast, token coverage, state coverage, slop tells.
52
+ 4. **Render & Inspect** (multimodal models) reference-first capture, then screenshot your own build and LOOK. Default: local headless-Chrome capture read back inline (offline, no daemon); alternative: `web_screenshot` (pi-web 0.6.2+, PNG returned inline) at a daemon-reachable address (LAN IP / host.docker.internal — SSRF-protected daemons block private ranges; cloudflared tunnel as last resort). Judge at viewer resolution (1×–3×); never chase sub-visible precision. Skip when text-only — the deterministic gates are the whole loop.
53
+ 5. **Slop-audit gate** — run `ux_audit` (measurable): APCA contrast, token coverage, state coverage, slop tells, tracked-eyebrow/near-black taste tells, reduced-motion coverage.
54
+
55
+ Taste rules ship in the skill: named cliché clusters (the cream/terracotta "Claude look", acid-on-black, broadsheet kit, SaaS-card kit, template chrome), typography-as-personality, one-orchestrated-motion, design-writing rules, and the generic-default check ("would I produce this plan for any similar brief?").
53
56
 
54
57
  ## The `ux_audit` tool
55
58
 
@@ -63,8 +66,8 @@ ux_audit css="..." pairs=[{fg:"#111",bg:"#fff",label:"body",weight:400,size:16,m
63
66
  |------|----------------|
64
67
  | **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. |
65
68
  | **Tokens** | Hardcoded hex outside `:root` token defs; `box-shadow` not built from `var(--…)` tokens |
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 |
69
+ | **States** | Interactive selectors (`button`/`a`/`input`/…/`[role=button]`) missing `:focus-visible` or `:disabled`; any transition/animation missing a `prefers-reduced-motion` fallback |
70
+ | **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, tracked-out eyebrows, tinted near-black backgrounds |
68
71
 
69
72
  Returns pass/fail per gate + a formatted report. In `strict` mode this is the gate that blocks handoff.
70
73
 
@@ -90,6 +93,7 @@ The skill tells the agent which model to use for each step. The gate is mechanic
90
93
 
91
94
  - **`ux-design`** — the Constraint-First method + deterministic-first model routing (auto-injected by the hook when active).
92
95
  - **`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.
96
+ - **`ux-capture`** — the Step 4 render-and-inspect capture playbook: local headless-Chrome capture read inline vs daemon-rendered `web_screenshot`, LAN IP/host.docker.internal addressing, SSRF-blocked daemons, cloudflared tunnel as last resort.
93
97
 
94
98
  ## Configuration
95
99
 
@@ -79,7 +79,7 @@ function auditParametersSchema() {
79
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: "WCAG compliance floor (4.5 body, 3.0 large/UI). Shown as a sidecar; the primary gate is APCA. 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. Pairs with min 3 and NO size/weight are treated as non-text graphics (APCA Lc 30 per the gate table)." },
83
83
  weight: { type: "number", description: "Font weight (400/500/700). With size, sets the APCA threshold. Defaults to 400." },
84
84
  size: { type: "number", description: "Font size in px. With weight, sets the APCA threshold. Defaults to 16." },
85
85
  },
@@ -112,6 +112,7 @@ function formatAuditResult(result) {
112
112
  lines.push(s.pass ? "✓ States" : "✗ States");
113
113
  for (const m of s.missingFocusVisible) lines.push(` ✗ ${m}`);
114
114
  for (const m of s.missingDisabled) lines.push(` ✗ ${m}`);
115
+ for (const m of s.missingReducedMotion || []) lines.push(` ✗ ${m}`);
115
116
 
116
117
  const st = result.gates.slopTells;
117
118
  lines.push(st.pass ? "✓ Slop tells" : "✗ Slop tells");
@@ -156,12 +157,13 @@ export default function uxExtension(pi) {
156
157
  name: "ux_audit",
157
158
  label: "UX Slop Audit",
158
159
  description:
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
+ "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 + prefers-reduced-motion), and named AI slop tells (glassmorphism, gradient orbs, neon glow, default-card, tracked-out eyebrows, tinted near-black). No model needed — all gates are computable. In strict mode, block handoff until this fails to pass. AUDIT THE COMPLETE STYLESHEET, not fragments. If no contrast pairs are supplied, they are auto-extracted from rules that declare both colour and background.",
160
161
  promptSnippet: "Run deterministic UX slop-audit (APCA contrast + tokens + states + slop tells)",
161
162
  promptGuidelines: [
162
163
  "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).",
164
+ "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. Omit pairs and they are auto-extracted from colour+background rules — but hand-picking catches text-on-inherited-backgrounds that auto-extraction misses.",
165
+ "Audit the COMPLETE stylesheet fragment input falsely fails the States gate (no interactive selectors present) and misses off-system values elsewhere.",
166
+ "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, tracked-out eyebrows, tinted near-black). Transition/animation CSS must ship a prefers-reduced-motion fallback.",
165
167
  "State coverage flags interactive elements (button/a/input/...) missing :focus-visible or :disabled rules.",
166
168
  ],
167
169
  parameters: auditParametersSchema(),
package/hooks/ux-audit.js CHANGED
@@ -109,7 +109,10 @@ function apcaContrastLc(textColor, bgColor) {
109
109
 
110
110
  // APCA threshold for a text/bg pair by font weight + size (px).
111
111
  // Spec guidance: Lc 75 body, 60 for 400@18px+, 45 large/bold, 30 non-text.
112
- function apcaThreshold(weight, size) {
112
+ // Non-text pairs (min: 3 = WCAG "large/UI" floor, no size/weight given) are
113
+ // graphics, not copy — they gate at Lc 30 per the gate table.
114
+ function apcaThreshold(weight, size, isNonText = false) {
115
+ if (isNonText) return 30;
113
116
  const w = typeof weight === 'number' ? weight : 400;
114
117
  const s = typeof size === 'number' ? size : 16;
115
118
  const bold = w >= 700;
@@ -202,7 +205,16 @@ function scanOffSystem(css, tokens) {
202
205
  // ponytail: presence check, not a CSS parser. Stateful /g regexes with
203
206
  // .test() flake across calls, so use plain includes().
204
207
  function scanStates(css) {
205
- const findings = { missingFocusVisible: [], missingDisabled: [] };
208
+ css = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // dead code must not fail the gate
209
+ const findings = { missingFocusVisible: [], missingDisabled: [], missingReducedMotion: [] };
210
+
211
+ // Motion needs a reduced-motion fallback regardless of interactive elements
212
+ // (a hero fade-in on a page with no buttons still needs one).
213
+ // ponytail: deliberately excludes transition-behavior — inert alone.
214
+ const hasMotion = /transition(?:-(?:property|duration|delay|timing-function))?\s*:|animation(?:-(?:name|duration|delay|timing-function|iteration-count|direction|fill-mode|play-state))?\s*:|@keyframes|scroll-behavior\s*:\s*smooth/i.test(css);
215
+ if (hasMotion && !css.includes('prefers-reduced-motion')) {
216
+ findings.missingReducedMotion.push('motion (transition/animation) with no prefers-reduced-motion fallback');
217
+ }
206
218
 
207
219
  const hasInteractive =
208
220
  /\b(?:button|a|input|select|textarea)\b/i.test(css) || /\[role\s*=\s*"?button"?\]/i.test(css);
@@ -220,6 +232,7 @@ function scanStates(css) {
220
232
  // catches and the conservative pattern that must all match.
221
233
 
222
234
  function scanSlopTells(css) {
235
+ css = css.replace(/\/\*[\s\S]*?\*\//g, ' '); // dead code must not fail the gate
223
236
  const tells = [];
224
237
  const lower = css.toLowerCase();
225
238
 
@@ -295,23 +308,125 @@ function scanSlopTells(css) {
295
308
  tells.push('1px gray card border (border-zinc/gray default or near-gray 1px solid) — the most reliable AI tell');
296
309
  }
297
310
 
311
+ // 6. Eyebrow labels — tracked-out ALL-CAPS micro-labels above headings are
312
+ // template chrome (anthropics/skills frontend-design). Conservative: only
313
+ // flag when text-transform: uppercase, font-size ≤13px AND wide tracking
314
+ // (letter-spacing ≥ 0.08em) co-occur in the same block — the "tracked-out"
315
+ // part is the slop signature; plain uppercase labels with normal tracking
316
+ // are a legitimate table/label style.
317
+ const ruleRe = /([^{}]+)\{([^}]*)\}/g;
318
+ let rm2;
319
+ while ((rm2 = ruleRe.exec(css)) !== null) {
320
+ const decls = rm2[2].toLowerCase();
321
+ if (!/text-transform\s*:\s*uppercase/.test(decls)) continue;
322
+ const fs = decls.match(/font-size\s*:\s*([0-9.]+)(px|rem|em|pt)/);
323
+ if (!fs) continue;
324
+ const px = parseFloat(fs[1]) * { px: 1, rem: 16, em: 16, pt: 16 / 12 }[fs[2]];
325
+ if (px > 13) continue;
326
+ const ls = decls.match(/letter-spacing\s*:\s*([0-9.]+)(px|rem|em)/);
327
+ // Threshold is documented in em (≥0.08em) and em scales with the element's
328
+ // OWN font-size — so normalise every unit to em before comparing.
329
+ const lsEm = ls
330
+ ? parseFloat(ls[1]) * (ls[2] === 'em' ? 1 : ls[2] === 'rem' ? 16 / px : 1 / px)
331
+ : 0;
332
+ if (lsEm >= 0.08) {
333
+ tells.push('eyebrow label (tracked-out uppercase at ≤13px, letter-spacing ≥0.08em) — template chrome; try sentence-case micro-labels or weight/colour instead');
334
+ break;
335
+ }
336
+ }
337
+
338
+ // 7. Tinted near-black backgrounds — #0B0B0B/#111 standing in for black is
339
+ // template chrome. Flag background hex where every channel ≤ 0x14 and the
340
+ // channel spread ≤ 3 (a near-neutral tint, not a real colour); pure #000
341
+ // is a deliberate choice and stays allowed. colour: declarations are
342
+ // exempt — near-black copy text is fine.
343
+ const bgRe = /background(?:-color)?\s*:\s*[^;}]*#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})(?![0-9a-f])/gi;
344
+ let bm;
345
+ while ((bm = bgRe.exec(css)) !== null) {
346
+ const rgb = parseHex('#' + bm[1]);
347
+ if (!rgb) continue;
348
+ const max = Math.max(rgb[0], rgb[1], rgb[2]);
349
+ const spread = max - Math.min(rgb[0], rgb[1], rgb[2]);
350
+ if (max > 0 && max <= 0x14 && spread <= 3) {
351
+ tells.push('tinted near-black background (#0B0B0B/#111 standing in for black) — template chrome tell');
352
+ break;
353
+ }
354
+ }
355
+
298
356
  return { tells };
299
357
  }
300
358
 
359
+ // Contrast pairs auto-extracted from rules that declare both a colour and a
360
+ // background. Var() references resolve through the :root token map. Blocks
361
+ // without an explicit background are skipped — no inherited-bg guessing.
362
+ function extractContrastPairs(css) {
363
+ const nameToValue = new Map();
364
+ const rootBlock = /:root\b[^{]*\{([^}]*)\}/g;
365
+ let rootMatch;
366
+ while ((rootMatch = rootBlock.exec(css)) !== null) {
367
+ const declRe = /--([a-zA-Z0-9-]+)\s*:\s*([^;]+);/g;
368
+ let decl;
369
+ while ((decl = declRe.exec(rootMatch[1])) !== null) {
370
+ nameToValue.set('--' + decl[1].trim(), decl[2].trim());
371
+ }
372
+ }
373
+ const resolve = (value) => {
374
+ let v = value.trim();
375
+ const varMatch = v.match(/^var\((--[^,)]+)(?:,\s*([^)]*))?\)$/);
376
+ if (varMatch) v = nameToValue.get(varMatch[1]) || (varMatch[2] || '').trim();
377
+ return /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$|^(?:rgb|hsl)a?\(/i.test(v) ? v : null;
378
+ };
379
+
380
+ const pairs = [];
381
+ const seen = new Set();
382
+ const ruleRe = /([^{}]+)\{([^}]*)\}/g;
383
+ let rm;
384
+ while ((rm = ruleRe.exec(css)) !== null && pairs.length < 24) {
385
+ const decls = rm[2];
386
+ const colorMatch = decls.match(/(?:^|;)\s*color\s*:\s*([^;]+)/);
387
+ const bgMatch = decls.match(/(?:^|;)\s*(?:background|background-color)\s*:\s*([^;]+)/);
388
+ if (!colorMatch || !bgMatch) continue;
389
+ const fg = resolve(colorMatch[1]);
390
+ const bg = resolve(bgMatch[1].split(' ')[0]);
391
+ if (!fg || !bg || fg.toLowerCase() === bg.toLowerCase()) continue;
392
+ // Normalise to rgb triples so #FFFFFF and #fff dedupe to one pair.
393
+ const keyOf = (c) => { const p = parseColor(c); return p ? `${p[0]},${p[1]},${p[2]}` : c.toLowerCase(); };
394
+ const key = keyOf(fg) + '|' + keyOf(bg);
395
+ if (seen.has(key)) continue;
396
+ seen.add(key);
397
+ const selector = rm[1].trim().replace(/\s+/g, ' ').slice(0, 40);
398
+ pairs.push({ fg, bg, label: `auto: ${selector}` });
399
+ }
400
+ return pairs;
401
+ }
402
+
301
403
  // --- aggregate gate -------------------------------------------------------
302
404
 
303
405
  function audit({ css = '', pairs = [] }) {
304
406
  const safeCss = typeof css === 'string' ? css : '';
305
- const safePairs = Array.isArray(pairs) ? pairs : [];
407
+ let safePairs = Array.isArray(pairs) ? pairs : [];
306
408
  const tokens = extractTokens(safeCss);
307
409
  const off = scanOffSystem(safeCss, tokens);
308
410
  const states = scanStates(safeCss);
309
411
  const tells = scanSlopTells(safeCss);
310
412
 
413
+ // Silent-gap fix: contrast is only checked for hand-supplied pairs, which
414
+ // callers routinely forget. When none are provided, extract pairs from rules
415
+ // that declare BOTH a colour and a background (resolved via :root tokens).
416
+ // Conservative: no inherited-background guessing.
417
+ let autoPairs = false;
418
+ if (safePairs.length === 0) {
419
+ safePairs = extractContrastPairs(safeCss);
420
+ autoPairs = safePairs.length > 0;
421
+ }
422
+
311
423
  const contrastResults = safePairs.map((p) => {
312
424
  const lc = apcaContrastLc(p.fg, p.bg);
313
425
  const ratio = contrastRatio(p.fg, p.bg);
314
- const apcaMin = apcaThreshold(p.weight, p.size);
426
+ // min: 3 with no size/weight signals a non-text graphic (WCAG 3:1 floor);
427
+ // anything carrying size/weight is copy and uses the text thresholds.
428
+ const isNonText = p.min === 3 && p.size === undefined && p.weight === undefined;
429
+ const apcaMin = apcaThreshold(p.weight, p.size, isNonText);
315
430
  // pass follows APCA (primary). A pair passes if APCA is present and meets
316
431
  // its threshold; if APCA is null (unparseable colour), fail on WCAG.
317
432
  let pass;
@@ -332,15 +447,17 @@ function audit({ css = '', pairs = [] }) {
332
447
  const contrastPass = contrastResults.every((r) => r.pass);
333
448
 
334
449
  return {
450
+ autoPairs,
335
451
  gates: {
336
452
  contrast: { pass: contrastPass, results: contrastResults },
337
453
  tokens: { pass: off.hardcodedHex.length === 0 && off.adhocShadow.length === 0, ...off },
338
- states: { pass: states.missingFocusVisible.length === 0 && states.missingDisabled.length === 0, ...states },
454
+ states: { pass: states.missingFocusVisible.length === 0 && states.missingDisabled.length === 0 && states.missingReducedMotion.length === 0, ...states },
339
455
  slopTells: { pass: tells.tells.length === 0, ...tells },
340
456
  },
341
457
  pass: contrastPass
342
458
  && off.hardcodedHex.length === 0 && off.adhocShadow.length === 0
343
459
  && states.missingFocusVisible.length === 0 && states.missingDisabled.length === 0
460
+ && states.missingReducedMotion.length === 0
344
461
  && tells.tells.length === 0,
345
462
  };
346
463
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-ux",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
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",
@@ -0,0 +1,29 @@
1
+ ---
2
+ name: ux-capture
3
+ description: >
4
+ Capture playbook for the pi-ux render-and-inspect loop: screenshotting a UI
5
+ you just built via local headless Chrome vs web_screenshot, daemon
6
+ reachability (localhost vs LAN IP vs host.docker.internal), SSRF-protected
7
+ daemons, and cloudflared tunnels. Load when setting up render inspection or
8
+ when a UI screenshot capture fails.
9
+ ---
10
+
11
+ # UX Capture Playbook
12
+
13
+ Judge captures at viewer resolution (1×–3×); never chase sub-visible precision.
14
+
15
+ ## Default — local capture (offline, no daemon)
16
+
17
+ Headless Chrome writes the PNG; the `read` tool shows it inline (multimodal models see it).
18
+
19
+ - Linux: `google-chrome --headless --screenshot=/tmp/shot.png --window-size=1280,800 http://localhost:PORT` (or `chromium`)
20
+ - macOS: `"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless --screenshot=/tmp/shot.png --window-size=1280,800 http://localhost:PORT`
21
+
22
+ ## Alternative — web_screenshot (daemon-rendered, pi-web 0.6.2+)
23
+
24
+ The screenshot URL is navigated by the DAEMON's browser, so addresses must resolve on the daemon host:
25
+
26
+ - Reachable: your machine's LAN IP, or `host.docker.internal` for a Dockerized daemon.
27
+ - Plain localhost/file:// only for a daemon you *know* runs natively on this machine — `web_status` can't tell you (a Dockerized daemon on a published port also shows 127.0.0.1 + healthy but cannot see your localhost).
28
+ - SSRF-protected daemons (common) block private/localhost URLs outright — failures say "URL blocked (SSRF protection)".
29
+ - Last resort: a temporary public tunnel (`cloudflared tunnel --url http://localhost:PORT`) when the remote daemon must render the page; quick tunnels are often flaky. If all capture paths fail, fall back to the deterministic `ux_audit` gates only.
@@ -6,8 +6,9 @@ description: >
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
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
9
+ write a 5-field brief, generate inside constraints, normalise, render-and-inspect
10
+ with vision (multimodal models see their own output), pass a measurable slop-audit
11
+ gate. Works deterministically with text-only models
11
12
  (DeepSeek-v4, GLM-5.2, Kimi K3); agy/Gemini/Claude is optional polish, never
12
13
  the review gate. Use when designing or building any UI — web, mobile, or
13
14
  desktop. Active via /ux lite|strict|off.
@@ -28,7 +29,7 @@ is anchored by a repo-root **DESIGN.md** that every generation reads.
28
29
  - **Accent:** ONLY the defined accent token. No purple/indigo gradients. No glow unless explicitly requested. (Glow is the #1 AI-slop signature.)
29
30
  - **Type:** modular scale only (e.g. 1.25 ratio). No custom font sizes.
30
31
  - **Spacing:** 8px grid via tokens. No magic pixel values.
31
- - **States:** every interactive element declares `default`, `hover`, `focus-visible`, `active`, `disabled` + `error`/`empty`/`loading` where relevant.
32
+ - **States:** every interactive element declares `default`, `hover`, `focus-visible`, `active`, `disabled` + `error`/`empty`/`loading` where relevant. Anything that moves respects `prefers-reduced-motion`.
32
33
  - **Inventory first:** before writing markup, output a 1-line inventory of components + states you will produce.
33
34
  - **Ambiguity = ask.** If a requirement is ambiguous, ASK. Do not guess aesthetics.
34
35
 
@@ -51,13 +52,14 @@ deterministic gate and the agent both read.
51
52
  **Resolution order (stop at the first that holds):**
52
53
  1. **Repo-root `DESIGN.md` exists** → read it; use its tokens verbatim. This is the happy path.
53
54
  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.
55
+ 3. **A reference site exists** (brand to match, look to echo) `web_extract` it and distill its design DNA (4–6 core hex, type roles, radius/spacing rhythm) into a DESIGN.md starter, lint it, then work inside it.
56
+ 4. **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).
57
+ 5. **No preset fits** → generate a DESIGN.md once with `agy_execute mode=plan pro-high` (Gemini); thereafter text-only models are sufficient.
56
58
 
57
59
  The non-negotiables a preset/system must define before any screen is written:
58
60
  - **Tokens:** colour (one accent + neutrals), type scale, spacing (8px grid), radius, elevation.
59
61
  - **Elevation scale:** 3–5 named levels only. Map every shadow to one.
60
- - **State contract:** every interactive component declares all states (web: + `:hover`/`:focus-visible`; mobile: + `:active`/pressed, NO hover).
62
+ - **State contract:** every interactive component declares all states (web: + `:hover`/`:focus-visible`; mobile: + `:active`/pressed, NO hover); motion ships with a `prefers-reduced-motion` fallback.
61
63
 
62
64
  **Lint the system (when persisted to disk):**
63
65
  ```bash
@@ -73,15 +75,34 @@ All five are required. If you can't produce inventory + states with confidence,
73
75
  4. **Required interaction states** per interactive component.
74
76
  5. **One reference** screen/component that already feels like the product (brand tone).
75
77
 
78
+ **Generic-default check (before building):** if there is no DESIGN.md, sketch a mini plan first — 4–6 named hex, type roles, a one-line layout concept or ASCII wireframe, one principle. Then ask: *would I produce this same plan for any similar brief?* Every part that answers yes is a default, not a choice — revise it and say what changed.
79
+
80
+ **Scale to the task:** internal single-file tools → one-line brief + one draft is fine; user-facing product UI → full brief + 2–3 variants. The gates are the same either way.
81
+
76
82
  ### Step 2 — Generate fast, converge early
77
83
  Generate several variants, commit early to the direction with the clearest hierarchy under real content.
84
+ - **Variants are files, not vibes:** write 2–3 drafts as separate HTML files (`.ux/drafts/<slug>-{a,b,c}.html`), screenshot each, view the images side by side, and commit to one direction before touching real code. When redesigning existing UI, first capture a BEFORE screenshot of the current page as reference context.
78
85
  - **Convergence trigger:** after two generate-revise loops, variants must visibly converge toward your constraints. If they don't, tighten the brief first — prompting forever compounds drift.
79
86
 
80
87
  ### Step 3 — Normalise the draft into the system
81
88
  Replace ad-hoc colours with tokens, remap shadows to the elevation scale, snap spacing to the rhythm, turn one-offs into component variants.
82
89
  - Gate: sample 10 components, verify token mapping. If fewer than 8 map cleanly, stop and repair the baseline.
83
90
 
84
- ### Step 4 — Slop-audit gate (blocks handoff on fail)
91
+ ### Step 4 — Render & Inspect (vision verify multimodal models)
92
+
93
+ The deterministic gates check CSS text; they never see the page. When the
94
+ generating model is multimodal (GLM-5.3, Claude, Gemini), close the
95
+ loop with eyes — this is the single biggest quality lever for flash-tier
96
+ models:
97
+
98
+ 1. **Reference-first.** If the task gives a URL or screenshot as the design target, capture it with `web_screenshot` (pi-web 0.6.2+ returns the PNG inline) BEFORE generating. Every visual judgment is made against that reference.
99
+ 2. **Inspect your own build.** Serve the UI (dev server or `python3 -m http.server`), then capture and LOOK — default: local headless Chrome + `read` (renders inline; commands in the `ux-capture` skill; offline, no daemon); alternative: `web_screenshot` (pi-web 0.6.2+) at a daemon-reachable address (full playbook in `ux-capture`). Fix what regex gates can't see: broken layout, weak hierarchy, spacing rhythm, brand fit. Batch fixes, re-screenshot, stop when converged (same 2-loop trigger as Step 2).
100
+ 3. **Visibility baseline.** Judge at what a viewer sees at 1×–3×. Nothing sub-visible can fail, and nothing sub-visible may be produced — no ±1px claims, no per-pixel diffs, no instrument-read values on either side.
101
+ 4. **Gates stay final.** `ux_audit` (Step 5) remains the blocking authority; vision settles only what looking can settle.
102
+
103
+ Text-only models, or no capture path at all (no headless Chrome, no reachable `web_screenshot`) → skip this step; the deterministic gates are the whole loop.
104
+
105
+ ### Step 5 — Slop-audit gate (blocks handoff on fail)
85
106
 
86
107
  Run `ux_audit` on the generated CSS. The contrast gate reports **APCA Lc**
87
108
  (perceptual, primary — Lc ≥75 body / ≥45 large-bold / ≥30 non-text) with a
@@ -94,9 +115,9 @@ tells (glassmorphism, gradient orbs, neon glow, default-card).
94
115
  | Token coverage | ≥8/10 components map to tokens | Pause, repair baseline |
95
116
  | Shadow recipes | ≤3 named recipes on core surfaces | Collapse to named elevations |
96
117
  | Contrast (APCA) | Lc ≥75 body, ≥45 large-bold, ≥30 non-text (WCAG sidecar shown) | Block handoff until fixed |
97
- | State coverage | all interactive elements have focus + disabled | Keep in draft |
118
+ | State coverage | all interactive elements have focus + disabled; any transition/animation ships a prefers-reduced-motion fallback | Keep in draft |
98
119
  | 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 |
120
+ | Slop tells | no glassmorphism / orbs / glow / default-card / 1px-gray-border / tracked-out eyebrow / tinted near-black bg | Refactor: space → bg shift → elevation before a border |
100
121
 
101
122
  ## Model routing (who does what)
102
123
 
@@ -115,6 +136,7 @@ never the review gate.
115
136
  | **Per-screen brief** | Main Pi model: **GLM-5.2** | 1M ctx holds the whole DESIGN.md while scoping one screen |
116
137
  | **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. |
117
138
  | **Normalise into system** | Main Pi model: **DeepSeek-v4** or **GLM-5.2** | Long context, token remapping, mechanical precision |
139
+ | **Render & inspect** | The generating model itself, when multimodal (GLM-5.3, Claude, Gemini) via `web_screenshot` | Eyes on your own output beat rules in a prompt — flash-tier models produce notably better UI when they see the rendered result (inline image blocks) |
118
140
  | **Slop audit** | `ux_audit` tool (deterministic) + DESIGN.md lint | Contrast (APCA) + token coverage + slop tells are computable, not judgement |
119
141
  | **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. |
120
142
 
@@ -137,3 +159,21 @@ screen stays in draft until refactored.
137
159
  - Magic pixel values; off-scale font sizes; ad-hoc accent colours.
138
160
  - Shipping a component without `focus-visible` + `disabled` states.
139
161
  - Prompting "make it modern/clean" with no DESIGN.md — the single biggest slop trigger.
162
+
163
+ ### Named cliché clusters (credit: anthropics/skills frontend-design)
164
+ All legitimate for *some* briefs — but they are defaults, not choices, and appear regardless of subject. Where the brief pins a direction, follow it exactly; where an axis is free, don't spend it here:
165
+
166
+ - **The cream-clay look** — warm cream bg (near `#F4F1EA`) + high-contrast serif display + terracotta accent (near `#D97757`); on anyone else's brief it reads as Claude-flavoured.
167
+ - **Acid on black** — near-black background + single acid-green/vermilion accent.
168
+ - **Broadsheet kit** — hairline rules, zero border-radius everywhere, dense newspaper columns.
169
+ - **The SaaS-card kit** — identical rounded cards, one radius for everything, the same soft shadow under each, gradient washes as decoration.
170
+ - **Template chrome** — tracked-out ALL-CAPS eyebrow above every heading; meta strings joined with middle dots (`A · B · C`); `WORD — fragment` labels; tinted near-black (`#0B0B0B`, `#111`) standing in for black; monospace for small data labels; `→` appended to links/buttons.
171
+
172
+ ## Taste rules
173
+
174
+ - **Ground it in the subject.** Distinctive choices come from the brief's industry, materials, and vernacular — a toy for kids and a trading dashboard should not share a visual language. If the subject is unclear, confirm it before designing.
175
+ - **Typography carries personality.** Choose typefaces deliberately per project (1–2 families, clearly distinct roles); body lines under ~80 chars. Never accent a single word of a headline; no ALL-CAPS labels by default; structural devices (numbers, rules, eyebrows) only when they encode real information — `01 / 02 / 03` is for actual sequences.
176
+ - **Motion: one orchestrated moment.** A single page-load sequence or reveal lands better than effects scattered everywhere; fade-and-slide-up on every section is an AI tell. Motion that answers an action (opening, confirming) is welcome. Always ship a `prefers-reduced-motion` fallback.
177
+ - **Design writing is design.** Use the user's words, not system words ("notifications", not "webhook config"). CTAs say what happens ("Save changes", not "Submit"); one name per action across the flow. Errors direct instead of apologising; empty states invite action.
178
+ - **Spend boldness in one place.** One memorable element; everything around it quiet and disciplined. Quality floor without announcing it: responsive, visible keyboard focus, reduced motion, accessible contrast.
179
+ - **Data-viz rules (dashboards & charts).** Chart fills must pass non-text contrast (≥3:1) against their track. Adjacent categorical fills must be nameably different, not opacity steps; cap ramps at 7. Secondary series ≤0.85 opacity or a muted token; today/selected gets full accent. Numeric cells get `font-variant-numeric: tabular-nums`. Empty/zero chart states show an axis or "no data" slot, never a blank canvas. Tables: row hover on bg, right-aligned numerics, sentence-case headers.