@hegemonart/get-design-done 1.47.0 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +5 -2
- package/CHANGELOG.md +91 -0
- package/README.md +4 -0
- package/agents/brief-auditor.md +147 -0
- package/agents/copy-auditor.md +215 -0
- package/agents/design-auditor.md +30 -7
- package/agents/design-context-builder.md +2 -0
- package/agents/design-debt-crawler.md +292 -0
- package/agents/design-executor.md +2 -0
- package/agents/design-fixer.md +6 -1
- package/agents/design-planner.md +2 -0
- package/agents/design-reflector.md +2 -0
- package/agents/design-research-synthesizer.md +2 -0
- package/agents/design-verifier.md +7 -15
- package/agents/quality-gate-runner.md +11 -10
- package/dist/claude-code/.claude/skills/brief/SKILL.md +17 -0
- package/dist/claude-code/.claude/skills/quality-gate/SKILL.md +2 -2
- package/hooks/gdd-a11y-gate.js +119 -0
- package/hooks/gdd-design-quality-check.js +340 -0
- package/hooks/hooks.json +17 -0
- package/package.json +5 -2
- package/reference/brief-quality-rubric.md +98 -0
- package/reference/copy-quality.md +135 -0
- package/reference/debt-categories.md +148 -0
- package/reference/registry.json +35 -0
- package/reference/reviewer-confidence-gate.md +108 -0
- package/reference/visual-tells.md +237 -0
- package/scripts/lib/confidence-route.cjs +60 -0
- package/scripts/lib/worktree-resolve.cjs +221 -0
- package/sdk/mcp/gdd-state/server.js +37 -4
- package/sdk/mcp/gdd-state/tools/shared.ts +61 -0
- package/skills/brief/SKILL.md +17 -0
- package/skills/quality-gate/SKILL.md +2 -2
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* hooks/gdd-design-quality-check.js — advisory PostToolUse hook for the
|
|
5
|
+
* default-AI-aesthetic regex floor (Phase 49, Quick Anti-Slop Floor).
|
|
6
|
+
*
|
|
7
|
+
* The cheapest possible anti-slop pass: on every front-end file write, scan the
|
|
8
|
+
* written content for the visual tells that mark a UI as "an AI generated this"
|
|
9
|
+
* (gradient spam, the purple/violet default palette, glassmorphism stacks, the
|
|
10
|
+
* Inter default, centered-everything heroes, undraw/isometric clip art, filler
|
|
11
|
+
* CTA copy, decorative motion with no loading intent). Each match is a non
|
|
12
|
+
* blocking WARN. The catalog the rules come from lives at
|
|
13
|
+
* reference/visual-tells.md (8 named categories, 1:1 with the 8 rules here).
|
|
14
|
+
*
|
|
15
|
+
* Contract (mirrors hooks/gdd-a11y-gate.js):
|
|
16
|
+
* - Read stdin JSON (the PostToolUse payload: {tool_name, tool_input,
|
|
17
|
+
* tool_response, cwd, ...}).
|
|
18
|
+
* - Only act on Write/Edit/MultiEdit tools targeting a .tsx/.vue/.svelte/.astro
|
|
19
|
+
* file. Everything else is a bare {continue:true}.
|
|
20
|
+
* - Scan the written content against 8 regex rules; collect matches as warnings.
|
|
21
|
+
* - Emit one `design_quality_warn` event through scripts/lib/event-chain.cjs
|
|
22
|
+
* (baseDir injected from cwd; the emit is best-effort and never fatal).
|
|
23
|
+
* - Print a concise advisory to stdout and ALWAYS write {continue:true}, exit 0.
|
|
24
|
+
* This hook is WARN-only. It never blocks a write.
|
|
25
|
+
*
|
|
26
|
+
* Dependency-free: core fs/path plus the in-repo event-chain helper. No npm deps.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
|
|
32
|
+
/** Front-end source extensions this hook scans. */
|
|
33
|
+
const FRONTEND_EXT = ['.tsx', '.vue', '.svelte', '.astro'];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The 8 v1 rules. Each `category` matches a heading in reference/visual-tells.md.
|
|
37
|
+
* `test(content)` returns an array of { line, match } hits (possibly empty).
|
|
38
|
+
* Regexes are tuned for precision (low false-positive) over recall.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Find the 1-based line number for a character offset in `content`. */
|
|
42
|
+
function lineOf(content, index) {
|
|
43
|
+
let line = 1;
|
|
44
|
+
for (let i = 0; i < index && i < content.length; i++) {
|
|
45
|
+
if (content[i] === '\n') line += 1;
|
|
46
|
+
}
|
|
47
|
+
return line;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Collect up to `cap` global-regex matches as {line, match}. */
|
|
51
|
+
function collect(content, re, cap = 5) {
|
|
52
|
+
const hits = [];
|
|
53
|
+
let m;
|
|
54
|
+
re.lastIndex = 0;
|
|
55
|
+
while ((m = re.exec(content)) !== null) {
|
|
56
|
+
hits.push({ line: lineOf(content, m.index), match: m[0] });
|
|
57
|
+
if (m.index === re.lastIndex) re.lastIndex += 1; // zero-width guard
|
|
58
|
+
if (hits.length >= cap) break;
|
|
59
|
+
}
|
|
60
|
+
return hits;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Count global-regex matches without allocating the match list. */
|
|
64
|
+
function countMatches(content, re) {
|
|
65
|
+
let n = 0;
|
|
66
|
+
let m;
|
|
67
|
+
re.lastIndex = 0;
|
|
68
|
+
while ((m = re.exec(content)) !== null) {
|
|
69
|
+
n += 1;
|
|
70
|
+
if (m.index === re.lastIndex) re.lastIndex += 1;
|
|
71
|
+
}
|
|
72
|
+
return n;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const RULES = [
|
|
76
|
+
{
|
|
77
|
+
rule: 'gradient-spam',
|
|
78
|
+
category: 'gradient-spam',
|
|
79
|
+
// >=3 Tailwind gradient-direction utilities in one file.
|
|
80
|
+
run(content) {
|
|
81
|
+
const re = /\bbg-gradient-to-(?:r|br|tr|b|bl|l|tl|t)\b/g;
|
|
82
|
+
const count = countMatches(content, re);
|
|
83
|
+
if (count < 3) return [];
|
|
84
|
+
const hits = collect(content, re, 5);
|
|
85
|
+
// Tag the first hit with the aggregate count for the advisory.
|
|
86
|
+
if (hits.length) hits[0].match = `${hits[0].match} (x${count})`;
|
|
87
|
+
return hits;
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
rule: 'generic-cta',
|
|
92
|
+
category: 'default-AI-hero',
|
|
93
|
+
// Filler hero / CTA copy. Word-boundaried, case-insensitive.
|
|
94
|
+
run(content) {
|
|
95
|
+
const re = /\b(?:Get Started|Welcome to|Lorem ipsum|Learn More)\b/gi;
|
|
96
|
+
return collect(content, re, 5);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
rule: 'centered-everything-syndrome',
|
|
101
|
+
category: 'centered-everything-syndrome',
|
|
102
|
+
// mx-auto AND text-center co-occurring inside one className string.
|
|
103
|
+
run(content) {
|
|
104
|
+
// Match a quoted class string that contains both utilities, in either order.
|
|
105
|
+
const re =
|
|
106
|
+
/(["'`])(?=[^"'`]*\bmx-auto\b)(?=[^"'`]*\btext-center\b)[^"'`]*\1/g;
|
|
107
|
+
return collect(content, re, 5);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
rule: 'inter-everything',
|
|
112
|
+
category: 'inter-everything',
|
|
113
|
+
// font-inter utility OR a font-family: Inter declaration, when no other
|
|
114
|
+
// custom font token (font-<name>, --font-*, or a second font-family) is near.
|
|
115
|
+
run(content) {
|
|
116
|
+
const interRe = /\bfont-inter\b|font-family:\s*['"]?Inter\b/gi;
|
|
117
|
+
const interCount = countMatches(content, interRe);
|
|
118
|
+
if (interCount === 0) return [];
|
|
119
|
+
// A sibling custom-font signal suppresses the warning (a deliberate stack).
|
|
120
|
+
const siblingFont =
|
|
121
|
+
/\bfont-(?!inter\b|sans\b|serif\b|mono\b|medium\b|semibold\b|bold\b|light\b|normal\b|thin\b|black\b|extrabold\b|extralight\b)[a-z]/i.test(
|
|
122
|
+
content,
|
|
123
|
+
) ||
|
|
124
|
+
/--font-[a-z]/i.test(content) ||
|
|
125
|
+
/font-family:\s*['"]?(?!Inter\b)[A-Za-z]/i.test(content);
|
|
126
|
+
if (siblingFont) return [];
|
|
127
|
+
return collect(content, interRe, 5);
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
rule: 'purple-violet-default',
|
|
132
|
+
category: 'purple-violet-default',
|
|
133
|
+
// The default-AI palette bg-(purple|violet)-(500|600|700) with no theme
|
|
134
|
+
// token class (bg-primary / bg-brand / bg-accent / a CSS var) nearby.
|
|
135
|
+
run(content) {
|
|
136
|
+
const re = /\bbg-(?:purple|violet)-(?:500|600|700)\b/g;
|
|
137
|
+
if (countMatches(content, re) === 0) return [];
|
|
138
|
+
const themeToken =
|
|
139
|
+
/\bbg-(?:primary|brand|accent|surface|foreground|background|muted)\b/i.test(
|
|
140
|
+
content,
|
|
141
|
+
) || /bg-\[(?:var\(--|hsl|oklch|rgb)/i.test(content);
|
|
142
|
+
if (themeToken) return [];
|
|
143
|
+
return collect(content, re, 5);
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
rule: 'glassmorphism-spam',
|
|
148
|
+
category: 'glassmorphism-spam',
|
|
149
|
+
// >=3 of backdrop-blur* / bg-white/(10|20|30) in one file.
|
|
150
|
+
run(content) {
|
|
151
|
+
const re = /\bbackdrop-blur(?:-\w+)?\b|\bbg-white\/(?:10|20|30)\b/g;
|
|
152
|
+
const count = countMatches(content, re);
|
|
153
|
+
if (count < 3) return [];
|
|
154
|
+
const hits = collect(content, re, 5);
|
|
155
|
+
if (hits.length) hits[0].match = `${hits[0].match} (x${count})`;
|
|
156
|
+
return hits;
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
rule: 'isometric-illustration-fallback',
|
|
161
|
+
category: 'isometric-illustration-fallback',
|
|
162
|
+
// undraw / isometric markers in an asset path or src attribute.
|
|
163
|
+
run(content) {
|
|
164
|
+
const re = /\b(?:undraw|isometric)[\w./-]*/gi;
|
|
165
|
+
return collect(content, re, 5);
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
rule: 'decorative-motion-without-intent',
|
|
170
|
+
category: 'decorative-motion-without-intent',
|
|
171
|
+
// animate-(pulse|bounce|spin) on a non-loading, non-icon element.
|
|
172
|
+
// Conservative: only flag a className that has the animate utility but no
|
|
173
|
+
// loading/skeleton/spinner/icon signal on the same class string.
|
|
174
|
+
run(content) {
|
|
175
|
+
const re =
|
|
176
|
+
/(["'`])(?=[^"'`]*\banimate-(?:pulse|bounce|spin)\b)(?![^"'`]*(?:\b(?:animate-(?:pulse|bounce|spin)\s+)?(?:loading|loader|spinner|skeleton|icon|i-)\b|sr-only))[^"'`]*\1/g;
|
|
177
|
+
return collect(content, re, 5);
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Resolve the written content from a PostToolUse payload, tolerating Write
|
|
184
|
+
* (tool_input.content), Edit (new_string), and MultiEdit (edits[].new_string),
|
|
185
|
+
* and falling back to a tool_response filePath/content when present.
|
|
186
|
+
*
|
|
187
|
+
* Returns { filename, content } or null when there is nothing front-end to scan.
|
|
188
|
+
*/
|
|
189
|
+
function extractWrite(payload) {
|
|
190
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
191
|
+
const tool = payload.tool_name || payload.toolName;
|
|
192
|
+
if (tool !== 'Write' && tool !== 'Edit' && tool !== 'MultiEdit') return null;
|
|
193
|
+
|
|
194
|
+
const input = payload.tool_input || payload.toolInput || {};
|
|
195
|
+
const filename =
|
|
196
|
+
input.file_path ||
|
|
197
|
+
input.filePath ||
|
|
198
|
+
input.path ||
|
|
199
|
+
(payload.tool_response &&
|
|
200
|
+
(payload.tool_response.filePath || payload.tool_response.file_path)) ||
|
|
201
|
+
'';
|
|
202
|
+
if (!filename) return null;
|
|
203
|
+
|
|
204
|
+
const ext = path.extname(String(filename)).toLowerCase();
|
|
205
|
+
if (!FRONTEND_EXT.includes(ext)) return null;
|
|
206
|
+
|
|
207
|
+
const parts = [];
|
|
208
|
+
if (typeof input.content === 'string') parts.push(input.content);
|
|
209
|
+
if (typeof input.new_string === 'string') parts.push(input.new_string);
|
|
210
|
+
if (Array.isArray(input.edits)) {
|
|
211
|
+
for (const e of input.edits) {
|
|
212
|
+
if (e && typeof e.new_string === 'string') parts.push(e.new_string);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Fall back to a post-write file content echo if the input carried none.
|
|
216
|
+
if (parts.length === 0 && payload.tool_response) {
|
|
217
|
+
const tr = payload.tool_response;
|
|
218
|
+
if (typeof tr.content === 'string') parts.push(tr.content);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const content = parts.join('\n');
|
|
222
|
+
if (!content) return null;
|
|
223
|
+
return { filename: String(filename), content };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Pure evaluator: scan `content` (with `filename` for category context) against
|
|
228
|
+
* the 8 rules. Exported for unit testing without a process.
|
|
229
|
+
*
|
|
230
|
+
* @returns {{ warnings: Array<{rule, category, line, match}>, count: number }}
|
|
231
|
+
*/
|
|
232
|
+
function evaluate(content, filename) {
|
|
233
|
+
const warnings = [];
|
|
234
|
+
if (typeof content !== 'string' || content.length === 0) {
|
|
235
|
+
return { warnings, count: 0 };
|
|
236
|
+
}
|
|
237
|
+
for (const r of RULES) {
|
|
238
|
+
let hits = [];
|
|
239
|
+
try {
|
|
240
|
+
hits = r.run(content) || [];
|
|
241
|
+
} catch {
|
|
242
|
+
hits = []; // a misbehaving rule must never break the advisory
|
|
243
|
+
}
|
|
244
|
+
for (const h of hits) {
|
|
245
|
+
warnings.push({
|
|
246
|
+
rule: r.rule,
|
|
247
|
+
category: r.category,
|
|
248
|
+
line: h.line,
|
|
249
|
+
match: h.match,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { warnings, count: warnings.length };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Best-effort event emit through the in-repo event-chain helper. Never throws. */
|
|
257
|
+
function emitEvent(cwd, filename, result) {
|
|
258
|
+
try {
|
|
259
|
+
const { appendChainEvent } = require('../scripts/lib/event-chain.cjs');
|
|
260
|
+
appendChainEvent({
|
|
261
|
+
agent: 'gdd-design-quality-check',
|
|
262
|
+
outcome: 'warn',
|
|
263
|
+
event: 'design_quality_warn',
|
|
264
|
+
file: filename,
|
|
265
|
+
warning_count: result.count,
|
|
266
|
+
categories: [...new Set(result.warnings.map((w) => w.category))],
|
|
267
|
+
warnings: result.warnings.slice(0, 20),
|
|
268
|
+
baseDir: cwd,
|
|
269
|
+
});
|
|
270
|
+
} catch {
|
|
271
|
+
/* observability is best-effort — swallow */
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Build the concise stdout advisory string for a non-empty result. */
|
|
276
|
+
function advisoryNote(filename, result) {
|
|
277
|
+
const cats = [...new Set(result.warnings.map((w) => w.category))];
|
|
278
|
+
const base = path.basename(filename);
|
|
279
|
+
const lines = [
|
|
280
|
+
`gdd-design-quality-check: ${result.count} visual-tell ` +
|
|
281
|
+
`warning${result.count === 1 ? '' : 's'} in ${base} ` +
|
|
282
|
+
`across ${cats.length} categor${cats.length === 1 ? 'y' : 'ies'} ` +
|
|
283
|
+
`(${cats.join(', ')}).`,
|
|
284
|
+
];
|
|
285
|
+
for (const w of result.warnings.slice(0, 8)) {
|
|
286
|
+
lines.push(` - [${w.rule}] line ${w.line}: ${w.match}`);
|
|
287
|
+
}
|
|
288
|
+
lines.push(' See reference/visual-tells.md for remediation patterns. (advisory, non-blocking)');
|
|
289
|
+
return lines.join('\n');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Core hook entry. Accepts a parsed payload, returns the decision object to
|
|
294
|
+
* write to stdout. Always returns { continue: true } (advisory only).
|
|
295
|
+
* Exported for unit testing without spawning a process.
|
|
296
|
+
*/
|
|
297
|
+
function main(payload, opts = {}) {
|
|
298
|
+
const cwd = (payload && payload.cwd) || opts.cwd || process.cwd();
|
|
299
|
+
const write = extractWrite(payload);
|
|
300
|
+
if (!write) return { continue: true };
|
|
301
|
+
|
|
302
|
+
const result = evaluate(write.content, write.filename);
|
|
303
|
+
if (result.count === 0) return { continue: true };
|
|
304
|
+
|
|
305
|
+
emitEvent(cwd, write.filename, result);
|
|
306
|
+
const note = advisoryNote(write.filename, result);
|
|
307
|
+
return { continue: true, systemMessage: note };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function run(stdin = process.stdin, stdout = process.stdout) {
|
|
311
|
+
let buf = '';
|
|
312
|
+
for await (const chunk of stdin) buf += chunk;
|
|
313
|
+
let payload;
|
|
314
|
+
try {
|
|
315
|
+
payload = JSON.parse(buf || '{}');
|
|
316
|
+
} catch {
|
|
317
|
+
stdout.write(JSON.stringify({ continue: true }));
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const decision = main(payload);
|
|
321
|
+
if (decision.systemMessage) {
|
|
322
|
+
// Surface the advisory on stderr too so it is visible in plain hook logs.
|
|
323
|
+
try {
|
|
324
|
+
process.stderr.write(decision.systemMessage + '\n');
|
|
325
|
+
} catch {
|
|
326
|
+
/* swallow */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
stdout.write(JSON.stringify(decision));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Run as a CLI only when invoked directly; tests require() this module and call
|
|
333
|
+
// evaluate()/main() against mock payloads without triggering stdin reads.
|
|
334
|
+
if (require.main === module) {
|
|
335
|
+
run().catch(() => {
|
|
336
|
+
process.stdout.write(JSON.stringify({ continue: true }));
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
module.exports = { main, evaluate, extractWrite, RULES, FRONTEND_EXT };
|
package/hooks/hooks.json
CHANGED
|
@@ -116,6 +116,23 @@
|
|
|
116
116
|
"command": "node --experimental-strip-types \"${CLAUDE_PLUGIN_ROOT}/hooks/context-exhaustion.ts\""
|
|
117
117
|
}
|
|
118
118
|
]
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
"hooks": [
|
|
122
|
+
{
|
|
123
|
+
"type": "command",
|
|
124
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gdd-a11y-gate.js\""
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
"matcher": "Write|Edit|MultiEdit",
|
|
130
|
+
"hooks": [
|
|
131
|
+
{
|
|
132
|
+
"type": "command",
|
|
133
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/gdd-design-quality-check.js\""
|
|
134
|
+
}
|
|
135
|
+
]
|
|
119
136
|
}
|
|
120
137
|
],
|
|
121
138
|
"Stop": [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hegemonart/get-design-done",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.49.0",
|
|
4
4
|
"description": "A design-quality pipeline for AI coding agents: brief, plan, implement, and verify UI work against your design system.",
|
|
5
5
|
"author": "Hegemon",
|
|
6
6
|
"homepage": "https://github.com/hegemonart/get-design-done",
|
|
@@ -115,7 +115,10 @@
|
|
|
115
115
|
"agent-sdk",
|
|
116
116
|
"figma",
|
|
117
117
|
"extractor",
|
|
118
|
-
"design-system-sync"
|
|
118
|
+
"design-system-sync",
|
|
119
|
+
"worktree-safe",
|
|
120
|
+
"anti-slop",
|
|
121
|
+
"confidence-gate"
|
|
119
122
|
],
|
|
120
123
|
"skills": [
|
|
121
124
|
"SKILL.md"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Brief Quality Rubric
|
|
2
|
+
|
|
3
|
+
The five anti-patterns `agents/brief-auditor.md` grades `.design/BRIEF.md` against. Each entry pairs a
|
|
4
|
+
definition with a good and bad example, the detection signal the auditor greps for, and a severity note.
|
|
5
|
+
This rubric is advisory: a flagged brief still proceeds to explore. The point is to surface vagueness
|
|
6
|
+
while the cost of fixing it is one sentence, not a redesign.
|
|
7
|
+
|
|
8
|
+
A brief is the contract every later stage checks against. A vague brief produces an unverifiable cycle,
|
|
9
|
+
because verify has nothing concrete to test. The auditor reads the brief once and writes findings to
|
|
10
|
+
`.design/BRIEF-AUDIT.md`; the brief skill then offers `/gdd:discuss brief` when any anti-pattern fires.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## AP-1: Vague verbs without a metric
|
|
15
|
+
|
|
16
|
+
**Definition:** The problem or goal uses a soft verb (improve, optimize, streamline, enhance, modernize,
|
|
17
|
+
refresh) with no number, threshold, or observable change attached. The verb hides the actual target.
|
|
18
|
+
|
|
19
|
+
- **Bad:** "Improve the checkout flow."
|
|
20
|
+
- **Good:** "Cut checkout abandonment from 38 percent to under 25 percent on mobile."
|
|
21
|
+
|
|
22
|
+
**Detection signal:** Match soft verbs (`improve`, `optimize`, `streamline`, `enhance`, `modernize`,
|
|
23
|
+
`refresh`) in the Problem or Success Metrics sections, then check the same sentence for a digit, a
|
|
24
|
+
percent sign, or a unit. A soft verb with no adjacent quantity is a hit.
|
|
25
|
+
|
|
26
|
+
**Severity:** Major. A goal with no metric cannot be verified, so the whole cycle inherits the ambiguity.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## AP-2: Missing audience
|
|
31
|
+
|
|
32
|
+
**Definition:** The brief never names who the design is for. No role, device, context, or skill level is
|
|
33
|
+
stated, so every later trade-off (density, reading level, input model) is a guess.
|
|
34
|
+
|
|
35
|
+
- **Bad:** "Build a dashboard for tracking orders."
|
|
36
|
+
- **Good:** "Build an order dashboard for warehouse leads on a shared floor tablet, glanceable at arm's length."
|
|
37
|
+
|
|
38
|
+
**Detection signal:** Read the Audience section. Flag when it is empty, a placeholder (`TBD`, `users`,
|
|
39
|
+
`everyone`, `all users`), or names no role plus context. A single generic noun with no qualifier is a hit.
|
|
40
|
+
|
|
41
|
+
**Severity:** Major. Audience drives density, tone, and accessibility floor; without it the design optimizes
|
|
42
|
+
for no one.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## AP-3: Immeasurable success criteria
|
|
47
|
+
|
|
48
|
+
**Definition:** Success is described in feelings rather than observables (looks modern, feels clean, is
|
|
49
|
+
intuitive, delights users). There is no event, count, or threshold a verifier could check.
|
|
50
|
+
|
|
51
|
+
- **Bad:** "Users should feel the app is fast and modern."
|
|
52
|
+
- **Good:** "First contentful paint under 1.5 seconds; task completion rate above 90 percent in five tests."
|
|
53
|
+
|
|
54
|
+
**Detection signal:** Scan Success Metrics for subjective adjectives (`modern`, `clean`, `intuitive`,
|
|
55
|
+
`delightful`, `nice`, `beautiful`) with no paired number or pass/fail condition. Subjective-only criteria
|
|
56
|
+
are a hit.
|
|
57
|
+
|
|
58
|
+
**Severity:** Major. Verify cannot grade a feeling; immeasurable criteria collapse the verify gate.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## AP-4: Scope creep
|
|
63
|
+
|
|
64
|
+
**Definition:** The Scope section lists more than the cycle can deliver, or mixes unrelated surfaces into
|
|
65
|
+
one brief, so the in-scope line stops constraining anything.
|
|
66
|
+
|
|
67
|
+
- **Bad:** "Redesign onboarding, billing, settings, the marketing site, and add dark mode."
|
|
68
|
+
- **Good:** "In scope: the three-step onboarding flow. Out of scope: billing, settings, marketing site."
|
|
69
|
+
|
|
70
|
+
**Detection signal:** Count distinct surfaces or top-level features named as in-scope. More than three
|
|
71
|
+
unrelated surfaces in one brief, or an in-scope list with no matching out-of-scope line, is a hit.
|
|
72
|
+
|
|
73
|
+
**Severity:** Minor. Wide scope is recoverable by splitting, but unsplit it inflates every later estimate.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## AP-5: Missing anti-goals
|
|
78
|
+
|
|
79
|
+
**Definition:** The brief states what to build but never what to avoid. With no anti-goals, explore widens
|
|
80
|
+
to fill the vacuum and the design picks up patterns the team never wanted.
|
|
81
|
+
|
|
82
|
+
- **Bad:** (Scope lists features only; no "we are deliberately not doing X" line anywhere.)
|
|
83
|
+
- **Good:** "Anti-goals: no new navigation paradigm, no carousel, do not touch the existing auth screens."
|
|
84
|
+
|
|
85
|
+
**Detection signal:** Look for an explicit non-goal, anti-goal, or out-of-scope statement framed as a
|
|
86
|
+
prohibition (`do not`, `avoid`, `no new`, `out of scope`). A brief with zero prohibition statements is a hit.
|
|
87
|
+
|
|
88
|
+
**Severity:** Minor. Anti-goals prevent drift; their absence is a warning, not a blocker.
|
|
89
|
+
|
|
90
|
+
---
|
|
91
|
+
|
|
92
|
+
## How findings are scored
|
|
93
|
+
|
|
94
|
+
The auditor reports a count of fired anti-patterns and lists each with its section and the matched text.
|
|
95
|
+
It does not compute a pass/fail gate and it does not block the brief to explore transition. Major findings
|
|
96
|
+
(AP-1, AP-2, AP-3) carry more weight in the summary line than Minor findings (AP-4, AP-5), so the user
|
|
97
|
+
knows which gaps most threaten a verifiable cycle. When any anti-pattern fires, the brief skill surfaces a
|
|
98
|
+
one-line pointer offering `/gdd:discuss brief` to refine before moving on.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: copy-quality
|
|
3
|
+
type: heuristic
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
phase: 48
|
|
6
|
+
tags: [copy, microcopy, ux-writing, ctas, errors, empty-states, aria, alt-text, i18n, voice, pillar-1]
|
|
7
|
+
last_updated: 2026-06-03
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Copy Quality - Microcopy Rubric
|
|
11
|
+
|
|
12
|
+
This file is the rubric the Copy pillar (Pillar 1 of `agents/design-auditor.md`) scores against, and the detailed reference that `agents/copy-auditor.md` applies. The design-auditor's Pillar 1 carries the 1-4 score and a one-line finding; this file holds the per-category criteria, the failure patterns, and the i18n lens that produce that score.
|
|
13
|
+
|
|
14
|
+
Voice and tone are owned by [`brand-voice.md`](./brand-voice.md): the five axes (formal/casual, serious/playful, expert/approachable, reverent/irreverent, authoritative/collaborative), the 12 archetypes, and the tone-by-context table. This file does not restate those. It checks whether the implemented strings honor the voice the product declared, and whether each microcopy surface does its specific job.
|
|
15
|
+
|
|
16
|
+
Cultural and locale meaning is owned by [`rtl-cjk-cultural.md`](./rtl-cjk-cultural.md); engineering primitives for localized strings are owned by [`i18n.md`](./i18n.md). This file consumes the i18n text-expansion table as a copy-length lens (see Internationalization Lens below).
|
|
17
|
+
|
|
18
|
+
## Why Copy Is a Pillar
|
|
19
|
+
|
|
20
|
+
Generic or default copy is the most common quiet failure in shipped UI. A "Submit" button, a "No data" empty state, and a raw stack-trace error each pass a functional test and each fail the user. Specific, purposeful language is a craft signal: it shows the team pictured a real person reading the screen. The Copy pillar measures that gap between functional text and intentional text.
|
|
21
|
+
|
|
22
|
+
## Category Rubrics
|
|
23
|
+
|
|
24
|
+
Each category below lists what good looks like and the failure patterns to grep for. A category is "weak" when the failure patterns dominate; "absent" when the surface exists with no considered copy at all.
|
|
25
|
+
|
|
26
|
+
### Button and CTA labels
|
|
27
|
+
|
|
28
|
+
Good labels are verb-first and name the object: "Export CSV", "Add member", "Send invite", "Delete project". The user reads the label and knows the outcome before clicking. Primary actions carry the specific verb; secondary actions ("Cancel", "Back") may stay plain because they are reversible and conventional.
|
|
29
|
+
|
|
30
|
+
Failure patterns: bare "Submit", "OK", "Go", "Click here", "Button", or a noun with no verb ("Form", "Settings" on an action). A primary action labeled "Save" with no object on a screen that saves several distinct things is weak, not broken.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
grep -rEn ">(Submit|Click Here|OK|Go|Button|Done)<" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Error messages
|
|
37
|
+
|
|
38
|
+
Good error copy names the cause and the recovery, and never blames the user. State what happened, then what to do: "We could not save your changes. Check your connection and try again." For validation, point at the field and the fix: "Enter a date in the future." The tone matches the stakes (see the tone-by-context table in `brand-voice.md`); data-loss and payment failures are calm and serious, never playful.
|
|
39
|
+
|
|
40
|
+
Failure patterns: raw codes or stack traces shown to users ("Error 500", "undefined is not a function"), blame language ("You entered an invalid value", "Your input was wrong"), and dead ends that state the failure with no next step.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
grep -rEn "went wrong|Error [0-9]|invalid input|you entered|try again" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Empty states
|
|
47
|
+
|
|
48
|
+
Good empty states orient and offer the first action. They explain why the screen is empty and give one clear thing to do: "No projects yet. Create your first project to get started." A first-run empty state is an onboarding moment, not an error.
|
|
49
|
+
|
|
50
|
+
Failure patterns: "No data", "Nothing here", a bare zero, or an empty container with no copy. An empty state that explains the emptiness but offers no action is weak.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
grep -rEn "No data|No results|Nothing here|No items|empty" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Loading and skeleton copy
|
|
57
|
+
|
|
58
|
+
Good loading copy reassures and signals progress without nagging: "Getting your data...", "Almost there." Skeleton screens are preferred to spinner-plus-text for content that has a known shape; when text is shown, it is brief and specific to what is loading. Long operations name the step ("Uploading 3 of 12 files").
|
|
59
|
+
|
|
60
|
+
Failure patterns: "Loading..." with no context on a multi-second operation, a spinner with no label on a process that can fail, or jokey loading copy on a high-stakes action.
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
grep -rEn "Loading|Please wait|spinner|Skeleton" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### ARIA text quality
|
|
67
|
+
|
|
68
|
+
ARIA strings are copy the screen-reader user hears. They must describe purpose, not implementation. `aria-label="Close dialog"` is good; `aria-label="button"` or `aria-label="icon"` is noise. Live-region announcements (`aria-live`, `role="status"`, `role="alert"`) carry the same cause-plus-recovery standard as visible errors. Labels must not duplicate adjacent visible text in a way that makes the reader say it twice.
|
|
69
|
+
|
|
70
|
+
Failure patterns: `aria-label` set to the element type, `aria-label` that restates a visible label verbatim, missing `aria-label` on icon-only controls, and silent live regions on async state changes.
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
grep -rEn "aria-label=\"(button|icon|link|image|click)\"" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Alt-text quality
|
|
77
|
+
|
|
78
|
+
Good alt text conveys the function or meaning of an image in context. A logo links home: `alt="Acme home"`, not `alt="logo"`. A decorative image takes empty alt (`alt=""`) so the reader skips it. An informative chart names its takeaway, not its file. Alt text never starts with "image of" or "picture of"; the reader already knows it is an image.
|
|
79
|
+
|
|
80
|
+
Failure patterns: `alt="image"`, `alt="photo"`, filename alt (`alt="IMG_2043.jpg"`), missing alt on informative images, and non-empty alt on purely decorative images.
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
grep -rEn "alt=\"(image|photo|picture|img|logo)\"|alt=\"[^\"]*\\.(png|jpg|jpeg|svg|webp)\"" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Form labels, helper, and validation copy
|
|
87
|
+
|
|
88
|
+
Every input has a persistent visible label (placeholders are not labels). Helper text sets expectations before the user types ("Use 8 or more characters"). Validation copy is specific and arrives at the right time: inline after blur for format rules, on submit for cross-field rules. Required and optional states are stated in words, not color alone.
|
|
89
|
+
|
|
90
|
+
Failure patterns: placeholder-as-label, helper text that only appears as an error after failure, generic "This field is required" with no field name when several are blank, and validation copy that says what is wrong but not how to fix it.
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
grep -rEn "placeholder=|required|This field|is invalid" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Voice and tone alignment
|
|
97
|
+
|
|
98
|
+
Read the declared voice from `.design/DESIGN-CONTEXT.md` (axis positions and archetype, if recorded) and check the implemented strings against it. A Caregiver health app with a curt, blaming error message is off-voice even if the error is technically clear. A formal finance app with "Oops!" on a failed transfer is off-voice. Use the tone-by-context table in `brand-voice.md` as the per-surface contract: error, empty state, success, onboarding, loading, destructive confirmation each have a recommended register.
|
|
99
|
+
|
|
100
|
+
Failure patterns: one tone for marketing copy and a flatly different tone for in-product copy, playful language on high-stakes actions, and copy that contradicts the declared archetype.
|
|
101
|
+
|
|
102
|
+
## Internationalization Lens
|
|
103
|
+
|
|
104
|
+
Apply this lens to copy-heavy components (anything where text drives width: buttons, nav, tabs, chips, table headers, banners). It folds the [`i18n.md`](./i18n.md) text-expansion table into the copy score.
|
|
105
|
+
|
|
106
|
+
Two probes:
|
|
107
|
+
|
|
108
|
+
1. **Hardcoded user-facing strings.** Strings rendered to users should flow through the project's i18n layer, not sit as literals in JSX. A literal English string in a component is a copy defect for any product that ships, or plans to ship, more than one locale.
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
grep -rEn ">[A-Z][a-z]+ [a-z]+.*<|aria-label=\"[A-Z][a-z]+ " src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
2. **Expansion-overflow at +40%.** German expands English by about +30%, Russian by about +40% (see the expansion table in `i18n.md`). A fixed-width control sized to English is the worst case. The lens: for each copy-bearing component with a width constraint, ask whether the label survives a +40% expansion without clipping or truncating mid-word. Containers sized to `EN base x 1.4` clear the worst row of that table. Flag fixed pixel widths on text controls and single-line labels with no wrap or ellipsis strategy.
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
grep -rEn "w-\[[0-9]+px\]|width:\s*[0-9]+px|truncate|whitespace-nowrap" src/ --include="*.tsx" --include="*.jsx" 2>/dev/null | head -10
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A copy-heavy component that hardcodes strings, or that clips at +40%, drops the Copy pillar by one point and appears in the findings with the `i18n_readiness` lens tag (see `audit-scoring.md` Lens-Tags).
|
|
121
|
+
|
|
122
|
+
## Scoring Guide (Copy Pillar, 1-4)
|
|
123
|
+
|
|
124
|
+
This scale matches the 1-4 definitions in `agents/design-auditor.md`. The Copy pillar score is a single 1-4 value; the categories above supply the evidence.
|
|
125
|
+
|
|
126
|
+
| Score | Label | Criteria |
|
|
127
|
+
|-------|-------|----------|
|
|
128
|
+
| 4 | Exemplary | CTAs are verb-object specific; error messages name cause and recovery without blame; empty states orient and offer a first action; ARIA and alt text describe purpose; form copy guides before and during input; voice matches the declared axes and archetype; copy-heavy components survive a +40% expansion and route strings through i18n. |
|
|
129
|
+
| 3 | Solid | Most copy is intentional; one or two generic labels remain (a plain "Save" on a single-purpose form); error and empty states are present and human but plain; minor voice drift only. |
|
|
130
|
+
| 2 | Present but weak | Mix of intentional and generic copy; some empty states missing; errors show raw codes or blame; ARIA or alt text restates element type; hardcoded strings or +40% overflow risk in copy-heavy components. |
|
|
131
|
+
| 1 | Absent or broken | Majority generic ("Submit", "OK", "Cancel"); empty states absent or "No data"; errors are developer-facing; ARIA labels are noise; alt text is "image" or filenames; no voice considered. |
|
|
132
|
+
|
|
133
|
+
## How findings feed the audit
|
|
134
|
+
|
|
135
|
+
`agents/copy-auditor.md` runs these probes, scores the pillar, and writes `.design/COPY-AUDIT.md` as a supplement. `agents/design-auditor.md` folds that score and the top finding into Pillar 1 of `.design/DESIGN-AUDIT.md`. Neither file changes the separate 7-category 0-10 system in `audit-scoring.md`; the Copy pillar is a qualitative 1-4 signal, not a weighted metric.
|