jig-ui 0.3.0 → 0.5.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/dist/index.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { Command } from "commander";
5
- import { readFileSync as readFileSync12 } from "fs";
5
+ import { readFileSync as readFileSync17 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join14 } from "path";
7
+ import { join as join22 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -37,12 +37,16 @@ function assetRoot(startDir = getPackageRoot()) {
37
37
  current = dirname(current);
38
38
  }
39
39
  }
40
+ function isPublishedBuild(packageRoot2) {
41
+ return packageRoot2.split(/[\\/]/).includes("node_modules");
42
+ }
40
43
 
41
44
  // src/commands/install.ts
42
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync, writeFileSync as writeFileSync2 } from "fs";
43
- import { dirname as dirname3, join as join3 } from "path";
45
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
46
+ import { join as join7 } from "path";
44
47
 
45
48
  // src/adapters/types.ts
49
+ var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, review the UI against the rules, or refresh the install.";
46
50
  var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
47
51
  function assertSafeRelPath(relPath, adapterName) {
48
52
  const isAbsolute = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
@@ -56,92 +60,172 @@ function quoteYamlString(value) {
56
60
  }
57
61
  var BLOCK_START = "<!-- jig:start -->";
58
62
  var BLOCK_END = "<!-- jig:end -->";
59
- function agentsBlock(skillBody) {
60
- return [BLOCK_START, "", "# Jig \u2014 UI rules", "", skillBody, "", BLOCK_END, ""].join("\n");
63
+ function agentsPointer(skillPath) {
64
+ return [
65
+ BLOCK_START,
66
+ "",
67
+ "# Jig \u2014 UI rules",
68
+ "",
69
+ `This project uses Jig, a design system for generating and reviewing UI.`,
70
+ `Before building or reviewing any interface, read \`${skillPath}\` and follow it.`,
71
+ "It is short, and it tells you which rule files to load for the task at hand.",
72
+ "",
73
+ BLOCK_END,
74
+ ""
75
+ ].join("\n");
61
76
  }
62
77
 
63
- // src/adapters/claude.ts
64
- var claude = {
65
- name: "claude",
66
- displayName: "Claude Code",
67
- supportsScope: () => true,
68
- skillFiles(ctx) {
69
- const content = [
70
- "---",
71
- "name: jig",
72
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
73
- "user-invocable: true",
74
- "---",
75
- "",
76
- ctx.skillBody,
77
- ""
78
- ].join("\n");
79
- return [{ relPath: ".claude/skills/jig/SKILL.md", content }];
80
- }
81
- };
82
-
83
78
  // src/adapters/codex.ts
84
79
  var codex = {
85
80
  name: "codex",
86
81
  displayName: "Codex",
87
- supportsScope: () => true,
88
82
  skillFiles(ctx) {
89
- const relPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
90
- return [{ relPath, content: agentsBlock(ctx.skillBody) }];
91
- }
92
- };
93
-
94
- // src/adapters/cursor.ts
95
- var cursor = {
96
- name: "cursor",
97
- displayName: "Cursor",
98
- supportsScope: (scope) => scope === "project",
99
- skillFiles(ctx) {
100
- const content = [
101
- "---",
102
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
103
- "alwaysApply: false",
104
- "---",
105
- "",
106
- ctx.skillBody,
107
- ""
108
- ].join("\n");
109
- return [{ relPath: ".cursor/rules/jig.mdc", content }];
110
- }
111
- };
112
-
113
- // src/adapters/opencode.ts
114
- var opencode = {
115
- name: "opencode",
116
- displayName: "opencode",
117
- supportsScope: () => true,
118
- skillFiles(ctx) {
119
- const content = [
120
- "---",
121
- "name: jig",
122
- `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
123
- "---",
124
- "",
125
- ctx.skillBody,
126
- ""
127
- ].join("\n");
128
- const relPath = ctx.scope === "global" ? ".config/opencode/skills/jig/SKILL.md" : ".opencode/skills/jig/SKILL.md";
129
- return [{ relPath, content }];
130
- }
83
+ const skillPath = ".agents/skills/jig/SKILL.md";
84
+ const files = [{ relPath: skillPath, content: ctx.skillBody }];
85
+ const agentsPath = ctx.scope === "global" ? ".codex/AGENTS.md" : "AGENTS.md";
86
+ files.push({ relPath: agentsPath, content: agentsPointer(skillPath) });
87
+ if (ctx.commandBody && ctx.scope === "global") {
88
+ files.push({ relPath: ".codex/prompts/jig.md", content: `${ctx.commandBody}
89
+ ` });
90
+ }
91
+ return files;
92
+ },
93
+ /**
94
+ * codex has no skill directory: AGENTS.md is a plain file (project root, or
95
+ * `.codex/` for a global install). Reference material lives under `.codex/`
96
+ * at BOTH scopes.
97
+ *
98
+ * Project scope used to be a bare `.jig`, which put Jig's rules, index,
99
+ * licence and manifest into the one directory 0.4.0 reserves for the
100
+ * project's own material — the brand file, the mode copies, `state.json`.
101
+ * That cost more than tidiness:
102
+ *
103
+ * - `detectLegacyRules` scans `.jig/` for install artifacts and offers to
104
+ * remove them. Under the old layout a *live* codex install looked exactly
105
+ * like the legacy one, so the migration path could offer to delete the
106
+ * install just made.
107
+ * - One directory holding two manifests is what let a stale
108
+ * `.jig/manifest.json` hijack `update` and resurrect the vendored layout.
109
+ *
110
+ * Mirroring the global path keeps codex's own convention and leaves `.jig/`
111
+ * unambiguously the project's.
112
+ */
113
+ /**
114
+ * Beside the skill, like every other harness — the rules are what `SKILL.md`
115
+ * tells the agent to read, so they belong next to it. This used to be
116
+ * `.codex/.jig`, and before that a bare `.jig`, which put Jig's property in
117
+ * the one directory the project owns.
118
+ */
119
+ referenceDir: () => ".agents/skills/jig",
120
+ argsPlaceholder: "$ARGUMENTS"
131
121
  };
132
122
 
133
- // src/adapters/generic.ts
134
- var generic = {
135
- name: "generic",
136
- displayName: "Generic (AGENTS.md)",
137
- supportsScope: (scope) => scope === "project",
138
- skillFiles(ctx) {
139
- return [{ relPath: "AGENTS.md", content: agentsBlock(ctx.skillBody) }];
123
+ // src/adapters/skill-dir.ts
124
+ var SKILL_DIR_HARNESSES = [
125
+ {
126
+ name: "claude",
127
+ dir: ".claude",
128
+ displayName: "Claude Code",
129
+ extraFrontmatter: ["user-invocable: true"],
130
+ // `commands/<name>.md`, YAML frontmatter, `$ARGUMENTS` in the body —
131
+ // verified against the shipped command files under
132
+ // ~/.claude/plugins/marketplaces/*/plugins/*/commands/.
133
+ commands: { dir: "commands", format: "md" }
134
+ },
135
+ { name: "cursor", dir: ".cursor", displayName: "Cursor", commands: { dir: "commands", format: "md" } },
136
+ {
137
+ name: "opencode",
138
+ dir: ".opencode",
139
+ globalDir: ".config/opencode",
140
+ displayName: "opencode",
141
+ // Singular `command/`, unlike everyone else's `commands/`.
142
+ commands: { dir: "command", format: "md" }
143
+ },
144
+ // No slash-command mechanism of its own — `.agents` is a skills convention
145
+ // rather than a harness, so there is nothing to write a command file for.
146
+ { name: "generic", dir: ".agents", displayName: "Generic (.agents/skills)" },
147
+ // Proves the table design: this row is the entire diff needed to support
148
+ // a sixth harness. Gemini CLI, Copilot CLI and others read `.agents/skills`
149
+ // too, but Gemini also has its own `.gemini` skill directory.
150
+ {
151
+ name: "gemini",
152
+ dir: ".gemini",
153
+ displayName: "Gemini CLI",
154
+ // Gemini reads TOML, with `{{args}}` where the others use `$ARGUMENTS`.
155
+ commands: { dir: "commands", format: "toml" }
140
156
  }
141
- };
157
+ ];
158
+ function dirFor(h, scope) {
159
+ return scope === "global" && h.globalDir ? h.globalDir : h.dir;
160
+ }
161
+ function skillDirAdapter(h) {
162
+ return {
163
+ name: h.name,
164
+ displayName: h.displayName,
165
+ skillFiles(ctx) {
166
+ const content = [
167
+ "---",
168
+ "name: jig",
169
+ `description: ${quoteYamlString(SKILL_DESCRIPTION)}`,
170
+ ...h.extraFrontmatter ?? [],
171
+ "---",
172
+ "",
173
+ ctx.skillBody,
174
+ ""
175
+ ].join("\n");
176
+ const files = [
177
+ { relPath: `${dirFor(h, ctx.scope)}/skills/jig/SKILL.md`, content }
178
+ ];
179
+ if (h.commands && ctx.commandBody) {
180
+ files.push({
181
+ relPath: `${dirFor(h, ctx.scope)}/${h.commands.dir}/jig.${h.commands.format}`,
182
+ content: h.commands.format === "toml" ? tomlCommand(ctx.commandBody) : markdownCommand(ctx.commandBody, ctx.subcommands ?? [])
183
+ });
184
+ }
185
+ return files;
186
+ },
187
+ referenceDir(scope) {
188
+ return `${dirFor(h, scope)}/skills/jig`;
189
+ },
190
+ argsPlaceholder: h.commands?.format === "toml" ? "{{args}}" : "$ARGUMENTS"
191
+ };
192
+ }
193
+ function markdownCommand(body, subcommands) {
194
+ return [
195
+ "---",
196
+ `description: ${quoteYamlString(COMMAND_DESCRIPTION)}`,
197
+ `argument-hint: ${subcommands.join("|")} [flags]`,
198
+ "---",
199
+ "",
200
+ body,
201
+ ""
202
+ ].join("\n");
203
+ }
204
+ var TOML_DELIM = "'''";
205
+ function tomlCommand(body) {
206
+ const safe = body.split(TOML_DELIM).join('"""');
207
+ return [
208
+ `description = ${JSON.stringify(COMMAND_DESCRIPTION)}`,
209
+ `prompt = ${TOML_DELIM}`,
210
+ safe,
211
+ TOML_DELIM,
212
+ ""
213
+ ].join("\n");
214
+ }
215
+ var SKILL_DIR_ADAPTERS = SKILL_DIR_HARNESSES.map(skillDirAdapter);
142
216
 
143
217
  // src/adapters/registry.ts
144
- var ADAPTERS = [claude, codex, cursor, opencode, generic];
218
+ var ADAPTERS = [codex, ...SKILL_DIR_ADAPTERS];
219
+ function referenceDirFor(adapter, scope) {
220
+ const dir = adapter.referenceDir(scope);
221
+ assertSafeRelPath(dir, adapter.name);
222
+ return dir;
223
+ }
224
+ for (const adapter of ADAPTERS) {
225
+ for (const scope of ["project", "global"]) {
226
+ assertSafeRelPath(adapter.referenceDir(scope), adapter.name);
227
+ }
228
+ }
145
229
  function adapterNames() {
146
230
  return ADAPTERS.map((a) => a.name);
147
231
  }
@@ -160,12 +244,59 @@ function skillFilesFor(adapter, ctx) {
160
244
  return files;
161
245
  }
162
246
 
247
+ // src/install/references.ts
248
+ import { existsSync as existsSync2, readFileSync, readdirSync } from "fs";
249
+ import { join as join2, posix } from "path";
250
+ function referenceFiles(packageRoot2) {
251
+ const root = join2(packageRoot2, "references");
252
+ if (!existsSync2(root)) return [];
253
+ const found = [];
254
+ const walk2 = (dir, prefix) => {
255
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort(
256
+ (a, b) => a.name.localeCompare(b.name)
257
+ )) {
258
+ const child = join2(dir, entry.name);
259
+ const rel = prefix ? posix.join(prefix, entry.name) : entry.name;
260
+ if (entry.isDirectory()) {
261
+ walk2(child, rel);
262
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
263
+ found.push({ relPath: rel, content: readFileSync(child, "utf8") });
264
+ }
265
+ }
266
+ };
267
+ walk2(root, "");
268
+ return found;
269
+ }
270
+
163
271
  // src/install/manifest.ts
164
272
  import { createHash } from "crypto";
165
- import { existsSync as existsSync2, mkdirSync, readFileSync, writeFileSync } from "fs";
166
- import { dirname as dirname2, join as join2 } from "path";
167
- var MANIFEST_REL = join2(".jig", "manifest.json");
273
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "fs";
274
+ import { dirname as dirname3, join as join4 } from "path";
275
+
276
+ // src/install/atomic.ts
277
+ import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "fs";
278
+ import { dirname as dirname2, join as join3 } from "path";
279
+ function writeFileAtomic(path, content) {
280
+ const dir = dirname2(path);
281
+ mkdirSync(dir, { recursive: true });
282
+ const tmp = join3(dir, `.${process.pid}-${Date.now()}.tmp`);
283
+ try {
284
+ writeFileSync(tmp, content, "utf8");
285
+ renameSync(tmp, path);
286
+ } catch (err) {
287
+ try {
288
+ unlinkSync(tmp);
289
+ } catch {
290
+ }
291
+ throw err;
292
+ }
293
+ }
294
+
295
+ // src/install/manifest.ts
168
296
  var REGENERATE_HINT = "Re-run 'jig install --agent <name>' to fix it.";
297
+ function manifestPath(root, manifestDir) {
298
+ return join4(root, ...manifestDir.split("/"), "manifest.json");
299
+ }
169
300
  function checksum(content) {
170
301
  const normalized = content.replace(/\r\n/g, "\n");
171
302
  return `sha256:${createHash("sha256").update(normalized, "utf8").digest("hex")}`;
@@ -200,29 +331,45 @@ function validateManifest(raw, path) {
200
331
  files
201
332
  };
202
333
  }
203
- function readManifest(projectRoot) {
204
- const path = join2(projectRoot, MANIFEST_REL);
205
- if (!existsSync2(path)) return null;
334
+ function readManifest(root, manifestDir = ".jig") {
335
+ const path = manifestPath(root, manifestDir);
336
+ if (!existsSync3(path)) return null;
206
337
  let raw;
207
338
  try {
208
- raw = JSON.parse(readFileSync(path, "utf8"));
339
+ raw = JSON.parse(readFileSync2(path, "utf8"));
209
340
  } catch {
210
341
  throw new Error(`Jig's manifest at ${path} is not valid JSON. ${REGENERATE_HINT}`);
211
342
  }
212
343
  return validateManifest(raw, path);
213
344
  }
214
- function writeManifest(projectRoot, m) {
215
- const path = join2(projectRoot, MANIFEST_REL);
216
- mkdirSync(dirname2(path), { recursive: true });
217
- writeFileSync(path, `${JSON.stringify(m, null, 2)}
218
- `, "utf8");
345
+ var MERGE_ATTEMPTS = 5;
346
+ function writeManifest(root, m, manifestDir = ".jig") {
347
+ const path = manifestPath(root, manifestDir);
348
+ mkdirSync2(dirname3(path), { recursive: true });
349
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
350
+ const onDisk = readManifestQuietly(path);
351
+ const merged = { ...m, files: { ...onDisk?.files ?? {}, ...m.files } };
352
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
353
+ `);
354
+ const after = readManifestQuietly(path);
355
+ if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
356
+ }
357
+ }
358
+ function readManifestQuietly(path) {
359
+ try {
360
+ const parsed = JSON.parse(readFileSync2(path, "utf8"));
361
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.files)) return null;
362
+ return parsed;
363
+ } catch {
364
+ return null;
365
+ }
219
366
  }
220
367
  function isModified(projectRoot, relPath, m) {
221
368
  const recorded = m.files[relPath];
222
369
  if (!recorded) return false;
223
- const abs = join2(projectRoot, relPath);
224
- if (!existsSync2(abs)) return false;
225
- return checksum(readFileSync(abs, "utf8")) !== recorded;
370
+ const abs = join4(projectRoot, relPath);
371
+ if (!existsSync3(abs)) return false;
372
+ return checksum(readFileSync2(abs, "utf8")) !== recorded;
226
373
  }
227
374
 
228
375
  // src/template/render.ts
@@ -250,15 +397,22 @@ function renderCommandTable(metadata) {
250
397
  }
251
398
 
252
399
  // src/install/vendor.ts
400
+ import { posix as posix2 } from "path";
401
+ var { join: join5 } = posix2;
253
402
  var DELIMITERS = {
254
403
  html: ["<!--", "-->"],
255
404
  css: ["/*", "*/"]
256
405
  };
257
- function vendorHeader(file, version2, style = "html") {
406
+ function licencePathFor(relPath) {
407
+ const depth = relPath.split("/").length - 1;
408
+ return depth === 0 ? "." : Array(depth).fill("..").join("/");
409
+ }
410
+ function vendorHeader(file, version2, style = "html", licencePath = ".") {
258
411
  const [open, close] = DELIMITERS[style];
412
+ const attribution = licencePath === null ? " Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill." : ` Licensed Apache-2.0. See ${join5(licencePath, "LICENSE")} and ${join5(licencePath, "NOTICE")}.`;
259
413
  return [
260
414
  `${open} ${file} \u2014 vendored from Jig v${version2}.`,
261
- " Licensed Apache-2.0. See .jig/LICENSE and .jig/NOTICE.",
415
+ attribution,
262
416
  ` Edit freely: \`jig update\` will not overwrite a file you have changed. ${close}`,
263
417
  "",
264
418
  ""
@@ -301,16 +455,16 @@ function stripMarkerRegions(existing) {
301
455
  return { text: existing, insertAt: existing.length, removed: false };
302
456
  }
303
457
  let text = "";
304
- let cursor2 = 0;
458
+ let cursor = 0;
305
459
  let insertAt = -1;
306
460
  regions.forEach((region, i) => {
307
- let chunk = existing.slice(cursor2, region.start);
461
+ let chunk = existing.slice(cursor, region.start);
308
462
  if (i > 0) chunk = chunk.replace(/^\n/, "");
309
463
  text += chunk;
310
464
  if (insertAt === -1) insertAt = text.length;
311
- cursor2 = region.end;
465
+ cursor = region.end;
312
466
  });
313
- const tail = existing.slice(cursor2).replace(/^\n/, "");
467
+ const tail = existing.slice(cursor).replace(/^\n/, "");
314
468
  text += tail;
315
469
  return { text, insertAt, removed: true };
316
470
  }
@@ -323,85 +477,161 @@ function upsertBlock(existing, block) {
323
477
  return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
324
478
  }
325
479
 
326
- // src/commands/install.ts
327
- var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
480
+ // src/install/writer.ts
481
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
482
+ import { dirname as dirname4, join as join6 } from "path";
483
+
484
+ // src/install/line-endings.ts
485
+ function dominantEnding(existing) {
486
+ const crlf = (existing.match(/\r\n/g) ?? []).length;
487
+ const lf = (existing.match(/(?<!\r)\n/g) ?? []).length;
488
+ if (crlf === 0 && lf === 0) return "\n";
489
+ return crlf >= lf ? "\r\n" : "\n";
490
+ }
491
+ function matchLineEndings(content, existing) {
492
+ const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
493
+ const target = existing === void 0 ? "\n" : dominantEnding(existing);
494
+ return target === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
495
+ }
496
+
497
+ // src/install/writer.ts
328
498
  function relKey(...parts) {
329
499
  return parts.join("/");
330
500
  }
331
- function writeFile(root, key, content, files) {
332
- const abs = join3(root, ...key.split("/"));
333
- mkdirSync2(dirname3(abs), { recursive: true });
334
- writeFileSync2(abs, content, "utf8");
335
- files[key] = checksum(content);
501
+ function createWriter(root, seed = {}) {
502
+ const files = { ...seed };
503
+ return {
504
+ files,
505
+ write(key, content) {
506
+ const abs = join6(root, ...key.split("/"));
507
+ mkdirSync3(dirname4(abs), { recursive: true });
508
+ const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
509
+ const toWrite = matchLineEndings(content, existing);
510
+ writeFileAtomic(abs, toWrite);
511
+ files[key] = checksum(toWrite);
512
+ return key;
513
+ }
514
+ };
515
+ }
516
+ function bundleFiles(packageRoot2, dir) {
517
+ const ext = dir === "rules" ? ".md" : ".css";
518
+ return readdirSync2(join6(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
336
519
  }
337
- function buildSkillBody(packageRoot2, scope) {
338
- const template = readFileSync2(join3(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
520
+
521
+ // src/commands/install.ts
522
+ var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
523
+ function buildSkillBody(packageRoot2, rulesPath, version2) {
524
+ const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
339
525
  const metadata = JSON.parse(
340
- readFileSync2(join3(packageRoot2, "templates", "command-metadata.json"), "utf8")
526
+ readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
341
527
  );
342
528
  return render(template, {
343
529
  command_prefix: "/jig ",
344
- scripts_path: "npx jig-ui",
530
+ scripts_path: version2 ? `npx jig-ui@${version2}` : "npx jig-ui",
531
+ // `update` must NOT carry the pin. Pinned, it refreshes to the version
532
+ // already installed — reporting "Updated Jig → <same version>" for a no-op
533
+ // — so a reader following the skill could never upgrade.
534
+ update_path: "npx jig-ui@latest",
345
535
  ask_instruction: ASK_INSTRUCTION,
346
536
  available_commands: renderCommandTable(metadata),
347
537
  config_file: "jig.config.json",
348
- rules_path: scope === "global" ? "~/.jig" : ".jig"
538
+ rules_path: rulesPath
349
539
  });
350
540
  }
541
+ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
542
+ const metadata = JSON.parse(
543
+ readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
544
+ );
545
+ const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
546
+ const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
547
+ if (!existsSync5(templatePath)) return { body: "", subcommands };
548
+ const template = readFileSync4(templatePath, "utf8");
549
+ const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath);
550
+ return { body, subcommands };
551
+ }
552
+ function rulesPathFor(referenceDir, scope) {
553
+ return scope === "global" ? `~/${referenceDir}/rules` : `${referenceDir}/rules`;
554
+ }
351
555
  function install(opts) {
352
556
  const adapter = getAdapter(opts.agent);
353
- if (!adapter.supportsScope(opts.scope)) {
354
- throw new Error(`Adapter '${adapter.name}' does not support ${opts.scope} scope.`);
557
+ if (opts.scope === "project") {
558
+ const globalReferenceDir = referenceDirFor(adapter, "global");
559
+ let globalManifest;
560
+ try {
561
+ globalManifest = readManifest(opts.homeDir, globalReferenceDir);
562
+ } catch {
563
+ globalManifest = null;
564
+ }
565
+ if (globalManifest) {
566
+ return {
567
+ written: [],
568
+ skipped: [],
569
+ warning: `Jig is already installed globally for '${opts.agent}' (${globalReferenceDir} under your home directory). Installing again at project scope would leave two contradicting '${opts.agent}' skills. Run 'jig update' to refresh the global install instead, or choose a different --agent for this project.`
570
+ };
571
+ }
355
572
  }
356
573
  const installRoot = opts.scope === "global" ? opts.homeDir : opts.projectRoot;
574
+ const referenceDir = referenceDirFor(adapter, opts.scope);
357
575
  let existingManifest;
358
576
  try {
359
- existingManifest = readManifest(installRoot);
577
+ existingManifest = readManifest(installRoot, referenceDir);
360
578
  } catch {
361
579
  existingManifest = null;
362
580
  }
363
581
  const planned = [];
364
- const rulesDir = join3(opts.packageRoot, "rules");
365
- for (const file of readdirSync(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
366
- const body = readFileSync2(join3(rulesDir, file), "utf8");
367
- planned.push({ key: relKey(".jig", file), content: vendorHeader(file, opts.version) + body, checkable: true });
368
- }
369
- const tokensDir = join3(opts.packageRoot, "tokens");
370
- for (const file of readdirSync(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
371
- const body = readFileSync2(join3(tokensDir, file), "utf8");
582
+ const rulesDir = join7(opts.packageRoot, "rules");
583
+ for (const file of bundleFiles(opts.packageRoot, "rules")) {
584
+ const body = readFileSync4(join7(rulesDir, file), "utf8");
372
585
  planned.push({
373
- key: relKey(".jig", "tokens", file),
374
- content: vendorHeader(file, opts.version, "css") + body,
586
+ key: relKey(referenceDir, "rules", file),
587
+ content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
588
+ checkable: true
589
+ });
590
+ }
591
+ for (const ref of referenceFiles(opts.packageRoot)) {
592
+ planned.push({
593
+ key: relKey(referenceDir, ...ref.relPath.split("/")),
594
+ content: vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content,
375
595
  checkable: true
376
596
  });
377
597
  }
378
598
  planned.push({
379
- key: relKey(".jig", "rules.index.json"),
380
- content: readFileSync2(join3(opts.packageRoot, "rules.index.json"), "utf8"),
599
+ key: relKey(referenceDir, "rules.index.json"),
600
+ content: readFileSync4(join7(opts.packageRoot, "rules.index.json"), "utf8"),
381
601
  checkable: true
382
602
  });
383
603
  for (const src of ["LICENSE", "NOTICE"]) {
384
604
  planned.push({
385
- key: relKey(".jig", src),
386
- content: readFileSync2(join3(opts.packageRoot, src), "utf8"),
605
+ key: relKey(referenceDir, src),
606
+ content: readFileSync4(join7(opts.packageRoot, src), "utf8"),
387
607
  checkable: false
388
608
  });
389
609
  }
390
- const skillBody = buildSkillBody(opts.packageRoot, opts.scope);
610
+ const rulesPath = rulesPathFor(referenceDir, opts.scope);
611
+ const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
612
+ const { body: commandBody, subcommands } = buildCommandBody(
613
+ opts.packageRoot,
614
+ rulesPath,
615
+ opts.version,
616
+ adapter.argsPlaceholder ?? "$ARGUMENTS"
617
+ );
618
+ const command = { commandBody, subcommands };
391
619
  const skillFiles = skillFilesFor(adapter, {
392
620
  version: opts.version,
393
621
  scope: opts.scope,
394
622
  skillBody,
395
- commandPrefix: "/jig "
623
+ commandPrefix: "/jig ",
624
+ ...command
396
625
  });
397
626
  for (const file of skillFiles) {
398
627
  const key = file.relPath;
399
- const abs = join3(installRoot, ...key.split("/"));
628
+ const abs = join7(installRoot, ...key.split("/"));
400
629
  const isBlockFile = file.content.includes(BLOCK_START);
401
- const content = isBlockFile && existsSync3(abs) ? upsertBlock(readFileSync2(abs, "utf8"), file.content) : file.content;
630
+ const content = isBlockFile && existsSync5(abs) ? upsertBlock(readFileSync4(abs, "utf8"), file.content) : file.content;
402
631
  planned.push({ key, content, checkable: !isBlockFile });
403
632
  }
404
- const files = {};
633
+ const writer = createWriter(installRoot);
634
+ const files = writer.files;
405
635
  const written = [];
406
636
  const skipped = [];
407
637
  for (const { key, content, checkable } of planned) {
@@ -411,7 +641,7 @@ function install(opts) {
411
641
  if (recorded) files[key] = recorded;
412
642
  continue;
413
643
  }
414
- writeFile(installRoot, key, content, files);
644
+ writer.write(key, content);
415
645
  written.push(key);
416
646
  }
417
647
  const manifest = {
@@ -421,118 +651,291 @@ function install(opts) {
421
651
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
422
652
  files
423
653
  };
424
- writeManifest(installRoot, manifest);
425
- written.push(relKey(".jig", "manifest.json"));
654
+ writeManifest(installRoot, manifest, referenceDir);
655
+ written.push(relKey(referenceDir, "manifest.json"));
426
656
  return { written, skipped };
427
657
  }
428
658
 
429
659
  // src/commands/update.ts
430
- import { basename, dirname as dirname5, join as join5 } from "path";
431
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, readdirSync as readdirSync2, writeFileSync as writeFileSync4 } from "fs";
660
+ import { join as join10 } from "path";
661
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
432
662
 
433
663
  // src/install/target.ts
434
664
  import { resolve } from "path";
435
- function resolveInstalled(projectRoot, homeDir) {
436
- const projectManifest = readManifest(projectRoot);
437
- const existing = projectManifest ?? readManifest(homeDir);
438
- if (!existing) return null;
665
+ function resolveAllInstalled(projectRoot, homeDir) {
666
+ const found = [];
667
+ const seen = /* @__PURE__ */ new Set();
439
668
  const sameRoot = resolve(projectRoot) === resolve(homeDir);
440
- const scope = !projectManifest ? "global" : sameRoot ? existing.scope : "project";
441
- const installRoot = scope === "global" ? homeDir : projectRoot;
442
- return { installRoot, scope, manifest: existing };
669
+ for (const adapter of ADAPTERS) {
670
+ const referenceDir = referenceDirFor(adapter, "project");
671
+ const manifest = readManifest(projectRoot, referenceDir);
672
+ if (manifest && manifest.agent !== adapter.name) {
673
+ getAdapter(manifest.agent);
674
+ }
675
+ if (manifest && manifest.agent === adapter.name) {
676
+ const collides = sameRoot && referenceDirFor(adapter, "global") === referenceDir;
677
+ const scope = collides ? manifest.scope : "project";
678
+ const installRoot = scope === "global" ? homeDir : projectRoot;
679
+ const key = `${resolve(installRoot)}\0${referenceDir}`;
680
+ if (!seen.has(key)) {
681
+ seen.add(key);
682
+ found.push({ installRoot, scope, manifest, referenceDir });
683
+ }
684
+ }
685
+ }
686
+ for (const adapter of ADAPTERS) {
687
+ const referenceDir = referenceDirFor(adapter, "global");
688
+ const manifest = readManifest(homeDir, referenceDir);
689
+ if (manifest && manifest.agent !== adapter.name) {
690
+ getAdapter(manifest.agent);
691
+ }
692
+ if (manifest && manifest.agent === adapter.name) {
693
+ const key = `${resolve(homeDir)}\0${referenceDir}`;
694
+ if (!seen.has(key)) {
695
+ seen.add(key);
696
+ found.push({ installRoot: homeDir, scope: "global", manifest, referenceDir });
697
+ }
698
+ }
699
+ }
700
+ return found;
443
701
  }
444
702
 
445
703
  // src/init/state.ts
446
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
447
- import { dirname as dirname4, join as join4 } from "path";
448
- var INIT_MANIFEST_REL = join4(".jig", "init-manifest.json");
704
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5 } from "fs";
705
+ import { dirname as dirname5, join as join8 } from "path";
706
+ var INIT_MANIFEST_REL = join8(".jig", "state.json");
449
707
  function readInitManifest(projectRoot) {
450
- const path = join4(projectRoot, INIT_MANIFEST_REL);
451
- if (!existsSync4(path)) return null;
708
+ const path = join8(projectRoot, INIT_MANIFEST_REL);
709
+ if (!existsSync6(path)) return null;
452
710
  try {
453
- const raw = JSON.parse(readFileSync3(path, "utf8"));
711
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
454
712
  if (typeof raw !== "object" || raw === null) return null;
455
- const files = raw.files;
713
+ const { files, version: version2, modes } = raw;
456
714
  if (typeof files !== "object" || files === null) return null;
457
- return { files };
715
+ return {
716
+ files,
717
+ version: typeof version2 === "string" ? version2 : void 0,
718
+ modes: Array.isArray(modes) && modes.every((m) => typeof m === "string") ? modes : void 0
719
+ };
458
720
  } catch {
459
721
  return null;
460
722
  }
461
723
  }
724
+ var MERGE_ATTEMPTS2 = 5;
462
725
  function writeInitManifest(projectRoot, m) {
463
- const path = join4(projectRoot, INIT_MANIFEST_REL);
464
- mkdirSync3(dirname4(path), { recursive: true });
465
- writeFileSync3(path, `${JSON.stringify(m, null, 2)}
466
- `, "utf8");
726
+ const path = join8(projectRoot, INIT_MANIFEST_REL);
727
+ mkdirSync4(dirname5(path), { recursive: true });
728
+ for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
729
+ const onDisk = readInitManifest(projectRoot);
730
+ const merged = {
731
+ ...m,
732
+ modes: [.../* @__PURE__ */ new Set([...onDisk?.modes ?? [], ...m.modes ?? []])],
733
+ files: { ...onDisk?.files ?? {}, ...m.files }
734
+ };
735
+ writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
736
+ `);
737
+ const after = readInitManifest(projectRoot);
738
+ if (after && Object.keys(merged.files).every((k) => k in after.files)) return;
739
+ }
467
740
  }
468
741
  function isInitFileModified(projectRoot, relPath, m) {
469
742
  const asManifest = { version: "", agent: "", scope: "project", installedAt: "", files: m.files };
470
743
  return isModified(projectRoot, relPath, asManifest);
471
744
  }
472
745
 
746
+ // src/init/migrate.ts
747
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync } from "fs";
748
+ import { join as join9 } from "path";
749
+ var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
750
+ function isLegacyInstallArtifact(name) {
751
+ return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
752
+ }
753
+ function readLegacyManifest(dirAbs) {
754
+ try {
755
+ const raw = JSON.parse(readFileSync6(join9(dirAbs, "manifest.json"), "utf8"));
756
+ if (raw && typeof raw === "object" && typeof raw.files === "object") {
757
+ return raw;
758
+ }
759
+ } catch {
760
+ }
761
+ return null;
762
+ }
763
+ function classify(projectRoot, relPath, legacyManifest) {
764
+ return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
765
+ }
766
+ function detectLegacyRules(projectRoot) {
767
+ const jigDir = join9(projectRoot, ".jig");
768
+ if (!existsSync7(jigDir)) return { present: false, files: [] };
769
+ const legacyManifest = readLegacyManifest(jigDir);
770
+ let entries;
771
+ try {
772
+ entries = readdirSync3(jigDir);
773
+ } catch {
774
+ entries = [];
775
+ }
776
+ const files = entries.filter(isLegacyInstallArtifact).sort().map((name) => {
777
+ const relPath = `.jig/${name}`;
778
+ return { relPath, modified: classify(projectRoot, relPath, legacyManifest) };
779
+ });
780
+ return { present: files.length > 0, files };
781
+ }
782
+ function detectLegacyCursorRules(projectRoot) {
783
+ const bundleDirRel = ".cursor/rules/jig";
784
+ const bundleDir = join9(projectRoot, ...bundleDirRel.split("/"));
785
+ const mdcRel = ".cursor/rules/jig.mdc";
786
+ const mdcAbs = join9(projectRoot, ...mdcRel.split("/"));
787
+ const legacyManifest = readLegacyManifest(bundleDir);
788
+ const files = [];
789
+ if (existsSync7(mdcAbs)) {
790
+ files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
791
+ }
792
+ let entries;
793
+ try {
794
+ entries = readdirSync3(bundleDir);
795
+ } catch {
796
+ entries = [];
797
+ }
798
+ for (const name of entries.filter(isLegacyInstallArtifact).sort()) {
799
+ const relPath = `${bundleDirRel}/${name}`;
800
+ files.push({ relPath, modified: classify(projectRoot, relPath, legacyManifest) });
801
+ }
802
+ return { present: files.length > 0, files };
803
+ }
804
+ function describeLegacyFiles(report, headerLine, subheaderLine) {
805
+ if (!report.present) return [];
806
+ const lines = [];
807
+ lines.push(`
808
+ ${headerLine}`);
809
+ lines.push(subheaderLine);
810
+ for (const f of report.files) lines.push(` ${f.relPath}`);
811
+ const edited = report.files.filter((f) => f.modified === true).map((f) => f.relPath);
812
+ const unknown = report.files.filter((f) => f.modified === "unknown").map((f) => f.relPath);
813
+ if (edited.length) {
814
+ lines.push(" Edited since install \u2014 kept, never removed automatically:");
815
+ for (const relPath of edited) lines.push(` ${relPath}`);
816
+ }
817
+ if (unknown.length) {
818
+ lines.push(" No install record to verify against \u2014 kept; remove manually if you are sure they are untouched:");
819
+ for (const relPath of unknown) lines.push(` ${relPath}`);
820
+ }
821
+ return lines;
822
+ }
823
+ function describeLegacyReport(report) {
824
+ return describeLegacyFiles(
825
+ report,
826
+ `Found a pre-0.4.0 Jig install in .jig/ (${report.files.length} file(s)). These are no longer used \u2014`,
827
+ " since 0.4.0 the skill and its rules live in your agent's skill directory, not the project:"
828
+ );
829
+ }
830
+ function describeLegacyCursorReport(report) {
831
+ return describeLegacyFiles(
832
+ report,
833
+ `Found a legacy Cursor install (${report.files.length} file(s)). Cursor's skill has moved \u2014`,
834
+ " from .cursor/rules/jig.mdc to .cursor/skills/jig/SKILL.md; these are no longer read:"
835
+ );
836
+ }
837
+ function removableLegacyFiles(report) {
838
+ return report.files.filter((f) => f.modified === false).map((f) => f.relPath);
839
+ }
840
+ function removeLegacyFiles(projectRoot, relPaths) {
841
+ for (const relPath of relPaths) {
842
+ try {
843
+ rmSync(join9(projectRoot, ...relPath.split("/")), { force: true });
844
+ } catch {
845
+ }
846
+ }
847
+ }
848
+
473
849
  // src/commands/update.ts
474
- var ALWAYS_REPLACE = [relKey(".jig", "LICENSE"), relKey(".jig", "NOTICE")];
475
850
  function update(opts) {
476
- const resolved = resolveInstalled(opts.projectRoot, opts.homeDir);
477
- if (!resolved) {
851
+ const resolved = resolveAllInstalled(opts.projectRoot, opts.homeDir);
852
+ if (resolved.length === 0) {
853
+ const legacy = detectLegacyRules(opts.projectRoot);
854
+ const hint = legacy.present ? ` A pre-0.4.0 bundle is still in .jig/ (${legacy.files.length} file(s)); since 0.4.0 the skill and its rules live beside your agent's skill file instead, so that copy is no longer read. Re-run install, then 'jig init' will report the leftovers \u2014 it never removes a file you have edited.` : "";
478
855
  throw new Error(
479
- `Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`
856
+ `Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.${hint}`
480
857
  );
481
858
  }
482
- const { installRoot, scope: discoveredScope, manifest: existing } = resolved;
483
- const adapter = getAdapter(existing.agent);
484
- if (!adapter.supportsScope(discoveredScope)) {
485
- throw new Error(
486
- `Jig's manifest at ${installRoot} records agent '${existing.agent}', which does not support ${discoveredScope} scope. Re-run 'jig install --agent <name>' to fix it.`
487
- );
859
+ const updated = [];
860
+ const skipped = [];
861
+ const targets = [];
862
+ for (const target of resolved) {
863
+ const one = updateTarget(opts, target);
864
+ updated.push(...one.updated);
865
+ skipped.push(...one.skipped);
866
+ targets.push({
867
+ agent: target.manifest.agent,
868
+ scope: target.scope,
869
+ referenceDir: target.referenceDir,
870
+ fromVersion: target.manifest.version
871
+ });
488
872
  }
873
+ const initResult = updateInitFiles(opts);
874
+ updated.push(...initResult.updated);
875
+ skipped.push(...initResult.skipped);
876
+ return {
877
+ updated,
878
+ skipped,
879
+ fromVersion: resolved[0].manifest.version,
880
+ toVersion: opts.version,
881
+ targets
882
+ };
883
+ }
884
+ function updateTarget(opts, resolved) {
885
+ const { installRoot, scope: discoveredScope, manifest: existing, referenceDir } = resolved;
886
+ const adapter = getAdapter(existing.agent);
489
887
  const updated = [];
490
888
  const skipped = [];
491
- const files = { ...existing.files };
889
+ const writer = createWriter(installRoot, existing.files);
890
+ const files = writer.files;
492
891
  const write = (key, content) => {
493
- const abs = join5(installRoot, ...key.split("/"));
494
- mkdirSync4(dirname5(abs), { recursive: true });
495
- writeFileSync4(abs, content, "utf8");
496
- files[key] = checksum(content);
497
- updated.push(key);
892
+ updated.push(writer.write(key, content));
498
893
  };
499
- const rulesDir = join5(opts.packageRoot, "rules");
500
- for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
501
- const key = relKey(".jig", file);
894
+ const rulesDir = join10(opts.packageRoot, "rules");
895
+ for (const file of bundleFiles(opts.packageRoot, "rules")) {
896
+ const key = relKey(referenceDir, "rules", file);
502
897
  if (isModified(installRoot, key, existing)) {
503
898
  skipped.push(key);
504
899
  continue;
505
900
  }
506
- write(key, vendorHeader(file, opts.version) + readFileSync4(join5(rulesDir, file), "utf8"));
901
+ write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync7(join10(rulesDir, file), "utf8"));
507
902
  }
508
- const tokensDir = join5(opts.packageRoot, "tokens");
509
- for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
510
- const key = relKey(".jig", "tokens", file);
903
+ for (const ref of referenceFiles(opts.packageRoot)) {
904
+ const key = relKey(referenceDir, ...ref.relPath.split("/"));
511
905
  if (isModified(installRoot, key, existing)) {
512
906
  skipped.push(key);
513
907
  continue;
514
908
  }
515
- write(key, vendorHeader(file, opts.version, "css") + readFileSync4(join5(tokensDir, file), "utf8"));
909
+ write(key, vendorHeader(ref.relPath, opts.version, "html", licencePathFor(ref.relPath)) + ref.content);
516
910
  }
517
- const indexKey = relKey(".jig", "rules.index.json");
911
+ const indexKey = relKey(referenceDir, "rules.index.json");
518
912
  if (isModified(installRoot, indexKey, existing)) {
519
913
  skipped.push(indexKey);
520
914
  } else {
521
- write(indexKey, readFileSync4(join5(opts.packageRoot, "rules.index.json"), "utf8"));
522
- }
523
- const skillBody = buildSkillBody(opts.packageRoot, discoveredScope);
915
+ write(indexKey, readFileSync7(join10(opts.packageRoot, "rules.index.json"), "utf8"));
916
+ }
917
+ const rulesPath = rulesPathFor(referenceDir, discoveredScope);
918
+ const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
919
+ const { body: commandBody, subcommands } = buildCommandBody(
920
+ opts.packageRoot,
921
+ rulesPath,
922
+ opts.version,
923
+ adapter.argsPlaceholder ?? "$ARGUMENTS"
924
+ );
925
+ const command = { commandBody, subcommands };
524
926
  const skillFiles = skillFilesFor(adapter, {
525
927
  version: opts.version,
526
928
  scope: discoveredScope,
527
929
  skillBody,
528
- commandPrefix: "/jig "
930
+ commandPrefix: "/jig ",
931
+ ...command
529
932
  });
530
933
  for (const file of skillFiles) {
531
934
  const key = file.relPath;
532
935
  const isBlockFile = file.content.includes(BLOCK_START);
533
936
  if (isBlockFile) {
534
- const abs = join5(installRoot, ...key.split("/"));
535
- const current = existsSync5(abs) ? readFileSync4(abs, "utf8") : "";
937
+ const abs = join10(installRoot, ...key.split("/"));
938
+ const current = existsSync8(abs) ? readFileSync7(abs, "utf8") : "";
536
939
  write(key, upsertBlock(current, file.content));
537
940
  continue;
538
941
  }
@@ -542,44 +945,88 @@ function update(opts) {
542
945
  }
543
946
  write(key, file.content);
544
947
  }
545
- for (const key of ALWAYS_REPLACE) {
546
- write(key, readFileSync4(join5(opts.packageRoot, basename(key)), "utf8"));
948
+ for (const src of ["LICENSE", "NOTICE"]) {
949
+ write(relKey(referenceDir, src), readFileSync7(join10(opts.packageRoot, src), "utf8"));
547
950
  }
951
+ const manifest = {
952
+ ...existing,
953
+ scope: discoveredScope,
954
+ version: opts.version,
955
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
956
+ files
957
+ };
958
+ writeManifest(installRoot, manifest, referenceDir);
959
+ return { updated, skipped };
960
+ }
961
+ function updateInitFiles(opts) {
962
+ const updated = [];
963
+ const skipped = [];
964
+ const tokensDir = join10(opts.packageRoot, "tokens");
548
965
  const initManifest = readInitManifest(opts.projectRoot);
549
966
  if (initManifest) {
550
967
  const initFiles = { ...initManifest.files };
551
968
  let initChanged = false;
552
- for (const file of readdirSync2(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
969
+ const writer = createWriter(opts.projectRoot, initFiles);
970
+ for (const file of bundleFiles(opts.packageRoot, "tokens")) {
553
971
  const key = relKey(".jig", "tokens", file);
554
972
  if (!(key in initManifest.files)) continue;
555
973
  if (isInitFileModified(opts.projectRoot, key, initManifest)) {
556
974
  skipped.push(key);
557
975
  continue;
558
976
  }
559
- const content = vendorHeader(file, opts.version, "css") + readFileSync4(join5(tokensDir, file), "utf8");
560
- const abs = join5(opts.projectRoot, ...key.split("/"));
561
- mkdirSync4(dirname5(abs), { recursive: true });
562
- writeFileSync4(abs, content, "utf8");
563
- initFiles[key] = checksum(content);
977
+ const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
978
+ updated.push(writer.write(key, content));
564
979
  initChanged = true;
565
- updated.push(key);
566
980
  }
567
- if (initChanged) writeInitManifest(opts.projectRoot, { files: initFiles });
981
+ Object.assign(initFiles, writer.files);
982
+ if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
568
983
  }
569
- const manifest = {
570
- ...existing,
571
- scope: discoveredScope,
572
- version: opts.version,
573
- installedAt: (/* @__PURE__ */ new Date()).toISOString(),
574
- files
575
- };
576
- writeManifest(installRoot, manifest);
577
- return { updated, skipped, fromVersion: existing.version, toVersion: opts.version };
984
+ return { updated, skipped };
578
985
  }
579
986
 
580
- // src/commands/check.ts
581
- import { readFileSync as readFileSync7 } from "fs";
582
- import { join as join9 } from "path";
987
+ // src/commands/explain.ts
988
+ import { join as join13 } from "path";
989
+
990
+ // src/rules/load.ts
991
+ import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
992
+ import { join as join11 } from "path";
993
+
994
+ // src/rules/parse.ts
995
+ var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
996
+ function parseRules(markdown, sourceFile) {
997
+ const lines = markdown.split("\n");
998
+ const rules = [];
999
+ let current = null;
1000
+ const push = () => {
1001
+ if (current) rules.push(current);
1002
+ };
1003
+ for (const line of lines) {
1004
+ const heading = HEADING.exec(line);
1005
+ if (heading) {
1006
+ push();
1007
+ const [, section, num, title] = heading;
1008
+ const id = `${section}-${num}`;
1009
+ current = {
1010
+ id,
1011
+ section,
1012
+ number: Number(num),
1013
+ title,
1014
+ wrong: "",
1015
+ correction: "",
1016
+ source: `${sourceFile}#${id.toLowerCase()}`
1017
+ };
1018
+ continue;
1019
+ }
1020
+ if (!current) continue;
1021
+ if (line.startsWith("\u274C") && !current.wrong) {
1022
+ current.wrong = line.slice(1).trim();
1023
+ } else if (line.startsWith("\u2705") && !current.correction) {
1024
+ current.correction = line.slice(1).trim();
1025
+ }
1026
+ }
1027
+ push();
1028
+ return rules;
1029
+ }
583
1030
 
584
1031
  // src/rules/schema.ts
585
1032
  var BUCKETS = ["mechanical", "judgment", "hybrid"];
@@ -612,10 +1059,183 @@ function validateIndex(entries) {
612
1059
  });
613
1060
  }
614
1061
 
1062
+ // src/rules/load.ts
1063
+ function loadRules(rulesDir, indexPath) {
1064
+ const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
1065
+ const parsed = [];
1066
+ for (const file of files) {
1067
+ parsed.push(...parseRules(readFileSync8(join11(rulesDir, file), "utf8"), file));
1068
+ }
1069
+ const entries = validateIndex(JSON.parse(readFileSync8(indexPath, "utf8")));
1070
+ const byId = new Map(entries.map((e) => [e.id, e]));
1071
+ const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
1072
+ if (missing.length) {
1073
+ throw new Error(
1074
+ `Rules present in markdown but missing from rules.index.json: ${missing.join(", ")}`
1075
+ );
1076
+ }
1077
+ const parsedIds = new Set(parsed.map((r) => r.id));
1078
+ const orphans = entries.filter((e) => !parsedIds.has(e.id)).map((e) => e.id);
1079
+ if (orphans.length) {
1080
+ throw new Error(
1081
+ `Entries in rules.index.json with no matching rule: ${orphans.join(", ")}`
1082
+ );
1083
+ }
1084
+ return parsed.map((r) => {
1085
+ const { id: _id, ...meta } = byId.get(r.id);
1086
+ return { ...r, ...meta };
1087
+ });
1088
+ }
1089
+
1090
+ // src/rules/specs.ts
1091
+ import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
1092
+ import { join as join12 } from "path";
1093
+ var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
1094
+ function parseSpecs(markdown, sourceFile) {
1095
+ const lines = markdown.split("\n");
1096
+ const specs = [];
1097
+ let current = null;
1098
+ const body = [];
1099
+ const push = () => {
1100
+ if (!current) return;
1101
+ specs.push({ ...current, body: body.join("\n").replace(/\n{3,}/g, "\n\n").trim() });
1102
+ body.length = 0;
1103
+ };
1104
+ for (const line of lines) {
1105
+ const heading = HEADING2.exec(line);
1106
+ if (heading) {
1107
+ push();
1108
+ const [, id, title] = heading;
1109
+ current = { id, title, body: "", source: `${sourceFile}#${id.toLowerCase()}` };
1110
+ continue;
1111
+ }
1112
+ if (current && /^##\s/.test(line)) {
1113
+ push();
1114
+ current = null;
1115
+ continue;
1116
+ }
1117
+ if (current) body.push(line);
1118
+ }
1119
+ push();
1120
+ return specs;
1121
+ }
1122
+ function loadSpecs(rulesDir) {
1123
+ const specs = [];
1124
+ for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1125
+ specs.push(...parseSpecs(readFileSync9(join12(rulesDir, file), "utf8"), file));
1126
+ }
1127
+ return specs;
1128
+ }
1129
+
1130
+ // src/commands/explain.ts
1131
+ var ID_SHAPE = /^([A-Z])-?(\d+)$/;
1132
+ var SECTION_SHAPE = /^([A-Z])$/;
1133
+ var matches = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
1134
+ var byNumber = (a, b) => {
1135
+ const [as, an] = a.split("-");
1136
+ const [bs, bn] = b.split("-");
1137
+ return as === bs ? Number(an) - Number(bn) : as.localeCompare(bs);
1138
+ };
1139
+ function renderRule(rule) {
1140
+ return [
1141
+ `${rule.id} ${rule.title}`,
1142
+ "",
1143
+ `\u274C ${rule.wrong}`,
1144
+ `\u2705 ${rule.correction}`,
1145
+ "",
1146
+ ` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
1147
+ ` since ${rule.since} \xB7 ${rule.source}`
1148
+ ].join("\n");
1149
+ }
1150
+ function renderSpec(spec) {
1151
+ return [
1152
+ `${spec.id} ${spec.title}`,
1153
+ "",
1154
+ spec.body,
1155
+ "",
1156
+ ` specification \u2014 component anatomy and behaviour, not a single rule,`,
1157
+ ` so it carries no \u274C/\u2705 pair and no detector.`,
1158
+ ` ${spec.source}`
1159
+ ].join("\n");
1160
+ }
1161
+ function explain(opts) {
1162
+ const raw = opts.ruleId.trim().toUpperCase();
1163
+ const root = opts.packageRoot ?? assetRoot();
1164
+ const rulesDir = join13(root, "rules");
1165
+ const rules = loadRules(rulesDir, join13(root, "rules.index.json"));
1166
+ const specs = loadSpecs(rulesDir);
1167
+ const entries = [
1168
+ ...rules.map((r) => ({ id: r.id, title: r.title, text: `${r.wrong}
1169
+ ${r.correction}` })),
1170
+ ...specs.map((s) => ({ id: s.id, title: s.title, text: s.body }))
1171
+ ];
1172
+ if (opts.list) {
1173
+ const section = SECTION_SHAPE.exec(raw)?.[1];
1174
+ const shown = entries.filter((e) => !section || e.id.startsWith(`${section}-`)).sort((a, b) => byNumber(a.id, b.id));
1175
+ if (shown.length === 0) {
1176
+ throw new Error(`No section '${raw}'. Sections: ${sectionsOf(entries).join(", ")}.`);
1177
+ }
1178
+ return [
1179
+ section ? `Section ${section} \u2014 ${shown.length} entries` : `${shown.length} rules and specs`,
1180
+ "",
1181
+ ...shown.map((e) => `${e.id.padEnd(6)} ${e.title}`)
1182
+ ].join("\n");
1183
+ }
1184
+ const shape = ID_SHAPE.exec(raw);
1185
+ if (shape) {
1186
+ const id = `${shape[1]}-${shape[2]}`;
1187
+ const rule = rules.find((r) => r.id === id);
1188
+ if (rule) return renderRule(rule);
1189
+ const spec = specs.find((s) => s.id === id);
1190
+ if (spec) return renderSpec(spec);
1191
+ const siblings = entries.filter((e) => e.id.startsWith(`${shape[1]}-`)).map((e) => e.id).sort(byNumber);
1192
+ throw new Error(
1193
+ siblings.length > 0 ? `No rule '${id}'. Section ${shape[1]} has: ${siblings.join(", ")}.` : `No rule '${id}', and no section '${shape[1]}' exists.`
1194
+ );
1195
+ }
1196
+ if (raw === "") {
1197
+ throw new Error(
1198
+ `Nothing to explain. Give a rule id ('C-19'), a word to search for ('contrast'), or --list.`
1199
+ );
1200
+ }
1201
+ const needle = opts.ruleId.trim();
1202
+ const inTitle = entries.filter((e) => matches(e.title, needle) || matches(e.id, needle));
1203
+ const inBody = entries.filter((e) => !inTitle.includes(e) && matches(e.text, needle));
1204
+ const hits = [
1205
+ ...inTitle.sort((a, b) => byNumber(a.id, b.id)),
1206
+ ...inBody.sort((a, b) => byNumber(a.id, b.id))
1207
+ ];
1208
+ if (hits.length === 0) {
1209
+ throw new Error(
1210
+ `No rule matches '${needle}'. Try --list to see every id, or a broader word.`
1211
+ );
1212
+ }
1213
+ if (hits.length === 1) {
1214
+ const rule = rules.find((r) => r.id === hits[0].id);
1215
+ return rule ? renderRule(rule) : renderSpec(specs.find((s) => s.id === hits[0].id));
1216
+ }
1217
+ return [
1218
+ // Title matches lead, so the list is ranked rather than sorted. Say so:
1219
+ // otherwise C-19 sitting above A-02 reads as a sorting bug.
1220
+ `${hits.length} entries match '${needle}' \u2014 title matches first:`,
1221
+ "",
1222
+ ...hits.map((e) => `${e.id.padEnd(6)} ${e.title}`),
1223
+ "",
1224
+ `Run 'jig explain <id>' for any of them.`
1225
+ ].join("\n");
1226
+ }
1227
+ function sectionsOf(entries) {
1228
+ return [...new Set(entries.map((e) => e.id.split("-")[0]))].sort();
1229
+ }
1230
+
1231
+ // src/commands/check.ts
1232
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
1233
+ import { join as join17 } from "path";
1234
+
615
1235
  // src/check/files.ts
616
1236
  import { execFileSync } from "child_process";
617
- import { readdirSync as readdirSync3 } from "fs";
618
- import { join as join6, relative, sep } from "path";
1237
+ import { readdirSync as readdirSync6, realpathSync, statSync } from "fs";
1238
+ import { join as join14, relative, sep } from "path";
619
1239
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
620
1240
  "node_modules",
621
1241
  "dist",
@@ -671,19 +1291,38 @@ function gitChangedFiles(root) {
671
1291
  function isExcluded(relPath) {
672
1292
  return relPath.split("/").some((segment) => EXCLUDE_DIRS.has(segment));
673
1293
  }
674
- function walk(root, dir, out) {
1294
+ function walk(root, dir, out, seen) {
675
1295
  let entries;
676
1296
  try {
677
- entries = readdirSync3(dir, { withFileTypes: true });
1297
+ entries = readdirSync6(dir, { withFileTypes: true });
678
1298
  } catch {
679
1299
  return;
680
1300
  }
681
1301
  for (const entry of entries) {
682
1302
  if (EXCLUDE_DIRS.has(entry.name)) continue;
683
- const abs = join6(dir, entry.name);
684
- if (entry.isDirectory()) {
685
- walk(root, abs, out);
686
- } else if (entry.isFile()) {
1303
+ const abs = join14(dir, entry.name);
1304
+ let isDir = entry.isDirectory();
1305
+ let isFile = entry.isFile();
1306
+ if (entry.isSymbolicLink()) {
1307
+ try {
1308
+ const target = statSync(abs);
1309
+ isDir = target.isDirectory();
1310
+ isFile = target.isFile();
1311
+ } catch {
1312
+ continue;
1313
+ }
1314
+ }
1315
+ if (isDir) {
1316
+ let real;
1317
+ try {
1318
+ real = realpathSync(abs);
1319
+ } catch {
1320
+ continue;
1321
+ }
1322
+ if (seen.has(real)) continue;
1323
+ seen.add(real);
1324
+ walk(root, abs, out, seen);
1325
+ } else if (isFile) {
687
1326
  out.push(relative(root, abs).split(sep).join("/"));
688
1327
  }
689
1328
  }
@@ -712,9 +1351,16 @@ function filterGitIgnored(root, files) {
712
1351
  return files;
713
1352
  }
714
1353
  }
1354
+ function safeRealpath(p) {
1355
+ try {
1356
+ return realpathSync(p);
1357
+ } catch {
1358
+ return p;
1359
+ }
1360
+ }
715
1361
  function wholeRepoFiles(root) {
716
1362
  const out = [];
717
- walk(root, root, out);
1363
+ walk(root, root, out, /* @__PURE__ */ new Set([safeRealpath(root)]));
718
1364
  return isGitRepo(root) ? filterGitIgnored(root, out) : out;
719
1365
  }
720
1366
  function selectFiles(root, all) {
@@ -738,7 +1384,7 @@ function formatReport(findings, meta) {
738
1384
  const loc = `${f.file}:${f.line}`;
739
1385
  const firstOccurrence = !seenRule.has(f.ruleId);
740
1386
  seenRule.add(f.ruleId);
741
- const hint = firstOccurrence ? ` (see .jig/00-anti-patterns.md#${f.ruleId.toLowerCase()})` : "";
1387
+ const hint = firstOccurrence ? ` (see rule ${f.ruleId} in your installed jig skill's rules/)` : "";
742
1388
  return { symbol, ruleId: f.ruleId, message: f.message, loc, fileOnly: f.file, bucket: f.bucket, hint };
743
1389
  });
744
1390
  if (rows.length > 0) {
@@ -768,9 +1414,7 @@ function formatReport(findings, meta) {
768
1414
  lines.push("");
769
1415
  if (meta.noTokenLayer) {
770
1416
  lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
771
- lines.push(" Import a brand and a mode file to bring your CSS under the token layer:");
772
- lines.push(' @import ".jig/tokens/brand.default.css";');
773
- lines.push(' @import ".jig/tokens/mode.product.css";');
1417
+ lines.push(" Run 'jig init' to generate a brand file and wire it (with a mode file) into your CSS.");
774
1418
  lines.push("");
775
1419
  }
776
1420
  }
@@ -781,16 +1425,28 @@ function formatReport(findings, meta) {
781
1425
  if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
782
1426
  if (notes > 0) summaryParts.push(plural(notes, "note"));
783
1427
  const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
784
- lines.push(` ${summaryParts.join(", ")} \xB7 ${meta.totalRules} rules, ${rulesFired} fired`);
1428
+ const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
1429
+ lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
1430
+ if (meta.unscanned && meta.unscanned.count > 0) {
1431
+ const exts = meta.unscanned.extensions.join(", ");
1432
+ lines.push("");
1433
+ lines.push(
1434
+ ` ${meta.unscanned.count} file(s) were not scanned (${exts}) \u2014 no style extraction exists for them, so any CSS they carry is invisible to this check and a clean result above does not cover it.`
1435
+ );
1436
+ }
785
1437
  const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
786
1438
  const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
787
- lines.push(` JIG_CHECK: version=${meta.version} mechanical=${mechStatus} judgment=not-run`);
1439
+ lines.push(
1440
+ ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
1441
+ );
788
1442
  return lines.join("\n");
789
1443
  }
790
1444
 
791
1445
  // src/check/token-layer.ts
792
1446
  var IMPORT_RE = /@import\s+(?:url\()?["']?[^"')]*\.jig\/tokens\/[^"')]+\.css["']?\)?/i;
793
1447
  var VAR_RE = /var\(\s*--([\w-]+)/g;
1448
+ var THEME_DECL_RE = /@theme\b[^{]*\{([\s\S]*?)\}/gi;
1449
+ var CUSTOM_PROP_RE = /--([\w-]+)\s*:/g;
794
1450
  function participatesInTokenLayer(source, tokens) {
795
1451
  if (IMPORT_RE.test(source)) return true;
796
1452
  VAR_RE.lastIndex = 0;
@@ -798,12 +1454,91 @@ function participatesInTokenLayer(source, tokens) {
798
1454
  while (m = VAR_RE.exec(source)) {
799
1455
  if (Object.prototype.hasOwnProperty.call(tokens, m[1])) return true;
800
1456
  }
1457
+ THEME_DECL_RE.lastIndex = 0;
1458
+ let theme;
1459
+ while (theme = THEME_DECL_RE.exec(source)) {
1460
+ if (IMPORT_RE.test(theme[1])) return true;
1461
+ CUSTOM_PROP_RE.lastIndex = 0;
1462
+ let prop;
1463
+ while (prop = CUSTOM_PROP_RE.exec(theme[1])) {
1464
+ if (Object.prototype.hasOwnProperty.call(tokens, prop[1])) return true;
1465
+ }
1466
+ }
801
1467
  return false;
802
1468
  }
803
1469
 
1470
+ // src/check/ext.ts
1471
+ var CSS_EXTENSIONS = [".css", ".scss", ".less"];
1472
+ function hasExtension(file, exts) {
1473
+ const lower = file.toLowerCase();
1474
+ return exts.some((e) => lower.endsWith(e));
1475
+ }
1476
+ var STYLE_HOST_EXTENSIONS = [
1477
+ ".html",
1478
+ ".htm",
1479
+ ".vue",
1480
+ ".svelte",
1481
+ ".astro",
1482
+ ".jsx",
1483
+ ".tsx",
1484
+ ".js",
1485
+ ".ts",
1486
+ ".mjs",
1487
+ ".cjs",
1488
+ ".mts",
1489
+ ".cts",
1490
+ ".php",
1491
+ ".erb",
1492
+ ".twig",
1493
+ ".hbs",
1494
+ ".mdx",
1495
+ // Server-rendered markup. Every one of these is HTML with a template syntax
1496
+ // threaded through it, so its `<style>` blocks and `style` attributes are the
1497
+ // same constructs already handled — the template tags sit outside the style
1498
+ // regions and are blanked with the rest of the markup.
1499
+ ".asp",
1500
+ ".aspx",
1501
+ ".ascx",
1502
+ ".master",
1503
+ // Classic ASP, ASP.NET
1504
+ ".cshtml",
1505
+ ".vbhtml",
1506
+ ".razor",
1507
+ // Razor
1508
+ ".jsp",
1509
+ ".jspx",
1510
+ // Java
1511
+ ".eex",
1512
+ ".heex",
1513
+ ".leex",
1514
+ // Phoenix
1515
+ ".ejs",
1516
+ ".njk",
1517
+ ".liquid",
1518
+ ".mustache",
1519
+ // Node/Jinja-family engines
1520
+ ".vm",
1521
+ ".ftl",
1522
+ // Velocity, FreeMarker
1523
+ ".jinja",
1524
+ ".jinja2",
1525
+ ".j2",
1526
+ // Jinja
1527
+ // Indentation-delimited templates. These carry no HTML-shaped constructs at
1528
+ // all — their style regions are found by their own block markers and
1529
+ // attribute syntax instead. See `INDENTED_SYNTAXES` in styles.ts.
1530
+ ".pug",
1531
+ ".jade",
1532
+ ".haml",
1533
+ ".slim"
1534
+ ];
1535
+ function isStyleBearing(file) {
1536
+ return hasExtension(file, CSS_EXTENSIONS) || hasExtension(file, STYLE_HOST_EXTENSIONS);
1537
+ }
1538
+
804
1539
  // src/check/run.ts
805
- import { readFileSync as readFileSync5 } from "fs";
806
- import { join as join7 } from "path";
1540
+ import { readFileSync as readFileSync10 } from "fs";
1541
+ import { join as join15 } from "path";
807
1542
 
808
1543
  // src/check/css.ts
809
1544
  function maskComments(source) {
@@ -852,7 +1587,12 @@ function splitRuleBlocks(source) {
852
1587
  const ch = source[i];
853
1588
  if (ch === "\n") line++;
854
1589
  if (ch === "{") {
855
- stack.push({ selectorStart: lastBoundary, bodyStart: i + 1, bodyStartLine: line });
1590
+ stack.push({
1591
+ selectorStart: lastBoundary,
1592
+ bodyStart: i + 1,
1593
+ bodyStartLine: line,
1594
+ isAtRule: cleanSelector(source.slice(lastBoundary, i)).startsWith("@")
1595
+ });
856
1596
  lastBoundary = i + 1;
857
1597
  } else if (ch === "}") {
858
1598
  const top = stack.pop();
@@ -860,7 +1600,8 @@ function splitRuleBlocks(source) {
860
1600
  blocks.push({
861
1601
  selector: cleanSelector(source.slice(top.selectorStart, top.bodyStart - 1)),
862
1602
  body: source.slice(top.bodyStart, i),
863
- bodyStartLine: top.bodyStartLine
1603
+ bodyStartLine: top.bodyStartLine,
1604
+ atRuleDepth: stack.filter((f) => f.isAtRule).length
864
1605
  });
865
1606
  }
866
1607
  lastBoundary = i + 1;
@@ -870,7 +1611,7 @@ function splitRuleBlocks(source) {
870
1611
  }
871
1612
  function blankNested(body) {
872
1613
  const out = body.split("");
873
- const blank = (from, to) => {
1614
+ const blank2 = (from, to) => {
874
1615
  for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
875
1616
  };
876
1617
  let depth = 0;
@@ -878,7 +1619,7 @@ function blankNested(body) {
878
1619
  for (let i = 0; i < body.length; i++) {
879
1620
  const ch = body[i];
880
1621
  if (ch === "{") {
881
- if (depth === 0) blank(segmentStart, i);
1622
+ if (depth === 0) blank2(segmentStart, i);
882
1623
  depth++;
883
1624
  out[i] = " ";
884
1625
  } else if (ch === "}") {
@@ -929,11 +1670,221 @@ function sourceLine(source, line) {
929
1670
  return (lines[line - 1] ?? "").trim();
930
1671
  }
931
1672
 
932
- // src/check/ext.ts
933
- var CSS_EXTENSIONS = [".css", ".scss", ".less"];
934
- function hasExtension(file, exts) {
1673
+ // src/check/styles.ts
1674
+ function isStyleHost(file) {
1675
+ return hasExtension(file, STYLE_HOST_EXTENSIONS);
1676
+ }
1677
+ function isMarkupHost(file) {
1678
+ return hasExtension(file, MARKUP_HOSTS);
1679
+ }
1680
+ var MARKUP_HOSTS = [
1681
+ ".html",
1682
+ ".htm",
1683
+ ".vue",
1684
+ ".svelte",
1685
+ ".astro",
1686
+ ".php",
1687
+ ".erb",
1688
+ ".twig",
1689
+ ".hbs",
1690
+ ".mdx",
1691
+ ".jsx",
1692
+ ".tsx",
1693
+ ".asp",
1694
+ ".aspx",
1695
+ ".ascx",
1696
+ ".master",
1697
+ ".cshtml",
1698
+ ".vbhtml",
1699
+ ".razor",
1700
+ ".jsp",
1701
+ ".jspx",
1702
+ ".eex",
1703
+ ".heex",
1704
+ ".leex",
1705
+ ".ejs",
1706
+ ".njk",
1707
+ ".liquid",
1708
+ ".mustache",
1709
+ ".vm",
1710
+ ".ftl",
1711
+ ".jinja",
1712
+ ".jinja2",
1713
+ ".j2"
1714
+ ];
1715
+ var INDENTED_SYNTAXES = {
1716
+ ".pug": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
1717
+ ".jade": { blockMarker: /^(\s*)style\.?\s*$/, attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g },
1718
+ ".haml": {
1719
+ blockMarker: /^(\s*)(?::css|%style)\s*$/,
1720
+ attr: /(?::style\s*=>|\bstyle\s*:)\s*(["'])([\s\S]*?)\1/g
1721
+ },
1722
+ ".slim": {
1723
+ blockMarker: /^(\s*)(?:css:|style:)\s*$/,
1724
+ attr: /\bstyle\s*=\s*(["'])([\s\S]*?)\1/g
1725
+ }
1726
+ };
1727
+ function indentedSyntaxFor(file) {
935
1728
  const lower = file.toLowerCase();
936
- return exts.some((e) => lower.endsWith(e));
1729
+ const ext = Object.keys(INDENTED_SYNTAXES).find((e) => lower.endsWith(e));
1730
+ return ext ? INDENTED_SYNTAXES[ext] : void 0;
1731
+ }
1732
+ function indentedSpans(source, syntax) {
1733
+ const spans = [];
1734
+ const lines = source.split("\n");
1735
+ const offsets = [];
1736
+ let at = 0;
1737
+ for (const line of lines) {
1738
+ offsets.push(at);
1739
+ at += line.length + 1;
1740
+ }
1741
+ for (let i = 0; i < lines.length; i++) {
1742
+ const marker = syntax.blockMarker.exec(lines[i]);
1743
+ if (!marker) continue;
1744
+ const markerIndent = marker[1].length;
1745
+ let end = i;
1746
+ for (let j = i + 1; j < lines.length; j++) {
1747
+ if (lines[j].trim() === "") {
1748
+ end = j;
1749
+ continue;
1750
+ }
1751
+ const indent = lines[j].length - lines[j].trimStart().length;
1752
+ if (indent <= markerIndent) break;
1753
+ end = j;
1754
+ }
1755
+ if (end === i) continue;
1756
+ const start = offsets[i + 1];
1757
+ const last = offsets[end] + lines[end].length;
1758
+ spans.push({ start, end: last });
1759
+ i = end;
1760
+ }
1761
+ return spans;
1762
+ }
1763
+ var STYLE_BLOCK = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
1764
+ var STYLE_ATTR = /\bstyle\s*=\s*(["'])([\s\S]*?)\1/gi;
1765
+ var CSS_TAG = /\b(?:styled(?:\.\w+|\([^)]*\))(?:\s*\.\w+\([^)]*\))*|css|createGlobalStyle|keyframes|injectGlobal)\s*`/g;
1766
+ function readTemplate(source, openBacktick) {
1767
+ let i = openBacktick + 1;
1768
+ const start = i;
1769
+ while (i < source.length) {
1770
+ const ch = source[i];
1771
+ if (ch === "\\") {
1772
+ i += 2;
1773
+ continue;
1774
+ }
1775
+ if (ch === "`") return { start, end: i };
1776
+ if (ch === "$" && source[i + 1] === "{") {
1777
+ let depth = 1;
1778
+ i += 2;
1779
+ while (i < source.length && depth > 0) {
1780
+ if (source[i] === "\\") {
1781
+ i += 2;
1782
+ continue;
1783
+ }
1784
+ if (source[i] === "{") depth++;
1785
+ else if (source[i] === "}") depth--;
1786
+ else if (source[i] === "`") {
1787
+ const inner = readTemplate(source, i);
1788
+ if (!inner) return null;
1789
+ i = inner.end;
1790
+ }
1791
+ i++;
1792
+ }
1793
+ continue;
1794
+ }
1795
+ i++;
1796
+ }
1797
+ return null;
1798
+ }
1799
+ var STYLE_OBJECT = /\bstyle\s*=\s*\{\{/g;
1800
+ function readObject(source, open) {
1801
+ let depth = 0;
1802
+ let i = open + 1;
1803
+ const start = open + 2;
1804
+ while (i < source.length) {
1805
+ const ch = source[i];
1806
+ if (ch === '"' || ch === "'" || ch === "`") {
1807
+ const quote = ch;
1808
+ i++;
1809
+ while (i < source.length && source[i] !== quote) {
1810
+ if (source[i] === "\\") i++;
1811
+ i++;
1812
+ }
1813
+ i++;
1814
+ continue;
1815
+ }
1816
+ if (ch === "{") depth++;
1817
+ else if (ch === "}") {
1818
+ depth--;
1819
+ if (depth === 0) return { start, end: i };
1820
+ }
1821
+ i++;
1822
+ }
1823
+ return null;
1824
+ }
1825
+ function styleObjectToCss(body) {
1826
+ let out = "";
1827
+ for (const ch of body) {
1828
+ if (ch === '"' || ch === "'" || ch === "`") out += " ";
1829
+ else if (ch === ",") out += ";";
1830
+ else out += ch;
1831
+ }
1832
+ return out;
1833
+ }
1834
+ function maskNonStyleRegions(source, file) {
1835
+ if (hasExtension(file, CSS_EXTENSIONS)) return source;
1836
+ if (!isStyleHost(file)) return blank(source);
1837
+ const spans = [];
1838
+ const indented = indentedSyntaxFor(file);
1839
+ if (indented) {
1840
+ spans.push(...indentedSpans(source, indented));
1841
+ for (const m of source.matchAll(indented.attr)) {
1842
+ const valueStart = m.index + m[0].indexOf(m[1]) + 1;
1843
+ spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
1844
+ }
1845
+ return applySpans(source, spans);
1846
+ }
1847
+ if (hasExtension(file, MARKUP_HOSTS)) {
1848
+ for (const m of source.matchAll(STYLE_BLOCK)) {
1849
+ const bodyStart = m.index + m[0].indexOf(">") + 1;
1850
+ spans.push({ start: bodyStart, end: bodyStart + m[1].length });
1851
+ }
1852
+ for (const m of source.matchAll(STYLE_ATTR)) {
1853
+ const valueStart = m.index + m[0].indexOf(m[1]) + 1;
1854
+ spans.push({ start: valueStart, end: valueStart + m[2].length, braced: true });
1855
+ }
1856
+ }
1857
+ for (const m of source.matchAll(CSS_TAG)) {
1858
+ const backtick = m.index + m[0].length - 1;
1859
+ const span = readTemplate(source, backtick);
1860
+ if (span) spans.push({ ...span, braced: true });
1861
+ }
1862
+ for (const m of source.matchAll(STYLE_OBJECT)) {
1863
+ const span = readObject(source, m.index + m[0].length - 2);
1864
+ if (span) spans.push({ ...span, transform: styleObjectToCss, braced: true });
1865
+ }
1866
+ return applySpans(source, spans);
1867
+ }
1868
+ function applySpans(source, spans) {
1869
+ if (spans.length === 0) return blank(source);
1870
+ const out = blank(source).split("");
1871
+ for (const span of spans) {
1872
+ const text = source.slice(span.start, span.end);
1873
+ const rendered = span.transform ? span.transform(text) : text;
1874
+ for (let i = 0; i < rendered.length; i++) {
1875
+ out[span.start + i] = rendered[i];
1876
+ }
1877
+ if (span.braced && span.start > 0 && span.end < out.length) {
1878
+ out[span.start - 1] = "{";
1879
+ out[span.end] = "}";
1880
+ }
1881
+ }
1882
+ return out.join("");
1883
+ }
1884
+ function blank(source) {
1885
+ let out = "";
1886
+ for (const ch of source) out += ch === "\n" ? "\n" : " ";
1887
+ return out;
937
1888
  }
938
1889
 
939
1890
  // src/check/finding.ts
@@ -955,7 +1906,7 @@ var CLIP_RE = /(?:-webkit-)?background-clip\s*:\s*text\b/i;
955
1906
  var GRADIENT_RE = /(?:background|background-image)\s*:[^;]*(?:linear|radial|conic)-gradient\s*\(/i;
956
1907
  var gradientText = {
957
1908
  name: "gradient-text",
958
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
1909
+ appliesTo: (file) => isStyleBearing(file),
959
1910
  run(source, file, ctx) {
960
1911
  const findings = [];
961
1912
  for (const block of leafBlocks(source)) {
@@ -1184,7 +2135,7 @@ function hasTranslucentBackground(body, tokens) {
1184
2135
  }
1185
2136
  var backdropBlur = {
1186
2137
  name: "backdrop-blur",
1187
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2138
+ appliesTo: (file) => isStyleBearing(file),
1188
2139
  run(source, file, ctx) {
1189
2140
  const findings = [];
1190
2141
  for (const block of leafBlocks(source)) {
@@ -1215,7 +2166,7 @@ function isPure(rgb, target) {
1215
2166
  }
1216
2167
  var pureBlackWhite = {
1217
2168
  name: "pure-black-white",
1218
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2169
+ appliesTo: (file) => isStyleBearing(file),
1219
2170
  run(source, file, ctx) {
1220
2171
  const findings = [];
1221
2172
  for (const block of leafBlocks(source)) {
@@ -1250,6 +2201,104 @@ var pureBlackWhite = {
1250
2201
  }
1251
2202
  };
1252
2203
 
2204
+ // src/check/tailwind.ts
2205
+ var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2206
+ function readBraced(source, open) {
2207
+ let depth = 0;
2208
+ for (let i = open; i < source.length; i++) {
2209
+ const ch = source[i];
2210
+ if (ch === '"' || ch === "'" || ch === "`") {
2211
+ const quote = ch;
2212
+ i++;
2213
+ while (i < source.length && source[i] !== quote) {
2214
+ if (source[i] === "\\") i++;
2215
+ i++;
2216
+ }
2217
+ continue;
2218
+ }
2219
+ if (ch === "{") depth++;
2220
+ else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2221
+ }
2222
+ return null;
2223
+ }
2224
+ function classAttributeValues(source) {
2225
+ const out = [];
2226
+ const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2227
+ for (const m of source.matchAll(CLASS_ATTR)) {
2228
+ const at = m.index + m[0].length;
2229
+ const opener = source[at];
2230
+ if (opener === '"' || opener === "'") {
2231
+ const end = source.indexOf(opener, at + 1);
2232
+ if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2233
+ continue;
2234
+ }
2235
+ if (opener !== "{") continue;
2236
+ const raw = readBraced(source, at);
2237
+ if (raw === null) continue;
2238
+ const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2239
+ (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2240
+ );
2241
+ if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2242
+ }
2243
+ return out;
2244
+ }
2245
+ var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2246
+ var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2247
+ var LENGTH = /^-?\d*\.?\d+(?:px|rem|em|pt|vh|vw|vmin|vmax|ch|ex)$/i;
2248
+ function arbitraryValues(classes) {
2249
+ const out = [];
2250
+ for (const m of classes.matchAll(ARBITRARY)) {
2251
+ const [, utility, rawValue] = m;
2252
+ const value = rawValue.replace(/_/g, " ");
2253
+ if (/var\(|--/.test(value)) continue;
2254
+ if (COLOUR.test(value)) out.push({ utility, value, kind: "colour" });
2255
+ else if (LENGTH.test(value)) out.push({ utility, value, kind: "length" });
2256
+ }
2257
+ return out;
2258
+ }
2259
+ var PALETTE = {
2260
+ white: "#ffffff",
2261
+ black: "#000000",
2262
+ "gray-50": "#f9fafb",
2263
+ "gray-100": "#f3f4f6",
2264
+ "gray-200": "#e5e7eb",
2265
+ "gray-300": "#d1d5db",
2266
+ "gray-400": "#9ca3af",
2267
+ "gray-500": "#6b7280",
2268
+ "gray-600": "#4b5563",
2269
+ "gray-700": "#374151",
2270
+ "gray-800": "#1f2937",
2271
+ "gray-900": "#111827",
2272
+ "gray-950": "#030712",
2273
+ "slate-50": "#f8fafc",
2274
+ "slate-400": "#94a3b8",
2275
+ "slate-500": "#64748b",
2276
+ "slate-800": "#1e293b",
2277
+ "slate-900": "#0f172a",
2278
+ "slate-950": "#020617",
2279
+ "zinc-400": "#a1a1aa",
2280
+ "zinc-500": "#71717a",
2281
+ "zinc-900": "#18181b",
2282
+ "neutral-400": "#a3a3a3",
2283
+ "neutral-500": "#737373",
2284
+ "neutral-900": "#171717",
2285
+ "stone-400": "#a8a29e",
2286
+ "stone-500": "#78716c",
2287
+ "stone-900": "#1c1917"
2288
+ };
2289
+ function palettePairs(classes) {
2290
+ let background;
2291
+ let foreground;
2292
+ for (const token of classes.split(/\s+/)) {
2293
+ const bare = token.replace(/^(?:[\w-]+:)*/, "");
2294
+ const bg = /^bg-(.+)$/.exec(bare);
2295
+ const fg = /^text-(.+)$/.exec(bare);
2296
+ if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2297
+ if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2298
+ }
2299
+ return background && foreground ? [{ background, foreground }] : [];
2300
+ }
2301
+
1253
2302
  // src/check/detectors/contrast-floor.ts
1254
2303
  var FLOOR = 4.5;
1255
2304
  var LARGE_FLOOR = 3;
@@ -1274,7 +2323,7 @@ function resolveFloor(body) {
1274
2323
  var DECL_RE = /(?<![-\w])(color|background(?:-color)?)\s*:\s*([^;]+)(?:;|$)/gi;
1275
2324
  var contrastFloor = {
1276
2325
  name: "contrast-floor",
1277
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2326
+ appliesTo: (file) => isStyleBearing(file),
1278
2327
  run(source, file, ctx) {
1279
2328
  const findings = [];
1280
2329
  for (const block of leafBlocks(source)) {
@@ -1311,6 +2360,25 @@ var contrastFloor = {
1311
2360
  )
1312
2361
  );
1313
2362
  }
2363
+ for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
2364
+ for (const pair of palettePairs(attr.classes)) {
2365
+ const fg = resolveOpaqueColor(pair.foreground, ctx.tokens);
2366
+ const bg = resolveOpaqueColor(pair.background, ctx.tokens);
2367
+ if (!fg || !bg) continue;
2368
+ const ratio = contrastRatio(fg, bg);
2369
+ if (ratio >= 4.5) continue;
2370
+ findings.push(
2371
+ mkFinding(
2372
+ ctx,
2373
+ "contrast-floor",
2374
+ file,
2375
+ attr.line,
2376
+ `Foreground/background contrast is ${ratio.toFixed(2)}:1, below the 4.5:1 floor (${pair.foreground} on ${pair.background})`,
2377
+ sourceLine(ctx.raw, attr.line)
2378
+ )
2379
+ );
2380
+ }
2381
+ }
1314
2382
  return findings;
1315
2383
  }
1316
2384
  };
@@ -1320,7 +2388,7 @@ var OUTLINE_NONE_RE = /(?<![-\w])outline\s*:\s*(none|0(?:px)?)\s*(?:!important)?
1320
2388
  var FOCUS_VISIBLE_RE = /:focus-visible\b/i;
1321
2389
  var focusRemoved = {
1322
2390
  name: "focus-removed",
1323
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2391
+ appliesTo: (file) => isStyleBearing(file),
1324
2392
  run(source, file, ctx) {
1325
2393
  if (FOCUS_VISIBLE_RE.test(source)) return [];
1326
2394
  const findings = [];
@@ -1381,9 +2449,10 @@ var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
1381
2449
  var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
1382
2450
  var hardcodedValue = {
1383
2451
  name: "hardcoded-value",
1384
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2452
+ appliesTo: (file) => isStyleBearing(file),
1385
2453
  run(source, file, ctx) {
1386
- if (!participatesInTokenLayer(source, ctx.tokens)) return [];
2454
+ const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
2455
+ if (!participates) return [];
1387
2456
  const findings = [];
1388
2457
  for (const block of leafBlocks(source)) {
1389
2458
  if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
@@ -1433,6 +2502,20 @@ var hardcodedValue = {
1433
2502
  }
1434
2503
  }
1435
2504
  }
2505
+ for (const attr of isMarkupHost(file) ? classAttributeValues(ctx.raw) : []) {
2506
+ for (const found of arbitraryValues(attr.classes)) {
2507
+ findings.push(
2508
+ mkFinding(
2509
+ ctx,
2510
+ "hardcoded-value",
2511
+ file,
2512
+ attr.line,
2513
+ found.kind === "colour" ? `Hard-coded colour \`${found.value}\` past the token layer (${found.utility}-[\u2026])` : `Hard-coded \`${found.value}\` past the token layer (${found.utility}-[\u2026])`,
2514
+ attr.classes.length > 80 ? `${attr.classes.slice(0, 77)}\u2026` : attr.classes
2515
+ )
2516
+ );
2517
+ }
2518
+ }
1436
2519
  return findings;
1437
2520
  }
1438
2521
  };
@@ -1446,7 +2529,7 @@ var MIN_LIGHTNESS = 8;
1446
2529
  var MAX_LIGHTNESS = 92;
1447
2530
  var violetBandHue = {
1448
2531
  name: "violet-band-hue",
1449
- appliesTo: (file) => hasExtension(file, CSS_EXTENSIONS),
2532
+ appliesTo: (file) => isStyleBearing(file),
1450
2533
  run(source, file, ctx) {
1451
2534
  const findings = [];
1452
2535
  const starts = buildLineIndex(source);
@@ -1489,7 +2572,7 @@ function getDetector(name) {
1489
2572
  }
1490
2573
 
1491
2574
  // src/check/run.ts
1492
- function runChecks(projectRoot, files, index, tokens, bucketFilter) {
2575
+ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
1493
2576
  const findings = [];
1494
2577
  const active = index.filter(
1495
2578
  (e) => !!e.detector && !!getDetector(e.detector)
@@ -1501,13 +2584,13 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
1501
2584
  if (applicable.length === 0) continue;
1502
2585
  let raw;
1503
2586
  try {
1504
- raw = readFileSync5(join7(projectRoot, file), "utf8");
2587
+ raw = readFileSync10(join15(projectRoot, file), "utf8");
1505
2588
  } catch {
1506
2589
  continue;
1507
2590
  }
1508
- const source = maskComments(raw);
2591
+ const source = maskComments(maskNonStyleRegions(raw, file));
1509
2592
  for (const { entry, detector } of applicable) {
1510
- const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens };
2593
+ const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
1511
2594
  findings.push(...detector.run(source, file, ctx));
1512
2595
  }
1513
2596
  }
@@ -1515,40 +2598,101 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter) {
1515
2598
  }
1516
2599
 
1517
2600
  // src/check/tokens.ts
1518
- import { existsSync as existsSync6, readdirSync as readdirSync4, readFileSync as readFileSync6 } from "fs";
1519
- import { join as join8 } from "path";
1520
- function loadTokenMap(installRoot) {
1521
- const tokensDir = join8(installRoot, ".jig", "tokens");
1522
- const tokens = {};
1523
- if (!existsSync6(tokensDir)) return tokens;
1524
- const files = readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort();
1525
- for (const file of files) {
1526
- const source = readFileSync6(join8(tokensDir, file), "utf8");
1527
- for (const block of splitRuleBlocks(source)) {
1528
- if (block.body.includes("{")) continue;
1529
- if (block.selector.trim() !== ":root") continue;
1530
- const re = /--([\w-]+)\s*:\s*([^;]+);/g;
1531
- let m;
1532
- while (m = re.exec(block.body)) {
1533
- tokens[m[1]] = m[2].trim();
1534
- }
2601
+ import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync11 } from "fs";
2602
+ import { join as join16 } from "path";
2603
+ function rootDeclarations(source) {
2604
+ const out = {};
2605
+ for (const block of splitRuleBlocks(source)) {
2606
+ if (block.atRuleDepth !== 0) continue;
2607
+ if (block.body.includes("{")) continue;
2608
+ if (block.selector.trim() !== ":root") continue;
2609
+ const re = /--([\w-]+)\s*:\s*([^;]+);/g;
2610
+ let m;
2611
+ while (m = re.exec(block.body)) out[m[1]] = m[2].trim();
2612
+ }
2613
+ return out;
2614
+ }
2615
+ function loadTokenMap(projectRoot, consumerFiles = []) {
2616
+ const jig = {};
2617
+ const tokensDir = join16(projectRoot, ".jig", "tokens");
2618
+ if (existsSync9(tokensDir)) {
2619
+ for (const file of readdirSync7(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
2620
+ Object.assign(jig, rootDeclarations(readFileSync11(join16(tokensDir, file), "utf8")));
1535
2621
  }
1536
2622
  }
2623
+ const consumer = {};
2624
+ const ambiguous = /* @__PURE__ */ new Set();
2625
+ const vendored = join16(".jig", "tokens");
2626
+ for (const file of consumerFiles) {
2627
+ if (file.includes(vendored)) continue;
2628
+ let source;
2629
+ try {
2630
+ source = readFileSync11(join16(projectRoot, file), "utf8");
2631
+ } catch {
2632
+ continue;
2633
+ }
2634
+ for (const [name, value] of Object.entries(rootDeclarations(maskNonStyleRegions(source, file)))) {
2635
+ if (name in consumer && consumer[name] !== value) ambiguous.add(name);
2636
+ consumer[name] = value;
2637
+ }
2638
+ }
2639
+ const tokens = { ...consumer };
2640
+ for (const [name, value] of Object.entries(jig)) {
2641
+ if (name in consumer && consumer[name] !== value) ambiguous.add(name);
2642
+ tokens[name] = value;
2643
+ }
2644
+ for (const name of ambiguous) delete tokens[name];
1537
2645
  return tokens;
1538
2646
  }
1539
2647
 
1540
2648
  // src/commands/check.ts
2649
+ var LEGACY_INDEX_REL = join17(".jig", "rules.index.json");
2650
+ function resolveIndexPath(projectRoot) {
2651
+ const legacy = join17(projectRoot, LEGACY_INDEX_REL);
2652
+ if (existsSync10(legacy)) return legacy;
2653
+ return join17(assetRoot(), "rules.index.json");
2654
+ }
2655
+ function resolveMode(projectRoot) {
2656
+ try {
2657
+ const config = JSON.parse(readFileSync12(join17(projectRoot, "jig.config.json"), "utf8"));
2658
+ const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
2659
+ if (modes.length === 1) return modes[0];
2660
+ return modes.length > 1 ? "mixed" : "unknown";
2661
+ } catch {
2662
+ return "unknown";
2663
+ }
2664
+ }
2665
+ var UNSUPPORTED_STYLE_HOSTS = [".elm", ".rs", ".templ", ".gohtml"];
2666
+ function summariseUnscanned(files) {
2667
+ const hit = files.filter((f) => !isStyleBearing(f) && hasExtension(f, UNSUPPORTED_STYLE_HOSTS));
2668
+ if (hit.length === 0) return void 0;
2669
+ const extensions = [...new Set(hit.map((f) => f.slice(f.lastIndexOf(".")).toLowerCase()))].sort();
2670
+ return { count: hit.length, extensions };
2671
+ }
2672
+ function countSpecs() {
2673
+ try {
2674
+ return loadSpecs(join17(assetRoot(), "rules")).length;
2675
+ } catch {
2676
+ return 0;
2677
+ }
2678
+ }
1541
2679
  function check(opts) {
1542
- const target = resolveInstalled(opts.projectRoot, opts.homeDir);
1543
- if (!target) {
1544
- throw new Error(`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`);
1545
- }
1546
- const indexPath = join9(target.installRoot, ".jig", "rules.index.json");
1547
- const index = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
1548
- const tokens = loadTokenMap(target.installRoot);
1549
- const { files } = selectFiles(opts.projectRoot, opts.all);
2680
+ const indexPath = resolveIndexPath(opts.projectRoot);
2681
+ const index = validateIndex(JSON.parse(readFileSync12(indexPath, "utf8")));
2682
+ const selection = selectFiles(opts.projectRoot, opts.all);
2683
+ const { files } = selection;
1550
2684
  const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
1551
- const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter);
2685
+ const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
2686
+ const tokens = loadTokenMap(opts.projectRoot, stylesheets);
2687
+ const projectParticipates = stylesheets.some((f) => {
2688
+ if (!hasExtension(f, CSS_EXTENSIONS)) return false;
2689
+ try {
2690
+ return participatesInTokenLayer(readFileSync12(join17(opts.projectRoot, f), "utf8"), tokens);
2691
+ } catch {
2692
+ return false;
2693
+ }
2694
+ });
2695
+ const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
1552
2696
  const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
1553
2697
  const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
1554
2698
  const noTokenLayer = (
@@ -1556,7 +2700,7 @@ function check(opts) {
1556
2700
  // told to add a token @import it has nowhere to put.
1557
2701
  cssFiles.length > 0 && cssFiles.every((f) => {
1558
2702
  try {
1559
- return !participatesInTokenLayer(readFileSync7(join9(opts.projectRoot, f), "utf8"), tokens);
2703
+ return !participatesInTokenLayer(readFileSync12(join17(opts.projectRoot, f), "utf8"), tokens);
1560
2704
  } catch {
1561
2705
  return true;
1562
2706
  }
@@ -1564,19 +2708,25 @@ function check(opts) {
1564
2708
  );
1565
2709
  const report = formatReport(findings, {
1566
2710
  totalRules: index.length,
2711
+ // From the CLI's own bundle, like `explain` — the spec count describes the
2712
+ // system, not whatever a project happens to have vendored. A legacy project
2713
+ // copy may have no `rules/` directory at all.
2714
+ totalSpecs: countSpecs(),
1567
2715
  version: opts.version,
1568
- noTokenLayer
2716
+ noTokenLayer,
2717
+ mode: resolveMode(opts.projectRoot),
2718
+ unscanned: summariseUnscanned(files)
1569
2719
  });
1570
2720
  return { findings, report, hasError };
1571
2721
  }
1572
2722
 
1573
2723
  // src/commands/init.ts
1574
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync11, writeFileSync as writeFileSync5 } from "fs";
1575
- import { dirname as dirname6, join as join13 } from "path";
2724
+ import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "fs";
2725
+ import { dirname as dirname6, join as join21 } from "path";
1576
2726
 
1577
2727
  // src/init/detect.ts
1578
- import { readFileSync as readFileSync8 } from "fs";
1579
- import { join as join10 } from "path";
2728
+ import { readFileSync as readFileSync13 } from "fs";
2729
+ import { join as join18 } from "path";
1580
2730
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
1581
2731
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
1582
2732
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -1593,7 +2743,7 @@ var FRAMEWORK_DEPS = [
1593
2743
  function detectFramework(projectRoot) {
1594
2744
  let pkg;
1595
2745
  try {
1596
- pkg = JSON.parse(readFileSync8(join10(projectRoot, "package.json"), "utf8"));
2746
+ pkg = JSON.parse(readFileSync13(join18(projectRoot, "package.json"), "utf8"));
1597
2747
  } catch {
1598
2748
  return void 0;
1599
2749
  }
@@ -1611,7 +2761,7 @@ function detect(projectRoot) {
1611
2761
  for (const f of cssFiles) {
1612
2762
  let source;
1613
2763
  try {
1614
- source = readFileSync8(join10(projectRoot, f), "utf8");
2764
+ source = readFileSync13(join18(projectRoot, f), "utf8");
1615
2765
  } catch {
1616
2766
  continue;
1617
2767
  }
@@ -1628,9 +2778,26 @@ function detect(projectRoot) {
1628
2778
  return { cssSystem, cssFiles, tailwindConfigFile, tailwindV4EntryFile, framework: detectFramework(projectRoot) };
1629
2779
  }
1630
2780
 
2781
+ // src/init/gitignore.ts
2782
+ import { execFileSync as execFileSync2 } from "child_process";
2783
+ function isIgnored(projectRoot, relPath) {
2784
+ const candidates = relPath.endsWith("/") ? [relPath] : [relPath, `${relPath}/`];
2785
+ return candidates.some((path) => {
2786
+ try {
2787
+ execFileSync2("git", ["check-ignore", "-q", "--", path], {
2788
+ cwd: projectRoot,
2789
+ stdio: ["ignore", "ignore", "ignore"]
2790
+ });
2791
+ return true;
2792
+ } catch {
2793
+ return false;
2794
+ }
2795
+ });
2796
+ }
2797
+
1631
2798
  // src/init/derive.ts
1632
- import { readFileSync as readFileSync9 } from "fs";
1633
- import { join as join11 } from "path";
2799
+ import { readFileSync as readFileSync14 } from "fs";
2800
+ import { join as join19 } from "path";
1634
2801
  var DEFAULT_PROPOSAL = {
1635
2802
  h: 264,
1636
2803
  s: 0,
@@ -1663,7 +2830,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
1663
2830
  for (const file of cssFiles) {
1664
2831
  let source;
1665
2832
  try {
1666
- source = readFileSync9(join11(projectRoot, file), "utf8");
2833
+ source = readFileSync14(join19(projectRoot, file), "utf8");
1667
2834
  } catch {
1668
2835
  continue;
1669
2836
  }
@@ -1712,7 +2879,7 @@ function balancedBraceBody(source, openIdx) {
1712
2879
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
1713
2880
  let source;
1714
2881
  try {
1715
- source = readFileSync9(join11(projectRoot, tailwindConfigFile), "utf8");
2882
+ source = readFileSync14(join19(projectRoot, tailwindConfigFile), "utf8");
1716
2883
  } catch {
1717
2884
  return null;
1718
2885
  }
@@ -1768,7 +2935,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
1768
2935
  for (const file of cssFiles) {
1769
2936
  let source;
1770
2937
  try {
1771
- source = readFileSync9(join11(projectRoot, file), "utf8");
2938
+ source = readFileSync14(join19(projectRoot, file), "utf8");
1772
2939
  } catch {
1773
2940
  continue;
1774
2941
  }
@@ -1818,6 +2985,20 @@ function worstRatio(h, s, l) {
1818
2985
  const vsFill = contrastRatio(rgb, FILL_COMPOSITED);
1819
2986
  return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
1820
2987
  }
2988
+ var DARK_BRAND_L = 88;
2989
+ var DARK_SURFACE_S = 6;
2990
+ var DARK_RAISED_L = 15;
2991
+ var DARK_BASE_L = 10;
2992
+ var DARK_FILL_ALPHA = 0.06;
2993
+ function darkWorstRatio(h, s) {
2994
+ const brand = toRgb(h, s, DARK_BRAND_L);
2995
+ const raised = toRgb(h, DARK_SURFACE_S, DARK_RAISED_L);
2996
+ const base = toRgb(h, DARK_SURFACE_S, DARK_BASE_L);
2997
+ const fill = compositeOver({ r: 255, g: 255, b: 255 }, DARK_FILL_ALPHA, base);
2998
+ const vsRaised = contrastRatio(brand, raised);
2999
+ const vsFill = contrastRatio(brand, fill);
3000
+ return { vsRaised, vsFill, worst: Math.min(vsRaised, vsFill) };
3001
+ }
1821
3002
  function nearestPassingLightness(h, s, l) {
1822
3003
  if (worstRatio(h, s, l).worst >= CONTRAST_FLOOR) return l;
1823
3004
  for (let delta = 1; delta <= 100; delta++) {
@@ -1847,11 +3028,19 @@ function systemCollision(h, s, l) {
1847
3028
  return null;
1848
3029
  }
1849
3030
  function validateBrandColor(h, s, l) {
1850
- const { vsRaised, vsFill, worst } = worstRatio(h, s, l);
3031
+ const light = worstRatio(h, s, l);
3032
+ const dark = darkWorstRatio(h, s);
3033
+ const worst = Math.min(light.worst, dark.worst);
1851
3034
  const passesContrast = worst >= CONTRAST_FLOOR;
1852
3035
  return {
1853
- ratioVsRaised: vsRaised,
1854
- ratioVsFill: vsFill,
3036
+ ratioVsRaised: light.vsRaised,
3037
+ ratioVsFill: light.vsFill,
3038
+ lightRatioVsRaised: light.vsRaised,
3039
+ lightRatioVsFill: light.vsFill,
3040
+ lightWorstRatio: light.worst,
3041
+ darkRatioVsRaised: dark.vsRaised,
3042
+ darkRatioVsFill: dark.vsFill,
3043
+ darkWorstRatio: dark.worst,
1855
3044
  worstRatio: worst,
1856
3045
  passesContrast,
1857
3046
  nearestPassingLightness: passesContrast ? void 0 : nearestPassingLightness(h, s, l) ?? void 0,
@@ -1868,7 +3057,7 @@ function round2(n) {
1868
3057
  }
1869
3058
  function renderBrandFile(defaultCssRaw, projectSlug, version2, proposal) {
1870
3059
  const fileName = `brand.${projectSlug}.css`;
1871
- const header = vendorHeader(fileName, version2, "css");
3060
+ const header = vendorHeader(fileName, version2, "css", null);
1872
3061
  if (!defaultCssRaw.includes(TOP_COMMENT_FIRST_LINE) || !defaultCssRaw.includes(TOP_COMMENT_LAST_LINE)) {
1873
3062
  throw new Error(
1874
3063
  `brand-file: tokens/brand.default.css does not match the expected shape \u2014 ${TOP_COMMENT_FIRST_LINE} / ${TOP_COMMENT_LAST_LINE} not found.`
@@ -1906,8 +3095,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
1906
3095
  }
1907
3096
 
1908
3097
  // src/init/project-name.ts
1909
- import { readFileSync as readFileSync10 } from "fs";
1910
- import { basename as basename2, join as join12 } from "path";
3098
+ import { readFileSync as readFileSync15 } from "fs";
3099
+ import { basename, join as join20 } from "path";
1911
3100
  function slugify(name) {
1912
3101
  const withoutScope = name.replace(/^@[^/]+\//, "");
1913
3102
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -1915,11 +3104,11 @@ function slugify(name) {
1915
3104
  }
1916
3105
  function deriveProjectSlug(projectRoot) {
1917
3106
  try {
1918
- const pkg = JSON.parse(readFileSync10(join12(projectRoot, "package.json"), "utf8"));
3107
+ const pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
1919
3108
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
1920
3109
  } catch {
1921
3110
  }
1922
- return slugify(basename2(projectRoot));
3111
+ return slugify(basename(projectRoot));
1923
3112
  }
1924
3113
 
1925
3114
  // src/commands/init.ts
@@ -1939,13 +3128,15 @@ function validateRounded(p) {
1939
3128
  }
1940
3129
  function describeValidation(v) {
1941
3130
  const lines = [];
3131
+ const modes = `light ${v.lightWorstRatio.toFixed(2)}:1, dark ${v.darkWorstRatio.toFixed(2)}:1`;
1942
3132
  if (v.passesContrast) {
1943
3133
  lines.push(
1944
- ` contrast OK \u2014 ${v.ratioVsRaised.toFixed(2)}:1 vs --color-bg-raised, ${v.ratioVsFill.toFixed(2)}:1 vs --color-fill (floor 4.5:1)`
3134
+ ` contrast OK \u2014 ${modes} (floor 4.5:1; light is ${v.ratioVsRaised.toFixed(2)} vs --color-bg-raised and ${v.ratioVsFill.toFixed(2)} vs --color-fill)`
1945
3135
  );
1946
3136
  } else {
3137
+ const failing = v.lightWorstRatio < v.darkWorstRatio ? "light" : "dark";
1947
3138
  lines.push(
1948
- ` contrast FAILS \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${v.ratioVsRaised.toFixed(2)}:1 vs --color-bg-raised, ${v.ratioVsFill.toFixed(2)}:1 vs --color-fill)`
3139
+ ` contrast FAILS in ${failing} mode \u2014 worst is ${v.worstRatio.toFixed(2)}:1 against the 4.5:1 floor (${modes})`
1949
3140
  );
1950
3141
  if (v.nearestPassingLightness !== void 0) {
1951
3142
  lines.push(` nearest passing lightness at the same hue/saturation: ${v.nearestPassingLightness}%`);
@@ -1979,7 +3170,7 @@ function parseSurfaces(answer, fallback) {
1979
3170
  return parsed.length > 0 ? parsed : fallback;
1980
3171
  }
1981
3172
  function fileState(projectRoot, absPath, relPath, initManifest) {
1982
- const existsOnDisk = existsSync7(absPath);
3173
+ const existsOnDisk = existsSync11(absPath);
1983
3174
  const tracked = !!initManifest && relPath in initManifest.files;
1984
3175
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
1985
3176
  return { existsOnDisk, tracked, modified };
@@ -1990,11 +3181,11 @@ function isValidSurfaceArray(v) {
1990
3181
  );
1991
3182
  }
1992
3183
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
1993
- if (!existsSync7(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3184
+ if (!existsSync11(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
1994
3185
  try {
1995
- const parsed = JSON.parse(readFileSync11(configAbsPath, "utf8"));
3186
+ const parsed = JSON.parse(readFileSync16(configAbsPath, "utf8"));
1996
3187
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
1997
- const brandResolves = brandCandidate ? existsSync7(join13(projectRoot, ...brandCandidate.split("/"))) : false;
3188
+ const brandResolves = brandCandidate ? existsSync11(join21(projectRoot, ...brandCandidate.split("/"))) : false;
1998
3189
  return {
1999
3190
  brand: brandResolves ? brandCandidate : fallbackBrand,
2000
3191
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -2020,7 +3211,7 @@ function insertAfterCharset(content, insertion) {
2020
3211
  return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
2021
3212
  }
2022
3213
  function wireImport(absPath, brandImport, modeImport, mode) {
2023
- const content = existsSync7(absPath) ? readFileSync11(absPath, "utf8") : "";
3214
+ const content = existsSync11(absPath) ? readFileSync16(absPath, "utf8") : "";
2024
3215
  const brandImportLine = `@import "${brandImport}";`;
2025
3216
  const modeImportLine = `@import "${modeImport}";`;
2026
3217
  const brandPresent = content.includes(brandImportLine);
@@ -2033,7 +3224,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
2033
3224
  const previousMode = existingModeMatch[2];
2034
3225
  const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
2035
3226
  ` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
2036
- writeFileSync5(absPath, next3, "utf8");
3227
+ writeFileSync4(absPath, next3, "utf8");
2037
3228
  return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
2038
3229
  }
2039
3230
  const brandIdx = content.indexOf(brandImportLine);
@@ -2041,7 +3232,7 @@ function wireImport(absPath, brandImport, modeImport, mode) {
2041
3232
  if (content[insertAt] === "\r") insertAt++;
2042
3233
  if (content[insertAt] === "\n") insertAt++;
2043
3234
  const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
2044
- writeFileSync5(absPath, next2, "utf8");
3235
+ writeFileSync4(absPath, next2, "utf8");
2045
3236
  return { status: "rewired", detail: `added missing mode import: ${mode}` };
2046
3237
  }
2047
3238
  const lines = `${brandImportLine}
@@ -2057,16 +3248,31 @@ ${modeImportLine}
2057
3248
  ` : lines);
2058
3249
  }
2059
3250
  mkdirSync5(dirname6(absPath), { recursive: true });
2060
- writeFileSync5(absPath, next, "utf8");
3251
+ writeFileSync4(absPath, next, "utf8");
2061
3252
  return { status: "wired" };
2062
3253
  }
2063
3254
  async function init(opts) {
2064
3255
  const log = opts.log ?? ((line) => console.log(line));
2065
3256
  const prompt = opts.prompt ?? defaultPrompt;
2066
- const resolved = resolveInstalled(opts.projectRoot, opts.homeDir);
2067
- if (!resolved) {
2068
- throw new Error(`Jig is not installed in ${opts.projectRoot}. Run 'jig install --agent <name>' first.`);
2069
- }
3257
+ const migrateLegacy = async (report, describe) => {
3258
+ if (!report.present) return;
3259
+ for (const line of describe(report)) log(line);
3260
+ const removable = removableLegacyFiles(report);
3261
+ if (removable.length === 0) return;
3262
+ if (opts.yes) {
3263
+ log(" Re-run without --yes to be prompted for removal, or delete them yourself.");
3264
+ return;
3265
+ }
3266
+ const answer = (await prompt(` Remove these ${removable.length} unedited legacy file(s)? [y/N]: `)).toLowerCase();
3267
+ if (answer === "y" || answer === "yes") {
3268
+ removeLegacyFiles(opts.projectRoot, removable);
3269
+ log(` Removed ${removable.length} file(s).`);
3270
+ } else {
3271
+ log(" Left them in place.");
3272
+ }
3273
+ };
3274
+ await migrateLegacy(detectLegacyRules(opts.projectRoot), describeLegacyReport);
3275
+ await migrateLegacy(detectLegacyCursorRules(opts.projectRoot), describeLegacyCursorReport);
2070
3276
  const detection = detect(opts.projectRoot);
2071
3277
  log(`Detected: ${detection.cssSystem}${detection.framework ? ` (${detection.framework})` : ""}`);
2072
3278
  if (detection.cssFiles.length === 0) {
@@ -2107,6 +3313,11 @@ async function init(opts) {
2107
3313
  );
2108
3314
  }
2109
3315
  }
3316
+ if (opts.yes) {
3317
+ log(
3318
+ `Surface \u2192 mode: ${surfaces.map((s) => `'${s.match}' \u2192 ${s.mode}`).join(", ")} \u2014 the default, not inferred from this project. Mode sets density, type scale and control sizes, and it wins over an agent's own inference, so change it in jig.config.json if another mode fits (${MODES.join("/")}).`
3319
+ );
3320
+ }
2110
3321
  if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
2111
3322
  const originalL = proposal.l;
2112
3323
  proposal = { ...proposal, l: validation.nearestPassingLightness };
@@ -2119,9 +3330,9 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
2119
3330
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
2120
3331
  const projectSlug = deriveProjectSlug(opts.projectRoot);
2121
3332
  const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
2122
- const brandAbsPath = join13(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
3333
+ const brandAbsPath = join21(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
2123
3334
  const configRelPath = "jig.config.json";
2124
- const configAbsPath = join13(opts.projectRoot, configRelPath);
3335
+ const configAbsPath = join21(opts.projectRoot, configRelPath);
2125
3336
  const initManifest = readInitManifest(opts.projectRoot);
2126
3337
  const files = { ...initManifest?.files ?? {} };
2127
3338
  const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
@@ -2150,68 +3361,72 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
2150
3361
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
2151
3362
  }
2152
3363
  if (brandAction === "written") {
2153
- const defaultCssRaw = readFileSync11(join13(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3364
+ const defaultCssRaw = readFileSync16(join21(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
2154
3365
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
2155
3366
  mkdirSync5(dirname6(brandAbsPath), { recursive: true });
2156
- writeFileSync5(brandAbsPath, content, "utf8");
3367
+ writeFileSync4(brandAbsPath, content, "utf8");
2157
3368
  files[brandRelPath] = checksum(content);
2158
3369
  }
2159
3370
  if (configAction === "written") {
2160
3371
  const config = { brand: brandRelPath, surfaces };
2161
3372
  const content = `${JSON.stringify(config, null, 2)}
2162
3373
  `;
2163
- writeFileSync5(configAbsPath, content, "utf8");
3374
+ writeFileSync4(configAbsPath, content, "utf8");
2164
3375
  files[configRelPath] = checksum(content);
2165
3376
  } else if (configAction === "merged") {
2166
3377
  let existing = {};
2167
3378
  try {
2168
- existing = JSON.parse(readFileSync11(configAbsPath, "utf8"));
3379
+ existing = JSON.parse(readFileSync16(configAbsPath, "utf8"));
2169
3380
  } catch {
2170
3381
  existing = {};
2171
3382
  }
2172
3383
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
2173
- const brandStillResolves = existingBrand ? existsSync7(join13(opts.projectRoot, ...existingBrand.split("/"))) : false;
3384
+ const brandStillResolves = existingBrand ? existsSync11(join21(opts.projectRoot, ...existingBrand.split("/"))) : false;
2174
3385
  const merged = {
2175
3386
  brand: brandStillResolves ? existingBrand : brandRelPath,
2176
3387
  surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
2177
3388
  };
2178
3389
  const content = `${JSON.stringify(merged, null, 2)}
2179
3390
  `;
2180
- writeFileSync5(configAbsPath, content, "utf8");
3391
+ writeFileSync4(configAbsPath, content, "utf8");
2181
3392
  files[configRelPath] = checksum(content);
2182
3393
  }
2183
3394
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
2184
3395
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
2185
- const wiringBrandAbsPath = join13(opts.projectRoot, ...effectiveConfig.brand.split("/"));
2186
- const modeFileName = `mode.${primaryMode}.css`;
2187
- const modeAbsPath = join13(opts.projectRoot, ".jig", "tokens", modeFileName);
2188
- if (resolved.scope === "global") {
3396
+ const wiringBrandAbsPath = join21(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3397
+ const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
3398
+ const modeAbsPaths = {};
3399
+ for (const mode of declaredModes) {
3400
+ const modeFileName = `mode.${mode}.css`;
3401
+ const modeAbsPath2 = join21(opts.projectRoot, ".jig", "tokens", modeFileName);
3402
+ modeAbsPaths[mode] = modeAbsPath2;
2189
3403
  const modeRelPath = relKey(".jig", "tokens", modeFileName);
2190
- const modeState = fileState(opts.projectRoot, modeAbsPath, modeRelPath, initManifest);
3404
+ const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
2191
3405
  let modeAction = "written";
2192
3406
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
2193
3407
  else if (modeState.modified) modeAction = "skipped-edited";
2194
3408
  if (modeAction === "written") {
2195
- const modeSourceRaw = readFileSync11(join13(opts.packageRoot, "tokens", modeFileName), "utf8");
2196
- const content = vendorHeader(modeFileName, opts.version, "css") + modeSourceRaw;
2197
- mkdirSync5(dirname6(modeAbsPath), { recursive: true });
2198
- writeFileSync5(modeAbsPath, content, "utf8");
3409
+ const modeSourceRaw = readFileSync16(join21(opts.packageRoot, "tokens", modeFileName), "utf8");
3410
+ const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
3411
+ mkdirSync5(dirname6(modeAbsPath2), { recursive: true });
3412
+ writeFileSync4(modeAbsPath2, content, "utf8");
2199
3413
  files[modeRelPath] = checksum(content);
2200
3414
  } else {
2201
3415
  log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
2202
3416
  }
2203
3417
  }
2204
- writeInitManifest(opts.projectRoot, { files });
3418
+ const modeAbsPath = modeAbsPaths[primaryMode];
3419
+ writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
2205
3420
  const wireTarget = findWireTarget(detection);
2206
3421
  let wiring;
2207
3422
  if (wireTarget) {
2208
- const targetAbsDir = dirname6(join13(opts.projectRoot, wireTarget));
3423
+ const targetAbsDir = dirname6(join21(opts.projectRoot, wireTarget));
2209
3424
  const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
2210
3425
  const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
2211
3426
  const snippet = `@import "${brandImport}";
2212
3427
  @import "${modeImport}";`;
2213
3428
  try {
2214
- const outcome = wireImport(join13(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
3429
+ const outcome = wireImport(join21(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
2215
3430
  wiring = { target: wireTarget, status: outcome.status, snippet };
2216
3431
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
2217
3432
  log(`
@@ -2231,11 +3446,29 @@ Could not write to ${wireTarget}: ${err.message}`);
2231
3446
  const snippet = `@import "${brandImport}";
2232
3447
  @import "${modeImport}";`;
2233
3448
  wiring = { target: null, status: "print-only", snippet };
3449
+ log("\nCould not find a single unambiguous stylesheet to wire the import into.");
3450
+ const pasteTargets = detection.cssFiles.filter((f) => !isCssModule(f));
3451
+ if (pasteTargets.length > 0) {
3452
+ log("Paste the block for whichever of these is your global stylesheet:");
3453
+ for (const target of pasteTargets) {
3454
+ const dir = dirname6(join21(opts.projectRoot, target));
3455
+ const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
3456
+ @import "${relativeImportPath(dir, modeAbsPath)}";`;
3457
+ log(`
3458
+ ${target}:`);
3459
+ log(` ${forTarget.split("\n").join("\n ")}`);
3460
+ }
3461
+ } else {
3462
+ log(
3463
+ "Add this near the top of your global stylesheet. A CSS @import resolves relative to the file it sits in, so these project-root paths need a ../ per directory of depth:"
3464
+ );
3465
+ log(` ${snippet.split("\n").join("\n ")}`);
3466
+ }
3467
+ }
3468
+ if (isIgnored(opts.projectRoot, ".jig")) {
2234
3469
  log(
2235
- `
2236
- Could not find a single unambiguous stylesheet to wire the import into. Add this near the top of your global stylesheet (paths shown relative to the project root \u2014 adjust to wherever you paste them):`
3470
+ "\n WARNING: .jig/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the .jig/ rule from .gitignore, or commit the directory explicitly."
2237
3471
  );
2238
- log(` ${snippet.split("\n").join("\n ")}`);
2239
3472
  }
2240
3473
  const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
2241
3474
  log("\nBaseline:");
@@ -2259,7 +3492,13 @@ Could not find a single unambiguous stylesheet to wire the import into. Add this
2259
3492
 
2260
3493
  // src/index.ts
2261
3494
  var packageRoot = getPackageRoot();
2262
- var { version } = JSON.parse(readFileSync12(join14(packageRoot, "package.json"), "utf8"));
3495
+ var { version } = JSON.parse(readFileSync17(join22(packageRoot, "package.json"), "utf8"));
3496
+ function warnIfUnpublishedPin() {
3497
+ if (isPublishedBuild(packageRoot)) return;
3498
+ console.warn(
3499
+ `Note: this is a source build, so the skill pins 'npx jig-ui@${version}' \u2014 a version that may not be published. Agents reading it will not be able to run the CLI until it is. Re-run install or update from a published build to correct the pin.`
3500
+ );
3501
+ }
2263
3502
  var program = new Command();
2264
3503
  program.name("jig").description("A design system for coding agents.").version(version);
2265
3504
  program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").action((opts) => {
@@ -2277,7 +3516,12 @@ program.command("install").description("Install Jig rules and the agent skill fi
2277
3516
  version,
2278
3517
  homeDir: homedir()
2279
3518
  });
3519
+ if (result.warning) {
3520
+ console.warn(result.warning);
3521
+ return;
3522
+ }
2280
3523
  console.log(`Installed Jig v${version} for ${opts.agent} (${opts.scope} scope)`);
3524
+ warnIfUnpublishedPin();
2281
3525
  for (const f of result.written) console.log(` + ${f}`);
2282
3526
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
2283
3527
  } catch (err) {
@@ -2285,6 +3529,14 @@ program.command("install").description("Install Jig rules and the agent skill fi
2285
3529
  process.exit(1);
2286
3530
  }
2287
3531
  });
3532
+ program.command("explain").description("Explain a rule, or find the rules you cannot name.").argument("[query]", "a rule id ('C-19'), a word to search for ('contrast'), or a section letter with --list").option("--list", "list every rule id and title, or one section with a section letter").action((query, options) => {
3533
+ try {
3534
+ console.log(explain({ ruleId: query ?? "", version, list: options.list }));
3535
+ } catch (err) {
3536
+ console.error(err.message);
3537
+ process.exit(1);
3538
+ }
3539
+ });
2288
3540
  program.command("update").description("Update vendored Jig rules, skipping files you have edited.").action(() => {
2289
3541
  const projectRoot = findProjectRoot(process.cwd());
2290
3542
  try {
@@ -2296,7 +3548,11 @@ program.command("update").description("Update vendored Jig rules, skipping files
2296
3548
  version,
2297
3549
  homeDir: homedir()
2298
3550
  });
2299
- console.log(`Updated Jig ${result.fromVersion} \u2192 ${result.toVersion}`);
3551
+ const label = result.targets.map((t) => `${t.agent} (${t.scope}, ${t.fromVersion})`).join(", ");
3552
+ warnIfUnpublishedPin();
3553
+ console.log(
3554
+ `Updated Jig \u2192 ${result.toVersion} in ${result.targets.length} harness${result.targets.length === 1 ? "" : "es"}: ${label}`
3555
+ );
2300
3556
  for (const f of result.updated) console.log(` ~ ${f}`);
2301
3557
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
2302
3558
  } catch (err) {