@hegemonart/get-design-done 1.46.0 → 1.48.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 +1 -1
- package/CHANGELOG.md +94 -0
- package/README.md +4 -0
- package/SKILL.md +2 -1
- package/agents/brief-auditor.md +147 -0
- package/agents/copy-auditor.md +215 -0
- package/agents/design-auditor.md +13 -3
- package/agents/design-debt-crawler.md +269 -0
- package/agents/design-fixer.md +2 -0
- 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/live/SKILL.md +98 -0
- package/dist/claude-code/.claude/skills/quality-gate/SKILL.md +2 -2
- package/hooks/gdd-a11y-gate.js +119 -0
- package/hooks/hooks.json +8 -0
- package/package.json +1 -1
- package/reference/brief-quality-rubric.md +98 -0
- package/reference/copy-quality.md +135 -0
- package/reference/debt-categories.md +148 -0
- package/reference/live-mode-integration.md +80 -0
- package/reference/registry.json +28 -0
- package/reference/schemas/events.schema.json +1 -1
- package/reference/schemas/live-session.schema.json +64 -0
- package/scripts/lib/live/bandit-feed.cjs +64 -0
- package/scripts/lib/live/events.cjs +86 -0
- package/scripts/lib/live/harness-mode.cjs +93 -0
- package/scripts/lib/live/postcheck.cjs +158 -0
- package/scripts/lib/live/runtime.cjs +233 -0
- package/scripts/lib/live/scope-guard.cjs +145 -0
- package/scripts/lib/live/session-store.cjs +364 -0
- package/scripts/lib/manifest/skills.json +8 -0
- package/skills/brief/SKILL.md +17 -0
- package/skills/live/SKILL.md +98 -0
- package/skills/quality-gate/SKILL.md +2 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/live/events.cjs — Phase 47 (Live Mode) telemetry emitter.
|
|
4
|
+
*
|
|
5
|
+
* /gdd:live emits six event types across a session — start, pick, generate, accept,
|
|
6
|
+
* discard, end. This module is the single typed entry point: it validates the `type`
|
|
7
|
+
* against the closed LIVE_EVENT_TYPES allow-list (an unknown live type is a programmer
|
|
8
|
+
* error and is rejected) and appends the event via the in-repo event-chain emitter
|
|
9
|
+
* (scripts/lib/event-chain.cjs), the same write path router_pick uses.
|
|
10
|
+
*
|
|
11
|
+
* The emitted row carries the events.schema.json envelope fields verbatim
|
|
12
|
+
* (type / timestamp / sessionId / payload) so that, projected back to
|
|
13
|
+
* {type, timestamp, sessionId, payload}, it validates against the additive `live_*`
|
|
14
|
+
* seed types in reference/schemas/events.schema.json. `agent` + `outcome` are the
|
|
15
|
+
* chain emitter's two required fields; we set agent='live' and outcome=type.
|
|
16
|
+
*
|
|
17
|
+
* Pure, dependency-free CommonJS:
|
|
18
|
+
* - NO top-level clock: the timestamp comes from an injectable `now` (default
|
|
19
|
+
* `Date.now`), so tests are deterministic.
|
|
20
|
+
* - `baseDir` is injectable (the emitter resolves the chain file relative to it),
|
|
21
|
+
* so tests write to a temp project and never touch the repo's real stream.
|
|
22
|
+
*
|
|
23
|
+
* Ships in the npm package; requires only the in-repo event-chain emitter.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const { appendChainEvent } = require('../event-chain.cjs');
|
|
27
|
+
|
|
28
|
+
/** The six Live Mode event types. Closed allow-list — an unknown type is rejected. */
|
|
29
|
+
const LIVE_EVENT_TYPES = Object.freeze([
|
|
30
|
+
'live_session_start',
|
|
31
|
+
'live_pick',
|
|
32
|
+
'live_generate',
|
|
33
|
+
'live_accept',
|
|
34
|
+
'live_discard',
|
|
35
|
+
'live_session_end',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const LIVE_EVENT_TYPE_SET = new Set(LIVE_EVENT_TYPES);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Emit a Live Mode telemetry event.
|
|
42
|
+
*
|
|
43
|
+
* @param {object} args
|
|
44
|
+
* @param {string} args.projectRoot Repo root — injected as the emitter `baseDir` so the
|
|
45
|
+
* chain file resolves under it (testable / hermetic).
|
|
46
|
+
* @param {string} args.type One of LIVE_EVENT_TYPES. Anything else throws.
|
|
47
|
+
* @param {string} args.sessionId Stable per-session id (correlates the event stream).
|
|
48
|
+
* @param {object} [args.payload] Free-form, event-specific payload (MVP: opaque object).
|
|
49
|
+
* @param {(() => number)} [args.now] Injectable clock returning epoch ms (default Date.now).
|
|
50
|
+
* @returns {{event_id: string, type: string, timestamp: string, sessionId: string, payload: object}}
|
|
51
|
+
* The projected envelope (also the shape that validates against events.schema.json).
|
|
52
|
+
*/
|
|
53
|
+
function emitLiveEvent(args = {}) {
|
|
54
|
+
const { projectRoot, type, sessionId, payload } = args;
|
|
55
|
+
const now = typeof args.now === 'function' ? args.now : Date.now;
|
|
56
|
+
|
|
57
|
+
if (!LIVE_EVENT_TYPE_SET.has(type)) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`emitLiveEvent: unknown live event type "${String(type)}". ` +
|
|
60
|
+
`Expected one of: ${LIVE_EVENT_TYPES.join(', ')}.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
64
|
+
throw new TypeError('emitLiveEvent: sessionId is required (non-empty string)');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const timestamp = new Date(now()).toISOString();
|
|
68
|
+
const envelopePayload = payload && typeof payload === 'object' ? payload : {};
|
|
69
|
+
|
|
70
|
+
// Mirror the router_pick emit surface: write a chain row carrying the events-schema
|
|
71
|
+
// envelope fields verbatim. agent/outcome are the chain emitter's required fields.
|
|
72
|
+
const event_id = appendChainEvent({
|
|
73
|
+
baseDir: projectRoot,
|
|
74
|
+
agent: 'live',
|
|
75
|
+
outcome: type,
|
|
76
|
+
// Envelope fields preserved verbatim by appendChainEvent (opaque-extras pass-through):
|
|
77
|
+
type,
|
|
78
|
+
timestamp,
|
|
79
|
+
sessionId,
|
|
80
|
+
payload: envelopePayload,
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { event_id, type, timestamp, sessionId, payload: envelopePayload };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { emitLiveEvent, LIVE_EVENT_TYPES };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/live/harness-mode.cjs — Phase 47 (Live Mode) capability gate.
|
|
4
|
+
*
|
|
5
|
+
* `/gdd:live` drives the Claude Preview MCP at runtime (preview_inspect /
|
|
6
|
+
* preview_click / preview_eval / preview_screenshot). That whole loop is only
|
|
7
|
+
* available on a harness whose capability matrix reports `mcp_support: true`.
|
|
8
|
+
* Harnesses without MCP support cannot inject the runtime or read picks back, so
|
|
9
|
+
* live mode degrades to a screenshot-only experience on them.
|
|
10
|
+
*
|
|
11
|
+
* This module is the single source of truth for that gate. It reads the canonical
|
|
12
|
+
* harness record (scripts/lib/manifest/harnesses.cjs, a typed re-export of
|
|
13
|
+
* harnesses.json) and projects each harness onto one of two live-mode strings:
|
|
14
|
+
*
|
|
15
|
+
* 'puppeteer' — full live mode: the harness has mcp_support, the skill probes
|
|
16
|
+
* Preview, injects scripts/lib/live/runtime.cjs, and hot-swaps
|
|
17
|
+
* variants in place. (Named for the Preview MCP's Playwright /
|
|
18
|
+
* puppeteer-class browser driver, not a bundled dependency: there
|
|
19
|
+
* is NO bundled puppeteer; the skill drives the MCP.)
|
|
20
|
+
* 'degraded' — screenshot-only: no mcp_support, so no live injection. The skill
|
|
21
|
+
* falls back to generating variants and capturing static
|
|
22
|
+
* screenshots, and says so up front.
|
|
23
|
+
*
|
|
24
|
+
* Design constraints (mirror the other scripts/lib/live/* modules):
|
|
25
|
+
* - Pure, dependency-free apart from the manifest re-export. No fs, no network,
|
|
26
|
+
* no Date / Math.random. Cross-platform (no path work, no OS calls).
|
|
27
|
+
* - The harness list is injectable so tests can pass a fixture; it defaults to
|
|
28
|
+
* the real manifest.
|
|
29
|
+
*
|
|
30
|
+
* Exports:
|
|
31
|
+
* - liveModeFor(harnessId, harnesses?) -> 'puppeteer' | 'degraded'
|
|
32
|
+
* - degradedHarnesses(harnesses?) -> string[] of ids in degraded mode
|
|
33
|
+
* - isMcpSupported(harnessId, harnesses?) -> boolean
|
|
34
|
+
* - MODE_FULL / MODE_DEGRADED -> the two mode string constants
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const MODE_FULL = 'puppeteer';
|
|
38
|
+
const MODE_DEGRADED = 'degraded';
|
|
39
|
+
|
|
40
|
+
/** Lazily resolve the default harness list (the typed re-export of harnesses.json). */
|
|
41
|
+
function defaultHarnesses() {
|
|
42
|
+
return require('../manifest/harnesses.cjs');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Find a harness record by id in a list. Returns undefined when absent. */
|
|
46
|
+
function findHarness(harnessId, harnesses) {
|
|
47
|
+
const list = Array.isArray(harnesses) ? harnesses : defaultHarnesses();
|
|
48
|
+
return list.find((h) => h && h.id === harnessId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* True when the named harness reports `capability_matrix.mcp_support === true`.
|
|
53
|
+
* Unknown harness ids and harnesses missing the flag are treated as false (no
|
|
54
|
+
* MCP -> no live injection).
|
|
55
|
+
*/
|
|
56
|
+
function isMcpSupported(harnessId, harnesses) {
|
|
57
|
+
const h = findHarness(harnessId, harnesses);
|
|
58
|
+
return Boolean(h && h.capability_matrix && h.capability_matrix.mcp_support === true);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Live mode for a harness: 'puppeteer' when mcp_support is true, else 'degraded'.
|
|
63
|
+
* An unknown harness id degrades (fail safe to screenshot-only).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} harnessId
|
|
66
|
+
* @param {Array=} harnesses Optional injected list; defaults to the manifest.
|
|
67
|
+
* @returns {'puppeteer'|'degraded'}
|
|
68
|
+
*/
|
|
69
|
+
function liveModeFor(harnessId, harnesses) {
|
|
70
|
+
return isMcpSupported(harnessId, harnesses) ? MODE_FULL : MODE_DEGRADED;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The ids of every harness currently in degraded (screenshot-only) live mode,
|
|
75
|
+
* in manifest order.
|
|
76
|
+
*
|
|
77
|
+
* @param {Array=} harnesses Optional injected list; defaults to the manifest.
|
|
78
|
+
* @returns {string[]}
|
|
79
|
+
*/
|
|
80
|
+
function degradedHarnesses(harnesses) {
|
|
81
|
+
const list = Array.isArray(harnesses) ? harnesses : defaultHarnesses();
|
|
82
|
+
return list
|
|
83
|
+
.filter((h) => h && h.id && !isMcpSupported(h.id, list))
|
|
84
|
+
.map((h) => h.id);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
liveModeFor,
|
|
89
|
+
degradedHarnesses,
|
|
90
|
+
isMcpSupported,
|
|
91
|
+
MODE_FULL,
|
|
92
|
+
MODE_DEGRADED,
|
|
93
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/live/postcheck.cjs — Phase 47 (Live Mode) variant post-check.
|
|
4
|
+
*
|
|
5
|
+
* After /gdd:live generates a design variant, we run that variant's source through the
|
|
6
|
+
* in-repo gdd-detect engine (Phase 41) and surface its anti-pattern findings on the
|
|
7
|
+
* variant card. Per the Live Mode spec, an `error`-severity finding is FLAGGED but NOT
|
|
8
|
+
* auto-rejected — the human still chooses; the detector is advisory at dev-time. So
|
|
9
|
+
* `autoReject` is ALWAYS false; we only surface counts + a compact card summary.
|
|
10
|
+
*
|
|
11
|
+
* Pure, dependency-free CommonJS. We reuse the detect engine's `scanContent` +`RULES`
|
|
12
|
+
* (NOT a CLI shell-out, NOT a temp-file write) so post-checking a generated variant is
|
|
13
|
+
* fully in-memory and hermetically testable. The engine is the single source of truth
|
|
14
|
+
* for the finding shape ({ruleId, category, name, severity, file, line, column, match,
|
|
15
|
+
* references}); we never reimplement rule matching.
|
|
16
|
+
*
|
|
17
|
+
* Ships in the npm package (scripts/lib/** is in package.json `files`), so it requires
|
|
18
|
+
* only the in-repo detect engine — no runtime dependency, no network, no optional dep.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { scanContent, run, RULES } = require('../detect/engine.cjs');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize the caller's `files`/`content` input into a list of
|
|
26
|
+
* `{ path, content, ext }` units to scan in-memory.
|
|
27
|
+
*
|
|
28
|
+
* Accepts either:
|
|
29
|
+
* - `content`: a raw string (single anonymous unit), or
|
|
30
|
+
* - `files`: an array of `{ path, content }` (or `{ name, content }`) units, or
|
|
31
|
+
* an object map of `{ "<path>": "<content>" }`.
|
|
32
|
+
*
|
|
33
|
+
* @param {{files?: any, content?: string}} input
|
|
34
|
+
* @returns {{path: string, content: string, ext: string}[]}
|
|
35
|
+
*/
|
|
36
|
+
function normalizeUnits(input) {
|
|
37
|
+
const units = [];
|
|
38
|
+
if (input && typeof input.content === 'string') {
|
|
39
|
+
const p = input.path || input.file || 'variant';
|
|
40
|
+
units.push({ path: p, content: input.content, ext: path.extname(p).toLowerCase() });
|
|
41
|
+
}
|
|
42
|
+
const files = input && input.files;
|
|
43
|
+
if (Array.isArray(files)) {
|
|
44
|
+
for (const f of files) {
|
|
45
|
+
if (!f) continue;
|
|
46
|
+
if (typeof f === 'string') continue; // a bare path with no content is not scannable in-memory
|
|
47
|
+
const p = f.path || f.name || f.file || 'variant';
|
|
48
|
+
const content = typeof f.content === 'string' ? f.content : '';
|
|
49
|
+
units.push({ path: p, content, ext: path.extname(p).toLowerCase() });
|
|
50
|
+
}
|
|
51
|
+
} else if (files && typeof files === 'object') {
|
|
52
|
+
for (const p of Object.keys(files)) {
|
|
53
|
+
const content = typeof files[p] === 'string' ? files[p] : '';
|
|
54
|
+
units.push({ path: p, content, ext: path.extname(p).toLowerCase() });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return units;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Post-check a generated variant against the gdd-detect anti-pattern rule set.
|
|
62
|
+
*
|
|
63
|
+
* Prefers an in-memory scan of the supplied variant source via the detect engine's
|
|
64
|
+
* `scanContent`. When NO inline content is supplied but a `projectRoot` is given,
|
|
65
|
+
* falls back to the documented programmatic engine form `run(projectRoot)` so the
|
|
66
|
+
* caller can still post-check files already written to disk.
|
|
67
|
+
*
|
|
68
|
+
* @param {object} args
|
|
69
|
+
* @param {string} [args.projectRoot] Repo root — used for the on-disk fallback and as the
|
|
70
|
+
* `cwd` so finding `file` paths are repo-relative.
|
|
71
|
+
* @param {Array|object} [args.files] Variant files: `[{path, content}]` or `{ "<path>": "<content>" }`.
|
|
72
|
+
* @param {string} [args.content] A single raw variant source string.
|
|
73
|
+
* @returns {{findings: object[], errorCount: number, warnCount: number, autoReject: boolean}}
|
|
74
|
+
*/
|
|
75
|
+
function postCheckVariant(args = {}) {
|
|
76
|
+
const { projectRoot } = args;
|
|
77
|
+
const units = normalizeUnits(args);
|
|
78
|
+
|
|
79
|
+
let findings = [];
|
|
80
|
+
if (units.length > 0) {
|
|
81
|
+
const cwd = projectRoot || process.cwd();
|
|
82
|
+
for (const u of units) {
|
|
83
|
+
// Make the reported `file` repo-relative when projectRoot is known, mirroring the
|
|
84
|
+
// engine's own relativization in run().
|
|
85
|
+
let rel = u.path;
|
|
86
|
+
if (projectRoot && path.isAbsolute(u.path)) {
|
|
87
|
+
rel = path.relative(cwd, u.path).split(path.sep).join('/') || u.path;
|
|
88
|
+
}
|
|
89
|
+
const hits = scanContent(u.content, { path: rel, ext: u.ext }, RULES);
|
|
90
|
+
findings.push(...hits);
|
|
91
|
+
}
|
|
92
|
+
// Match the engine's deterministic ordering (file, line, column, ruleId).
|
|
93
|
+
findings.sort(
|
|
94
|
+
(a, b) =>
|
|
95
|
+
a.file.localeCompare(b.file) ||
|
|
96
|
+
a.line - b.line ||
|
|
97
|
+
a.column - b.column ||
|
|
98
|
+
a.ruleId.localeCompare(b.ruleId),
|
|
99
|
+
);
|
|
100
|
+
} else if (projectRoot) {
|
|
101
|
+
// No inline content — fall back to the documented programmatic engine form against
|
|
102
|
+
// files already on disk under projectRoot.
|
|
103
|
+
const result = run(projectRoot, { cwd: projectRoot });
|
|
104
|
+
findings = result.findings;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let errorCount = 0;
|
|
108
|
+
let warnCount = 0;
|
|
109
|
+
for (const f of findings) {
|
|
110
|
+
if (f.severity === 'error') errorCount += 1;
|
|
111
|
+
else warnCount += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Spec D: error-severity is flagged, NOT auto-rejected. autoReject is always false.
|
|
115
|
+
return { findings, errorCount, warnCount, autoReject: false };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Compact, single-line summary of a findings array for a variant card.
|
|
120
|
+
* Always returns a string (never null/undefined) so the skill can render it verbatim.
|
|
121
|
+
*
|
|
122
|
+
* [] → "clean — no anti-patterns"
|
|
123
|
+
* [1 error, 2 warn] → "2 issue(s): 1 error, 2 warn — BAN-06, BAN-01, BAN-08"
|
|
124
|
+
*
|
|
125
|
+
* Rule ids are de-duplicated and listed in first-seen order, capped so the card stays compact.
|
|
126
|
+
*
|
|
127
|
+
* @param {object[]} findings
|
|
128
|
+
* @returns {string}
|
|
129
|
+
*/
|
|
130
|
+
function summarizeForCard(findings) {
|
|
131
|
+
const list = Array.isArray(findings) ? findings : [];
|
|
132
|
+
if (list.length === 0) return 'clean — no anti-patterns';
|
|
133
|
+
|
|
134
|
+
let errors = 0;
|
|
135
|
+
let warns = 0;
|
|
136
|
+
const ids = [];
|
|
137
|
+
const seen = new Set();
|
|
138
|
+
for (const f of list) {
|
|
139
|
+
if (f && f.severity === 'error') errors += 1;
|
|
140
|
+
else warns += 1;
|
|
141
|
+
const id = f && f.ruleId;
|
|
142
|
+
if (id && !seen.has(id)) {
|
|
143
|
+
seen.add(id);
|
|
144
|
+
ids.push(id);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const MAX_IDS = 5;
|
|
149
|
+
const shownIds = ids.slice(0, MAX_IDS);
|
|
150
|
+
const idTail = ids.length > MAX_IDS ? `, +${ids.length - MAX_IDS} more` : '';
|
|
151
|
+
const parts = [];
|
|
152
|
+
if (errors) parts.push(`${errors} error`);
|
|
153
|
+
if (warns) parts.push(`${warns} warn`);
|
|
154
|
+
|
|
155
|
+
return `${list.length} issue(s): ${parts.join(', ')} — ${shownIds.join(', ')}${idTail}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { postCheckVariant, summarizeForCard, normalizeUnits };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* scripts/lib/live/runtime.cjs — Phase 47 (Live Mode) browser-side runtime.
|
|
4
|
+
*
|
|
5
|
+
* The `/gdd:live` skill injects a small browser runtime into the running dev
|
|
6
|
+
* server via the Claude Preview MCP `preview_eval` tool. That runtime does two
|
|
7
|
+
* jobs:
|
|
8
|
+
*
|
|
9
|
+
* 1. PICK — install a one-shot click handler that captures the element the
|
|
10
|
+
* user clicks and reports `{selector, computedStyle subset, boundingRect,
|
|
11
|
+
* tagName, classList}` back to the agent (the live_pick payload).
|
|
12
|
+
* 2. SWAP — apply one of the N generated design variants in-place by setting a
|
|
13
|
+
* `data-gdd-variant="N"` attribute on the picked element and applying that
|
|
14
|
+
* variant's inline style / markup, and read back which variant is live.
|
|
15
|
+
*
|
|
16
|
+
* Why a string and not a real module: `preview_eval` ships a JS source string to
|
|
17
|
+
* the page and evaluates it there. There is no bundler in the loop, so the
|
|
18
|
+
* runtime is authored here as a plain template string (`RUNTIME_JS`) and injected
|
|
19
|
+
* verbatim. The IIFE is idempotent: re-injecting it rebinds the same singleton on
|
|
20
|
+
* `window.__gddLive` rather than stacking duplicate listeners.
|
|
21
|
+
*
|
|
22
|
+
* Design constraints (mirror the other scripts/lib/live/* modules):
|
|
23
|
+
* - Pure, dependency-free CommonJS. No `fs`, no network, no Date.now() at the
|
|
24
|
+
* module top level. `buildSelector` is a pure function of its input.
|
|
25
|
+
* - Cross-platform: this file is plain JS text; nothing here touches the OS.
|
|
26
|
+
* - Ships in the npm package (scripts/lib/ is in package.json `files`), so it
|
|
27
|
+
* stays runtime-safe (no dev-only requires).
|
|
28
|
+
*
|
|
29
|
+
* Exports:
|
|
30
|
+
* - RUNTIME_JS the browser IIFE source string injected via preview_eval.
|
|
31
|
+
* - pickReportShape a plain object documenting the live_pick payload fields.
|
|
32
|
+
* - buildSelector pure helper a test can assert against (id > data-attr >
|
|
33
|
+
* nth-of-type class path), shared with the in-page logic.
|
|
34
|
+
* - DATA_ATTR the variant marker attribute name ("data-gdd-variant").
|
|
35
|
+
* - GLOBAL_KEY the window singleton key ("__gddLive").
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/** The attribute the runtime stamps on the picked element to mark the live variant. */
|
|
39
|
+
const DATA_ATTR = 'data-gdd-variant';
|
|
40
|
+
|
|
41
|
+
/** The window-singleton key the IIFE installs itself under (idempotent re-inject). */
|
|
42
|
+
const GLOBAL_KEY = '__gddLive';
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shape of the payload the runtime reports for a pick. Documented here so the
|
|
46
|
+
* skill and the tests agree on the contract without parsing RUNTIME_JS. Values
|
|
47
|
+
* are field descriptions, not live data.
|
|
48
|
+
*/
|
|
49
|
+
const pickReportShape = {
|
|
50
|
+
selector: 'string — a stable CSS selector for the picked element (see buildSelector)',
|
|
51
|
+
tagName: 'string — lowercased tag name, e.g. "button"',
|
|
52
|
+
classList: 'string[] — the element classList as an array',
|
|
53
|
+
boundingRect: 'object — {x, y, width, height, top, right, bottom, left} from getBoundingClientRect',
|
|
54
|
+
computedStyle:
|
|
55
|
+
'object — a curated subset of getComputedStyle: color, backgroundColor, fontSize, ' +
|
|
56
|
+
'fontWeight, fontFamily, lineHeight, padding, margin, borderRadius, borderColor, ' +
|
|
57
|
+
'borderWidth, display, boxShadow',
|
|
58
|
+
variant: 'number|null — the current data-gdd-variant value on the element, or null',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Pure selector strategy, shared with the in-page runtime by being inlined into
|
|
63
|
+
* RUNTIME_JS below. Strategy, most-specific first:
|
|
64
|
+
* 1. `#id` when the element has an id.
|
|
65
|
+
* 2. `[data-testid="..."]` when a stable data-testid / data-test is present.
|
|
66
|
+
* 3. `tag.class1.class2:nth-of-type(k)` — tag + (up to 2) classes + structural
|
|
67
|
+
* nth-of-type index among same-tag siblings, for everything else.
|
|
68
|
+
*
|
|
69
|
+
* @param {{id?:string, dataTestId?:string, tagName?:string, classList?:string[], nthOfType?:number}} elInfo
|
|
70
|
+
* @returns {string}
|
|
71
|
+
*/
|
|
72
|
+
function buildSelector(elInfo) {
|
|
73
|
+
const info = elInfo || {};
|
|
74
|
+
if (info.id) return `#${info.id}`;
|
|
75
|
+
if (info.dataTestId) return `[data-testid="${info.dataTestId}"]`;
|
|
76
|
+
const tag = (info.tagName || 'div').toLowerCase();
|
|
77
|
+
const classes = Array.isArray(info.classList) ? info.classList.filter(Boolean).slice(0, 2) : [];
|
|
78
|
+
const classPart = classes.length ? '.' + classes.join('.') : '';
|
|
79
|
+
const nth = Number.isInteger(info.nthOfType) && info.nthOfType > 0 ? `:nth-of-type(${info.nthOfType})` : '';
|
|
80
|
+
return `${tag}${classPart}${nth}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The browser-side runtime, authored as a plain string. Injected verbatim by the
|
|
85
|
+
* skill via `preview_eval`. The in-page `buildSelector` mirrors the exported one
|
|
86
|
+
* above (kept in sync by the phase-47-runtime test asserting both prefer id over
|
|
87
|
+
* class). The IIFE is idempotent: it reuses window.__gddLive if already present.
|
|
88
|
+
*/
|
|
89
|
+
const RUNTIME_JS = `
|
|
90
|
+
(function () {
|
|
91
|
+
var GLOBAL_KEY = ${JSON.stringify(GLOBAL_KEY)};
|
|
92
|
+
var DATA_ATTR = ${JSON.stringify(DATA_ATTR)};
|
|
93
|
+
|
|
94
|
+
// Idempotent: reuse the singleton on re-inject so listeners never stack.
|
|
95
|
+
if (window[GLOBAL_KEY] && window[GLOBAL_KEY].__installed) {
|
|
96
|
+
return window[GLOBAL_KEY];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- pure selector strategy (mirror of runtime.cjs buildSelector) ---
|
|
100
|
+
function buildSelector(el) {
|
|
101
|
+
if (!el || el.nodeType !== 1) return '';
|
|
102
|
+
if (el.id) return '#' + el.id;
|
|
103
|
+
var testId = el.getAttribute && (el.getAttribute('data-testid') || el.getAttribute('data-test'));
|
|
104
|
+
if (testId) return '[data-testid="' + testId + '"]';
|
|
105
|
+
var tag = (el.tagName || 'DIV').toLowerCase();
|
|
106
|
+
var classes = [];
|
|
107
|
+
if (el.classList && el.classList.length) {
|
|
108
|
+
for (var i = 0; i < el.classList.length && classes.length < 2; i++) classes.push(el.classList[i]);
|
|
109
|
+
}
|
|
110
|
+
var classPart = classes.length ? '.' + classes.join('.') : '';
|
|
111
|
+
var nth = '';
|
|
112
|
+
if (el.parentNode) {
|
|
113
|
+
var sameTag = [];
|
|
114
|
+
var sibs = el.parentNode.children || [];
|
|
115
|
+
for (var j = 0; j < sibs.length; j++) if (sibs[j].tagName === el.tagName) sameTag.push(sibs[j]);
|
|
116
|
+
if (sameTag.length > 1) {
|
|
117
|
+
var idx = sameTag.indexOf(el) + 1;
|
|
118
|
+
if (idx > 0) nth = ':nth-of-type(' + idx + ')';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return tag + classPart + nth;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// --- curated computed-style subset for the pick report ---
|
|
125
|
+
var STYLE_KEYS = [
|
|
126
|
+
'color', 'backgroundColor', 'fontSize', 'fontWeight', 'fontFamily', 'lineHeight',
|
|
127
|
+
'padding', 'margin', 'borderRadius', 'borderColor', 'borderWidth', 'display', 'boxShadow'
|
|
128
|
+
];
|
|
129
|
+
function styleSubset(el) {
|
|
130
|
+
var out = {};
|
|
131
|
+
var cs = window.getComputedStyle ? window.getComputedStyle(el) : null;
|
|
132
|
+
if (!cs) return out;
|
|
133
|
+
for (var i = 0; i < STYLE_KEYS.length; i++) out[STYLE_KEYS[i]] = cs[STYLE_KEYS[i]];
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function report(el) {
|
|
138
|
+
var r = el.getBoundingClientRect ? el.getBoundingClientRect() : {};
|
|
139
|
+
var classList = [];
|
|
140
|
+
if (el.classList) for (var i = 0; i < el.classList.length; i++) classList.push(el.classList[i]);
|
|
141
|
+
var v = el.getAttribute(DATA_ATTR);
|
|
142
|
+
return {
|
|
143
|
+
selector: buildSelector(el),
|
|
144
|
+
tagName: (el.tagName || '').toLowerCase(),
|
|
145
|
+
classList: classList,
|
|
146
|
+
boundingRect: {
|
|
147
|
+
x: r.x, y: r.y, width: r.width, height: r.height,
|
|
148
|
+
top: r.top, right: r.right, bottom: r.bottom, left: r.left
|
|
149
|
+
},
|
|
150
|
+
computedStyle: styleSubset(el),
|
|
151
|
+
variant: v == null ? null : Number(v)
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
var state = {
|
|
156
|
+
__installed: true,
|
|
157
|
+
picked: null, // last picked element
|
|
158
|
+
lastReport: null, // last pick report (read by the agent after a click)
|
|
159
|
+
originals: new WeakMap() // element -> {style, html} captured before first swap
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
// --- PICK: one-shot capture click handler ---
|
|
163
|
+
function onPick(ev) {
|
|
164
|
+
var el = ev.target;
|
|
165
|
+
if (!el || el.nodeType !== 1) return;
|
|
166
|
+
ev.preventDefault();
|
|
167
|
+
ev.stopPropagation();
|
|
168
|
+
state.picked = el;
|
|
169
|
+
state.lastReport = report(el);
|
|
170
|
+
document.removeEventListener('click', onPick, true);
|
|
171
|
+
state.picking = false;
|
|
172
|
+
return state.lastReport;
|
|
173
|
+
}
|
|
174
|
+
state.pick = function () {
|
|
175
|
+
state.picking = true;
|
|
176
|
+
document.addEventListener('click', onPick, true);
|
|
177
|
+
return true;
|
|
178
|
+
};
|
|
179
|
+
state.getLastPick = function () { return state.lastReport; };
|
|
180
|
+
|
|
181
|
+
// --- SWAP: apply / read a variant on the picked (or given) element ---
|
|
182
|
+
function captureOriginal(el) {
|
|
183
|
+
if (!state.originals.has(el)) {
|
|
184
|
+
state.originals.set(el, { style: el.getAttribute('style'), html: el.innerHTML });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// variant = {n:Number, style?:Object, html?:String}
|
|
188
|
+
state.swapVariant = function (variant, el) {
|
|
189
|
+
var target = el || state.picked;
|
|
190
|
+
if (!target || !variant) return null;
|
|
191
|
+
captureOriginal(target);
|
|
192
|
+
target.setAttribute(DATA_ATTR, String(variant.n));
|
|
193
|
+
if (variant.style && typeof variant.style === 'object') {
|
|
194
|
+
for (var k in variant.style) if (Object.prototype.hasOwnProperty.call(variant.style, k)) {
|
|
195
|
+
target.style[k] = variant.style[k];
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (typeof variant.html === 'string') target.innerHTML = variant.html;
|
|
199
|
+
return Number(target.getAttribute(DATA_ATTR));
|
|
200
|
+
};
|
|
201
|
+
state.currentVariant = function (el) {
|
|
202
|
+
var target = el || state.picked;
|
|
203
|
+
if (!target) return null;
|
|
204
|
+
var v = target.getAttribute(DATA_ATTR);
|
|
205
|
+
return v == null ? null : Number(v);
|
|
206
|
+
};
|
|
207
|
+
state.revert = function (el) {
|
|
208
|
+
var target = el || state.picked;
|
|
209
|
+
if (!target) return false;
|
|
210
|
+
var orig = state.originals.get(target);
|
|
211
|
+
if (orig) {
|
|
212
|
+
if (orig.style == null) target.removeAttribute('style'); else target.setAttribute('style', orig.style);
|
|
213
|
+
target.innerHTML = orig.html;
|
|
214
|
+
}
|
|
215
|
+
target.removeAttribute(DATA_ATTR);
|
|
216
|
+
return true;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// expose the pure helper for assertions / debugging
|
|
220
|
+
state.buildSelector = buildSelector;
|
|
221
|
+
|
|
222
|
+
window[GLOBAL_KEY] = state;
|
|
223
|
+
return state;
|
|
224
|
+
})();
|
|
225
|
+
`;
|
|
226
|
+
|
|
227
|
+
module.exports = {
|
|
228
|
+
RUNTIME_JS,
|
|
229
|
+
pickReportShape,
|
|
230
|
+
buildSelector,
|
|
231
|
+
DATA_ATTR,
|
|
232
|
+
GLOBAL_KEY,
|
|
233
|
+
};
|