@godxjp/ui 32.12.1 → 32.14.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/DESIGN.md +1 -1
- package/README.md +2 -2
- package/agent/DESIGN.md +1 -1
- package/agent/START-HERE.md +4 -4
- package/agent/components/ListRow.json +1 -1
- package/agent/components/PageContainer.json +2 -2
- package/agent/components/Segmented.json +1 -0
- package/agent/components/Tabs.json +3 -2
- package/agent/components.json +7 -5
- package/agent/index.json +4 -4
- package/agent/llms.txt +5 -5
- package/agent/patterns/inertia-list-page.json +1 -1
- package/agent/patterns.json +1 -1
- package/agent/tokens.json +38 -2
- package/dist/components/layout/topbar.js +1 -0
- package/dist/contracts/measurement.json +1 -1
- package/dist/styles/base.css +19 -19
- package/dist/styles/data-display-layout.css +16 -17
- package/dist/styles/layers.json +1 -1
- package/dist/styles/layout.css +1 -1
- package/dist/styles/shell-layout.css +1 -0
- package/dist/styles.css +1 -1
- package/dist/tokens/components/data-display.css +10 -0
- package/dist/tokens/semantic/layout.css +1 -1
- package/package.json +5 -2
- package/scripts/_agent-setup.mjs +118 -0
- package/scripts/agent-guard.mjs +176 -0
- package/scripts/cli.mjs +25 -2
- package/scripts/codemod.mjs +313 -42
- package/scripts/init-agent-kit.mjs +14 -0
- package/scripts/mcp-call.mjs +40 -0
- package/scripts/ui-audit.mjs +77 -0
- package/scripts/user-guard.mjs +78 -0
- package/scripts/visual-audit.mjs +75 -9
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @godxjp/ui user-level guard — the SHIM `npx godxjp-ui init-agent --user` copies to
|
|
4
|
+
* ~/.claude/hooks/godxjp-ui-guard.mjs (gh#1324).
|
|
5
|
+
*
|
|
6
|
+
* A repo's `.mcp.json`, CLAUDE.md and `.claude/settings.json` hooks load only when Claude Code
|
|
7
|
+
* STARTS in that repo. A session started anywhere else writes UI blind: measured in godx-qc,
|
|
8
|
+
* 0 of 25 UI-writing subagents had the godx-ui MCP and no audit fired. User settings always load,
|
|
9
|
+
* so this shim is registered there for PreToolUse and PostToolUse on Write|Edit|MultiEdit.
|
|
10
|
+
*
|
|
11
|
+
* It runs on EVERY write in EVERY repo on the machine, so it does as little as possible: it
|
|
12
|
+
* leaves unless the file is a .tsx/.jsx inside a repo whose `.mcp.json` mounts @godxjp/ui-mcp,
|
|
13
|
+
* and then hands the payload to that repo's own installed `@godxjp/ui/scripts/agent-guard.mjs`,
|
|
14
|
+
* which holds the logic and moves with the package version. Self-contained on purpose: it is a
|
|
15
|
+
* copy that lives outside every package. No network.
|
|
16
|
+
*
|
|
17
|
+
* FAIL OPEN: any error in this shim — unreadable stdin, a malformed .mcp.json, a missing or older
|
|
18
|
+
* package without agent-guard.mjs, a crash — exits 0, and a write is never blocked because the
|
|
19
|
+
* guard itself broke. Only agent-guard.mjs's deliberate exit 2 blocks.
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
|
|
25
|
+
process.on("uncaughtException", () => process.exit(0)); // fail open, see above
|
|
26
|
+
|
|
27
|
+
const event = process.argv[2] === "post" ? "post" : "pre";
|
|
28
|
+
let raw;
|
|
29
|
+
try {
|
|
30
|
+
raw = readFileSync(0, "utf8"); // fd 0 = the hook payload piped by Claude Code
|
|
31
|
+
} catch {
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
34
|
+
const payload = JSON.parse(raw); // a throw lands in the fail-open handler
|
|
35
|
+
const given = payload?.tool_input?.file_path ?? payload?.tool_input?.filePath;
|
|
36
|
+
if (typeof given !== "string" || !/\.(tsx|jsx)$/.test(given)) process.exit(0);
|
|
37
|
+
const file = resolve(payload?.cwd ?? process.cwd(), given);
|
|
38
|
+
|
|
39
|
+
/** Mirrors runsUiMcp in @godxjp/ui scripts/_agent-setup.mjs: the catalog server, any key. */
|
|
40
|
+
const runsUiMcp = (entry) =>
|
|
41
|
+
[entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])].some(
|
|
42
|
+
(part) => typeof part === "string" && /(^|\/)@godxjp\/ui-mcp(@|$)/.test(part),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
// The nearest ancestor `.mcp.json` decides: it mounts the catalog, or this is not our repo.
|
|
46
|
+
let root = null;
|
|
47
|
+
for (let dir = dirname(file); ; dir = dirname(dir)) {
|
|
48
|
+
const cfg = join(dir, ".mcp.json");
|
|
49
|
+
if (existsSync(cfg)) {
|
|
50
|
+
const servers = JSON.parse(readFileSync(cfg, "utf8"))?.mcpServers ?? {};
|
|
51
|
+
if (Object.values(servers).some(runsUiMcp) || servers["godx-ui"]) root = dir;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (dirname(dir) === dir) break;
|
|
55
|
+
}
|
|
56
|
+
if (!root) process.exit(0);
|
|
57
|
+
|
|
58
|
+
// The repo's installed package, up the node_modules chain (a workspace hoists it to the top).
|
|
59
|
+
let guard = null;
|
|
60
|
+
for (let dir = root; ; dir = dirname(dir)) {
|
|
61
|
+
const candidate = join(dir, "node_modules", "@godxjp", "ui", "scripts", "agent-guard.mjs");
|
|
62
|
+
if (existsSync(candidate)) {
|
|
63
|
+
guard = candidate;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (dirname(dir) === dir) break;
|
|
67
|
+
}
|
|
68
|
+
if (!guard) process.exit(0); // not installed, or a release older than the guard: fail open
|
|
69
|
+
|
|
70
|
+
const r = spawnSync(process.execPath, [guard, event, root], {
|
|
71
|
+
input: raw,
|
|
72
|
+
encoding: "utf8",
|
|
73
|
+
timeout: 45_000, // under Claude Code's 60s hook limit, so a slow guard still ends in exit 0
|
|
74
|
+
});
|
|
75
|
+
if (r.error || r.status === null) process.exit(0);
|
|
76
|
+
if (r.stdout) process.stdout.write(r.stdout);
|
|
77
|
+
if (r.stderr) process.stderr.write(r.stderr);
|
|
78
|
+
process.exit(r.status === 2 ? 2 : 0);
|
package/scripts/visual-audit.mjs
CHANGED
|
@@ -1496,19 +1496,38 @@ export function parseTextZoom(spec) {
|
|
|
1496
1496
|
|
|
1497
1497
|
/**
|
|
1498
1498
|
* Did the override reach the page? (pure). `before`/`after` are `snapshotText()` results of the
|
|
1499
|
-
* SAME document
|
|
1500
|
-
*
|
|
1501
|
-
*
|
|
1502
|
-
*
|
|
1499
|
+
* SAME document. Applied = the override's computed values reached EVERY visible text element AND
|
|
1500
|
+
* the geometry moved; either half alone is not enough — a computed value with zero geometry change
|
|
1501
|
+
* is a render that was never re-laid out, and geometry that moved without the values is something
|
|
1502
|
+
* else moving.
|
|
1503
|
+
*
|
|
1504
|
+
* "Every", not "any" (gh#1320): "took > 0" reported `applied: true` on an editor whose four h2
|
|
1505
|
+
* blocks kept their size (ratio 0.897), so a heading smaller than its own body passed as evidence.
|
|
1506
|
+
* The tolerance is ZERO visible elements; what is exempt is listed on the snapshot
|
|
1507
|
+
* (`exempt`: hidden, zero-area / sr-only, aria-hidden, inside an svg) and never counted.
|
|
1508
|
+
*
|
|
1509
|
+
* Elements are paired by index when the two snapshots agree there, otherwise by their `key`
|
|
1510
|
+
* (selector + text): a bar that folds under larger text, or an editor that re-creates a block,
|
|
1511
|
+
* changes the list, and pairing by index alone would compare unrelated elements.
|
|
1503
1512
|
*/
|
|
1504
1513
|
export function textOverrideVerdict(mode, before, after, { zoom = 200 } = {}) {
|
|
1505
|
-
const
|
|
1514
|
+
const used = new Set();
|
|
1515
|
+
const pairs = [];
|
|
1516
|
+
before.texts.forEach((a, i) => {
|
|
1517
|
+
const same = after.texts[i];
|
|
1518
|
+
let j = same && !used.has(i) && (same.key ?? null) === (a.key ?? null) ? i : -1;
|
|
1519
|
+
if (j < 0 && a.key != null)
|
|
1520
|
+
j = after.texts.findIndex((b, k) => !used.has(k) && b.key === a.key);
|
|
1521
|
+
if (j < 0) return;
|
|
1522
|
+
used.add(j);
|
|
1523
|
+
pairs.push([a, after.texts[j]]);
|
|
1524
|
+
});
|
|
1525
|
+
const n = pairs.length;
|
|
1506
1526
|
let moved = 0;
|
|
1507
1527
|
let maxShiftPx = 0;
|
|
1508
1528
|
let took = 0;
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const b = after.texts[i];
|
|
1529
|
+
const notTaken = [];
|
|
1530
|
+
for (const [a, b] of pairs) {
|
|
1512
1531
|
const shift = Math.max(
|
|
1513
1532
|
Math.abs(a.x - b.x),
|
|
1514
1533
|
Math.abs(a.y - b.y),
|
|
@@ -1523,6 +1542,14 @@ export function textOverrideVerdict(mode, before, after, { zoom = 200 } = {}) {
|
|
|
1523
1542
|
Math.abs(b.wordSpacingPx - 0.16 * b.fontPx) <= 0.5
|
|
1524
1543
|
: Math.abs(b.fontPx - (a.fontPx * zoom) / 100) <= 0.5;
|
|
1525
1544
|
if (ok) took++;
|
|
1545
|
+
else if (!a.exempt && !b.exempt)
|
|
1546
|
+
notTaken.push({
|
|
1547
|
+
selector: a.selector ?? "?",
|
|
1548
|
+
text: a.text ?? "",
|
|
1549
|
+
...(mode === "text-zoom"
|
|
1550
|
+
? { fontPx: a.fontPx, expectedPx: +((a.fontPx * zoom) / 100).toFixed(2), gotPx: b.fontPx }
|
|
1551
|
+
: { letterSpacingPx: b.letterSpacingPx, wordSpacingPx: b.wordSpacingPx }),
|
|
1552
|
+
});
|
|
1526
1553
|
}
|
|
1527
1554
|
const delta = {
|
|
1528
1555
|
textElements: n,
|
|
@@ -1537,7 +1564,15 @@ export function textOverrideVerdict(mode, before, after, { zoom = 200 } = {}) {
|
|
|
1537
1564
|
else if (took === 0) reason = `the ${mode} values reached 0 of ${n} text elements`;
|
|
1538
1565
|
else if (moved === 0 && delta.documentHeightPx === 0 && delta.documentWidthPx === 0)
|
|
1539
1566
|
reason = "zero geometry delta against the unmodified render";
|
|
1540
|
-
|
|
1567
|
+
else if (notTaken.length)
|
|
1568
|
+
reason =
|
|
1569
|
+
`override partially applied: ${notTaken.length} of ${n} visible text elements did not take it — ` +
|
|
1570
|
+
notTaken
|
|
1571
|
+
.slice(0, 5)
|
|
1572
|
+
.map((m) => `${m.selector} "${m.text}"`)
|
|
1573
|
+
.join(", ") +
|
|
1574
|
+
(notTaken.length > 5 ? `, … ${notTaken.length - 5} more` : "");
|
|
1575
|
+
return { applied: reason === null, reason, appliedRatio, delta, notTaken };
|
|
1541
1576
|
}
|
|
1542
1577
|
|
|
1543
1578
|
/** Text-bearing elements' boxes + computed text metrics, in document order. Runs in the page. */
|
|
@@ -1554,11 +1589,36 @@ export function snapshotText() {
|
|
|
1554
1589
|
seen.add(p);
|
|
1555
1590
|
owners.push(p);
|
|
1556
1591
|
}
|
|
1592
|
+
// `tag#id` or `tag.class.class`, three levels up — enough to find the element, stable across a
|
|
1593
|
+
// re-render that re-creates it (no data attributes, no indices).
|
|
1594
|
+
const step = (el) =>
|
|
1595
|
+
el.tagName.toLowerCase() +
|
|
1596
|
+
(el.id
|
|
1597
|
+
? `#${el.id}`
|
|
1598
|
+
: [...el.classList]
|
|
1599
|
+
.slice(0, 2)
|
|
1600
|
+
.map((c) => `.${c}`)
|
|
1601
|
+
.join(""));
|
|
1602
|
+
const selectorOf = (el) => {
|
|
1603
|
+
const parts = [];
|
|
1604
|
+
for (let e = el; e && e !== document.body && parts.length < 3; e = e.parentElement)
|
|
1605
|
+
parts.unshift(step(e));
|
|
1606
|
+
return parts.join(" > ");
|
|
1607
|
+
};
|
|
1557
1608
|
const texts = owners.map((el) => {
|
|
1558
1609
|
const r = el.getBoundingClientRect();
|
|
1559
1610
|
const s = getComputedStyle(el);
|
|
1560
1611
|
const fontPx = parseFloat(s.fontSize) || 0;
|
|
1561
1612
|
const px = (v) => (v === "normal" ? 0 : parseFloat(v) || 0);
|
|
1613
|
+
const selector = selectorOf(el);
|
|
1614
|
+
const text = (el.textContent || "").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
1615
|
+
// Not text a reader sees, so never counted as "did not take the override".
|
|
1616
|
+
const exempt =
|
|
1617
|
+
r.width <= 1 ||
|
|
1618
|
+
r.height <= 1 ||
|
|
1619
|
+
s.visibility !== "visible" ||
|
|
1620
|
+
Number(s.opacity) === 0 ||
|
|
1621
|
+
!!el.closest('[aria-hidden="true"], svg');
|
|
1562
1622
|
return {
|
|
1563
1623
|
x: r.left + se.scrollLeft,
|
|
1564
1624
|
y: r.top + se.scrollTop,
|
|
@@ -1567,6 +1627,10 @@ export function snapshotText() {
|
|
|
1567
1627
|
fontPx,
|
|
1568
1628
|
letterSpacingPx: px(s.letterSpacing),
|
|
1569
1629
|
wordSpacingPx: px(s.wordSpacing),
|
|
1630
|
+
selector,
|
|
1631
|
+
text,
|
|
1632
|
+
key: `${selector}|${text}`,
|
|
1633
|
+
exempt,
|
|
1570
1634
|
};
|
|
1571
1635
|
});
|
|
1572
1636
|
return { texts, docHeight: se.scrollHeight, docWidth: se.scrollWidth };
|
|
@@ -1642,6 +1706,8 @@ async function runEvidence(page, url, viewport, mode, { zoom, screenshots }) {
|
|
|
1642
1706
|
...(mode === "text-zoom" ? { zoomPercent: zoom } : null),
|
|
1643
1707
|
applied: verdict.applied,
|
|
1644
1708
|
appliedRatio: verdict.appliedRatio,
|
|
1709
|
+
// The visible text elements that did NOT take the override (gh#1320): selector + snippet.
|
|
1710
|
+
notTaken: verdict.notTaken,
|
|
1645
1711
|
delta: verdict.delta,
|
|
1646
1712
|
c01: "not-run",
|
|
1647
1713
|
screenshots: screenshots ? { base, override } : null,
|