@bacnh85/pi-ux 0.4.4 → 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 +58 -0
- package/README.md +7 -3
- package/extensions/index.js +12 -4
- package/hooks/ux-audit.js +122 -5
- package/package.json +1 -1
- package/skills/ux-capture/SKILL.md +29 -0
- package/skills/ux-design/SKILL.md +49 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
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
|
+
|
|
55
|
+
## 0.4.5 (2026-08-29)
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- `/ux` argument completion offers runtime modes plus `status|default`.
|
|
60
|
+
|
|
3
61
|
## 0.4.4
|
|
4
62
|
|
|
5
63
|
- Patch version bump for release sync and package documentation update.
|
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. **
|
|
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
|
|
package/extensions/index.js
CHANGED
|
@@ -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
|
|
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
|
-
"
|
|
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(),
|
|
@@ -178,6 +180,12 @@ export default function uxExtension(pi) {
|
|
|
178
180
|
|
|
179
181
|
pi.registerCommand("ux", {
|
|
180
182
|
description: `Anti-slop UI/UX discipline. Modes: ${RUNTIME_MODES.join("|")}. Commands: status, default <mode>`,
|
|
183
|
+
getArgumentCompletions: (prefix) => {
|
|
184
|
+
const q = String(prefix || "").trim().toLowerCase();
|
|
185
|
+
const vocab = [...RUNTIME_MODES, "status", "default"];
|
|
186
|
+
const items = vocab.filter((k) => k.startsWith(q)).map((k) => ({ value: k, label: k }));
|
|
187
|
+
return items.length > 0 ? items : null;
|
|
188
|
+
},
|
|
181
189
|
handler: async (args, ctx) => {
|
|
182
190
|
const parsed = parseUxCommand(args, configuredDefaultMode);
|
|
183
191
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
10
|
-
|
|
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. **
|
|
55
|
-
4. **
|
|
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 —
|
|
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.
|