jig-ui 0.9.0 → 0.10.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 readFileSync19 } from "fs";
5
+ import { readFileSync as readFileSync27 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join24 } from "path";
7
+ import { join as join33 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -42,11 +42,11 @@ function isPublishedBuild(packageRoot2) {
42
42
  }
43
43
 
44
44
  // src/commands/install.ts
45
- import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
45
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
46
46
  import { join as join7 } from "path";
47
47
 
48
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.";
49
+ var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), check the UI against the rules, or refresh the install.";
50
50
  var SKILL_DESCRIPTION = "Design system rules for generating and reviewing UI. Load before building any interface.";
51
51
  function assertSafeRelPath(relPath, adapterName) {
52
52
  const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
@@ -390,7 +390,7 @@ function escapeTableCell(value) {
390
390
  function renderCommandTable(metadata) {
391
391
  const rows = Object.entries(metadata).map(([name, meta]) => {
392
392
  const signature = meta.argumentHint ? `${name} ${meta.argumentHint}` : name;
393
- const status = meta.status === "planned" ? "planned \u2014 not yet implemented" : "available";
393
+ const status = meta.status === "planned" ? "planned \u2014 not yet implemented" : meta.status === "agent" ? "agent procedure \u2014 no CLI to run" : "available";
394
394
  return `| \`${escapeTableCell(signature)}\` | ${escapeTableCell(meta.description)} | ${status} |`;
395
395
  });
396
396
  return ["| Command | Description | Status |", "| --- | --- | --- |", ...rows].join("\n");
@@ -542,11 +542,17 @@ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
542
542
  const metadata = JSON.parse(
543
543
  readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
544
544
  );
545
- const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
545
+ const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
546
546
  const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
547
547
  if (!existsSync5(templatePath)) return { body: "", subcommands };
548
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);
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).replace(/\{\{config_file\}\}/g, "jig.config.json");
550
+ const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
551
+ if (unresolved.length > 0) {
552
+ throw new Error(
553
+ `COMMAND.md.tmpl has placeholders nothing substitutes: ${unresolved.join(", ")}. Add them to the replace chain in buildCommandBody, or remove them from the template.`
554
+ );
555
+ }
550
556
  return { body, subcommands };
551
557
  }
552
558
  function rulesPathFor(referenceDir, scope) {
@@ -653,7 +659,36 @@ function install(opts) {
653
659
  };
654
660
  writeManifest(installRoot, manifest, referenceDir);
655
661
  written.push(relKey(referenceDir, "manifest.json"));
656
- return { written, skipped };
662
+ const stopHook = opts.hook && opts.agent === "claude" && opts.scope === "project" ? installStopHook(installRoot, opts.version) : void 0;
663
+ return { written, skipped, stopHook };
664
+ }
665
+ function hasStopHook(projectRoot) {
666
+ try {
667
+ const settings = readFileSync4(join7(projectRoot, ".claude", "settings.json"), "utf8");
668
+ return /\bjig-ui@[^\s"]+ gate\b/.test(settings);
669
+ } catch {
670
+ return false;
671
+ }
672
+ }
673
+ function installStopHook(projectRoot, version2) {
674
+ const path = join7(projectRoot, ".claude", "settings.json");
675
+ let settings = {};
676
+ if (existsSync5(path)) {
677
+ try {
678
+ settings = JSON.parse(readFileSync4(path, "utf8"));
679
+ } catch {
680
+ return false;
681
+ }
682
+ }
683
+ const command = `npx --yes jig-ui@${version2} gate`;
684
+ const hooks = settings.hooks ?? {};
685
+ const isJig = (h) => /\bjig-ui@[^\s]+ gate\b/.test(h.command ?? "");
686
+ const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
687
+ stop.push({ hooks: [{ type: "command", command }] });
688
+ settings.hooks = { ...hooks, Stop: stop };
689
+ mkdirSync4(join7(projectRoot, ".claude"), { recursive: true });
690
+ writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
691
+ return true;
657
692
  }
658
693
 
659
694
  // src/commands/update.ts
@@ -701,7 +736,7 @@ function resolveAllInstalled(projectRoot, homeDir) {
701
736
  }
702
737
 
703
738
  // src/init/state.ts
704
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5 } from "fs";
739
+ import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5 } from "fs";
705
740
  import { dirname as dirname5, join as join8 } from "path";
706
741
  var INIT_MANIFEST_REL = join8(".jig", "state.json");
