@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.
@@ -54,6 +54,14 @@
54
54
  --prose-quote-border-width: initial;
55
55
  --prose-quote-space-inline: var(--space-3);
56
56
  --prose-code-font-size: calc(1em / var(--font-size-ratio));
57
+ --prose-code-block-font-size: 1em;
58
+
59
+ --prose-heading-h1-font-size: calc(
60
+ 1em * var(--font-size-ratio) * var(--font-size-ratio) * var(--font-size-ratio)
61
+ );
62
+ --prose-heading-h2-font-size: calc(1em * var(--font-size-ratio) * var(--font-size-ratio));
63
+ --prose-heading-h3-font-size: calc(1em * var(--font-size-ratio));
64
+ --prose-heading-h4-font-size: 1em;
57
65
  --prose-code-space-inline: var(--space-1);
58
66
 
59
67
  --prose-cell-image-max-inline-size: 20rem;
@@ -63,6 +71,8 @@
63
71
  --block-editor-gutter-inline-size: calc(2 * var(--control-height-xs) + var(--space-2));
64
72
 
65
73
  --block-editor-compact-gutter-inline-size: var(--block-editor-frame-padding);
74
+
75
+ --block-editor-raw-block-head-font-size: calc(1em / var(--font-size-ratio));
66
76
  --block-editor-min-height: var(--band-height-3xl);
67
77
 
68
78
  --block-editor-frame-padding: var(--space-3);
@@ -56,7 +56,7 @@
56
56
  --page-header-extra-max-measure: initial;
57
57
 
58
58
  --page-toolbar-background: transparent;
59
- --page-toolbar-pad-block: 0px;
59
+ --page-toolbar-pad-block: initial;
60
60
  --page-toolbar-divider: initial;
61
61
 
62
62
  --page-footer-divider: initial;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "32.12.1",
4
- "godxUiMcp": "32.12.1",
3
+ "version": "32.14.0",
4
+ "godxUiMcp": "32.14.0",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
7
7
  "type": "git",
@@ -38,6 +38,9 @@
38
38
  "scripts/visual-audit-rules.mjs",
39
39
  "scripts/brand-accent.generated.mjs",
40
40
  "scripts/audit-hook.mjs",
41
+ "scripts/agent-guard.mjs",
42
+ "scripts/user-guard.mjs",
43
+ "scripts/mcp-call.mjs",
41
44
  "scripts/init-agent-kit.mjs",
42
45
  "scripts/init-guinea-pig.mjs",
43
46
  "scripts/guinea-pig-skill.md",