707
742
  function readInitManifest(projectRoot) {
@@ -724,7 +759,7 @@ function readInitManifest(projectRoot) {
724
759
  var MERGE_ATTEMPTS2 = 5;
725
760
  function writeInitManifest(projectRoot, m) {
726
761
  const path = join8(projectRoot, INIT_MANIFEST_REL);
727
- mkdirSync4(dirname5(path), { recursive: true });
762
+ mkdirSync5(dirname5(path), { recursive: true });
728
763
  for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
729
764
  const onDisk = readInitManifest(projectRoot);
730
765
  const merged = {
@@ -884,6 +919,9 @@ function update(opts) {
884
919
  function updateTarget(opts, resolved) {
885
920
  const { installRoot, scope: discoveredScope, manifest: existing, referenceDir } = resolved;
886
921
  const adapter = getAdapter(existing.agent);
922
+ if (existing.agent === "claude" && discoveredScope === "project" && hasStopHook(installRoot)) {
923
+ installStopHook(installRoot, opts.version);
924
+ }
887
925
  const updated = [];
888
926
  const skipped = [];
889
927
  const writer = createWriter(installRoot, existing.files);
@@ -985,7 +1023,7 @@ function updateInitFiles(opts) {
985
1023
  }
986
1024
 
987
1025
  // src/commands/explain.ts
988
- import { join as join13 } from "path";
1026
+ import { join as join15 } from "path";
989
1027
 
990
1028
  // src/rules/load.ts
991
1029
  import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
@@ -1041,6 +1079,7 @@ function parseRules(markdown, sourceFile) {
1041
1079
 
1042
1080
  // src/rules/schema.ts
1043
1081
  var BUCKETS = ["mechanical", "judgment", "hybrid"];
1082
+ var PASSES = ["code", "screen"];
1044
1083
  var SEVERITIES = ["error", "warning", "note"];
1045
1084
  function validateIndex(entries) {
1046
1085
  if (!Array.isArray(entries)) throw new Error("rules.index.json must be an array");
@@ -1059,13 +1098,22 @@ function validateIndex(entries) {
1059
1098
  if (typeof e.since !== "string") {
1060
1099
  throw new Error(`${at} (${e.id}): since is required`);
1061
1100
  }
1101
+ if (e.bucket === "judgment" && !PASSES.includes(e.pass)) {
1102
+ throw new Error(
1103
+ `${at} (${e.id}): judgment rules need pass: ${PASSES.join(" or ")} \u2014 'code' if it can be decided by reading files, 'screen' if it needs the rendered page`
1104
+ );
1105
+ }
1106
+ if (e.pass !== void 0 && !PASSES.includes(e.pass)) {
1107
+ throw new Error(`${at} (${e.id}): pass must be one of ${PASSES.join(", ")}`);
1108
+ }
1062
1109
  return {
1063
1110
  id: e.id,
1064
1111
  bucket: e.bucket,
1065
1112
  severity: e.severity,
1066
1113
  detector: typeof e.detector === "string" ? e.detector : void 0,
1067
1114
  fix: typeof e.fix === "string" ? e.fix : void 0,
1068
- since: e.since
1115
+ since: e.since,
1116
+ pass: e.pass
1069
1117
  };
1070
1118
  });
1071
1119
  }
@@ -1101,7 +1149,26 @@ function loadRules(rulesDir, indexPath) {
1101
1149
  // src/rules/specs.ts
1102
1150
  import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
1103
1151
  import { join as join12 } from "path";
1104
- var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
1152
+ var KINDS = {
1153
+ P: "pattern",
1154
+ M: "mode",
1155
+ L: "method",
1156
+ // `R` — principles. Both halves of `04-principles.md`: the five frames, which
1157
+ // are generative (how to recognise a failure no rule covers yet), and the
1158
+ // seven tiebreakers, which are adjudicative (which rule yields when two
1159
+ // conflict). The file itself was 0% addressable, so `04` could be cited by
1160
+ // nothing and loaded only as a whole — and the protocol says to load it only
1161
+ // when two rules conflict, which is exactly when a reader wants one entry and
1162
+ // not 141 lines.
1163
+ R: "principle",
1164
+ // `T` — token guidance. The contracts and conventions around the token layer:
1165
+ // what the names mean, what the contrast floor is, how dark mode resolves.
1166
+ // 02-tokens.md was 517 lines at 0% addressable, the largest single block in
1167
+ // the corpus, and the protocol sends readers there "for setup or when adding a
1168
+ // token" — both of which want one contract, not the file.
1169
+ T: "token"
1170
+ };
1171
+ var HEADING2 = /^##\s+([PMLRT]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
1105
1172
  function parseSpecs(markdown, sourceFile) {
1106
1173
  const lines = markdown.split("\n");
1107
1174
  const specs = [];
@@ -1117,7 +1184,13 @@ function parseSpecs(markdown, sourceFile) {
1117
1184
  if (heading) {
1118
1185
  push();
1119
1186
  const [, id, title] = heading;
1120
- current = { id, title, body: "", source: `${sourceFile}#${id.toLowerCase()}` };
1187
+ current = {
1188
+ id,
1189
+ title,
1190
+ kind: KINDS[id[0]],
1191
+ body: "",
1192
+ source: `${sourceFile}#${id.toLowerCase()}`
1193
+ };
1121
1194
  continue;
1122
1195
  }
1123
1196
  if (current && /^##\s/.test(line)) {
@@ -1139,6 +1212,61 @@ function loadSpecs(rulesDir) {
1139
1212
  return specs;
1140
1213
  }
1141
1214
 
1215
+ // src/rules/sections.ts
1216
+ import { readFileSync as readFileSync10, readdirSync as readdirSync6 } from "fs";
1217
+ import { join as join13 } from "path";
1218
+ var RULE_HEADING = /^###\s+[A-Z]-\d+\b/;
1219
+ var SPEC_HEADING = /^##\s+[PMLRT]-\d+\b/;
1220
+ var ANY_HEADING = /^(#{1,6})\s+(.*?)\s*$/;
1221
+ function anchorOf(file, heading) {
1222
+ const slug = heading.toLowerCase().replace(/[`*_]/g, "").replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
1223
+ return `${file}#${slug}`;
1224
+ }
1225
+ function parseSections(markdown, file) {
1226
+ const sections = [];
1227
+ let owner = null;
1228
+ let heading = "";
1229
+ let body = [];
1230
+ const push = () => {
1231
+ if (owner !== "section") return;
1232
+ const text = body.map((l) => l.trim()).filter((l) => !/^-{3,}$/.test(l)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
1233
+ if (text) sections.push({ file, heading, anchor: anchorOf(file, heading), body: text });
1234
+ };
1235
+ for (const line of markdown.split("\n")) {
1236
+ const h = ANY_HEADING.exec(line);
1237
+ if (h) {
1238
+ const level = h[1].length;
1239
+ if (owner === "spec" && level >= 3) continue;
1240
+ push();
1241
+ body = [];
1242
+ if (RULE_HEADING.test(line)) owner = "rule";
1243
+ else if (SPEC_HEADING.test(line)) owner = "spec";
1244
+ else {
1245
+ owner = "section";
1246
+ heading = h[2];
1247
+ }
1248
+ continue;
1249
+ }
1250
+ if (owner === "section") body.push(line);
1251
+ }
1252
+ push();
1253
+ return sections;
1254
+ }
1255
+ function loadSections(rulesDir) {
1256
+ const out = [];
1257
+ for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1258
+ out.push(...parseSections(readFileSync10(join13(rulesDir, file), "utf8"), file));
1259
+ }
1260
+ return out;
1261
+ }
1262
+
1263
+ // src/rules/layers.ts
1264
+ import { readFileSync as readFileSync11 } from "fs";
1265
+ import { join as join14 } from "path";
1266
+ function loadLayers(packageRoot2) {
1267
+ return JSON.parse(readFileSync11(join14(packageRoot2, "layers.json"), "utf8"));
1268
+ }
1269
+
1142
1270
  // src/commands/explain.ts
1143
1271
  var ID_SHAPE = /^([A-Z])-?(\d+)$/;
1144
1272
  var SECTION_SHAPE = /^([A-Z])$/;
@@ -1167,28 +1295,97 @@ function renderRule(rule) {
1167
1295
  ` since ${rule.since} \xB7 ${rule.source}`
1168
1296
  ].join("\n");
1169
1297
  }
1298
+ var KIND_NOTE = {
1299
+ pattern: [
1300
+ "specification \u2014 component anatomy and behaviour, not a single rule,",
1301
+ "so it carries no \u274C/\u2705 pair and no detector."
1302
+ ],
1303
+ mode: [
1304
+ "mode profile \u2014 the character a surface inherits, not a single rule,",
1305
+ "so it carries no \u274C/\u2705 pair and no detector."
1306
+ ],
1307
+ method: [
1308
+ "method \u2014 a procedure to follow before building, not a component to build.",
1309
+ "It applies to any screen, so nothing selects it for you: run it."
1310
+ ],
1311
+ principle: [
1312
+ "principle \u2014 for the case no rule covers, or the case two rules answer",
1313
+ "differently. Not a rule: it has no \u274C/\u2705 pair and nothing detects it."
1314
+ ],
1315
+ token: [
1316
+ "token contract \u2014 what the token layer guarantees and what it expects back.",
1317
+ "Values live in the token files; this is what they mean and how they resolve."
1318
+ ]
1319
+ };
1170
1320
  function renderSpec(spec) {
1321
+ const [what, more] = KIND_NOTE[spec.kind];
1171
1322
  return [
1172
1323
  `${spec.id} ${spec.title}`,
1173
1324
  "",
1174
1325
  spec.body,
1175
1326
  "",
1176
- ` specification \u2014 component anatomy and behaviour, not a single rule,`,
1177
- ` so it carries no \u274C/\u2705 pair and no detector.`,
1327
+ ` ${what}`,
1328
+ ` ${more}`,
1178
1329
  ` ${spec.source}`
1179
1330
  ].join("\n");
1180
1331
  }
1332
+ function searchEntries(rules, specs, sections = []) {
1333
+ return [
1334
+ ...rules.map((r) => ({
1335
+ id: r.id,
1336
+ title: r.title,
1337
+ text: [...r.preamble, r.wrong, r.correction, ...r.notes].join("\n")
1338
+ })),
1339
+ ...specs.map((s) => ({ id: s.id, title: s.title, text: s.body })),
1340
+ ...sections.map((s) => ({ id: s.anchor, title: s.heading, text: s.body, section: s }))
1341
+ ];
1342
+ }
1343
+ function renderSection(s) {
1344
+ return [s.heading, "", s.body, "", ` ${s.anchor}`].join("\n");
1345
+ }
1181
1346
  function explain(opts) {
1182
1347
  const raw = opts.ruleId.trim().toUpperCase();
1183
1348
  const root = opts.packageRoot ?? assetRoot();
1184
- const rulesDir = join13(root, "rules");
1185
- const rules = loadRules(rulesDir, join13(root, "rules.index.json"));
1349
+ const rulesDir = join15(root, "rules");
1350
+ const rules = loadRules(rulesDir, join15(root, "rules.index.json"));
1186
1351
  const specs = loadSpecs(rulesDir);
1187
- const entries = [
1188
- ...rules.map((r) => ({ id: r.id, title: r.title, text: `${r.wrong}
1189
- ${r.correction}` })),
1190
- ...specs.map((s) => ({ id: s.id, title: s.title, text: s.body }))
1191
- ];
1352
+ const sections = loadSections(rulesDir);
1353
+ const entries = searchEntries(rules, specs, sections);
1354
+ if (opts.layer) {
1355
+ const l = loadLayers(root);
1356
+ if (!raw) {
1357
+ const rows = Object.entries(l.layers).map(
1358
+ ([name2, layer2]) => `${name2.padEnd(14)} ${layer2.ids.length === 0 ? String(rules.length).padStart(3) : String(layer2.ids.length).padStart(3)} ${layer2.question}`
1359
+ );
1360
+ const extra = Object.entries(l.not_a_layer).filter(([, g]) => Array.isArray(g.ids)).map(([name2, g]) => `${name2.padEnd(14)} ${String(g.ids.length).padStart(3)} ${g.question ?? ""}`);
1361
+ return [
1362
+ "The six layers \u2014 a view over the corpus, not where anything is stored.",
1363
+ "",
1364
+ ...rows,
1365
+ "",
1366
+ "Not one of the six \u2014 the machinery, rather than the design knowledge:",
1367
+ "",
1368
+ ...extra,
1369
+ "",
1370
+ "Run 'jig explain --layer <name>' for one, or 'jig explain <id>' for an entry."
1371
+ ].join("\n");
1372
+ }
1373
+ const name = raw.toLowerCase();
1374
+ const layer = l.layers[name] ?? l.not_a_layer[name];
1375
+ if (!layer) {
1376
+ throw new Error(
1377
+ `No layer '${raw}'. The six are: ${Object.keys(l.layers).join(", ")}; plus ${Object.keys(l.not_a_layer).filter((k) => !k.startsWith("_")).join(" and ")}.`
1378
+ );
1379
+ }
1380
+ const members = "ids" in layer && layer.ids && layer.ids.length > 0 ? entries.filter((e) => layer.ids.includes(e.id)) : entries.filter((e) => rules.some((r) => r.id === e.id));
1381
+ return [
1382
+ `${name} \u2014 ${members.length} entries`,
1383
+ ..."question" in layer && layer.question ? ["", layer.question] : [],
1384
+ ..."note" in layer && layer.note ? ["", layer.note] : [],
1385
+ "",
1386
+ ...members.sort((a, b) => byNumber(a.id, b.id)).map((e) => `${e.id.padEnd(6)} ${e.title}`)
1387
+ ].join("\n");
1388
+ }
1192
1389
  if (opts.list) {
1193
1390
  const section = SECTION_SHAPE.exec(raw)?.[1];
1194
1391
  const shown = entries.filter((e) => !section || e.id.startsWith(`${section}-`)).sort((a, b) => byNumber(a.id, b.id));
@@ -1219,11 +1416,19 @@ ${r.correction}` })),
1219
1416
  );
1220
1417
  }
1221
1418
  const needle = opts.ruleId.trim();
1222
- const inTitle = entries.filter((e) => matches(e.title, needle) || matches(e.id, needle));
1223
- const inBody = entries.filter((e) => !inTitle.includes(e) && matches(e.text, needle));
1419
+ const byAnchor = sections.find((sec) => sec.anchor === needle.toLowerCase());
1420
+ if (byAnchor) return renderSection(byAnchor);
1421
+ const ids = entries.filter((e) => !e.section);
1422
+ const secs = entries.filter((e) => e.section);
1423
+ const idTitle = ids.filter((e) => matches(e.title, needle) || matches(e.id, needle));
1424
+ const idBody = ids.filter((e) => !idTitle.includes(e) && matches(e.text, needle));
1425
+ const secTitle = secs.filter((e) => matches(e.title, needle));
1426
+ const secBody = secs.filter((e) => !secTitle.includes(e) && matches(e.text, needle));
1224
1427
  const hits = [
1225
- ...inTitle.sort((a, b) => byNumber(a.id, b.id)),
1226
- ...inBody.sort((a, b) => byNumber(a.id, b.id))
1428
+ ...idTitle.sort((a, b) => byNumber(a.id, b.id)),
1429
+ ...idBody.sort((a, b) => byNumber(a.id, b.id)),
1430
+ ...secTitle,
1431
+ ...secBody
1227
1432
  ];
1228
1433
  if (hits.length === 0) {
1229
1434
  throw new Error(
@@ -1231,6 +1436,7 @@ ${r.correction}` })),
1231
1436
  );
1232
1437
  }
1233
1438
  if (hits.length === 1) {
1439
+ if (hits[0].section) return renderSection(hits[0].section);
1234
1440
  const rule = rules.find((r) => r.id === hits[0].id);
1235
1441
  return rule ? renderRule(rule) : renderSpec(specs.find((s) => s.id === hits[0].id));
1236
1442
  }
@@ -1239,7 +1445,7 @@ ${r.correction}` })),
1239
1445
  // otherwise C-19 sitting above A-02 reads as a sorting bug.
1240
1446
  `${hits.length} entries match '${needle}' \u2014 title matches first:`,
1241
1447
  "",
1242
- ...hits.map((e) => `${e.id.padEnd(6)} ${e.title}`),
1448
+ ...hits.map((e) => e.section ? `${e.id} ${e.title}` : `${e.id.padEnd(6)} ${e.title}`),
1243
1449
  "",
1244
1450
  `Run 'jig explain <id>' for any of them.`
1245
1451
  ].join("\n");
@@ -1249,13 +1455,13 @@ function sectionsOf(entries) {
1249
1455
  }
1250
1456
 
1251
1457
  // src/commands/check.ts
1252
- import { existsSync as existsSync12, readFileSync as readFileSync14 } from "fs";
1253
- import { join as join19 } from "path";
1458
+ import { existsSync as existsSync14, readFileSync as readFileSync18 } from "fs";
1459
+ import { join as join23 } from "path";
1254
1460
 
1255
1461
  // src/check/files.ts
1256
1462
  import { execFileSync } from "child_process";
1257
- import { readdirSync as readdirSync6, realpathSync, statSync } from "fs";
1258
- import { join as join14, relative, sep } from "path";
1463
+ import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
1464
+ import { join as join16, relative, sep } from "path";
1259
1465
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1260
1466
  "node_modules",
1261
1467
  "dist",
@@ -1314,13 +1520,13 @@ function isExcluded(relPath) {
1314
1520
  function walk(root, dir, out, seen) {
1315
1521
  let entries;
1316
1522
  try {
1317
- entries = readdirSync6(dir, { withFileTypes: true });
1523
+ entries = readdirSync7(dir, { withFileTypes: true });
1318
1524
  } catch {
1319
1525
  return;
1320
1526
  }
1321
1527
  for (const entry of entries) {
1322
1528
  if (EXCLUDE_DIRS.has(entry.name)) continue;
1323
- const abs = join14(dir, entry.name);
1529
+ const abs = join16(dir, entry.name);
1324
1530
  let isDir = entry.isDirectory();
1325
1531
  let isFile = entry.isFile();
1326
1532
  if (entry.isSymbolicLink()) {
@@ -1441,7 +1647,7 @@ function formatReport(findings, meta) {
1441
1647
  }
1442
1648
  }
1443
1649
  const errors = findings.filter((f) => f.severity === "error").length;
1444
- const warnings = findings.filter((f) => f.severity === "warning").length;
1650
+ const warnings = findings.filter((f) => f.severity === "warning").length + (meta.modeUnwired?.length ?? 0);
1445
1651
  const notes = findings.filter((f) => f.severity === "note").length;
1446
1652
  const summaryParts = [plural(errors, "error")];
1447
1653
  if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
@@ -1480,10 +1686,19 @@ function formatReport(findings, meta) {
1480
1686
  ` ${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.`
1481
1687
  );
1482
1688
  }
1689
+ if (meta.modeUnwired && meta.modeUnwired.length > 0) {
1690
+ lines.push("");
1691
+ for (const p of meta.modeUnwired) lines.push(` \u26A0 ${p.message}.`);
1692
+ if (meta.modeUnwired.some((p) => p.barrel === "jig.config.json")) {
1693
+ lines.push(` Fix jig.config.json by hand \u2014 init leaves an edited config alone \u2014 then run 'jig init' again.`);
1694
+ } else {
1695
+ lines.push(` Run 'jig init' again: it rewrites the token layer for the modes jig.config.json declares.`);
1696
+ }
1697
+ }
1483
1698
  const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
1484
1699
  const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
1485
1700
  lines.push(
1486
- ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run files=${meta.scanned ?? "unknown"} styled=${meta.withStyles ?? "unknown"}`
1701
+ ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} warnings=${warnings} judgment=not-run files=${meta.scanned ?? "unknown"} styled=${meta.withStyles ?? "unknown"}`
1487
1702
  );
1488
1703
  return lines.join("\n");
1489
1704
  }
@@ -1583,8 +1798,8 @@ function isStyleBearing(file) {
1583
1798
  }
1584
1799
 
1585
1800
  // src/check/run.ts
1586
- import { readFileSync as readFileSync10 } from "fs";
1587
- import { join as join15 } from "path";
1801
+ import { readFileSync as readFileSync13 } from "fs";
1802
+ import { join as join18 } from "path";
1588
1803
 
1589
1804
  // src/check/css.ts
1590
1805
  function maskComments(source) {
@@ -2772,6 +2987,318 @@ var violetBandHue = {
2772
2987
  }
2773
2988
  };
2774
2989
 
2990
+ // src/check/detectors/fixed-width.ts
2991
+ var PHONE_WIDTH_PX = 480;
2992
+ var GRID_COLUMNS_RE = /\bgrid-template-columns\s*:\s*([^;}]+)/i;
2993
+ var FLEX_RE = /\bdisplay\s*:\s*(?:inline-)?flex\b/i;
2994
+ var COLUMN_DIRECTION_RE = /\bflex-direction\s*:\s*column(?:-reverse)?\b|\bflex-flow\s*:\s*column\b/i;
2995
+ var FIXED_WIDTH_RE = /(?<![-\w])(?:min-)?width\s*:\s*(\d+(?:\.\d+)?)px\b/i;
2996
+ function isMultiTrack(value) {
2997
+ const v = value.trim();
2998
+ const repeat = /repeat\s*\(\s*(\d+)\s*,/i.exec(v);
2999
+ if (repeat) return Number(repeat[1]) > 1;
3000
+ const tracks = v.replace(/\([^)]*\)/g, "()").split(/\s+/).filter(Boolean);
3001
+ return tracks.length > 1;
3002
+ }
3003
+ var fixedWidth = {
3004
+ name: "fixed-width",
3005
+ appliesTo: (file) => isStyleBearing(file),
3006
+ run(source, file, ctx) {
3007
+ if (ctx.projectResponsive !== false) return [];
3008
+ const findings = [];
3009
+ for (const block of leafBlocks(source)) {
3010
+ const body = block.body;
3011
+ let offset = -1;
3012
+ let message = "";
3013
+ const grid = GRID_COLUMNS_RE.exec(body);
3014
+ const width = FIXED_WIDTH_RE.exec(body);
3015
+ if (grid && isMultiTrack(grid[1])) {
3016
+ offset = grid.index;
3017
+ message = "multi-column grid, and nothing in the project changes it at a narrow width";
3018
+ } else if (FLEX_RE.test(body) && !COLUMN_DIRECTION_RE.test(body)) {
3019
+ offset = FLEX_RE.exec(body).index;
3020
+ message = "row of flex items with no wrap, and nothing in the project changes it at a narrow width";
3021
+ } else if (width && Number(width[1]) > PHONE_WIDTH_PX) {
3022
+ offset = width.index;
3023
+ message = `fixed ${width[1]}px width, wider than a phone, and nothing in the project adapts it`;
3024
+ }
3025
+ if (offset < 0) continue;
3026
+ const line = lineOfOffset(block, offset);
3027
+ findings.push(mkFinding(ctx, "fixed-width", file, line, message, sourceLine(source, line)));
3028
+ }
3029
+ return findings;
3030
+ }
3031
+ };
3032
+
3033
+ // src/check/detectors/viewport-height.ts
3034
+ var VH_DECL_RE = /(?<![-\w])((?:min-|max-)?height)\s*:\s*([^;}]*\b100vh\b[^;}]*)/gi;
3035
+ var MODERN_UNIT_RE = /\b\d*\.?\d+(?:dvh|svh|lvh)\b/i;
3036
+ var viewportHeight = {
3037
+ name: "viewport-height",
3038
+ appliesTo: (file) => isStyleBearing(file),
3039
+ run(source, file, ctx) {
3040
+ if (MODERN_UNIT_RE.test(source)) return [];
3041
+ const findings = [];
3042
+ for (const block of leafBlocks(source)) {
3043
+ VH_DECL_RE.lastIndex = 0;
3044
+ let m;
3045
+ while (m = VH_DECL_RE.exec(block.body)) {
3046
+ const line = lineOfOffset(block, m.index);
3047
+ findings.push(
3048
+ mkFinding(
3049
+ ctx,
3050
+ "viewport-height",
3051
+ file,
3052
+ line,
3053
+ `${m[1]} uses 100vh, which is taller than the visible area on a phone while its browser bars show`,
3054
+ sourceLine(source, line)
3055
+ )
3056
+ );
3057
+ }
3058
+ }
3059
+ return findings;
3060
+ }
3061
+ };
3062
+
3063
+ // src/check/detectors/input-zoom.ts
3064
+ var SELECTOR_RE = /(?:^|[\s,>+~(])(?:input|select|textarea)\b(?![-\w])|\[contenteditable/i;
3065
+ var SIZE_RE = /(?<![-\w])font-size\s*:\s*([^;}]+)/gi;
3066
+ var TOUCH_QUERY_RE = /@media[^{]*\b(?:pointer\s*:\s*coarse|hover\s*:\s*none)\b/i;
3067
+ var ROOT_PX = 16;
3068
+ function pixels(value, mode) {
3069
+ const v = value.trim().replace(/\s*!important$/i, "");
3070
+ const unit = /^(\d*\.?\d+)(px|rem|em)$/i.exec(v);
3071
+ if (unit) return unit[2].toLowerCase() === "px" ? Number(unit[1]) : Number(unit[1]) * ROOT_PX;
3072
+ if (/^var\(\s*--text-caption\s*\)$/i.test(v)) return 14;
3073
+ if (/^var\(\s*--text-body\s*\)$/i.test(v) && mode === "operator") return 14;
3074
+ return void 0;
3075
+ }
3076
+ var inputZoom = {
3077
+ name: "input-zoom",
3078
+ appliesTo: (file) => isStyleBearing(file),
3079
+ run(source, file, ctx) {
3080
+ if (TOUCH_QUERY_RE.test(source)) return [];
3081
+ const findings = [];
3082
+ for (const block of leafBlocks(source)) {
3083
+ if (!SELECTOR_RE.test(block.selector)) continue;
3084
+ SIZE_RE.lastIndex = 0;
3085
+ let m;
3086
+ while (m = SIZE_RE.exec(block.body)) {
3087
+ const px = pixels(m[1], ctx.mode);
3088
+ if (px === void 0 || px >= ROOT_PX) continue;
3089
+ const line = lineOfOffset(block, m.index);
3090
+ findings.push(
3091
+ mkFinding(
3092
+ ctx,
3093
+ "input-zoom",
3094
+ file,
3095
+ line,
3096
+ `form control text resolves to ${px}px; iOS zooms the page on focus below 16px`,
3097
+ sourceLine(source, line)
3098
+ )
3099
+ );
3100
+ }
3101
+ }
3102
+ return findings;
3103
+ }
3104
+ };
3105
+
3106
+ // src/check/detectors/safe-area.ts
3107
+ var PINNED_RE = /\bposition\s*:\s*(?:fixed|sticky)\b/i;
3108
+ var EDGE_RE = /(?<![-\w])(top|bottom|left|right|inset(?:-block|-inline)?(?:-start|-end)?)\s*:\s*0(?:px|rem|em)?\s*(?:[;}]|$)/i;
3109
+ var INSET_RE = /\b(?:env|constant)\(\s*safe-area-inset-/i;
3110
+ var safeArea = {
3111
+ name: "safe-area",
3112
+ appliesTo: (file) => isStyleBearing(file),
3113
+ run(source, file, ctx) {
3114
+ if (ctx.viewportFitCover !== true) return [];
3115
+ const findings = [];
3116
+ for (const block of leafBlocks(source)) {
3117
+ const body = block.body;
3118
+ if (!PINNED_RE.test(body) || INSET_RE.test(body)) continue;
3119
+ const edge = EDGE_RE.exec(body);
3120
+ if (!edge) continue;
3121
+ const line = lineOfOffset(block, PINNED_RE.exec(body).index);
3122
+ findings.push(
3123
+ mkFinding(
3124
+ ctx,
3125
+ "safe-area",
3126
+ file,
3127
+ line,
3128
+ `pinned to the ${edge[1]} edge of a page that extends under the notch, with no safe-area inset`,
3129
+ sourceLine(source, line)
3130
+ )
3131
+ );
3132
+ }
3133
+ return findings;
3134
+ }
3135
+ };
3136
+
3137
+ // src/check/detectors/menu-state.ts
3138
+ var HIDDEN_RE = /(?<![-\w])display\s*:\s*none\b/i;
3139
+ var NAV_SELECTOR_RE = /(?:^|[\s>+~.#])(?:nav|[\w-]*(?:nav|navigation|menu)[\w-]*)(?:$|[\s.#:[>+~])/i;
3140
+ var SKIP_RE = /sub-?menu|dropdown|flyout|mega|button|btn|toggle|trigger|burger|icon/i;
3141
+ var NESTED_LIST_RE = /\bli\b[^,]*\b(?:ul|ol)\b/i;
3142
+ function navSelectors(selectorList) {
3143
+ return selectorList.split(",").map((s) => s.trim()).filter((s) => s && !SKIP_RE.test(s) && !NESTED_LIST_RE.test(s) && !/:hover|:focus/i.test(s)).filter((s) => s.split(/\s+|>|\+|~/).some((c) => NAV_SELECTOR_RE.test(c)));
3144
+ }
3145
+ var MENU_BUTTON_RE = /<button\b([^>]*)>([\s\S]{0,200}?)<\/button>/gi;
3146
+ var MENU_NAME_RE = /^\s*(?:open |toggle |show )?(?:the )?(?:site |main )?(?:menu|navigation|nav)\s*$/i;
3147
+ function deadMenuButtons(raw) {
3148
+ const offsets = [];
3149
+ for (const m of raw.matchAll(MENU_BUTTON_RE)) {
3150
+ const label = /\baria-label\s*=\s*["']([^"']*)["']/i.exec(m[1])?.[1];
3151
+ const text = m[2].replace(/<[^>]*>/g, "").trim();
3152
+ if (MENU_NAME_RE.test(label ?? "") || MENU_NAME_RE.test(text)) offsets.push(m.index);
3153
+ }
3154
+ return offsets;
3155
+ }
3156
+ var menuState = {
3157
+ name: "menu-state",
3158
+ appliesTo: (file) => isStyleBearing(file),
3159
+ run(source, file, ctx) {
3160
+ if (ctx.projectMenuToggle !== false) return [];
3161
+ const findings = [];
3162
+ if (/\.(html?|vue|svelte|astro|jsx|tsx|php|erb|twig|hbs)$/i.test(file)) {
3163
+ const starts = buildLineIndex(ctx.raw);
3164
+ for (const offset of deadMenuButtons(ctx.raw)) {
3165
+ const line = lineForOffset(starts, offset);
3166
+ findings.push(
3167
+ mkFinding(
3168
+ ctx,
3169
+ "menu-state",
3170
+ file,
3171
+ line,
3172
+ "a Menu button that nothing opens \u2014 no script or binding ever changes aria-expanded, and no <details>",
3173
+ sourceLine(ctx.raw, line)
3174
+ )
3175
+ );
3176
+ }
3177
+ }
3178
+ for (const block of leafBlocks(source)) {
3179
+ const hidden = HIDDEN_RE.exec(block.body);
3180
+ if (!hidden) continue;
3181
+ const names = navSelectors(block.selector.replace(/^[\s\S]*[;}]/, ""));
3182
+ if (names.length === 0) continue;
3183
+ const line = lineOfOffset(block, hidden.index);
3184
+ findings.push(
3185
+ mkFinding(
3186
+ ctx,
3187
+ "menu-state",
3188
+ file,
3189
+ line,
3190
+ `${names[0]} is hidden, and nothing in the project opens it with aria-expanded or <details>`,
3191
+ sourceLine(source, line)
3192
+ )
3193
+ );
3194
+ }
3195
+ return findings;
3196
+ }
3197
+ };
3198
+
3199
+ // src/check/declared-properties.ts
3200
+ import { existsSync as existsSync9, readFileSync as readFileSync12 } from "fs";
3201
+ import { join as join17 } from "path";
3202
+ var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
3203
+ var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
3204
+ var SET_PROPERTY_RE = /setProperty\(\s*['"`](--[\w-]+)/g;
3205
+ var IMPORT_RE2 = /@import\s+(?:url\(\s*)?["']([^"')]+)["']/g;
3206
+ var TAILWIND_RE = /@import\s+["']tailwindcss["']/;
3207
+ function addAll(names, text) {
3208
+ for (const re of [DECLARATION_RE, AT_PROPERTY_RE, SET_PROPERTY_RE]) {
3209
+ for (const m of text.matchAll(re)) names.add(m[1]);
3210
+ }
3211
+ }
3212
+ function readPackageImport(projectRoot, spec) {
3213
+ const base = join17(projectRoot, "node_modules", spec);
3214
+ for (const candidate of [base, `${base}.css`]) {
3215
+ try {
3216
+ if (existsSync9(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
3217
+ } catch {
3218
+ }
3219
+ }
3220
+ try {
3221
+ const pkg = JSON.parse(readFileSync12(join17(base, "package.json"), "utf8"));
3222
+ if (pkg.style) return readFileSync12(join17(base, pkg.style), "utf8");
3223
+ } catch {
3224
+ }
3225
+ return void 0;
3226
+ }
3227
+ function collectDeclaredProperties(projectRoot, files, tokenNames = []) {
3228
+ const names = new Set(tokenNames.map((n) => `--${n}`));
3229
+ let tailwind = false;
3230
+ let complete = true;
3231
+ for (const file of files) {
3232
+ let raw;
3233
+ try {
3234
+ raw = readFileSync12(join17(projectRoot, file), "utf8");
3235
+ } catch {
3236
+ continue;
3237
+ }
3238
+ addAll(names, raw);
3239
+ if (TAILWIND_RE.test(raw)) tailwind = true;
3240
+ for (const m of raw.matchAll(IMPORT_RE2)) {
3241
+ const spec = m[1];
3242
+ if (spec === "tailwindcss" || /^(\.|\/|https?:)/.test(spec)) continue;
3243
+ const imported = readPackageImport(projectRoot, spec);
3244
+ if (imported === void 0) complete = false;
3245
+ else addAll(names, imported);
3246
+ }
3247
+ }
3248
+ return { names, tailwind, complete };
3249
+ }
3250
+ var PALETTE2 = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|mauve|olive|mist|taupe";
3251
+ var SIZE = "3xs|2xs|xs|sm|md|lg|xl|[2-9]xl|base";
3252
+ var TAILWIND_DEFAULT_RE = new RegExp(
3253
+ `^--(?:tw-[\\w-]+|color-(?:(?:${PALETTE2})-(?:50|[1-9]00|950)|black|white)|spacing|container-(?:${SIZE})|text-(?:${SIZE})(?:--line-height|--letter-spacing|--font-weight)?|font-(?:sans|serif|mono)|font-weight-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|tracking-(?:tighter|tight|normal|wide|wider|widest)|leading-(?:tight|snug|normal|relaxed|loose)|breakpoint-(?:${SIZE})|radius-(?:${SIZE})|(?:inset-shadow|drop-shadow|text-shadow|shadow|blur|perspective|ease|animate|aspect|default)-[\\w-]+|shadow|blur)$`
3254
+ );
3255
+ function isTailwindDefault(name) {
3256
+ return TAILWIND_DEFAULT_RE.test(name);
3257
+ }
3258
+
3259
+ // src/check/detectors/undeclared-token.ts
3260
+ var VAR_RE2 = /var\(\s*(--[\w-]+)\s*([,)])/g;
3261
+ var CLASS_ATTR_RE = /\bclass(?:Name)?\s*=\s*(?:"[^"]*"|'[^']*'|\{`[^`]*`\})/g;
3262
+ var CLASS_REF_RE = /(?:-\(\s*|var\(\s*)(--[\w-]+)\s*\)/g;
3263
+ var undeclaredToken = {
3264
+ name: "undeclared-token",
3265
+ appliesTo: (file) => isStyleBearing(file),
3266
+ run(source, file, ctx) {
3267
+ const declared = ctx.declaredProperties;
3268
+ if (!declared || !declared.complete) return [];
3269
+ const known = (name) => declared.names.has(name) || declared.tailwind && isTailwindDefault(name) || name.startsWith("--tw-");
3270
+ const hits = /* @__PURE__ */ new Map();
3271
+ const note = (name, offset, text) => {
3272
+ if (known(name)) return;
3273
+ const key = `${name}@${offset}`;
3274
+ if (!hits.has(key)) hits.set(key, { name, offset, text });
3275
+ };
3276
+ for (const m of source.matchAll(VAR_RE2)) {
3277
+ if (m[2] === ",") continue;
3278
+ note(m[1], m.index, source);
3279
+ }
3280
+ for (const attr of ctx.raw.matchAll(CLASS_ATTR_RE)) {
3281
+ for (const m of attr[0].matchAll(CLASS_REF_RE)) note(m[1], attr.index + m.index, ctx.raw);
3282
+ }
3283
+ const lines = buildLineIndex(ctx.raw);
3284
+ const findings = [];
3285
+ for (const { name, offset } of hits.values()) {
3286
+ const line = lineForOffset(lines, offset);
3287
+ findings.push(
3288
+ mkFinding(
3289
+ ctx,
3290
+ "undeclared-token",
3291
+ file,
3292
+ line,
3293
+ `${name} is not declared anywhere in the project, so every property using it is dropped`,
3294
+ sourceLine(ctx.raw, line)
3295
+ )
3296
+ );
3297
+ }
3298
+ return findings.sort((a, b) => a.line - b.line);
3299
+ }
3300
+ };
3301
+
2775
3302
  // src/check/registry.ts
2776
3303
  var DETECTORS = [
2777
3304
  gradientText,
@@ -2783,14 +3310,20 @@ var DETECTORS = [
2783
3310
  contrastFloor,
2784
3311
  focusRemoved,
2785
3312
  hardcodedValue,
2786
- violetBandHue
3313
+ violetBandHue,
3314
+ fixedWidth,
3315
+ viewportHeight,
3316
+ inputZoom,
3317
+ safeArea,
3318
+ menuState,
3319
+ undeclaredToken
2787
3320
  ];
2788
3321
  function getDetector(name) {
2789
3322
  return DETECTORS.find((d) => d.name === name);
2790
3323
  }
2791
3324
 
2792
3325
  // src/check/run.ts
2793
- function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode) {
3326
+ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties) {
2794
3327
  const findings = [];
2795
3328
  const active = index.filter(
2796
3329
  (e) => !!e.detector && !!getDetector(e.detector)
@@ -2802,13 +3335,13 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
2802
3335
  if (applicable.length === 0) continue;
2803
3336
  let raw;
2804
3337
  try {
2805
- raw = readFileSync10(join15(projectRoot, file), "utf8");
3338
+ raw = readFileSync13(join18(projectRoot, file), "utf8");
2806
3339
  } catch {
2807
3340
  continue;
2808
3341
  }
2809
3342
  const source = maskComments(maskNonStyleRegions(raw, file));
2810
3343
  for (const { entry, detector } of applicable) {
2811
- const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode };
3344
+ const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties };
2812
3345
  findings.push(...detector.run(source, file, ctx));
2813
3346
  }
2814
3347
  }
@@ -2816,8 +3349,8 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
2816
3349
  }
2817
3350
 
2818
3351
  // src/check/tokens.ts
2819
- import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync11 } from "fs";
2820
- import { join as join16 } from "path";
3352
+ import { existsSync as existsSync10, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
3353
+ import { join as join19 } from "path";
2821
3354
  function rootDeclarations(source) {
2822
3355
  const out = {};
2823
3356
  for (const block of splitRuleBlocks(source)) {
@@ -2832,20 +3365,20 @@ function rootDeclarations(source) {
2832
3365
  }
2833
3366
  function loadTokenMap(projectRoot, consumerFiles = []) {
2834
3367
  const jig = {};
2835
- const tokensDir = join16(projectRoot, ".jig", "tokens");
2836
- if (existsSync9(tokensDir)) {
2837
- for (const file of readdirSync7(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
2838
- Object.assign(jig, rootDeclarations(readFileSync11(join16(tokensDir, file), "utf8")));
3368
+ const tokensDir = join19(projectRoot, ".jig", "tokens");
3369
+ if (existsSync10(tokensDir)) {
3370
+ for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
3371
+ Object.assign(jig, rootDeclarations(readFileSync14(join19(tokensDir, file), "utf8")));
2839
3372
  }
2840
3373
  }
2841
3374
  const consumer = {};
2842
3375
  const ambiguous = /* @__PURE__ */ new Set();
2843
- const vendored = join16(".jig", "tokens");
3376
+ const vendored = join19(".jig", "tokens");
2844
3377
  for (const file of consumerFiles) {
2845
3378
  if (file.includes(vendored)) continue;
2846
3379
  let source;
2847
3380
  try {
2848
- source = readFileSync11(join16(projectRoot, file), "utf8");
3381
+ source = readFileSync14(join19(projectRoot, file), "utf8");
2849
3382
  } catch {
2850
3383
  continue;
2851
3384
  }
@@ -2864,8 +3397,8 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
2864
3397
  }
2865
3398
 
2866
3399
  // src/check/exempt.ts
2867
- import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
2868
- import { join as join17 } from "path";
3400
+ import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
3401
+ import { join as join20 } from "path";
2869
3402
  function globToRegExp(glob) {
2870
3403
  let out = "";
2871
3404
  for (let i = 0; i < glob.length; i++) {
@@ -2884,11 +3417,11 @@ function globToRegExp(glob) {
2884
3417
  return new RegExp(`^${out}$`);
2885
3418
  }
2886
3419
  function readExemptions(projectRoot) {
2887
- const configPath = join17(projectRoot, "jig.config.json");
2888
- if (!existsSync10(configPath)) return [];
3420
+ const configPath = join20(projectRoot, "jig.config.json");
3421
+ if (!existsSync11(configPath)) return [];
2889
3422
  let declared;
2890
3423
  try {
2891
- declared = JSON.parse(readFileSync12(configPath, "utf8")).exempt;
3424
+ declared = JSON.parse(readFileSync15(configPath, "utf8")).exempt;
2892
3425
  } catch {
2893
3426
  return [];
2894
3427
  }
@@ -2919,9 +3452,114 @@ function applyExemptions(files, patterns) {
2919
3452
  };
2920
3453
  }
2921
3454
 
3455
+ // src/check/responsive.ts
3456
+ var MEDIA_RE = /@media\b([^{]*)\{/gi;
3457
+ var VIEWPORT_FEATURE_RE = /\b(?:min-|max-)?(?:width|height|aspect-ratio|orientation|resolution)\b|\b(?:any-)?(?:pointer|hover)\b/i;
3458
+ var INTRINSIC_RE = [
3459
+ /@container\b/i,
3460
+ /\bminmax\s*\(/i,
3461
+ /\bauto-(?:fit|fill)\b/i,
3462
+ /\bflex-wrap\s*:\s*wrap(?:-reverse)?\b/i,
3463
+ /\bclamp\s*\(/i
3464
+ ];
3465
+ var TAILWIND_BREAKPOINT_RE = /(?:^|[\s"'`{])(?:max-)?(?:sm|md|lg|xl|2xl|@(?:xs|sm|md|lg|xl))(?:\/[\w-]+)?:[\w[-]/;
3466
+ function isResponsive(source, raw) {
3467
+ MEDIA_RE.lastIndex = 0;
3468
+ let m;
3469
+ while (m = MEDIA_RE.exec(source)) {
3470
+ if (VIEWPORT_FEATURE_RE.test(m[1])) return true;
3471
+ }
3472
+ if (INTRINSIC_RE.some((re) => re.test(source))) return true;
3473
+ return TAILWIND_BREAKPOINT_RE.test(raw);
3474
+ }
3475
+
3476
+ // src/check/menu-toggle.ts
3477
+ var NAV_REGION_RE = /<(nav|header)\b[\s\S]*?<\/\1\s*>/gi;
3478
+ var DETAILS_RE = /<details\b/i;
3479
+ var BOUND_RE = /(?::|x-bind:|\[attr\.)aria-expanded\b|\baria-expanded\s*=\s*(?:\{|["'][^"']*[{$@])/i;
3480
+ var EXPANDED_TAG_RE = /<[a-z][\w-]*\b[^>]*\baria-expanded\b[^>]*>/gi;
3481
+ var NAMES_MENU_RE = /menu|nav/i;
3482
+ var SCRIPT_STATE_RE = /setAttribute\(\s*['"`]aria-expanded['"`]|toggleAttribute\(\s*['"`]aria-expanded|\.ariaExpanded\s*=|dataset\.expanded|\[['"]aria-expanded['"]\]\s*=/;
3483
+ function hasMenuToggle(raw) {
3484
+ const scripted = SCRIPT_STATE_RE.test(raw);
3485
+ for (const region of raw.match(NAV_REGION_RE) ?? []) {
3486
+ if (DETAILS_RE.test(region)) return true;
3487
+ if (/\baria-expanded\b/i.test(region) && (scripted || BOUND_RE.test(region))) return true;
3488
+ }
3489
+ for (const tag of raw.match(EXPANDED_TAG_RE) ?? []) {
3490
+ if (!NAMES_MENU_RE.test(tag.replace(/\baria-expanded\b/i, ""))) continue;
3491
+ if (scripted || BOUND_RE.test(tag)) return true;
3492
+ }
3493
+ return scripted;
3494
+ }
3495
+
3496
+ // src/check/mode-wiring.ts
3497
+ import { existsSync as existsSync12, readFileSync as readFileSync16 } from "fs";
3498
+ import { dirname as dirname6, join as join21 } from "path";
3499
+ var MODES = ["editorial", "product", "operator"];
3500
+ function configProblems(config) {
3501
+ const problems = [];
3502
+ const at = "jig.config.json";
3503
+ if (config.brand !== void 0) {
3504
+ if (typeof config.brand !== "string" || !config.brand.endsWith(".css")) {
3505
+ problems.push({ mode: "", barrel: at, message: `jig.config.json "brand" is ${JSON.stringify(config.brand)}, which is not a brand .css file in this project` });
3506
+ }
3507
+ }
3508
+ if (config.surfaces !== void 0) {
3509
+ const ok = Array.isArray(config.surfaces) && config.surfaces.every(
3510
+ (x) => x && typeof x === "object" && typeof x.match === "string" && MODES.includes(x.mode ?? "")
3511
+ );
3512
+ if (!ok) {
3513
+ problems.push({ mode: "", barrel: at, message: `jig.config.json "surfaces" must be a list of {"match": "/", "mode": "${MODES.join("|")}"} \u2014 as written, no mode applies and mode-gated rules are silent` });
3514
+ }
3515
+ }
3516
+ return problems;
3517
+ }
3518
+ function modeWiringProblems(projectRoot) {
3519
+ let config;
3520
+ const path = join21(projectRoot, "jig.config.json");
3521
+ if (!existsSync12(path)) return [];
3522
+ try {
3523
+ config = JSON.parse(readFileSync16(path, "utf8"));
3524
+ } catch {
3525
+ return [{ mode: "", barrel: "jig.config.json", message: "jig.config.json is not valid JSON, so nothing in it applies" }];
3526
+ }
3527
+ const invalid = configProblems(config);
3528
+ if (invalid.length > 0) return invalid;
3529
+ if (typeof config.brand !== "string" || !Array.isArray(config.surfaces)) return [];
3530
+ const modes = [...new Set(config.surfaces.map((s) => s?.mode).filter((m) => typeof m === "string"))];
3531
+ if (modes.length === 0) return [];
3532
+ const dir = dirname6(config.brand);
3533
+ const rel = (f) => dir === "." ? f : `${dir}/${f}`;
3534
+ if (!existsSync12(join21(projectRoot, rel("theme.css")))) return [];
3535
+ const problems = [];
3536
+ modes.forEach((mode, i) => {
3537
+ const barrel = rel(i === 0 ? "theme.css" : `theme.${mode}.css`);
3538
+ const modeFile = `mode.${mode}.css`;
3539
+ let body;
3540
+ try {
3541
+ body = readFileSync16(join21(projectRoot, barrel), "utf8");
3542
+ } catch {
3543
+ problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${barrel} does not exist` });
3544
+ return;
3545
+ }
3546
+ const imported = /@import\s+["'][^"']*mode\.(\w+)\.css["']/.exec(body)?.[1];
3547
+ if (imported !== mode) {
3548
+ problems.push({
3549
+ mode,
3550
+ barrel,
3551
+ message: `jig.config.json declares ${mode}, but ${barrel} imports ${imported ? `mode.${imported}.css` : "no mode file"} \u2014 the page is styled at ${imported ?? "no"} density`
3552
+ });
3553
+ } else if (!existsSync12(join21(projectRoot, rel(modeFile)))) {
3554
+ problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
3555
+ }
3556
+ });
3557
+ return problems;
3558
+ }
3559
+
2922
3560
  // src/check/token-audit.ts
2923
- import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync13 } from "fs";
2924
- import { join as join18 } from "path";
3561
+ import { existsSync as existsSync13, readdirSync as readdirSync9, readFileSync as readFileSync17 } from "fs";
3562
+ import { join as join22 } from "path";
2925
3563
  var TEXT_FLOOR = 4.5;
2926
3564
  var UI_FLOOR = 3;
2927
3565
  var FOREGROUNDS = [
@@ -2977,24 +3615,24 @@ function floorFor(token) {
2977
3615
  function tokenFiles(projectRoot) {
2978
3616
  const read = (rel) => {
2979
3617
  try {
2980
- return readFileSync13(join18(projectRoot, ...rel.split("/")), "utf8");
3618
+ return readFileSync17(join22(projectRoot, ...rel.split("/")), "utf8");
2981
3619
  } catch {
2982
3620
  return null;
2983
3621
  }
2984
3622
  };
2985
- const statePath = join18(projectRoot, ".jig", "state.json");
2986
- if (existsSync11(statePath)) {
3623
+ const statePath = join22(projectRoot, ".jig", "state.json");
3624
+ if (existsSync13(statePath)) {
2987
3625
  try {
2988
- const state = JSON.parse(readFileSync13(statePath, "utf8"));
3626
+ const state = JSON.parse(readFileSync17(statePath, "utf8"));
2989
3627
  const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
2990
3628
  const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
2991
3629
  if (out.length > 0) return out;
2992
3630
  } catch {
2993
3631
  }
2994
3632
  }
2995
- const legacy = join18(projectRoot, ".jig", "tokens");
2996
- if (!existsSync11(legacy)) return [];
2997
- return readdirSync8(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync13(join18(legacy, f), "utf8") }));
3633
+ const legacy = join22(projectRoot, ".jig", "tokens");
3634
+ if (!existsSync13(legacy)) return [];
3635
+ return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync17(join22(legacy, f), "utf8") }));
2998
3636
  }
2999
3637
  function auditTokenLayer(projectRoot) {
3000
3638
  const files = tokenFiles(projectRoot);
@@ -3062,15 +3700,15 @@ function auditTokenLayer(projectRoot) {
3062
3700
  }
3063
3701
 
3064
3702
  // src/commands/check.ts
3065
- var LEGACY_INDEX_REL = join19(".jig", "rules.index.json");
3703
+ var LEGACY_INDEX_REL = join23(".jig", "rules.index.json");
3066
3704
  function resolveIndexPath(projectRoot) {
3067
- const legacy = join19(projectRoot, LEGACY_INDEX_REL);
3068
- if (existsSync12(legacy)) return legacy;
3069
- return join19(assetRoot(), "rules.index.json");
3705
+ const legacy = join23(projectRoot, LEGACY_INDEX_REL);
3706
+ if (existsSync14(legacy)) return legacy;
3707
+ return join23(assetRoot(), "rules.index.json");
3070
3708
  }
3071
3709
  function resolveMode(projectRoot) {
3072
3710
  try {
3073
- const config = JSON.parse(readFileSync14(join19(projectRoot, "jig.config.json"), "utf8"));
3711
+ const config = JSON.parse(readFileSync18(join23(projectRoot, "jig.config.json"), "utf8"));
3074
3712
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
3075
3713
  if (modes.length === 1) return modes[0];
3076
3714
  return modes.length > 1 ? "mixed" : "unknown";
@@ -3087,14 +3725,14 @@ function summariseUnscanned(files) {
3087
3725
  }
3088
3726
  function countSpecs() {
3089
3727
  try {
3090
- return loadSpecs(join19(assetRoot(), "rules")).length;
3728
+ return loadSpecs(join23(assetRoot(), "rules")).length;
3091
3729
  } catch {
3092
3730
  return 0;
3093
3731
  }
3094
3732
  }
3095
3733
  function check(opts) {
3096
3734
  const indexPath = resolveIndexPath(opts.projectRoot);
3097
- const index = validateIndex(JSON.parse(readFileSync14(indexPath, "utf8")));
3735
+ const index = validateIndex(JSON.parse(readFileSync18(indexPath, "utf8")));
3098
3736
  const selection = selectFiles(opts.projectRoot, opts.all);
3099
3737
  const exemptions = readExemptions(opts.projectRoot);
3100
3738
  const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
@@ -3104,21 +3742,46 @@ function check(opts) {
3104
3742
  const projectParticipates = stylesheets.some((f) => {
3105
3743
  if (!hasExtension(f, CSS_EXTENSIONS)) return false;
3106
3744
  try {
3107
- return participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
3745
+ return participatesInTokenLayer(readFileSync18(join23(opts.projectRoot, f), "utf8"), tokens);
3746
+ } catch {
3747
+ return false;
3748
+ }
3749
+ });
3750
+ const projectResponsive = stylesheets.some((f) => {
3751
+ if (!isStyleBearing(f)) return false;
3752
+ try {
3753
+ const raw = readFileSync18(join23(opts.projectRoot, f), "utf8");
3754
+ return isResponsive(maskComments(maskNonStyleRegions(raw, f)), raw);
3755
+ } catch {
3756
+ return false;
3757
+ }
3758
+ });
3759
+ const viewportFitCover = stylesheets.some((f) => {
3760
+ if (!isStyleBearing(f)) return false;
3761
+ try {
3762
+ return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync18(join23(opts.projectRoot, f), "utf8"));
3108
3763
  } catch {
3109
3764
  return false;
3110
3765
  }
3111
3766
  });
3767
+ const projectMenuToggle = stylesheets.some((f) => {
3768
+ try {
3769
+ return hasMenuToggle(readFileSync18(join23(opts.projectRoot, f), "utf8"));
3770
+ } catch {
3771
+ return false;
3772
+ }
3773
+ });
3774
+ const declaredProperties = collectDeclaredProperties(opts.projectRoot, stylesheets, Object.keys(tokens));
3112
3775
  const withStyles = files.filter((f) => {
3113
3776
  try {
3114
- const src = readFileSync14(join19(opts.projectRoot, f), "utf8");
3777
+ const src = readFileSync18(join23(opts.projectRoot, f), "utf8");
3115
3778
  return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
3116
3779
  } catch {
3117
3780
  return false;
3118
3781
  }
3119
3782
  }).length;
3120
3783
  const resolvedMode = resolveMode(opts.projectRoot);
3121
- const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode);
3784
+ const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties);
3122
3785
  const TOKEN_RULE = {
3123
3786
  "--text-prose": "B-75",
3124
3787
  "--size-touch-target": "E-51"
@@ -3143,7 +3806,7 @@ function check(opts) {
3143
3806
  // told to add a token @import it has nowhere to put.
3144
3807
  cssFiles.length > 0 && cssFiles.every((f) => {
3145
3808
  try {
3146
- return !participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
3809
+ return !participatesInTokenLayer(readFileSync18(join23(opts.projectRoot, f), "utf8"), tokens);
3147
3810
  } catch {
3148
3811
  return true;
3149
3812
  }
@@ -3163,18 +3826,19 @@ function check(opts) {
3163
3826
  withStyles,
3164
3827
  exempt,
3165
3828
  exemptPatterns: byPattern,
3166
- scope: selection.mode
3829
+ scope: selection.mode,
3830
+ modeUnwired: modeWiringProblems(opts.projectRoot)
3167
3831
  });
3168
3832
  return { findings, report, hasError };
3169
3833
  }
3170
3834
 
3171
3835
  // src/commands/init.ts
3172
- import { existsSync as existsSync13, mkdirSync as mkdirSync5, readdirSync as readdirSync9, readFileSync as readFileSync18, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
3173
- import { dirname as dirname6, isAbsolute, join as join23, resolve as resolve2, sep as sep3 } from "path";
3836
+ import { existsSync as existsSync15, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync22, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
3837
+ import { dirname as dirname7, isAbsolute, join as join27, resolve as resolve2, sep as sep3 } from "path";
3174
3838
 
3175
3839
  // src/init/detect.ts
3176
- import { readFileSync as readFileSync15 } from "fs";
3177
- import { join as join20 } from "path";
3840
+ import { readFileSync as readFileSync19 } from "fs";
3841
+ import { join as join24 } from "path";
3178
3842
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
3179
3843
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
3180
3844
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -3191,7 +3855,7 @@ var FRAMEWORK_DEPS = [
3191
3855
  function detectFramework(projectRoot) {
3192
3856
  let pkg;
3193
3857
  try {
3194
- pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
3858
+ pkg = JSON.parse(readFileSync19(join24(projectRoot, "package.json"), "utf8"));
3195
3859
  } catch {
3196
3860
  return void 0;
3197
3861
  }
@@ -3209,7 +3873,7 @@ function detect(projectRoot) {
3209
3873
  for (const f of cssFiles) {
3210
3874
  let source;
3211
3875
  try {
3212
- source = readFileSync15(join20(projectRoot, f), "utf8");
3876
+ source = readFileSync19(join24(projectRoot, f), "utf8");
3213
3877
  } catch {
3214
3878
  continue;
3215
3879
  }
@@ -3244,8 +3908,8 @@ function isIgnored(projectRoot, relPath) {
3244
3908
  }
3245
3909
 
3246
3910
  // src/init/derive.ts
3247
- import { readFileSync as readFileSync16 } from "fs";
3248
- import { join as join21 } from "path";
3911
+ import { readFileSync as readFileSync20 } from "fs";
3912
+ import { join as join25 } from "path";
3249
3913
  var DEFAULT_PROPOSAL = {
3250
3914
  h: 264,
3251
3915
  s: 0,
@@ -3278,7 +3942,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
3278
3942
  for (const file of cssFiles) {
3279
3943
  let source;
3280
3944
  try {
3281
- source = readFileSync16(join21(projectRoot, file), "utf8");
3945
+ source = readFileSync20(join25(projectRoot, file), "utf8");
3282
3946
  } catch {
3283
3947
  continue;
3284
3948
  }
@@ -3327,7 +3991,7 @@ function balancedBraceBody(source, openIdx) {
3327
3991
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
3328
3992
  let source;
3329
3993
  try {
3330
- source = readFileSync16(join21(projectRoot, tailwindConfigFile), "utf8");
3994
+ source = readFileSync20(join25(projectRoot, tailwindConfigFile), "utf8");
3331
3995
  } catch {
3332
3996
  return null;
3333
3997
  }
@@ -3383,7 +4047,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
3383
4047
  for (const file of cssFiles) {
3384
4048
  let source;
3385
4049
  try {
3386
- source = readFileSync16(join21(projectRoot, file), "utf8");
4050
+ source = readFileSync20(join25(projectRoot, file), "utf8");
3387
4051
  } catch {
3388
4052
  continue;
3389
4053
  }
@@ -3543,8 +4207,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
3543
4207
  }
3544
4208
 
3545
4209
  // src/init/project-name.ts
3546
- import { readFileSync as readFileSync17 } from "fs";
3547
- import { basename, join as join22 } from "path";
4210
+ import { readFileSync as readFileSync21 } from "fs";
4211
+ import { basename, join as join26 } from "path";
3548
4212
  function slugify(name) {
3549
4213
  const withoutScope = name.replace(/^@[^/]+\//, "");
3550
4214
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3552,7 +4216,7 @@ function slugify(name) {
3552
4216
  }
3553
4217
  function deriveProjectSlug(projectRoot) {
3554
4218
  try {
3555
- const pkg = JSON.parse(readFileSync17(join22(projectRoot, "package.json"), "utf8"));
4219
+ const pkg = JSON.parse(readFileSync21(join26(projectRoot, "package.json"), "utf8"));
3556
4220
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
3557
4221
  } catch {
3558
4222
  }
@@ -3635,7 +4299,7 @@ ${lines.join("\n")}
3635
4299
  }
3636
4300
 
3637
4301
  // src/commands/init.ts
3638
- var MODES = ["editorial", "product", "operator"];
4302
+ var MODES2 = ["editorial", "product", "operator"];
3639
4303
  var DEFAULT_SURFACES = [{ match: "/", mode: "product" }];
3640
4304
  async function defaultPrompt(question) {
3641
4305
  const { createInterface } = await import("readline/promises");
@@ -3687,28 +4351,28 @@ function parseSurfaces(answer, fallback) {
3687
4351
  if (idx === -1) return fallback;
3688
4352
  const match = part.slice(0, idx).trim();
3689
4353
  const mode = part.slice(idx + 1).trim();
3690
- if (!match || !MODES.includes(mode)) return fallback;
4354
+ if (!match || !MODES2.includes(mode)) return fallback;
3691
4355
  parsed.push({ match, mode });
3692
4356
  }
3693
4357
  return parsed.length > 0 ? parsed : fallback;
3694
4358
  }
3695
4359
  function fileState(projectRoot, absPath, relPath, initManifest) {
3696
- const existsOnDisk = existsSync13(absPath);
4360
+ const existsOnDisk = existsSync15(absPath);
3697
4361
  const tracked = !!initManifest && relPath in initManifest.files;
3698
4362
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
3699
4363
  return { existsOnDisk, tracked, modified };
3700
4364
  }
3701
4365
  function isValidSurfaceArray(v) {
3702
4366
  return Array.isArray(v) && v.length > 0 && v.every(
3703
- (s) => typeof s === "object" && s !== null && typeof s.match === "string" && MODES.includes(s.mode)
4367
+ (s) => typeof s === "object" && s !== null && typeof s.match === "string" && MODES2.includes(s.mode)
3704
4368
  );
3705
4369
  }
3706
4370
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
3707
- if (!existsSync13(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
4371
+ if (!existsSync15(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3708
4372
  try {
3709
- const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
4373
+ const parsed = JSON.parse(readFileSync22(configAbsPath, "utf8"));
3710
4374
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
3711
- const brandResolves = brandCandidate ? existsSync13(join23(projectRoot, ...brandCandidate.split("/"))) : false;
4375
+ const brandResolves = brandCandidate ? existsSync15(join27(projectRoot, ...brandCandidate.split("/"))) : false;
3712
4376
  return {
3713
4377
  brand: brandResolves ? brandCandidate : fallbackBrand,
3714
4378
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -3718,10 +4382,10 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
3718
4382
  }
3719
4383
  }
3720
4384
  function configuredBrandPath(projectRoot, configAbsPath) {
3721
- if (!existsSync13(configAbsPath)) return { path: null };
4385
+ if (!existsSync15(configAbsPath)) return { path: null };
3722
4386
  let declared;
3723
4387
  try {
3724
- declared = JSON.parse(readFileSync18(configAbsPath, "utf8")).brand;
4388
+ declared = JSON.parse(readFileSync22(configAbsPath, "utf8")).brand;
3725
4389
  } catch {
3726
4390
  return { path: null };
3727
4391
  }
@@ -3749,9 +4413,9 @@ function defaultTokenDir(detection) {
3749
4413
  return ["jig"];
3750
4414
  }
3751
4415
  function readConfiguredSurfaces(configAbsPath) {
3752
- if (!existsSync13(configAbsPath)) return null;
4416
+ if (!existsSync15(configAbsPath)) return null;
3753
4417
  try {
3754
- const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
4418
+ const parsed = JSON.parse(readFileSync22(configAbsPath, "utf8"));
3755
4419
  return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
3756
4420
  } catch {
3757
4421
  return null;
@@ -3761,9 +4425,9 @@ function isCssModule(path) {
3761
4425
  return /\.module\.(css|scss)$/i.test(path);
3762
4426
  }
3763
4427
  function legacyBrandFile(projectRoot) {
3764
- const dir = join23(projectRoot, ".jig", "tokens");
3765
- if (!existsSync13(dir)) return void 0;
3766
- const found = readdirSync9(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
4428
+ const dir = join27(projectRoot, ".jig", "tokens");
4429
+ if (!existsSync15(dir)) return void 0;
4430
+ const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
3767
4431
  return found ? relKey(".jig", "tokens", found) : void 0;
3768
4432
  }
3769
4433
  function isTokenLayerFile(f) {
@@ -3775,7 +4439,7 @@ function findWireTarget(d) {
3775
4439
  if (candidates.length === 1) return candidates[0];
3776
4440
  return null;
3777
4441
  }
3778
- var MODE_IMPORT_RE = new RegExp(`@import\\s+["']([^"']*/)?mode\\.(${MODES.join("|")})\\.css["'];?\\r?\\n?`);
4442
+ var MODE_IMPORT_RE = new RegExp(`@import\\s+["']([^"']*/)?mode\\.(${MODES2.join("|")})\\.css["'];?\\r?\\n?`);
3779
4443
  var CHARSET_RE = /^@charset\s+["'][^"']*["'];\r?\n?/;
3780
4444
  function insertAfterCharset(content, insertion) {
3781
4445
  const m = CHARSET_RE.exec(content);
@@ -3790,17 +4454,17 @@ function barrelBody(fileName, brandFile, mode, version2) {
3790
4454
  `;
3791
4455
  }
3792
4456
  var LEGACY_PAIR_RE = new RegExp(
3793
- `@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES.join("|")})\\.css["'];?\\r?\\n?`
4457
+ `@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
3794
4458
  );
3795
4459
  function wireBarrel(absPath, barrelImport) {
3796
- const content = existsSync13(absPath) ? readFileSync18(absPath, "utf8") : "";
4460
+ const content = existsSync15(absPath) ? readFileSync22(absPath, "utf8") : "";
3797
4461
  const line = `@import "${barrelImport}";`;
3798
4462
  if (content.includes(line)) return { status: "already-present" };
3799
4463
  const legacy = LEGACY_PAIR_RE.exec(content);
3800
4464
  if (legacy) {
3801
4465
  const next2 = content.slice(0, legacy.index) + `${line}
3802
4466
  ` + content.slice(legacy.index + legacy[0].length);
3803
- writeFileSync4(absPath, next2, "utf8");
4467
+ writeFileSync5(absPath, next2, "utf8");
3804
4468
  return { status: "rewired", detail: "replaced the separate brand and mode imports with the barrel" };
3805
4469
  }
3806
4470
  const tailwind = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
@@ -3809,8 +4473,8 @@ function wireBarrel(absPath, barrelImport) {
3809
4473
 
3810
4474
  ` : `${line}
3811
4475
  `);
3812
- mkdirSync5(dirname6(absPath), { recursive: true });
3813
- writeFileSync4(next === content ? absPath : absPath, next, "utf8");
4476
+ mkdirSync6(dirname7(absPath), { recursive: true });
4477
+ writeFileSync5(next === content ? absPath : absPath, next, "utf8");
3814
4478
  return { status: "wired" };
3815
4479
  }
3816
4480
  async function init(opts) {
@@ -3871,21 +4535,21 @@ async function init(opts) {
3871
4535
  for (const line of describeValidation(validation)) log(line);
3872
4536
  }
3873
4537
  const surfaceAnswer = await prompt(
3874
- `Surface \u2192 mode mapping. Enter to accept '/' \u2192 product, or 'match:mode,match:mode' (modes: ${MODES.join("/")}): `
4538
+ `Surface \u2192 mode mapping. Enter to accept '/' \u2192 product, or 'match:mode,match:mode' (modes: ${MODES2.join("/")}): `
3875
4539
  );
3876
4540
  surfaces = parseSurfaces(surfaceAnswer, DEFAULT_SURFACES);
3877
4541
  if (surfaceAnswer.trim() && surfaces === DEFAULT_SURFACES) {
3878
4542
  log(
3879
- ` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
4543
+ ` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
3880
4544
  );
3881
4545
  }
3882
4546
  }
3883
4547
  if (opts.yes) {
3884
- const declared = readConfiguredSurfaces(join23(opts.projectRoot, "jig.config.json"));
4548
+ const declared = readConfiguredSurfaces(join27(opts.projectRoot, "jig.config.json"));
3885
4549
  const inEffect = declared ?? surfaces;
3886
4550
  const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
3887
4551
  log(
3888
- declared ? `Surface \u2192 mode: ${map} \u2014 from jig.config.json. Mode sets density, type scale and control sizes, and it outranks an agent's own reading of the project.` : `Surface \u2192 mode: ${map} \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("/")}).`
4552
+ declared ? `Surface \u2192 mode: ${map} \u2014 from jig.config.json. Mode sets density, type scale and control sizes, and it outranks an agent's own reading of the project.` : `Surface \u2192 mode: not declared. jig.config.json records no mapping, because --yes has nobody to ask and a guess written there cannot be told from a decision. Mode-gated rules stay silent until you declare one. Token files are written at ${inEffect[0]?.mode ?? "product"} as a baseline. Add "surfaces": [{"match": "/", "mode": "..."}] to jig.config.json and run jig init again \u2014 the config alone changes what agents read, not the token files the page imports \u2014 or run jig init without --yes (modes: ${MODES2.join("/")}).`
3889
4553
  );
3890
4554
  }
3891
4555
  if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
@@ -3900,7 +4564,7 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
3900
4564
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
3901
4565
  const projectSlug = deriveProjectSlug(opts.projectRoot);
3902
4566
  const configRelPath = "jig.config.json";
3903
- const configAbsPath = join23(opts.projectRoot, configRelPath);
4567
+ const configAbsPath = join27(opts.projectRoot, configRelPath);
3904
4568
  const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
3905
4569
  if (configuredBrand.refused) {
3906
4570
  log(
@@ -3934,7 +4598,7 @@ The token layer is at ${currentDir}/, and this project's layout suggests ${would
3934
4598
  }
3935
4599
  }
3936
4600
  const brandRelPath = relocateTo ?? configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
3937
- const brandAbsPath = join23(opts.projectRoot, ...brandRelPath.split("/"));
4601
+ const brandAbsPath = join27(opts.projectRoot, ...brandRelPath.split("/"));
3938
4602
  const tokensRelDir = brandRelPath.split("/").slice(0, -1);
3939
4603
  log(
3940
4604
  `Token layer: ${tokensRelDir.join("/") || "."}/ \u2014 ` + (configuredBrand.path ? "from jig.config.json." : priorBrand ? "the layout this project already had. Set `brand` in jig.config.json to move it." : findWireTarget(detection) ? "beside the stylesheet being wired. Set `brand` in jig.config.json to put it elsewhere." : "no stylesheet found to follow, so the project root. Set `brand` in jig.config.json to move it.")
@@ -3967,18 +4631,18 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3967
4631
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
3968
4632
  }
3969
4633
  if (brandAction === "written") {
3970
- const defaultCssRaw = readFileSync18(join23(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
4634
+ const defaultCssRaw = readFileSync22(join27(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3971
4635
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
3972
- mkdirSync5(dirname6(brandAbsPath), { recursive: true });
3973
- writeFileSync4(brandAbsPath, content, "utf8");
4636
+ mkdirSync6(dirname7(brandAbsPath), { recursive: true });
4637
+ writeFileSync5(brandAbsPath, content, "utf8");
3974
4638
  files[brandRelPath] = checksum(content);
3975
4639
  }
3976
4640
  if (relocateTo && configAction !== "written") {
3977
4641
  try {
3978
- const existing = JSON.parse(readFileSync18(configAbsPath, "utf8"));
4642
+ const existing = JSON.parse(readFileSync22(configAbsPath, "utf8"));
3979
4643
  const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
3980
4644
  `;
3981
- writeFileSync4(configAbsPath, content, "utf8");
4645
+ writeFileSync5(configAbsPath, content, "utf8");
3982
4646
  files[configRelPath] = checksum(content);
3983
4647
  log(` Updated ${configRelPath} to point at the new location.`);
3984
4648
  } catch {
@@ -3986,37 +4650,40 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3986
4650
  }
3987
4651
  }
3988
4652
  if (configAction === "written") {
3989
- const config = { brand: brandRelPath, surfaces };
4653
+ const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
3990
4654
  const content = `${JSON.stringify(config, null, 2)}
3991
4655
  `;
3992
- writeFileSync4(configAbsPath, content, "utf8");
4656
+ writeFileSync5(configAbsPath, content, "utf8");
3993
4657
  files[configRelPath] = checksum(content);
3994
4658
  } else if (configAction === "merged") {
3995
4659
  let existing = {};
3996
4660
  try {
3997
- existing = JSON.parse(readFileSync18(configAbsPath, "utf8"));
4661
+ existing = JSON.parse(readFileSync22(configAbsPath, "utf8"));
3998
4662
  } catch {
3999
4663
  existing = {};
4000
4664
  }
4001
4665
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
4002
- const brandStillResolves = existingBrand ? existsSync13(join23(opts.projectRoot, ...existingBrand.split("/"))) : false;
4666
+ const brandStillResolves = existingBrand ? existsSync15(join27(opts.projectRoot, ...existingBrand.split("/"))) : false;
4667
+ const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
4003
4668
  const merged = {
4004
4669
  brand: brandStillResolves ? existingBrand : brandRelPath,
4005
- surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
4670
+ // Same contract as the 'written' branch: never invent a mapping under
4671
+ // `--yes`. An existing declaration is always kept.
4672
+ ...existingSurfaces ? { surfaces: existingSurfaces } : opts.yes ? {} : { surfaces }
4006
4673
  };
4007
4674
  const content = `${JSON.stringify(merged, null, 2)}
4008
4675
  `;
4009
- writeFileSync4(configAbsPath, content, "utf8");
4676
+ writeFileSync5(configAbsPath, content, "utf8");
4010
4677
  files[configRelPath] = checksum(content);
4011
4678
  }
4012
4679
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
4013
4680
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
4014
- const wiringBrandAbsPath = join23(opts.projectRoot, ...effectiveConfig.brand.split("/"));
4681
+ const wiringBrandAbsPath = join27(opts.projectRoot, ...effectiveConfig.brand.split("/"));
4015
4682
  const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
4016
4683
  const modeAbsPaths = {};
4017
4684
  for (const mode of declaredModes) {
4018
4685
  const modeFileName = `mode.${mode}.css`;
4019
- const modeAbsPath2 = join23(opts.projectRoot, ...tokensRelDir, modeFileName);
4686
+ const modeAbsPath2 = join27(opts.projectRoot, ...tokensRelDir, modeFileName);
4020
4687
  modeAbsPaths[mode] = modeAbsPath2;
4021
4688
  const modeRelPath = relKey(...tokensRelDir, modeFileName);
4022
4689
  const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
@@ -4024,10 +4691,10 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
4024
4691
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
4025
4692
  else if (modeState.modified) modeAction = "skipped-edited";
4026
4693
  if (modeAction === "written") {
4027
- const modeSourceRaw = readFileSync18(join23(opts.packageRoot, "tokens", modeFileName), "utf8");
4694
+ const modeSourceRaw = readFileSync22(join27(opts.packageRoot, "tokens", modeFileName), "utf8");
4028
4695
  const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
4029
- mkdirSync5(dirname6(modeAbsPath2), { recursive: true });
4030
- writeFileSync4(modeAbsPath2, content, "utf8");
4696
+ mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
4697
+ writeFileSync5(modeAbsPath2, content, "utf8");
4031
4698
  files[modeRelPath] = checksum(content);
4032
4699
  } else {
4033
4700
  log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
@@ -4038,22 +4705,22 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
4038
4705
  const barrelFor = (mode) => mode === primaryMode ? "theme.css" : `theme.${mode}.css`;
4039
4706
  for (const mode of declaredModes) {
4040
4707
  const rel = relKey(...tokensRelDir, barrelFor(mode));
4041
- const abs = join23(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
4708
+ const abs = join27(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
4042
4709
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
4043
4710
  if (state.existsOnDisk && (!state.tracked || state.modified)) {
4044
4711
  log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
4045
4712
  continue;
4046
4713
  }
4047
4714
  const content = barrelBody(barrelFor(mode), brandFileOnly, mode, opts.version);
4048
- mkdirSync5(dirname6(abs), { recursive: true });
4049
- writeFileSync4(abs, content, "utf8");
4715
+ mkdirSync6(dirname7(abs), { recursive: true });
4716
+ writeFileSync5(abs, content, "utf8");
4050
4717
  files[rel] = checksum(content);
4051
4718
  }
4052
4719
  if (detection.cssSystem === "tailwind-v4") {
4053
4720
  const declared = declaredTokenNames(
4054
4721
  [brandAbsPath, modeAbsPath].map((f) => {
4055
4722
  try {
4056
- return readFileSync18(f, "utf8");
4723
+ return readFileSync22(f, "utf8");
4057
4724
  } catch {
4058
4725
  return "";
4059
4726
  }
@@ -4078,19 +4745,19 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
4078
4745
  log(` This changes how components are written. Both styles are correct.`);
4079
4746
  const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
4080
4747
  if (answer === "y" || answer === "yes") {
4081
- const abs = join23(opts.projectRoot, ...tokensRelDir, "utilities.css");
4748
+ const abs = join27(opts.projectRoot, ...tokensRelDir, "utilities.css");
4082
4749
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
4083
4750
  if (state.existsOnDisk && (!state.tracked || state.modified)) {
4084
4751
  log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
4085
4752
  } else {
4086
4753
  const content = utilitiesBody(aliasable, opts.version);
4087
- mkdirSync5(dirname6(abs), { recursive: true });
4088
- writeFileSync4(abs, content, "utf8");
4754
+ mkdirSync6(dirname7(abs), { recursive: true });
4755
+ writeFileSync5(abs, content, "utf8");
4089
4756
  files[rel] = checksum(content);
4090
4757
  const target = findWireTarget(detection);
4091
4758
  const importPath = target ? relativeImportPath(
4092
- dirname6(join23(opts.projectRoot, target)),
4093
- join23(opts.projectRoot, ...tokensRelDir, "utilities.css")
4759
+ dirname7(join27(opts.projectRoot, target)),
4760
+ join27(opts.projectRoot, ...tokensRelDir, "utilities.css")
4094
4761
  ) : `./${rel}`;
4095
4762
  log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
4096
4763
  log(` @import "tailwindcss";`);
@@ -4108,11 +4775,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
4108
4775
  writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
4109
4776
  if (relocateTo && currentBrand) {
4110
4777
  const oldDir = currentBrand.split("/").slice(0, -1);
4111
- const oldAbsDir = join23(opts.projectRoot, ...oldDir);
4112
- if (existsSync13(oldAbsDir)) {
4113
- for (const name of readdirSync9(oldAbsDir).filter((f) => f.endsWith(".css"))) {
4778
+ const oldAbsDir = join27(opts.projectRoot, ...oldDir);
4779
+ if (existsSync15(oldAbsDir)) {
4780
+ for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
4114
4781
  const rel = relKey(...oldDir, name);
4115
- const abs = join23(oldAbsDir, name);
4782
+ const abs = join27(oldAbsDir, name);
4116
4783
  const state = fileState(opts.projectRoot, abs, rel, initManifest);
4117
4784
  if (!state.tracked || state.modified) {
4118
4785
  log(` ${rel} was edited \u2014 left in place. Delete it yourself once you have moved anything you need.`);
@@ -4126,21 +4793,21 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
4126
4793
  const wireTarget = findWireTarget(detection);
4127
4794
  let wiring;
4128
4795
  if (wireTarget) {
4129
- const targetAbsDir = dirname6(join23(opts.projectRoot, wireTarget));
4796
+ const targetAbsDir = dirname7(join27(opts.projectRoot, wireTarget));
4130
4797
  const barrelImport = relativeImportPath(
4131
4798
  targetAbsDir,
4132
- join23(opts.projectRoot, ...tokensRelDir, "theme.css")
4799
+ join27(opts.projectRoot, ...tokensRelDir, "theme.css")
4133
4800
  );
4134
4801
  const snippet = `@import "${barrelImport}";`;
4135
4802
  try {
4136
4803
  if (relocateTo && currentBrand) {
4137
- const abs = join23(opts.projectRoot, wireTarget);
4804
+ const abs = join27(opts.projectRoot, wireTarget);
4138
4805
  const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
4139
- const before = existsSync13(abs) ? readFileSync18(abs, "utf8") : "";
4806
+ const before = existsSync15(abs) ? readFileSync22(abs, "utf8") : "";
4140
4807
  const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
4141
- if (after !== before) writeFileSync4(abs, after, "utf8");
4808
+ if (after !== before) writeFileSync5(abs, after, "utf8");
4142
4809
  }
4143
- const outcome = wireBarrel(join23(opts.projectRoot, wireTarget), barrelImport);
4810
+ const outcome = wireBarrel(join27(opts.projectRoot, wireTarget), barrelImport);
4144
4811
  wiring = { target: wireTarget, status: outcome.status, snippet };
4145
4812
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
4146
4813
  log(`
@@ -4157,7 +4824,7 @@ Could not write to ${wireTarget}: ${err.message}`);
4157
4824
  } else {
4158
4825
  const barrelImport = relativeImportPath(
4159
4826
  opts.projectRoot,
4160
- join23(opts.projectRoot, ...tokensRelDir, "theme.css")
4827
+ join27(opts.projectRoot, ...tokensRelDir, "theme.css")
4161
4828
  );
4162
4829
  const snippet = `@import "${barrelImport}";`;
4163
4830
  wiring = { target: null, status: "print-only", snippet };
@@ -4166,7 +4833,7 @@ Could not write to ${wireTarget}: ${err.message}`);
4166
4833
  if (pasteTargets.length > 0) {
4167
4834
  log("Paste the block for whichever of these is your global stylesheet:");
4168
4835
  for (const target of pasteTargets) {
4169
- const dir = dirname6(join23(opts.projectRoot, target));
4836
+ const dir = dirname7(join27(opts.projectRoot, target));
4170
4837
  const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
4171
4838
  @import "${relativeImportPath(dir, modeAbsPath)}";`;
4172
4839
  log(`
@@ -4205,9 +4872,498 @@ Could not write to ${wireTarget}: ${err.message}`);
4205
4872
  };
4206
4873
  }
4207
4874
 
4875
+ // src/commands/verdicts.ts
4876
+ import { existsSync as existsSync17, readFileSync as readFileSync24 } from "fs";
4877
+ import { join as join30, resolve as resolve3 } from "path";
4878
+
4879
+ // src/rules/citations.ts
4880
+ import { join as join28 } from "path";
4881
+ function citableIds(packageRoot2) {
4882
+ const rulesDir = join28(packageRoot2, "rules");
4883
+ const ids = [
4884
+ ...loadRules(rulesDir, join28(packageRoot2, "rules.index.json")).map((r) => r.id),
4885
+ ...loadSpecs(rulesDir).map((s) => s.id)
4886
+ ];
4887
+ return [...new Set(ids)].sort();
4888
+ }
4889
+
4890
+ // src/probe/check.ts
4891
+ import { existsSync as existsSync16, readdirSync as readdirSync11, readFileSync as readFileSync23 } from "fs";
4892
+ import { join as join29 } from "path";
4893
+
4894
+ // src/probe/script.ts
4895
+ var PROBE_VERSION = 1;
4896
+ var PROBE_SCRIPT = `(async () => {
4897
+ const doc = document.documentElement;
4898
+ const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
4899
+ const probe = document.createElement('div');
4900
+ probe.style.all = 'initial';
4901
+ document.body.appendChild(probe);
4902
+ const defaultFont = getComputedStyle(probe).fontFamily;
4903
+ probe.remove();
4904
+ const text = document.body.innerText || '';
4905
+ const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
4906
+ const unresolved = new Set();
4907
+ for (const sheet of document.styleSheets) {
4908
+ let rules; try { rules = sheet.cssRules; } catch (e) { continue; }
4909
+ for (const rule of rules) {
4910
+ for (const m of (rule.cssText || '').matchAll(/var\\(\\s*(--[\\w-]+)\\s*\\)/g)) {
4911
+ if (!getComputedStyle(doc).getPropertyValue(m[1]).trim()) unresolved.add(m[1]);
4912
+ }
4913
+ }
4914
+ }
4915
+ const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
4916
+ // Measured before anything is clicked: what a reader sees on arrival.
4917
+ const navAtRest = navLinks();
4918
+ // The menu control: inside the header or navigation, or named as the menu.
4919
+ // Not any disclosure \u2014 an FAQ <summary> is not a menu.
4920
+ const inChrome = (b) => !!b.closest('header, nav, [role=navigation], [role=banner]');
4921
+ const named = (b) => /\b(menu|navigation)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + b.textContent);
4922
+ const toggle = [...document.querySelectorAll('button, summary, [role=button]')].find((b) => vis(b) &&
4923
+ (named(b) || (inChrome(b) && (b.tagName === 'SUMMARY' || b.hasAttribute('aria-expanded') || b.hasAttribute('aria-controls')))));
4924
+ let menu = null;
4925
+ if (toggle) {
4926
+ const name = () => ((toggle.getAttribute('aria-label') || '') + ' ' + (toggle.textContent || '')).replace(/\\s+/g, ' ').trim();
4927
+ // Opening is judged by what becomes visible anywhere: a menu panel is often
4928
+ // a sibling of the header, not inside it (arm test 3, tw-1).
4929
+ const allLinks = () => [...document.querySelectorAll('a')].filter(vis).length;
4930
+ const controlled = () => { const id = toggle.getAttribute('aria-controls'); const el = id && document.getElementById(id); return el ? vis(el) : null; };
4931
+ const before = { links: allLinks(), controlled: controlled(), expanded: toggle.getAttribute('aria-expanded'), name: name(), html: toggle.innerHTML };
4932
+ toggle.click();
4933
+ await new Promise((r) => setTimeout(r, 350));
4934
+ const after = { links: allLinks(), controlled: controlled(), expanded: toggle.getAttribute('aria-expanded'), name: name(), htmlChanged: toggle.innerHTML !== before.html };
4935
+ toggle.focus();
4936
+ toggle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
4937
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
4938
+ await new Promise((r) => setTimeout(r, 350));
4939
+ menu = {
4940
+ linksBefore: before.links, linksAfter: after.links,
4941
+ expandedBefore: before.expanded, expandedAfter: after.expanded,
4942
+ opened: after.links > before.links || (before.controlled === false && after.controlled === true),
4943
+ labelChanged: after.name !== before.name || after.htmlChanged,
4944
+ escapeCloses: toggle.getAttribute('aria-expanded') !== 'true' && allLinks() <= before.links,
4945
+ focusReturned: document.activeElement === toggle,
4946
+ };
4947
+ }
4948
+ return JSON.stringify({
4949
+ jigProbe: ${PROBE_VERSION},
4950
+ url: location.href,
4951
+ width: innerWidth,
4952
+ scrollWidth: doc.scrollWidth,
4953
+ clientWidth: doc.clientWidth,
4954
+ sidewaysScroll: doc.scrollWidth > doc.clientWidth,
4955
+ bodyFont: getComputedStyle(document.body).fontFamily,
4956
+ defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
4957
+ unresolvedTokens: [...unresolved],
4958
+ junkText: junk,
4959
+ brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
4960
+ navLinksVisible: navAtRest,
4961
+ menu,
4962
+ });
4963
+ })()`;
4964
+
4965
+ // src/probe/check.ts
4966
+ function readProbes(dir, errors) {
4967
+ if (!existsSync16(dir)) return [];
4968
+ const probes = [];
4969
+ for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
4970
+ try {
4971
+ const p = JSON.parse(readFileSync23(join29(dir, f), "utf8"));
4972
+ if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
4973
+ errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
4974
+ continue;
4975
+ }
4976
+ probes.push(p);
4977
+ } catch (e) {
4978
+ errors.push(`${f}: not valid JSON (${e.message}).`);
4979
+ }
4980
+ }
4981
+ return probes;
4982
+ }
4983
+ function probeContradictions(probes, verdictOf) {
4984
+ const errors = [];
4985
+ const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
4986
+ const at = (p) => `probe-${p.width}.json`;
4987
+ for (const p of probes) {
4988
+ if (p.sidewaysScroll && clean("D-115")) {
4989
+ errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
4990
+ }
4991
+ if (p.defaultFont) {
4992
+ errors.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
4993
+ }
4994
+ if (p.unresolvedTokens.length) {
4995
+ errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
4996
+ }
4997
+ if (p.junkText.length) {
4998
+ errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
4999
+ }
5000
+ }
5001
+ const navClean = clean("P-14") || clean("E-61");
5002
+ const cite = [clean("P-14") && `P-14 is "${verdictOf("P-14")}"`, clean("E-61") && `E-61 is "${verdictOf("E-61")}"`].filter(Boolean).join(" and ");
5003
+ for (const p of probes.filter((x) => x.width >= 768)) {
5004
+ if (!navClean) continue;
5005
+ const m = p.menu;
5006
+ if (m && p.navLinksVisible > 0) {
5007
+ errors.push(`${cite}, but at ${p.width}px ${at(p)} found a menu button beside navigation links that already show \u2014 ${m.opened ? "it opens a second copy of them" : "it opens nothing"}. At a width where the links show, there is no menu button.`);
5008
+ } else if (m && m.opened && m.linksAfter - m.linksBefore <= 8) {
5009
+ errors.push(`${cite}, but at ${p.width}px ${at(p)} found the navigation behind a menu \u2014 ${m.linksAfter - m.linksBefore} link(s) that fit on one row at this width. Show them.`);
5010
+ } else if (p.navLinksVisible === 0 && !(m && m.opened)) {
5011
+ errors.push(`${cite}, but at ${p.width}px ${at(p)} found no visible navigation links and no menu that opens.`);
5012
+ }
5013
+ }
5014
+ const phone = probes.filter((p) => p.width <= 480).sort((a, b) => a.width - b.width)[0];
5015
+ if (phone && clean("P-14")) {
5016
+ const m = phone.menu;
5017
+ if (!m && phone.navLinksVisible === 0) {
5018
+ errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} found no visible navigation links and no menu control.`);
5019
+ } else if (m) {
5020
+ const broken = [
5021
+ !m.opened && `tapping it showed no more links (${m.linksBefore} \u2192 ${m.linksAfter})`,
5022
+ m.expandedAfter !== "true" && `aria-expanded was ${JSON.stringify(m.expandedAfter)} after opening`,
5023
+ !m.labelChanged && "its label and icon did not change to close",
5024
+ !m.escapeCloses && "Escape did not close it"
5025
+ ].filter(Boolean);
5026
+ if (broken.length) errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} operated the menu: ${broken.join("; ")}.`);
5027
+ }
5028
+ }
5029
+ return errors;
5030
+ }
5031
+
5032
+ // src/commands/verdicts.ts
5033
+ var VERDICTS = ["ok", "finding", "n/a"];
5034
+ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
5035
+ function readJson(path, errors) {
5036
+ if (!existsSync17(path)) return null;
5037
+ try {
5038
+ return JSON.parse(readFileSync24(path, "utf8"));
5039
+ } catch (e) {
5040
+ errors.push(`${path}: not valid JSON (${e.message})`);
5041
+ return {};
5042
+ }
5043
+ }
5044
+ function specNeedsNav(projectRoot, surface) {
5045
+ const path = join30(projectRoot, ".jig", "specs", `${surface}.spec.md`);
5046
+ if (!existsSync17(path)) return false;
5047
+ const front = readFileSync24(path, "utf8").split(/^---\s*$/m)[1] ?? "";
5048
+ const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
5049
+ const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
5050
+ return navField || navRegion;
5051
+ }
5052
+ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors) {
5053
+ const total = required.length + extraRequired.length;
5054
+ if (file === null) {
5055
+ errors.push(`${name}.json is missing \u2014 the ${name} arm has not run, or did not write its verdicts.`);
5056
+ return { state: "missing", judged: 0, total, findings: 0 };
5057
+ }
5058
+ const list = Array.isArray(file.verdicts) ? file.verdicts : [];
5059
+ if (!Array.isArray(file.verdicts)) errors.push(`${name}.json has no "verdicts" array.`);
5060
+ const seen = /* @__PURE__ */ new Set();
5061
+ let findings = 0;
5062
+ for (const v of list) {
5063
+ const written = typeof v.id === "string" ? v.id.trim() : "";
5064
+ const id = written.toUpperCase();
5065
+ if (!id) {
5066
+ errors.push(`${name}.json: a verdict has no id.`);
5067
+ continue;
5068
+ }
5069
+ if (seen.has(id)) {
5070
+ errors.push(`${name}.json: ${id} is judged more than once.`);
5071
+ continue;
5072
+ }
5073
+ seen.add(id);
5074
+ const pass = otherPass.get(id);
5075
+ if (!required.includes(id) && !extraAllowed.has(id)) {
5076
+ errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
5077
+ continue;
5078
+ }
5079
+ if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
5080
+ errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
5081
+ continue;
5082
+ }
5083
+ const reason = typeof v.reason === "string" ? v.reason.trim() : "";
5084
+ if (!reason) errors.push(`${name}.json: ${id} has no reason.`);
5085
+ else if (ABSENCE.test(reason)) errors.push(`${name}.json: ${id} \u2014 "${reason}" says the rule was not read. Read it with \`jig explain ${id}\` and judge it.`);
5086
+ if (v.verdict === "finding") findings++;
5087
+ }
5088
+ const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
5089
+ if (missing.length) {
5090
+ errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
5091
+ }
5092
+ const judged = total - missing.length;
5093
+ return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
5094
+ }
5095
+ function verifyVerdicts(opts) {
5096
+ const root = opts.packageRoot ?? assetRoot();
5097
+ const errors = [];
5098
+ const index = JSON.parse(readFileSync24(join30(root, "rules.index.json"), "utf8"));
5099
+ const judgment = index.filter((r) => r.bucket === "judgment");
5100
+ const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
5101
+ const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
5102
+ const passOf = new Map([
5103
+ ...index.filter((r) => r.bucket === "mechanical").map((r) => [r.id, "mechanical"]),
5104
+ ...judgment.map((r) => [r.id, r.pass ?? ""])
5105
+ ]);
5106
+ const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
5107
+ const dir = join30(opts.projectRoot, ".jig", "critique", opts.surface);
5108
+ const screenFile = readJson(join30(dir, "screen.json"), errors);
5109
+ const codeFile = readJson(join30(dir, "code.json"), errors);
5110
+ const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
5111
+ const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
5112
+ const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
5113
+ let rendered = false;
5114
+ if (screenFile && screenFile.rendered === true) {
5115
+ const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
5116
+ const absent = artefacts.filter((a) => !existsSync17(resolve3(opts.projectRoot, a)));
5117
+ if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
5118
+ else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
5119
+ else rendered = true;
5120
+ }
5121
+ const probes = readProbes(dir, errors);
5122
+ if (screenFile && screenFile.rendered === true) {
5123
+ const widths = new Set(probes.map((p) => p.width));
5124
+ const missing = [360, 768, 1280].filter((w) => !widths.has(w));
5125
+ if (missing.length) {
5126
+ errors.push(`screen.json says rendered: true, but there is no probe at ${missing.join(", ")}px. At each width run \`jig probe\` in the browser and save its output as probe-<width>.json.`);
5127
+ rendered = false;
5128
+ }
5129
+ }
5130
+ const screenVerdicts = Array.isArray(screenFile?.verdicts) ? screenFile.verdicts : [];
5131
+ const verdictOf = (id) => {
5132
+ const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
5133
+ return typeof v?.verdict === "string" ? v.verdict : void 0;
5134
+ };
5135
+ errors.push(...probeContradictions(probes, verdictOf));
5136
+ if (screen.state === "ran" && !rendered) screen.state = "skipped";
5137
+ const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
5138
+ const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings}`;
5139
+ return { ok: errors.length === 0, errors, screen, code, rendered, line };
5140
+ }
5141
+
5142
+ // src/commands/gate.ts
5143
+ import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync13, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
5144
+ import { join as join32 } from "path";
5145
+
5146
+ // src/check/spec-shape.ts
5147
+ import { existsSync as existsSync18, readFileSync as readFileSync25, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
5148
+ import { join as join31 } from "path";
5149
+ function newestSpec(projectRoot) {
5150
+ const dir = join31(projectRoot, ".jig", "specs");
5151
+ if (!existsSync18(dir)) return void 0;
5152
+ const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
5153
+ if (files.length === 0) return void 0;
5154
+ const newest = files.map((f) => ({ f, at: statSync2(join31(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
5155
+ return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync25(join31(dir, newest), "utf8") };
5156
+ }
5157
+ function specProblems(spec) {
5158
+ const parts = spec.body.split(/^---\s*$/m);
5159
+ const front = parts.length >= 3 ? parts[1] : "";
5160
+ if (!front.trim()) {
5161
+ return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
5162
+ }
5163
+ const problems = [];
5164
+ const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
5165
+ for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
5166
+ if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
5167
+ }
5168
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
5169
+ for (const size of ["phone", "tablet", "desktop"]) {
5170
+ const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
5171
+ if (!line) {
5172
+ problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
5173
+ continue;
5174
+ }
5175
+ if (line[1].trim()) {
5176
+ problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
5177
+ continue;
5178
+ }
5179
+ const block = sizeBlock(sizes, size);
5180
+ if (/^\s*same-as\s*:/im.test(block)) {
5181
+ if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
5182
+ continue;
5183
+ }
5184
+ for (const field of ["regions", "nav"]) {
5185
+ if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
5186
+ problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
5187
+ }
5188
+ }
5189
+ }
5190
+ return problems;
5191
+ }
5192
+ function sizeBlock(sizes, size) {
5193
+ const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
5194
+ if (!start) return "";
5195
+ const rest = sizes.slice(start.index + start[0].length);
5196
+ const indent = /^\s*/.exec(start[0])[0].length;
5197
+ const lines = [];
5198
+ for (const line of rest.split("\n")) {
5199
+ if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
5200
+ lines.push(line);
5201
+ }
5202
+ return lines.join("\n");
5203
+ }
5204
+ var MENU_RE = /\b(menu|hamburger|drawer|burger)\b/i;
5205
+ var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
5206
+ function navProblems(spec) {
5207
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
5208
+ const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
5209
+ const problems = [];
5210
+ for (const size of ["tablet", "desktop"]) {
5211
+ const block = sizeBlock(sizes, size);
5212
+ const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
5213
+ if (!nav || NONE_RE.test(nav)) continue;
5214
+ if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
5215
+ problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
5216
+ }
5217
+ }
5218
+ return problems;
5219
+ }
5220
+
5221
+ // src/commands/gate.ts
5222
+ var MAX_BLOCKS = 3;
5223
+ function lastJigCommand(transcriptPath) {
5224
+ if (!transcriptPath || !existsSync19(transcriptPath)) return void 0;
5225
+ let text;
5226
+ try {
5227
+ text = readFileSync26(transcriptPath, "utf8");
5228
+ } catch {
5229
+ return void 0;
5230
+ }
5231
+ let found;
5232
+ for (const line of text.split("\n")) {
5233
+ if (!line.includes("/jig")) continue;
5234
+ const name = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(line);
5235
+ const plain = /(?:^|["\s>])\/jig\s+([a-z]+)/.exec(line);
5236
+ const arg = (name?.[1] ?? plain?.[1] ?? "").trim().split(/\s+/)[0];
5237
+ if (arg) found = arg.toLowerCase();
5238
+ }
5239
+ return found;
5240
+ }
5241
+ function commandProblems(root, command) {
5242
+ const problems = [];
5243
+ const spec = newestSpec(root);
5244
+ if (command === "decide") {
5245
+ const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join32(root, p)).find((p) => existsSync19(p));
5246
+ if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
5247
+ else {
5248
+ const body = readFileSync26(file, "utf8");
5249
+ if (!/^##\s+Unresolved\s*$/im.test(body)) {
5250
+ problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
5251
+ }
5252
+ if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
5253
+ }
5254
+ }
5255
+ if (command === "spec" || command === "mockup" || command === "make" || command === "critique") {
5256
+ if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
5257
+ else {
5258
+ problems.push(...specProblems(spec));
5259
+ if (problems.length === 0) problems.push(...navProblems(spec));
5260
+ }
5261
+ }
5262
+ if (command === "mockup" && spec) {
5263
+ const front = spec.body.split(/^---\s*$/m)[1] ?? "";
5264
+ const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
5265
+ if (/^pending/i.test(mockup) || !mockup) problems.push(`${spec.path}: \`mockup:\` is still pending. It records the user's own word \u2014 approved, or skipped with their reason.`);
5266
+ const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
5267
+ if (/^approved/i.test(mockup)) {
5268
+ if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
5269
+ else if (!/^https?:/i.test(at) && !existsSync19(join32(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
5270
+ else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
5271
+ }
5272
+ }
5273
+ if (command === "critique") {
5274
+ const dir = join32(root, ".jig", "critique");
5275
+ const surfaces = existsSync19(dir) ? readdirSync13(dir).filter((s) => existsSync19(join32(dir, s, "screen.json")) || existsSync19(join32(dir, s, "code.json"))) : [];
5276
+ for (const surface of surfaces) {
5277
+ const v = verifyVerdicts({ projectRoot: root, surface });
5278
+ if (v.ok && v.screen.state === "skipped") {
5279
+ problems.push(`${surface}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
5280
+ }
5281
+ }
5282
+ if (surfaces.length === 0) {
5283
+ problems.push("critique wrote no verdict files. Each reader arm writes .jig/critique/<surface>/screen.json or code.json, and `jig verdicts <surface>` \u2014 not your own count \u2014 decides whether the review is complete. A report without them is not a review.");
5284
+ }
5285
+ }
5286
+ return problems;
5287
+ }
5288
+ function gate(opts) {
5289
+ const root = opts.projectRoot;
5290
+ if (!existsSync19(join32(root, "jig.config.json")) && !existsSync19(join32(root, ".jig"))) {
5291
+ return { block: false, reason: "" };
5292
+ }
5293
+ const command = lastJigCommand(opts.input.transcript_path);
5294
+ const problems = command ? commandProblems(root, command).map((p) => `/jig ${command}: ${p}`) : [];
5295
+ const selection = selectFiles(root, false);
5296
+ const changedStyles = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f));
5297
+ if (changedStyles) {
5298
+ const result = check({ projectRoot: root, homeDir: "", version: opts.version, all: false, ci: false });
5299
+ const errors = result.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error");
5300
+ if (errors.length > 0) {
5301
+ const shown = errors.slice(0, 8).map((f) => ` ${f.ruleId} ${f.file}:${f.line} ${f.message}`);
5302
+ problems.push(
5303
+ `jig check: ${errors.length} mechanical error(s) in the files you changed. Fix every one, then run \`jig check\` again.
5304
+ ` + shown.join("\n") + (errors.length > shown.length ? `
5305
+ \u2026 and ${errors.length - shown.length} more` : "")
5306
+ );
5307
+ }
5308
+ }
5309
+ const critiqueDir = join32(root, ".jig", "critique");
5310
+ if (existsSync19(critiqueDir)) {
5311
+ for (const surface of readdirSync13(critiqueDir)) {
5312
+ const dir = join32(critiqueDir, surface);
5313
+ if (!existsSync19(join32(dir, "screen.json")) && !existsSync19(join32(dir, "code.json"))) continue;
5314
+ const v = verifyVerdicts({ projectRoot: root, surface });
5315
+ if (!v.ok) {
5316
+ const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
5317
+ problems.push(
5318
+ `jig verdicts ${surface}: the critique is not complete. Re-run the arm it names \u2014 do not edit the verdict files to pass.
5319
+ ` + shown.join("\n") + (v.errors.length > shown.length ? `
5320
+ \u2026 and ${v.errors.length - shown.length} more` : "")
5321
+ );
5322
+ }
5323
+ }
5324
+ }
5325
+ const session = opts.input.session_id ?? "unknown";
5326
+ const stateFile = join32(root, ".jig", "gate.json");
5327
+ let state = {};
5328
+ try {
5329
+ state = JSON.parse(readFileSync26(stateFile, "utf8"));
5330
+ } catch {
5331
+ }
5332
+ if (problems.length === 0) {
5333
+ if (state[session]) {
5334
+ delete state[session];
5335
+ save(stateFile, state);
5336
+ }
5337
+ return { block: false, reason: "" };
5338
+ }
5339
+ const count = (state[session] ?? 0) + 1;
5340
+ if (count > MAX_BLOCKS) {
5341
+ return {
5342
+ block: false,
5343
+ reason: `jig gate: still failing after ${MAX_BLOCKS} attempts; letting you stop. Tell the user plainly that the work is not finished:
5344
+ ${problems.join("\n\n")}`
5345
+ };
5346
+ }
5347
+ state[session] = count;
5348
+ save(stateFile, state);
5349
+ return {
5350
+ block: true,
5351
+ reason: `Not finished \u2014 Jig's gate failed (attempt ${count} of ${MAX_BLOCKS}). Fix these before you stop, and do not report the work as done until they pass:
5352
+
5353
+ ${problems.join("\n\n")}`
5354
+ };
5355
+ }
5356
+ function save(file, state) {
5357
+ try {
5358
+ mkdirSync7(join32(file, ".."), { recursive: true });
5359
+ writeFileSync6(file, JSON.stringify(state), "utf8");
5360
+ } catch {
5361
+ }
5362
+ }
5363
+
4208
5364
  // src/index.ts
4209
5365
  var packageRoot = getPackageRoot();
4210
- var { version } = JSON.parse(readFileSync19(join24(packageRoot, "package.json"), "utf8"));
5366
+ var { version } = JSON.parse(readFileSync27(join33(packageRoot, "package.json"), "utf8"));
4211
5367
  function warnIfUnpublishedPin() {
4212
5368
  if (isPublishedBuild(packageRoot)) return;
4213
5369
  console.warn(
@@ -4216,12 +5372,28 @@ function warnIfUnpublishedPin() {
4216
5372
  }
4217
5373
  var program = new Command();
4218
5374
  program.name("jig").description("A design system for coding agents.").version(version);
4219
- 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) => {
5375
+ 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").option("--hook", "add the Stop hook that blocks finishing while check or a step fails (Claude Code, project scope)").option("--no-hook", "do not add the Stop hook, and do not ask for it").option("--yes", "non-interactive: ask nothing, and add no Stop hook unless --hook is given", false).action(async (opts) => {
4220
5376
  if (opts.scope !== "project" && opts.scope !== "global") {
4221
5377
  console.error(`Invalid scope '${opts.scope}'. Use 'project' or 'global'.`);
4222
5378
  process.exit(1);
4223
5379
  }
4224
5380
  const projectRoot = findProjectRoot(process.cwd());
5381
+ let hook = opts.hook === true;
5382
+ const unanswered = opts.hook === void 0;
5383
+ if (unanswered && opts.agent === "claude" && opts.scope === "project" && !opts.yes && process.stdin.isTTY) {
5384
+ const { createInterface } = await import("readline/promises");
5385
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5386
+ try {
5387
+ console.log("\nClaude Code can run a Jig check when the agent tries to finish, and hold it there while");
5388
+ console.log("the files it changed fail `check`, or the /jig step it just ran is unfinished. It adds one");
5389
+ console.log("entry to .claude/settings.json, keeps everything else in that file, and lets go after three");
5390
+ console.log("attempts. Weak models skip steps they are only asked to run; this is what catches that.");
5391
+ const answer = (await rl.question(" Add it? [y/N]: ")).trim().toLowerCase();
5392
+ hook = answer === "y" || answer === "yes";
5393
+ } finally {
5394
+ rl.close();
5395
+ }
5396
+ }
4225
5397
  try {
4226
5398
  const result = install({
4227
5399
  agent: opts.agent,
@@ -4229,7 +5401,8 @@ program.command("install").description("Install Jig rules and the agent skill fi
4229
5401
  projectRoot,
4230
5402
  packageRoot: assetRoot(),
4231
5403
  version,
4232
- homeDir: homedir()
5404
+ homeDir: homedir(),
5405
+ hook
4233
5406
  });
4234
5407
  if (result.warning) {
4235
5408
  console.warn(result.warning);
@@ -4239,14 +5412,19 @@ program.command("install").description("Install Jig rules and the agent skill fi
4239
5412
  warnIfUnpublishedPin();
4240
5413
  for (const f of result.written) console.log(` + ${f}`);
4241
5414
  for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
5415
+ if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
5416
+ if (result.stopHook === false) console.log(" ! .claude/settings.json is not valid JSON \u2014 the Stop hook was not added. Fix the file and run install again.");
5417
+ if (result.stopHook === void 0 && opts.agent === "claude" && opts.scope === "project") {
5418
+ console.log(" \xB7 No Stop hook. Add it any time with: npx jig-ui@" + version + " install --agent claude --hook");
5419
+ }
4242
5420
  } catch (err) {
4243
5421
  console.error(err.message);
4244
5422
  process.exit(1);
4245
5423
  }
4246
5424
  });
4247
- 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) => {
5425
+ 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").option("--layer", "name the six layers, or list one of them by name").action((query, options) => {
4248
5426
  try {
4249
- console.log(explain({ ruleId: query ?? "", version, list: options.list }));
5427
+ console.log(explain({ ruleId: query ?? "", version, list: options.list, layer: options.layer }));
4250
5428
  } catch (err) {
4251
5429
  console.error(err.message);
4252
5430
  process.exit(1);
@@ -4275,6 +5453,19 @@ program.command("update").description("Update vendored Jig rules, skipping files
4275
5453
  process.exit(1);
4276
5454
  }
4277
5455
  });
5456
+ program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").action((surface) => {
5457
+ const projectRoot = findProjectRoot(process.cwd());
5458
+ try {
5459
+ const result = verifyVerdicts({ projectRoot, surface });
5460
+ for (const error of result.errors) console.error(` \u2717 ${error}`);
5461
+ if (result.ok) console.log(` Every rule in both passes has a verdict.`);
5462
+ console.log(` ${result.line}`);
5463
+ process.exit(result.ok ? 0 : 1);
5464
+ } catch (err) {
5465
+ console.error(err.message);
5466
+ process.exit(1);
5467
+ }
5468
+ });
4278
5469
  program.command("check").description("Check the repo against Jig's mechanical + hybrid rules.").option(
4279
5470
  "--all",
4280
5471
  // Names the files, because the default scope surprised a user who read
@@ -4298,6 +5489,25 @@ program.command("check").description("Check the repo against Jig's mechanical +
4298
5489
  process.exit(1);
4299
5490
  }
4300
5491
  });
5492
+ program.command("probe").description("Print the render probe: run it in a browser at each width and save its output for `jig verdicts`.").action(() => {
5493
+ console.log(PROBE_SCRIPT);
5494
+ });
5495
+ program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
5496
+ let input = {};
5497
+ try {
5498
+ if (!process.stdin.isTTY) input = JSON.parse(readFileSync27(0, "utf8") || "{}");
5499
+ } catch {
5500
+ }
5501
+ const cwd = input.cwd ?? process.cwd();
5502
+ try {
5503
+ const result = gate({ projectRoot: findProjectRoot(cwd), version, input });
5504
+ if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
5505
+ else if (result.reason) console.error(result.reason);
5506
+ } catch (err) {
5507
+ console.error(`jig gate: ${err.message}`);
5508
+ }
5509
+ process.exit(0);
5510
+ });
4301
5511
  program.command("init").description("Set the project up to use Jig: a brand file, jig.config.json, and a baseline check.").option("--yes", "non-interactive: derive everything, accept the proposal, ask nothing", false).action(async (opts) => {
4302
5512
  const projectRoot = findProjectRoot(process.cwd());
4303
5513
  try {