@@ -234,6 +234,9 @@ Before any JSX for a screen:
234
234
  3. \`get_exemplar\` (no argument lists the archetypes: list, detail, editor, reader, settings,
235
235
  dashboard, approval-queue) — match its structure.
236
236
 
237
+ No \`mcp__godx-ui__*\` tools (the session started outside this repo)? The same catalog over stdio:
238
+ \`npx --no-install @godxjp/ui mcp-call search_components '{"query":"<area>"}'\` (no tool lists them).
239
+
237
240
  After building:
238
241
  4. \`npx @godxjp/ui audit <files>\` and \`npx @godxjp/ui visual-audit <url> <routes>\` (6 widths) — zero.
239
242
  5. Inspect every screenshot at every width, region by region; no top-N cap. Report "probes passed;
@@ -566,6 +569,121 @@ export function ensureClaudeHooks(root) {
566
569
  return added;
567
570
  }
568
571
 
572
+ /**
573
+ * The nearest `.mcp.json` at or above `fromDir` that serves the catalog: `{ dir, key, entry }`, or
574
+ * null. Read-only; a malformed file reads as "no catalog here".
575
+ */
576
+ export function findUiMcpConfig(fromDir) {
577
+ for (let dir = resolve(fromDir); ; dir = dirname(dir)) {
578
+ const read = readJsonFile(join(dir, ".mcp.json"));
579
+ if (read.state === "ok") {
580
+ const servers = read.json.mcpServers;
581
+ if (servers && typeof servers === "object" && !Array.isArray(servers)) {
582
+ // The `godx-ui` key under any launcher (a local `node …/mcp/dist/index.js` link included),
583
+ // else whichever key launches @godxjp/ui-mcp.
584
+ const key = servers[MCP_KEY]
585
+ ? MCP_KEY
586
+ : Object.keys(servers).find((name) => runsUiMcp(servers[name]));
587
+ if (key) return { dir, key, entry: servers[key] };
588
+ }
589
+ }
590
+ if (dirname(dir) === dir) return null;
591
+ }
592
+ }
593
+
594
+ /** Where `init-agent --user` puts the guard shim, under Claude Code's user config dir. */
595
+ export const USER_GUARD_FILE = "godxjp-ui-guard.mjs";
596
+ const USER_GUARD_SOURCE = join(SELF_ROOT, "scripts", "user-guard.mjs");
597
+
598
+ /** Claude Code's user config dir: `$CLAUDE_CONFIG_DIR`, else `~/.claude` (HOME-relative). */
599
+ export function userConfigDir(env = process.env) {
600
+ return env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
601
+ }
602
+
603
+ /**
604
+ * `init-agent --user` (gh#1324): copy the guard shim to `<config>/hooks/godxjp-ui-guard.mjs` and
605
+ * register it in `<config>/settings.json` for PreToolUse and PostToolUse on Write|Edit|MultiEdit.
606
+ * Merges: every other key and hook is kept, an existing godxjp-ui-guard entry is left as it is,
607
+ * and the file is only rewritten when something was added. A settings.json that cannot be read,
608
+ * parsed or recognised is refused with a sidecar, like the project files.
609
+ * Returns the lines to print.
610
+ */
611
+ export function ensureUserGuard(configDir = userConfigDir()) {
612
+ const lines = [];
613
+ const shim = join(configDir, "hooks", USER_GUARD_FILE);
614
+ const source = readFileSync(USER_GUARD_SOURCE, "utf8");
615
+ let current = null;
616
+ try {
617
+ current = readFileSync(shim, "utf8");
618
+ } catch {
619
+ // Not installed yet.
620
+ }
621
+ if (current === source) lines.push(`${shim} — present`);
622
+ else {
623
+ mkdirp(dirname(shim));
624
+ writeFileAtomic(shim, source);
625
+ lines.push(`${shim} — ${current === null ? "created" : "updated"}`);
626
+ }
627
+
628
+ const path = join(configDir, "settings.json");
629
+ const group = (event) => ({
630
+ matcher: "Write|Edit|MultiEdit",
631
+ hooks: [{ type: "command", command: `node "${shim}" ${event}` }],
632
+ });
633
+ const suggested = {
634
+ hooks: { PreToolUse: [group("pre")], PostToolUse: [group("post")] },
635
+ };
636
+ const read = readJsonFile(path);
637
+ if (read.state !== "ok" && read.state !== "missing") {
638
+ lines.push(
639
+ `${path} — ` +
640
+ refuseAndSuggest(path, JSON.stringify(suggested, null, 2) + "\n", READ_FAILURE[read.state]),
641
+ );
642
+ return lines;
643
+ }
644
+ const json = read.state === "ok" ? read.json : {};
645
+ if (
646
+ json.hooks !== undefined &&
647
+ (json.hooks === null || typeof json.hooks !== "object" || Array.isArray(json.hooks))
648
+ ) {
649
+ lines.push(
650
+ `${path} — ` +
651
+ refuseAndSuggest(
652
+ path,
653
+ JSON.stringify(suggested, null, 2) + "\n",
654
+ "`hooks` is not an object",
655
+ ),
656
+ );
657
+ return lines;
658
+ }
659
+ json.hooks = json.hooks ?? {};
660
+ const added = [];
661
+ for (const [event, arg] of [
662
+ ["PreToolUse", "pre"],
663
+ ["PostToolUse", "post"],
664
+ ]) {
665
+ const groups = json.hooks[event] ?? [];
666
+ if (!Array.isArray(groups)) {
667
+ lines.push(`${path} — hooks.${event} is not an array, left untouched`);
668
+ continue;
669
+ }
670
+ const has = groups.some((g) =>
671
+ (g?.hooks ?? []).some((h) => String(h?.command ?? "").includes(USER_GUARD_FILE)),
672
+ );
673
+ if (has) continue;
674
+ json.hooks[event] = [...groups, group(arg)];
675
+ added.push(event);
676
+ }
677
+ if (added.length === 0) {
678
+ lines.push(`${path} — guard hooks already present`);
679
+ return lines;
680
+ }
681
+ mkdirp(dirname(path));
682
+ writeFileAtomic(path, JSON.stringify(json, null, read.state === "ok" ? read.indent : 2) + "\n");
683
+ lines.push(`${path} — added ${added.join(" + ")} (Write|Edit|MultiEdit)`);
684
+ return lines;
685
+ }
686
+
569
687
  /** Write the workflow mandate the SessionStart hook reads (only if absent). */
570
688
  export function writeWorkflowMd(root) {
571
689
  const path = join(root, ".claude", "godxjp-ui-workflow.md");
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * agent-guard — the logic behind the user-level shim (scripts/user-guard.mjs, installed by
4
+ * `npx godxjp-ui init-agent --user`; gh#1324). Usage, from the shim only:
5
+ *
6
+ * node agent-guard.mjs pre|post <repo root> < Claude Code hook payload on stdin
7
+ *
8
+ * The shim has already established that the edited .tsx/.jsx sits in <repo root> and that the
9
+ * repo's `.mcp.json` mounts @godxjp/ui-mcp.
10
+ *
11
+ * pre BLOCK (exit 2) unless the agent's own transcript shows it consulted the catalog: a tool
12
+ * call to `mcp__<key>__*` for the repo's catalog key, or a Bash call to the stdio fallback
13
+ * (`godxjp-ui mcp-call …`, or `godx-ui-mcp call …`). A subagent is judged by ITS transcript
14
+ * (`agent_transcript_path`), so a parent's lookup does not license a subagent's write.
15
+ * post When the session did not start in <repo root>, the repo's own PostToolUse audit hook
16
+ * never loaded: run this package's audit-hook.mjs in its place. Otherwise do nothing, so
17
+ * a session that did start there is not audited twice.
18
+ *
19
+ * No network. FAIL OPEN: an internal error — unreadable payload, missing or unparseable
20
+ * transcript, a crash — exits 0. Only the deliberate "not consulted" verdict blocks.
21
+ */
22
+ import { spawnSync } from "node:child_process";
23
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
24
+ import { basename, dirname, join, resolve, sep } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const HERE = dirname(fileURLToPath(import.meta.url));
28
+
29
+ /** Same test as runsUiMcp in _agent-setup.mjs — the catalog server under any key. */
30
+ const runsUiMcp = (entry) =>
31
+ [entry?.command, ...(Array.isArray(entry?.args) ? entry.args : [])].some(
32
+ (part) => typeof part === "string" && /(^|\/)@godxjp\/ui-mcp(@|$)/.test(part),
33
+ );
34
+
35
+ /** The `.mcp.json` keys that serve the catalog — the `<key>` in `mcp__<key>__<tool>`. */
36
+ export function catalogKeys(root) {
37
+ const servers = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8"))?.mcpServers ?? {};
38
+ const keys = Object.keys(servers).filter((k) => k === "godx-ui" || runsUiMcp(servers[k]));
39
+ return keys.length ? keys : ["godx-ui"];
40
+ }
41
+
42
+ /** A Bash command that queries the catalog over the stdio fallback. */
43
+ const CLI_CALL =
44
+ /\bmcp-call\s+[a-z_]+|\bui-mcp(?:@\S+)?\s+call\s+[a-z_]+|\bgodx-ui-mcp\s+call\s+[a-z_]+/;
45
+
46
+ /**
47
+ * True when the transcript (JSONL) holds a tool_use that consulted the catalog. Only tool_use
48
+ * blocks count: the block message itself quotes the fallback command, and a transcript that
49
+ * merely CONTAINS that text (in a tool result, in the agent's prose) has not run it.
50
+ */
51
+ export function consulted(transcript, keys) {
52
+ const prefixes = keys.map((k) => `mcp__${k}__`);
53
+ for (const line of transcript.split("\n")) {
54
+ if (!line.includes('"tool_use"')) continue;
55
+ let entry;
56
+ try {
57
+ entry = JSON.parse(line);
58
+ } catch {
59
+ continue; // a torn last line while the session is still writing
60
+ }
61
+ const content = entry?.message?.content;
62
+ if (!Array.isArray(content)) continue;
63
+ for (const block of content) {
64
+ if (block?.type !== "tool_use" || typeof block.name !== "string") continue;
65
+ if (prefixes.some((p) => block.name.startsWith(p))) return true;
66
+ if (block.name === "Bash" && CLI_CALL.test(String(block.input?.command ?? ""))) return true;
67
+ }
68
+ }
69
+ return false;
70
+ }
71
+
72
+ const realOrSelf = (p) => {
73
+ try {
74
+ return realpathSync(p);
75
+ } catch {
76
+ return resolve(p);
77
+ }
78
+ };
79
+
80
+ /**
81
+ * Did the session START in <root>? Claude Code loads project hooks from the start directory and
82
+ * hands hooks `CLAUDE_PROJECT_DIR`; without it, the transcript's project folder is the start
83
+ * directory with every non-alphanumeric character turned into "-" (the measured layout:
84
+ * ~/.claude/projects/<slug>/<session>.jsonl, a subagent's under <session>/subagents/). A write in
85
+ * <top>/.claude/worktrees/<x> of a session started at <top> counts as started there: <top>'s
86
+ * hooks are the ones that loaded.
87
+ */
88
+ export function startedIn(root, payload, env = process.env) {
89
+ const roots = new Set([resolve(root), realOrSelf(root)]);
90
+ for (const r of [...roots]) {
91
+ const at = r.indexOf(`${sep}.claude${sep}worktrees${sep}`);
92
+ if (at > 0) roots.add(r.slice(0, at));
93
+ }
94
+ if (env.CLAUDE_PROJECT_DIR) {
95
+ const start = env.CLAUDE_PROJECT_DIR;
96
+ return roots.has(resolve(start)) || roots.has(realOrSelf(start));
97
+ }
98
+ let transcript = String(payload?.transcript_path ?? "");
99
+ if (!transcript) return false;
100
+ if (transcript.includes(`${sep}subagents${sep}`)) transcript = dirname(dirname(transcript));
101
+ const slug = (p) => p.replace(/[^A-Za-z0-9]/g, "-");
102
+ return [...roots].some((r) => basename(dirname(transcript)) === slug(r));
103
+ }
104
+
105
+ /** The @godxjp/ui repo itself builds primitives from raw elements; the guard is for consumers. */
106
+ function isLibrary(root) {
107
+ try {
108
+ const name = JSON.parse(readFileSync(join(root, "package.json"), "utf8"))?.name;
109
+ return name === "@godxjp/ui" || name === "@godxjp/ui-mcp";
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ export function blockMessage(file, root, keys, started) {
116
+ const at = /^[\w./@-]+$/.test(root) ? root : `'${root.replace(/'/g, "'\\''")}'`;
117
+ const key = keys[0];
118
+ return [
119
+ `godxjp-ui guard: ${file} was NOT written.`,
120
+ `${root} builds its UI with @godxjp/ui, and this agent has not consulted the catalog yet.`,
121
+ started
122
+ ? null
123
+ : `- This session did not start in ${root}, so its .mcp.json (the ${key} MCP), CLAUDE.md and hooks are NOT loaded.`,
124
+ `- Consult the catalog first: call mcp__${key}__search_components, then get_component for each component you use.`,
125
+ ` No mcp__${key}__* tools in this session? Query the same catalog over stdio:`,
126
+ ` cd ${at} && npx --no-install @godxjp/ui mcp-call search_components '{"query":"<what you are building>"}'`,
127
+ ` cd ${at} && npx --no-install @godxjp/ui mcp-call get_component '{"name":"<Component>"}'`,
128
+ ` (\`npx --no-install @godxjp/ui mcp-call\` with no tool lists every tool.)`,
129
+ `- Read ${join(root, "CLAUDE.md")} (the @godxjp/ui section), then retry the write.`,
130
+ ]
131
+ .filter(Boolean)
132
+ .join("\n");
133
+ }
134
+
135
+ function main() {
136
+ const [event, root] = process.argv.slice(2);
137
+ if (!root || (event !== "pre" && event !== "post") || isLibrary(root)) process.exit(0);
138
+ const raw = readFileSync(0, "utf8");
139
+ const payload = JSON.parse(raw);
140
+ const file = payload?.tool_input?.file_path ?? payload?.tool_input?.filePath;
141
+ if (typeof file !== "string" || /\.(test|stories)\.[jt]sx$/.test(file)) process.exit(0);
142
+ const started = startedIn(root, payload);
143
+
144
+ if (event === "post") {
145
+ if (started) process.exit(0); // the repo's own PostToolUse audit hook already ran
146
+ const hook = join(HERE, "audit-hook.mjs");
147
+ if (!existsSync(hook)) process.exit(0);
148
+ const r = spawnSync(process.execPath, [hook], {
149
+ input: raw,
150
+ cwd: root,
151
+ encoding: "utf8",
152
+ timeout: 30_000,
153
+ });
154
+ if (r.error || r.status === null) process.exit(0);
155
+ if (r.stdout) process.stdout.write(r.stdout);
156
+ if (r.stderr) process.stderr.write(r.stderr);
157
+ process.exit(r.status === 2 ? 2 : 0);
158
+ }
159
+
160
+ const transcriptPath = payload.agent_transcript_path ?? payload.transcript_path;
161
+ if (!transcriptPath || !existsSync(transcriptPath)) process.exit(0); // nothing to judge by
162
+ const keys = catalogKeys(root);
163
+ if (consulted(readFileSync(transcriptPath, "utf8"), keys)) process.exit(0);
164
+ process.stderr.write(blockMessage(file, root, keys, started) + "\n");
165
+ process.exit(2); // exit 2 on PreToolUse → Claude Code blocks the call and shows stderr
166
+ }
167
+
168
+ // Run only as a script, so tests can import the helpers.
169
+ if (process.argv[1] && realOrSelf(process.argv[1]) === realOrSelf(fileURLToPath(import.meta.url))) {
170
+ process.on("uncaughtException", () => process.exit(0)); // fail open, see the header
171
+ try {
172
+ main();
173
+ } catch {
174
+ process.exit(0); // fail open
175
+ }
176
+ }
package/scripts/cli.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * @godxjp/ui CLI — `npx @godxjp/ui <command>`.
4
- * init-agent scaffold the agent forcing-kit (MCP + auto-audit hook + mandate)
4
+ * init-agent scaffold the agent forcing-kit (MCP + auto-audit hook + mandate); --user: guard
5
+ * mcp-call query the godx-ui catalog over plain stdio, for a session without the MCP
5
6
  * sync-rules write/refresh package-owned agent rules (--dry-run previews)
6
7
  * audit static UI audit (regex over source)
7
8
  * visual-audit runtime audit (Playwright) against a running app, at six widths
@@ -17,6 +18,7 @@ const MAP = {
17
18
  "init-agent": "init-agent-kit.mjs",
18
19
  audit: "ui-audit.mjs",
19
20
  "audit-hook": "audit-hook.mjs",
21
+ "mcp-call": "mcp-call.mjs",
20
22
  "visual-audit": "visual-audit.mjs",
21
23
  "prune-css": "prune-css.mjs",
22
24
  codemod: "codemod.mjs",
@@ -32,7 +34,18 @@ const HELP = {
32
34
  .claude/settings.json a PostToolUse hook that audits every Write/Edit of a .tsx file
33
35
  .claude/godxjp-ui-workflow.md
34
36
  CLAUDE.md the godxjp-ui mandate block
35
- Idempotent. Refuses inside the @godxjp/ui repo itself. Restart the agent afterwards.`,
37
+ Idempotent. Refuses inside the @godxjp/ui repo itself. Restart the agent afterwards.
38
+
39
+ --user install the USER-level guard instead (gh#1324), for sessions that start outside the
40
+ app: the project files above load only when Claude Code starts in the app, so a
41
+ session started elsewhere has no MCP, no CLAUDE.md and no audit hook. Writes
42
+ ~/.claude/hooks/godxjp-ui-guard.mjs ($CLAUDE_CONFIG_DIR/hooks when set)
43
+ ~/.claude/settings.json PreToolUse + PostToolUse on Write|Edit|MultiEdit
44
+ PreToolUse blocks a .tsx/.jsx write in a repo whose .mcp.json mounts @godxjp/ui-mcp
45
+ until the agent has consulted the catalog (an mcp__<key>__* call, or mcp-call).
46
+ PostToolUse runs that repo's audit hook when the session did not start there.
47
+ Merges into existing settings, idempotent, no network; the guard fails open on its
48
+ own errors. Remove the two entries and the file to uninstall.`,
36
49
  "sync-rules": `godxjp-ui sync-rules
37
50
 
38
51
  Refresh the package-owned agent files for the installed @godxjp/ui version: the godx-ui entry in
@@ -60,6 +73,14 @@ const HELP = {
60
73
  --rules print the rule catalog as JSON and exit
61
74
  --consumer inside the @godxjp/ui repo only: apply the consumer rules (always on in an app)
62
75
  Pre-commit: npx godxjp-ui audit resources/js || exit 1`,
76
+ "mcp-call": `godxjp-ui mcp-call [tool] ['{"arg":"value"}']
77
+
78
+ Query the godx-ui catalog without an MCP client — for a session that did not load the app's
79
+ .mcp.json. Launches the app's own .mcp.json catalog entry (the pinned @godxjp/ui-mcp, its env)
80
+ with \`call <tool> <json>\` appended, so the text is what the MCP tool returns. No tool: list them.
81
+ npx --no-install @godxjp/ui mcp-call search_components '{"query":"settings page"}'
82
+ npx --no-install @godxjp/ui mcp-call get_component '{"name":"Segmented"}'
83
+ Network: as the MCP itself — npx may fetch the pinned server once on a cold cache.`,
63
84
  "audit-hook": `godxjp-ui audit-hook
64
85
 
65
86
  The Claude Code PostToolUse hook init-agent wires in: reads the hook payload on stdin and audits
@@ -114,6 +135,8 @@ const USAGE = `usage: godxjp-ui <command> [args]
114
135
 
115
136
  commands:
116
137
  init-agent install the agent forcing-kit (MCP + auto-audit hook + CLAUDE.md mandate)
138
+ --user: the user-level guard for sessions started outside the app
139
+ mcp-call query the godx-ui catalog over plain stdio (no MCP client needed)
117
140
  sync-rules write/refresh package-owned agent rules (--dry-run previews)
118
141
  audit static UI audit over source
119
142
  audit-hook the per-edit audit hook (wired by init-agent)