jig-ui 0.4.0 → 0.6.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 readFileSync16 } from "fs";
5
+ import { readFileSync as readFileSync19 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join21 } from "path";
7
+ import { join as join24 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -42,16 +42,16 @@ function isPublishedBuild(packageRoot2) {
42
42
  }
43
43
 
44
44
  // src/commands/install.ts
45
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, writeFileSync as writeFileSync3 } from "fs";
46
- import { dirname as dirname4, join as join6 } from "path";
45
+ import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
46
+ import { join as join7 } from "path";
47
47
 
48
48
  // src/adapters/types.ts
49
49
  var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, review 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
- const isAbsolute = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
52
+ const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
53
53
  const hasDotDotSegment = relPath.split(/[\\/]/).includes("..");
54
- if (isAbsolute || hasDotDotSegment) {
54
+ if (isAbsolute2 || hasDotDotSegment) {
55
55
  throw new Error(`Adapter '${adapterName}' produced an unsafe relPath: ${relPath}`);
56
56
  }
57
57
  }
@@ -477,6 +477,10 @@ function upsertBlock(existing, block) {
477
477
  return `${text.slice(0, insertAt)}${block}${text.slice(insertAt)}`;
478
478
  }
479
479
 
480
+ // src/install/writer.ts
481
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
482
+ import { dirname as dirname4, join as join6 } from "path";
483
+
480
484
  // src/install/line-endings.ts
481
485
  function dominantEnding(existing) {
482
486
  const crlf = (existing.match(/\r\n/g) ?? []).length;
@@ -490,23 +494,36 @@ function matchLineEndings(content, existing) {
490
494
  return target === "\n" ? normalized : normalized.replace(/\n/g, "\r\n");
491
495
  }
492
496
 
493
- // src/commands/install.ts
494
- var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
497
+ // src/install/writer.ts
495
498
  function relKey(...parts) {
496
499
  return parts.join("/");
497
500
  }
498
- function writeFile(root, key, content, files) {
499
- const abs = join6(root, ...key.split("/"));
500
- mkdirSync3(dirname4(abs), { recursive: true });
501
- const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
502
- const toWrite = matchLineEndings(content, existing);
503
- writeFileSync3(abs, toWrite, "utf8");
504
- files[key] = checksum(toWrite);
501
+ function createWriter(root, seed = {}) {
502
+ const files = { ...seed };
503
+ return {
504
+ files,
505
+ write(key, content) {
506
+ const abs = join6(root, ...key.split("/"));
507
+ mkdirSync3(dirname4(abs), { recursive: true });
508
+ const existing = existsSync4(abs) ? readFileSync3(abs, "utf8") : void 0;
509
+ const toWrite = matchLineEndings(content, existing);
510
+ writeFileAtomic(abs, toWrite);
511
+ files[key] = checksum(toWrite);
512
+ return key;
513
+ }
514
+ };
515
+ }
516
+ function bundleFiles(packageRoot2, dir) {
517
+ const ext = dir === "rules" ? ".md" : ".css";
518
+ return readdirSync2(join6(packageRoot2, dir)).filter((f) => f.endsWith(ext)).sort();
505
519
  }
520
+
521
+ // src/commands/install.ts
522
+ var ASK_INSTRUCTION = "Ask the user directly in chat, one question at a time, and wait for an answer before continuing.";
506
523
  function buildSkillBody(packageRoot2, rulesPath, version2) {
507
- const template = readFileSync3(join6(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
524
+ const template = readFileSync4(join7(packageRoot2, "templates", "SKILL.md.tmpl"), "utf8");
508
525
  const metadata = JSON.parse(
509
- readFileSync3(join6(packageRoot2, "templates", "command-metadata.json"), "utf8")
526
+ readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
510
527
  );
511
528
  return render(template, {
512
529
  command_prefix: "/jig ",
@@ -523,13 +540,13 @@ function buildSkillBody(packageRoot2, rulesPath, version2) {
523
540
  }
524
541
  function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
525
542
  const metadata = JSON.parse(
526
- readFileSync3(join6(packageRoot2, "templates", "command-metadata.json"), "utf8")
543
+ readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
527
544
  );
528
545
  const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
529
- const templatePath = join6(packageRoot2, "templates", "COMMAND.md.tmpl");
530
- if (!existsSync4(templatePath)) return { body: "", subcommands };
531
- const template = readFileSync3(templatePath, "utf8");
532
- 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(/\{\{rules_path\}\}/g, rulesPath);
546
+ const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
547
+ if (!existsSync5(templatePath)) return { body: "", subcommands };
548
+ const template = readFileSync4(templatePath, "utf8");
549
+ const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath);
533
550
  return { body, subcommands };
534
551
  }
535
552
  function rulesPathFor(referenceDir, scope) {
@@ -562,9 +579,9 @@ function install(opts) {
562
579
  existingManifest = null;
563
580
  }
564
581
  const planned = [];
565
- const rulesDir = join6(opts.packageRoot, "rules");
566
- for (const file of readdirSync2(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
567
- const body = readFileSync3(join6(rulesDir, file), "utf8");
582
+ const rulesDir = join7(opts.packageRoot, "rules");
583
+ for (const file of bundleFiles(opts.packageRoot, "rules")) {
584
+ const body = readFileSync4(join7(rulesDir, file), "utf8");
568
585
  planned.push({
569
586
  key: relKey(referenceDir, "rules", file),
570
587
  content: vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + body,
@@ -580,13 +597,13 @@ function install(opts) {
580
597
  }
581
598
  planned.push({
582
599
  key: relKey(referenceDir, "rules.index.json"),
583
- content: readFileSync3(join6(opts.packageRoot, "rules.index.json"), "utf8"),
600
+ content: readFileSync4(join7(opts.packageRoot, "rules.index.json"), "utf8"),
584
601
  checkable: true
585
602
  });
586
603
  for (const src of ["LICENSE", "NOTICE"]) {
587
604
  planned.push({
588
605
  key: relKey(referenceDir, src),
589
- content: readFileSync3(join6(opts.packageRoot, src), "utf8"),
606
+ content: readFileSync4(join7(opts.packageRoot, src), "utf8"),
590
607
  checkable: false
591
608
  });
592
609
  }
@@ -608,12 +625,13 @@ function install(opts) {
608
625
  });
609
626
  for (const file of skillFiles) {
610
627
  const key = file.relPath;
611
- const abs = join6(installRoot, ...key.split("/"));
628
+ const abs = join7(installRoot, ...key.split("/"));
612
629
  const isBlockFile = file.content.includes(BLOCK_START);
613
- const content = isBlockFile && existsSync4(abs) ? upsertBlock(readFileSync3(abs, "utf8"), file.content) : file.content;
630
+ const content = isBlockFile && existsSync5(abs) ? upsertBlock(readFileSync4(abs, "utf8"), file.content) : file.content;
614
631
  planned.push({ key, content, checkable: !isBlockFile });
615
632
  }
616
- const files = {};
633
+ const writer = createWriter(installRoot);
634
+ const files = writer.files;
617
635
  const written = [];
618
636
  const skipped = [];
619
637
  for (const { key, content, checkable } of planned) {
@@ -623,7 +641,7 @@ function install(opts) {
623
641
  if (recorded) files[key] = recorded;
624
642
  continue;
625
643
  }
626
- writeFile(installRoot, key, content, files);
644
+ writer.write(key, content);
627
645
  written.push(key);
628
646
  }
629
647
  const manifest = {
@@ -639,8 +657,8 @@ function install(opts) {
639
657
  }
640
658
 
641
659
  // src/commands/update.ts
642
- import { dirname as dirname6, join as join9 } from "path";
643
- import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, readdirSync as readdirSync4, writeFileSync as writeFileSync5 } from "fs";
660
+ import { join as join10 } from "path";
661
+ import { existsSync as existsSync8, readFileSync as readFileSync7 } from "fs";
644
662
 
645
663
  // src/install/target.ts
646
664
  import { resolve } from "path";
@@ -683,14 +701,14 @@ function resolveAllInstalled(projectRoot, homeDir) {
683
701
  }
684
702
 
685
703
  // src/init/state.ts
686
- import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4 } from "fs";
687
- import { dirname as dirname5, join as join7 } from "path";
688
- var INIT_MANIFEST_REL = join7(".jig", "state.json");
704
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync5 } from "fs";
705
+ import { dirname as dirname5, join as join8 } from "path";
706
+ var INIT_MANIFEST_REL = join8(".jig", "state.json");
689
707
  function readInitManifest(projectRoot) {
690
- const path = join7(projectRoot, INIT_MANIFEST_REL);
691
- if (!existsSync5(path)) return null;
708
+ const path = join8(projectRoot, INIT_MANIFEST_REL);
709
+ if (!existsSync6(path)) return null;
692
710
  try {
693
- const raw = JSON.parse(readFileSync4(path, "utf8"));
711
+ const raw = JSON.parse(readFileSync5(path, "utf8"));
694
712
  if (typeof raw !== "object" || raw === null) return null;
695
713
  const { files, version: version2, modes } = raw;
696
714
  if (typeof files !== "object" || files === null) return null;
@@ -705,7 +723,7 @@ function readInitManifest(projectRoot) {
705
723
  }
706
724
  var MERGE_ATTEMPTS2 = 5;
707
725
  function writeInitManifest(projectRoot, m) {
708
- const path = join7(projectRoot, INIT_MANIFEST_REL);
726
+ const path = join8(projectRoot, INIT_MANIFEST_REL);
709
727
  mkdirSync4(dirname5(path), { recursive: true });
710
728
  for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
711
729
  const onDisk = readInitManifest(projectRoot);
@@ -726,15 +744,15 @@ function isInitFileModified(projectRoot, relPath, m) {
726
744
  }
727
745
 
728
746
  // src/init/migrate.ts
729
- import { existsSync as existsSync6, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync } from "fs";
730
- import { join as join8 } from "path";
747
+ import { existsSync as existsSync7, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync } from "fs";
748
+ import { join as join9 } from "path";
731
749
  var LEGACY_RULE_RE = /^\d{2}-.*\.md$/;
732
750
  function isLegacyInstallArtifact(name) {
733
751
  return LEGACY_RULE_RE.test(name) || name === "rules.index.json" || name === "LICENSE" || name === "NOTICE" || name === "manifest.json";
734
752
  }
735
753
  function readLegacyManifest(dirAbs) {
736
754
  try {
737
- const raw = JSON.parse(readFileSync5(join8(dirAbs, "manifest.json"), "utf8"));
755
+ const raw = JSON.parse(readFileSync6(join9(dirAbs, "manifest.json"), "utf8"));
738
756
  if (raw && typeof raw === "object" && typeof raw.files === "object") {
739
757
  return raw;
740
758
  }
@@ -746,8 +764,8 @@ function classify(projectRoot, relPath, legacyManifest) {
746
764
  return legacyManifest && relPath in legacyManifest.files ? isModified(projectRoot, relPath, legacyManifest) : "unknown";
747
765
  }
748
766
  function detectLegacyRules(projectRoot) {
749
- const jigDir = join8(projectRoot, ".jig");
750
- if (!existsSync6(jigDir)) return { present: false, files: [] };
767
+ const jigDir = join9(projectRoot, ".jig");
768
+ if (!existsSync7(jigDir)) return { present: false, files: [] };
751
769
  const legacyManifest = readLegacyManifest(jigDir);
752
770
  let entries;
753
771
  try {
@@ -763,12 +781,12 @@ function detectLegacyRules(projectRoot) {
763
781
  }
764
782
  function detectLegacyCursorRules(projectRoot) {
765
783
  const bundleDirRel = ".cursor/rules/jig";
766
- const bundleDir = join8(projectRoot, ...bundleDirRel.split("/"));
784
+ const bundleDir = join9(projectRoot, ...bundleDirRel.split("/"));
767
785
  const mdcRel = ".cursor/rules/jig.mdc";
768
- const mdcAbs = join8(projectRoot, ...mdcRel.split("/"));
786
+ const mdcAbs = join9(projectRoot, ...mdcRel.split("/"));
769
787
  const legacyManifest = readLegacyManifest(bundleDir);
770
788
  const files = [];
771
- if (existsSync6(mdcAbs)) {
789
+ if (existsSync7(mdcAbs)) {
772
790
  files.push({ relPath: mdcRel, modified: classify(projectRoot, mdcRel, legacyManifest) });
773
791
  }
774
792
  let entries;
@@ -822,7 +840,7 @@ function removableLegacyFiles(report) {
822
840
  function removeLegacyFiles(projectRoot, relPaths) {
823
841
  for (const relPath of relPaths) {
824
842
  try {
825
- rmSync(join8(projectRoot, ...relPath.split("/")), { force: true });
843
+ rmSync(join9(projectRoot, ...relPath.split("/")), { force: true });
826
844
  } catch {
827
845
  }
828
846
  }
@@ -868,24 +886,19 @@ function updateTarget(opts, resolved) {
868
886
  const adapter = getAdapter(existing.agent);
869
887
  const updated = [];
870
888
  const skipped = [];
871
- const files = { ...existing.files };
889
+ const writer = createWriter(installRoot, existing.files);
890
+ const files = writer.files;
872
891
  const write = (key, content) => {
873
- const abs = join9(installRoot, ...key.split("/"));
874
- mkdirSync5(dirname6(abs), { recursive: true });
875
- const existing2 = existsSync7(abs) ? readFileSync6(abs, "utf8") : void 0;
876
- const toWrite = matchLineEndings(content, existing2);
877
- writeFileSync5(abs, toWrite, "utf8");
878
- files[key] = checksum(toWrite);
879
- updated.push(key);
892
+ updated.push(writer.write(key, content));
880
893
  };
881
- const rulesDir = join9(opts.packageRoot, "rules");
882
- for (const file of readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
894
+ const rulesDir = join10(opts.packageRoot, "rules");
895
+ for (const file of bundleFiles(opts.packageRoot, "rules")) {
883
896
  const key = relKey(referenceDir, "rules", file);
884
897
  if (isModified(installRoot, key, existing)) {
885
898
  skipped.push(key);
886
899
  continue;
887
900
  }
888
- write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync6(join9(rulesDir, file), "utf8"));
901
+ write(key, vendorHeader(file, opts.version, "html", licencePathFor(`rules/${file}`)) + readFileSync7(join10(rulesDir, file), "utf8"));
889
902
  }
890
903
  for (const ref of referenceFiles(opts.packageRoot)) {
891
904
  const key = relKey(referenceDir, ...ref.relPath.split("/"));
@@ -899,7 +912,7 @@ function updateTarget(opts, resolved) {
899
912
  if (isModified(installRoot, indexKey, existing)) {
900
913
  skipped.push(indexKey);
901
914
  } else {
902
- write(indexKey, readFileSync6(join9(opts.packageRoot, "rules.index.json"), "utf8"));
915
+ write(indexKey, readFileSync7(join10(opts.packageRoot, "rules.index.json"), "utf8"));
903
916
  }
904
917
  const rulesPath = rulesPathFor(referenceDir, discoveredScope);
905
918
  const skillBody = buildSkillBody(opts.packageRoot, rulesPath, opts.version);
@@ -921,8 +934,8 @@ function updateTarget(opts, resolved) {
921
934
  const key = file.relPath;
922
935
  const isBlockFile = file.content.includes(BLOCK_START);
923
936
  if (isBlockFile) {
924
- const abs = join9(installRoot, ...key.split("/"));
925
- const current = existsSync7(abs) ? readFileSync6(abs, "utf8") : "";
937
+ const abs = join10(installRoot, ...key.split("/"));
938
+ const current = existsSync8(abs) ? readFileSync7(abs, "utf8") : "";
926
939
  write(key, upsertBlock(current, file.content));
927
940
  continue;
928
941
  }
@@ -933,7 +946,7 @@ function updateTarget(opts, resolved) {
933
946
  write(key, file.content);
934
947
  }
935
948
  for (const src of ["LICENSE", "NOTICE"]) {
936
- write(relKey(referenceDir, src), readFileSync6(join9(opts.packageRoot, src), "utf8"));
949
+ write(relKey(referenceDir, src), readFileSync7(join10(opts.packageRoot, src), "utf8"));
937
950
  }
938
951
  const manifest = {
939
952
  ...existing,
@@ -948,37 +961,35 @@ function updateTarget(opts, resolved) {
948
961
  function updateInitFiles(opts) {
949
962
  const updated = [];
950
963
  const skipped = [];
951
- const tokensDir = join9(opts.packageRoot, "tokens");
964
+ const tokensDir = join10(opts.packageRoot, "tokens");
952
965
  const initManifest = readInitManifest(opts.projectRoot);
953
966
  if (initManifest) {
954
967
  const initFiles = { ...initManifest.files };
955
968
  let initChanged = false;
956
- for (const file of readdirSync4(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
969
+ const writer = createWriter(opts.projectRoot, initFiles);
970
+ for (const file of bundleFiles(opts.packageRoot, "tokens")) {
957
971
  const key = relKey(".jig", "tokens", file);
958
972
  if (!(key in initManifest.files)) continue;
959
973
  if (isInitFileModified(opts.projectRoot, key, initManifest)) {
960
974
  skipped.push(key);
961
975
  continue;
962
976
  }
963
- const content = vendorHeader(file, opts.version, "css", null) + readFileSync6(join9(tokensDir, file), "utf8");
964
- const abs = join9(opts.projectRoot, ...key.split("/"));
965
- mkdirSync5(dirname6(abs), { recursive: true });
966
- writeFileSync5(abs, content, "utf8");
967
- initFiles[key] = checksum(content);
977
+ const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
978
+ updated.push(writer.write(key, content));
968
979
  initChanged = true;
969
- updated.push(key);
970
980
  }
981
+ Object.assign(initFiles, writer.files);
971
982
  if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
972
983
  }
973
984
  return { updated, skipped };
974
985
  }
975
986
 
976
987
  // src/commands/explain.ts
977
- import { join as join12 } from "path";
988
+ import { join as join13 } from "path";
978
989
 
979
990
  // src/rules/load.ts
980
- import { readFileSync as readFileSync7, readdirSync as readdirSync5 } from "fs";
981
- import { join as join10 } from "path";
991
+ import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
992
+ import { join as join11 } from "path";
982
993
 
983
994
  // src/rules/parse.ts
984
995
  var HEADING = /^###\s+([A-Z])-(\d+)\s+(.+?)\s*$/;
@@ -1050,12 +1061,12 @@ function validateIndex(entries) {
1050
1061
 
1051
1062
  // src/rules/load.ts
1052
1063
  function loadRules(rulesDir, indexPath) {
1053
- const files = readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort();
1064
+ const files = readdirSync4(rulesDir).filter((f) => f.endsWith(".md")).sort();
1054
1065
  const parsed = [];
1055
1066
  for (const file of files) {
1056
- parsed.push(...parseRules(readFileSync7(join10(rulesDir, file), "utf8"), file));
1067
+ parsed.push(...parseRules(readFileSync8(join11(rulesDir, file), "utf8"), file));
1057
1068
  }
1058
- const entries = validateIndex(JSON.parse(readFileSync7(indexPath, "utf8")));
1069
+ const entries = validateIndex(JSON.parse(readFileSync8(indexPath, "utf8")));
1059
1070
  const byId = new Map(entries.map((e) => [e.id, e]));
1060
1071
  const missing = parsed.filter((r) => !byId.has(r.id)).map((r) => r.id);
1061
1072
  if (missing.length) {
@@ -1077,8 +1088,8 @@ function loadRules(rulesDir, indexPath) {
1077
1088
  }
1078
1089
 
1079
1090
  // src/rules/specs.ts
1080
- import { readFileSync as readFileSync8, readdirSync as readdirSync6 } from "fs";
1081
- import { join as join11 } from "path";
1091
+ import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
1092
+ import { join as join12 } from "path";
1082
1093
  var HEADING2 = /^##\s+([PM]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
1083
1094
  function parseSpecs(markdown, sourceFile) {
1084
1095
  const lines = markdown.split("\n");
@@ -1110,68 +1121,121 @@ function parseSpecs(markdown, sourceFile) {
1110
1121
  }
1111
1122
  function loadSpecs(rulesDir) {
1112
1123
  const specs = [];
1113
- for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1114
- specs.push(...parseSpecs(readFileSync8(join11(rulesDir, file), "utf8"), file));
1124
+ for (const file of readdirSync5(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
1125
+ specs.push(...parseSpecs(readFileSync9(join12(rulesDir, file), "utf8"), file));
1115
1126
  }
1116
1127
  return specs;
1117
1128
  }
1118
1129
 
1119
1130
  // src/commands/explain.ts
1120
- var ID_SHAPE = /^([A-Z])-(\d+)$/;
1131
+ var ID_SHAPE = /^([A-Z])-?(\d+)$/;
1132
+ var SECTION_SHAPE = /^([A-Z])$/;
1133
+ var matches = (haystack, needle) => haystack.toLowerCase().includes(needle.toLowerCase());
1134
+ var byNumber = (a, b) => {
1135
+ const [as, an] = a.split("-");
1136
+ const [bs, bn] = b.split("-");
1137
+ return as === bs ? Number(an) - Number(bn) : as.localeCompare(bs);
1138
+ };
1139
+ function renderRule(rule) {
1140
+ return [
1141
+ `${rule.id} ${rule.title}`,
1142
+ "",
1143
+ `\u274C ${rule.wrong}`,
1144
+ `\u2705 ${rule.correction}`,
1145
+ "",
1146
+ ` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
1147
+ ` since ${rule.since} \xB7 ${rule.source}`
1148
+ ].join("\n");
1149
+ }
1150
+ function renderSpec(spec) {
1151
+ return [
1152
+ `${spec.id} ${spec.title}`,
1153
+ "",
1154
+ spec.body,
1155
+ "",
1156
+ ` specification \u2014 component anatomy and behaviour, not a single rule,`,
1157
+ ` so it carries no \u274C/\u2705 pair and no detector.`,
1158
+ ` ${spec.source}`
1159
+ ].join("\n");
1160
+ }
1121
1161
  function explain(opts) {
1122
1162
  const raw = opts.ruleId.trim().toUpperCase();
1163
+ const root = opts.packageRoot ?? assetRoot();
1164
+ const rulesDir = join13(root, "rules");
1165
+ const rules = loadRules(rulesDir, join13(root, "rules.index.json"));
1166
+ const specs = loadSpecs(rulesDir);
1167
+ const entries = [
1168
+ ...rules.map((r) => ({ id: r.id, title: r.title, text: `${r.wrong}
1169
+ ${r.correction}` })),
1170
+ ...specs.map((s) => ({ id: s.id, title: s.title, text: s.body }))
1171
+ ];
1172
+ if (opts.list) {
1173
+ const section = SECTION_SHAPE.exec(raw)?.[1];
1174
+ const shown = entries.filter((e) => !section || e.id.startsWith(`${section}-`)).sort((a, b) => byNumber(a.id, b.id));
1175
+ if (shown.length === 0) {
1176
+ throw new Error(`No section '${raw}'. Sections: ${sectionsOf(entries).join(", ")}.`);
1177
+ }
1178
+ return [
1179
+ section ? `Section ${section} \u2014 ${shown.length} entries` : `${shown.length} rules and specs`,
1180
+ "",
1181
+ ...shown.map((e) => `${e.id.padEnd(6)} ${e.title}`)
1182
+ ].join("\n");
1183
+ }
1123
1184
  const shape = ID_SHAPE.exec(raw);
1124
- if (!shape) {
1185
+ if (shape) {
1186
+ const id = `${shape[1]}-${shape[2]}`;
1187
+ const rule = rules.find((r) => r.id === id);
1188
+ if (rule) return renderRule(rule);
1189
+ const spec = specs.find((s) => s.id === id);
1190
+ if (spec) return renderSpec(spec);
1191
+ const siblings = entries.filter((e) => e.id.startsWith(`${shape[1]}-`)).map((e) => e.id).sort(byNumber);
1125
1192
  throw new Error(
1126
- `'${opts.ruleId.trim()}' is not a rule id. Ids look like 'C-19' or 'P-02' \u2014 a section letter, a hyphen, and a number.`
1193
+ siblings.length > 0 ? `No rule '${id}'. Section ${shape[1]} has: ${siblings.join(", ")}.` : `No rule '${id}', and no section '${shape[1]}' exists.`
1127
1194
  );
1128
1195
  }
1129
- const root = opts.packageRoot ?? assetRoot();
1130
- const rulesDir = join12(root, "rules");
1131
- const rules = loadRules(rulesDir, join12(root, "rules.index.json"));
1132
- const rule = rules.find((r) => r.id === raw);
1133
- if (rule) {
1134
- const lines = [
1135
- `${rule.id} ${rule.title}`,
1136
- "",
1137
- `\u274C ${rule.wrong}`,
1138
- `\u2705 ${rule.correction}`,
1139
- "",
1140
- ` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
1141
- ` since ${rule.since} \xB7 ${rule.source}`
1142
- ];
1143
- return lines.join("\n");
1196
+ if (raw === "") {
1197
+ throw new Error(
1198
+ `Nothing to explain. Give a rule id ('C-19'), a word to search for ('contrast'), or --list.`
1199
+ );
1144
1200
  }
1145
- const spec = loadSpecs(rulesDir).find((s) => s.id === raw);
1146
- if (spec) {
1147
- return [
1148
- `${spec.id} ${spec.title}`,
1149
- "",
1150
- spec.body,
1151
- "",
1152
- ` specification \u2014 component anatomy and behaviour, not a single rule,`,
1153
- ` so it carries no \u274C/\u2705 pair and no detector.`,
1154
- ` ${spec.source}`
1155
- ].join("\n");
1201
+ const needle = opts.ruleId.trim();
1202
+ const inTitle = entries.filter((e) => matches(e.title, needle) || matches(e.id, needle));
1203
+ const inBody = entries.filter((e) => !inTitle.includes(e) && matches(e.text, needle));
1204
+ const hits = [
1205
+ ...inTitle.sort((a, b) => byNumber(a.id, b.id)),
1206
+ ...inBody.sort((a, b) => byNumber(a.id, b.id))
1207
+ ];
1208
+ if (hits.length === 0) {
1209
+ throw new Error(
1210
+ `No rule matches '${needle}'. Try --list to see every id, or a broader word.`
1211
+ );
1156
1212
  }
1157
- const section = shape[1];
1158
- const siblings = [
1159
- ...rules.filter((r) => r.id.startsWith(`${section}-`)).map((r) => r.id),
1160
- ...loadSpecs(rulesDir).filter((s) => s.id.startsWith(`${section}-`)).map((s) => s.id)
1161
- ].sort((a, b) => Number(a.split("-")[1]) - Number(b.split("-")[1]));
1162
- throw new Error(
1163
- siblings.length > 0 ? `No rule '${raw}'. Section ${section} has: ${siblings.join(", ")}.` : `No rule '${raw}', and no section '${section}' exists.`
1164
- );
1213
+ if (hits.length === 1) {
1214
+ const rule = rules.find((r) => r.id === hits[0].id);
1215
+ return rule ? renderRule(rule) : renderSpec(specs.find((s) => s.id === hits[0].id));
1216
+ }
1217
+ return [
1218
+ // Title matches lead, so the list is ranked rather than sorted. Say so:
1219
+ // otherwise C-19 sitting above A-02 reads as a sorting bug.
1220
+ `${hits.length} entries match '${needle}' \u2014 title matches first:`,
1221
+ "",
1222
+ ...hits.map((e) => `${e.id.padEnd(6)} ${e.title}`),
1223
+ "",
1224
+ `Run 'jig explain <id>' for any of them.`
1225
+ ].join("\n");
1226
+ }
1227
+ function sectionsOf(entries) {
1228
+ return [...new Set(entries.map((e) => e.id.split("-")[0]))].sort();
1165
1229
  }
1166
1230
 
1167
1231
  // src/commands/check.ts
1168
- import { existsSync as existsSync9, readFileSync as readFileSync11 } from "fs";
1169
- import { join as join16 } from "path";
1232
+ import { existsSync as existsSync12, readFileSync as readFileSync14 } from "fs";
1233
+ import { join as join19 } from "path";
1170
1234
 
1171
1235
  // src/check/files.ts
1172
1236
  import { execFileSync } from "child_process";
1173
- import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
1174
- import { join as join13, relative, sep } from "path";
1237
+ import { readdirSync as readdirSync6, realpathSync, statSync } from "fs";
1238
+ import { join as join14, relative, sep } from "path";
1175
1239
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
1176
1240
  "node_modules",
1177
1241
  "dist",
@@ -1230,13 +1294,13 @@ function isExcluded(relPath) {
1230
1294
  function walk(root, dir, out, seen) {
1231
1295
  let entries;
1232
1296
  try {
1233
- entries = readdirSync7(dir, { withFileTypes: true });
1297
+ entries = readdirSync6(dir, { withFileTypes: true });
1234
1298
  } catch {
1235
1299
  return;
1236
1300
  }
1237
1301
  for (const entry of entries) {
1238
1302
  if (EXCLUDE_DIRS.has(entry.name)) continue;
1239
- const abs = join13(dir, entry.name);
1303
+ const abs = join14(dir, entry.name);
1240
1304
  let isDir = entry.isDirectory();
1241
1305
  let isFile = entry.isFile();
1242
1306
  if (entry.isSymbolicLink()) {
@@ -1363,6 +1427,17 @@ function formatReport(findings, meta) {
1363
1427
  const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
1364
1428
  const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
1365
1429
  lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
1430
+ if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
1431
+ const n = meta.exempt?.length ?? 0;
1432
+ lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
1433
+ for (const { pattern, count, tooBroad } of meta.exemptPatterns) {
1434
+ const note = count === 0 ? "matches nothing \u2014 check the path" : tooBroad ? `${count} files \u2014 likely too broad, review it` : `${count} file${count > 1 ? "s" : ""}`;
1435
+ lines.push(` ${pattern} (${note})`);
1436
+ }
1437
+ if (n > 0) {
1438
+ lines.push(` ${meta.exempt.slice(0, 8).join(", ")}${n > 8 ? `, and ${n - 8} more` : ""}`);
1439
+ }
1440
+ }
1366
1441
  if (meta.unscanned && meta.unscanned.count > 0) {
1367
1442
  const exts = meta.unscanned.extensions.join(", ");
1368
1443
  lines.push("");
@@ -1473,8 +1548,8 @@ function isStyleBearing(file) {
1473
1548
  }
1474
1549
 
1475
1550
  // src/check/run.ts
1476
- import { readFileSync as readFileSync9 } from "fs";
1477
- import { join as join14 } from "path";
1551
+ import { readFileSync as readFileSync10 } from "fs";
1552
+ import { join as join15 } from "path";
1478
1553
 
1479
1554
  // src/check/css.ts
1480
1555
  function maskComments(source) {
@@ -1523,7 +1598,12 @@ function splitRuleBlocks(source) {
1523
1598
  const ch = source[i];
1524
1599
  if (ch === "\n") line++;
1525
1600
  if (ch === "{") {
1526
- stack.push({ selectorStart: lastBoundary, bodyStart: i + 1, bodyStartLine: line });
1601
+ stack.push({
1602
+ selectorStart: lastBoundary,
1603
+ bodyStart: i + 1,
1604
+ bodyStartLine: line,
1605
+ isAtRule: cleanSelector(source.slice(lastBoundary, i)).startsWith("@")
1606
+ });
1527
1607
  lastBoundary = i + 1;
1528
1608
  } else if (ch === "}") {
1529
1609
  const top = stack.pop();
@@ -1531,7 +1611,8 @@ function splitRuleBlocks(source) {
1531
1611
  blocks.push({
1532
1612
  selector: cleanSelector(source.slice(top.selectorStart, top.bodyStart - 1)),
1533
1613
  body: source.slice(top.bodyStart, i),
1534
- bodyStartLine: top.bodyStartLine
1614
+ bodyStartLine: top.bodyStartLine,
1615
+ atRuleDepth: stack.filter((f) => f.isAtRule).length
1535
1616
  });
1536
1617
  }
1537
1618
  lastBoundary = i + 1;
@@ -2377,13 +2458,38 @@ var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/;
2377
2458
  var PX_RE = /(-?\d*\.?\d+)px/g;
2378
2459
  var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
2379
2460
  var KEYFRAME_STEP_RE = /^(from|to|\d+(\.\d+)?%)$/i;
2461
+ var PRIMITIVE_RE = /var\(\s*(--(?:brand|error|warning|success|info)-(?:h|s|l|fill-a))\s*[,)]/g;
2462
+ var TOKEN_LAYER_RE = /(^|\/)(\.jig\/tokens|jig)\/[^/]+\.css$/;
2463
+ function primitiveConsumption(source, file, ctx) {
2464
+ if (TOKEN_LAYER_RE.test(file)) return [];
2465
+ const out = [];
2466
+ const seen = /* @__PURE__ */ new Set();
2467
+ for (const m of source.matchAll(PRIMITIVE_RE)) {
2468
+ const token = m[1];
2469
+ const line = source.slice(0, m.index ?? 0).split("\n").length;
2470
+ const key = `${token}:${line}`;
2471
+ if (seen.has(key)) continue;
2472
+ seen.add(key);
2473
+ out.push(
2474
+ mkFinding(
2475
+ ctx,
2476
+ "hardcoded-value",
2477
+ file,
2478
+ line,
2479
+ `${token} is a raw channel input, not a semantic role \u2014 consuming it bypasses the theme overrides, which remap \`--color-*\` and never the channels. Use the semantic token for what this is (\`--color-text-*\`, \`--color-fill-*\`, \`--color-stroke-*\`).`,
2480
+ sourceLine(source, line)
2481
+ )
2482
+ );
2483
+ }
2484
+ return out;
2485
+ }
2380
2486
  var hardcodedValue = {
2381
2487
  name: "hardcoded-value",
2382
2488
  appliesTo: (file) => isStyleBearing(file),
2383
2489
  run(source, file, ctx) {
2490
+ const findings = primitiveConsumption(source, file, ctx);
2384
2491
  const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
2385
- if (!participates) return [];
2386
- const findings = [];
2492
+ if (!participates) return findings;
2387
2493
  for (const block of leafBlocks(source)) {
2388
2494
  if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
2389
2495
  DECL_RE2.lastIndex = 0;
@@ -2514,7 +2620,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
2514
2620
  if (applicable.length === 0) continue;
2515
2621
  let raw;
2516
2622
  try {
2517
- raw = readFileSync9(join14(projectRoot, file), "utf8");
2623
+ raw = readFileSync10(join15(projectRoot, file), "utf8");
2518
2624
  } catch {
2519
2625
  continue;
2520
2626
  }
@@ -2528,38 +2634,261 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
2528
2634
  }
2529
2635
 
2530
2636
  // src/check/tokens.ts
2531
- import { existsSync as existsSync8, readdirSync as readdirSync8, readFileSync as readFileSync10 } from "fs";
2532
- import { join as join15 } from "path";
2533
- function loadTokenMap(projectRoot) {
2534
- const tokensDir = join15(projectRoot, ".jig", "tokens");
2535
- const tokens = {};
2536
- if (!existsSync8(tokensDir)) return tokens;
2537
- const files = readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort();
2637
+ import { existsSync as existsSync9, readdirSync as readdirSync7, readFileSync as readFileSync11 } from "fs";
2638
+ import { join as join16 } from "path";
2639
+ function rootDeclarations(source) {
2640
+ const out = {};
2641
+ for (const block of splitRuleBlocks(source)) {
2642
+ if (block.atRuleDepth !== 0) continue;
2643
+ if (block.body.includes("{")) continue;
2644
+ if (block.selector.trim() !== ":root") continue;
2645
+ const re = /--([\w-]+)\s*:\s*([^;]+);/g;
2646
+ let m;
2647
+ while (m = re.exec(block.body)) out[m[1]] = m[2].trim();
2648
+ }
2649
+ return out;
2650
+ }
2651
+ function loadTokenMap(projectRoot, consumerFiles = []) {
2652
+ const jig = {};
2653
+ const tokensDir = join16(projectRoot, ".jig", "tokens");
2654
+ if (existsSync9(tokensDir)) {
2655
+ for (const file of readdirSync7(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
2656
+ Object.assign(jig, rootDeclarations(readFileSync11(join16(tokensDir, file), "utf8")));
2657
+ }
2658
+ }
2659
+ const consumer = {};
2660
+ const ambiguous = /* @__PURE__ */ new Set();
2661
+ const vendored = join16(".jig", "tokens");
2662
+ for (const file of consumerFiles) {
2663
+ if (file.includes(vendored)) continue;
2664
+ let source;
2665
+ try {
2666
+ source = readFileSync11(join16(projectRoot, file), "utf8");
2667
+ } catch {
2668
+ continue;
2669
+ }
2670
+ for (const [name, value] of Object.entries(rootDeclarations(maskNonStyleRegions(source, file)))) {
2671
+ if (name in consumer && consumer[name] !== value) ambiguous.add(name);
2672
+ consumer[name] = value;
2673
+ }
2674
+ }
2675
+ const tokens = { ...consumer };
2676
+ for (const [name, value] of Object.entries(jig)) {
2677
+ if (name in consumer && consumer[name] !== value) ambiguous.add(name);
2678
+ tokens[name] = value;
2679
+ }
2680
+ for (const name of ambiguous) delete tokens[name];
2681
+ return tokens;
2682
+ }
2683
+
2684
+ // src/check/exempt.ts
2685
+ import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
2686
+ import { join as join17 } from "path";
2687
+ function globToRegExp(glob) {
2688
+ let out = "";
2689
+ for (let i = 0; i < glob.length; i++) {
2690
+ const c = glob[i];
2691
+ if (c === "*") {
2692
+ if (glob[i + 1] === "*") {
2693
+ out += ".*";
2694
+ i++;
2695
+ if (glob[i + 1] === "/") i++;
2696
+ } else {
2697
+ out += "[^/]*";
2698
+ }
2699
+ } else if (c === "?") out += "[^/]";
2700
+ else out += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
2701
+ }
2702
+ return new RegExp(`^${out}$`);
2703
+ }
2704
+ function readExemptions(projectRoot) {
2705
+ const configPath = join17(projectRoot, "jig.config.json");
2706
+ if (!existsSync10(configPath)) return [];
2707
+ let declared;
2708
+ try {
2709
+ declared = JSON.parse(readFileSync12(configPath, "utf8")).exempt;
2710
+ } catch {
2711
+ return [];
2712
+ }
2713
+ if (!Array.isArray(declared)) return [];
2714
+ return declared.filter(
2715
+ (p) => typeof p === "string" && p.trim() !== "" && !p.startsWith("/") && !p.split("/").includes("..")
2716
+ );
2717
+ }
2718
+ var LIKELY_TOO_BROAD = 3;
2719
+ function applyExemptions(files, patterns) {
2720
+ if (patterns.length === 0) return { scanned: files, exempt: [], byPattern: [] };
2721
+ const compiled = patterns.map((pattern) => ({ pattern, re: globToRegExp(pattern), count: 0 }));
2722
+ const scanned = [];
2723
+ const exempt = [];
2538
2724
  for (const file of files) {
2539
- const source = readFileSync10(join15(tokensDir, file), "utf8");
2725
+ const matches2 = compiled.filter((c) => c.re.test(file));
2726
+ for (const m of matches2) m.count++;
2727
+ (matches2.length > 0 ? exempt : scanned).push(file);
2728
+ }
2729
+ return {
2730
+ scanned,
2731
+ exempt,
2732
+ byPattern: compiled.map(({ pattern, count }) => ({
2733
+ pattern,
2734
+ count,
2735
+ tooBroad: count > LIKELY_TOO_BROAD
2736
+ }))
2737
+ };
2738
+ }
2739
+
2740
+ // src/check/token-audit.ts
2741
+ import { existsSync as existsSync11, readdirSync as readdirSync8, readFileSync as readFileSync13 } from "fs";
2742
+ import { join as join18 } from "path";
2743
+ var TEXT_FLOOR = 4.5;
2744
+ var UI_FLOOR = 3;
2745
+ var FOREGROUNDS = [
2746
+ { prefix: "--color-text-", floor: TEXT_FLOOR },
2747
+ { prefix: "--color-stroke-", floor: UI_FLOOR },
2748
+ { prefix: "--color-focus", floor: UI_FLOOR }
2749
+ ];
2750
+ var SURFACES = ["--color-bg-base", "--color-bg-raised", "--color-bg-overlay", "--color-fill"];
2751
+ var NUMERIC_FLOORS = [
2752
+ { token: "--text-prose", min: 18, why: "sustained reading (B-75)" },
2753
+ { token: "--size-touch-target", min: 48, why: "hit area, unchanged in every mode" }
2754
+ ];
2755
+ function composite(fg, alpha, bg) {
2756
+ return {
2757
+ r: Math.round(fg.r * alpha + bg.r * (1 - alpha)),
2758
+ g: Math.round(fg.g * alpha + bg.g * (1 - alpha)),
2759
+ b: Math.round(fg.b * alpha + bg.b * (1 - alpha))
2760
+ };
2761
+ }
2762
+ function isDarkBlock(selector, atRuleDepth) {
2763
+ return /data-theme\s*=\s*["']?dark/.test(selector) || atRuleDepth > 0;
2764
+ }
2765
+ function collectScopes(files) {
2766
+ const light = {};
2767
+ const darkOverrides = {};
2768
+ for (const { path, source } of files) {
2540
2769
  for (const block of splitRuleBlocks(source)) {
2541
2770
  if (block.body.includes("{")) continue;
2542
- if (block.selector.trim() !== ":root") continue;
2543
- const re = /--([\w-]+)\s*:\s*([^;]+);/g;
2544
- let m;
2545
- while (m = re.exec(block.body)) {
2546
- tokens[m[1]] = m[2].trim();
2771
+ if (!/^:root/.test(block.selector.trim())) continue;
2772
+ const target = isDarkBlock(block.selector, block.atRuleDepth) ? darkOverrides : light;
2773
+ for (const m of block.body.matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) {
2774
+ const offsetInBody = m.index ?? 0;
2775
+ target[m[1]] = {
2776
+ value: m[2].trim(),
2777
+ file: path,
2778
+ // `bodyStartLine` is where the block's body begins; add the newlines
2779
+ // before this declaration inside it.
2780
+ line: block.bodyStartLine + block.body.slice(0, offsetInBody).split("\n").length - 1
2781
+ };
2547
2782
  }
2548
2783
  }
2549
2784
  }
2550
- return tokens;
2785
+ return { light, dark: { ...light, ...darkOverrides } };
2786
+ }
2787
+ var asTokenMap = (scope) => Object.fromEntries(Object.entries(scope).map(([k, v]) => [k.replace(/^--/, ""), v.value]));
2788
+ function floorFor(token) {
2789
+ if (/^--color-stroke-.*-weak$/.test(token) || token === "--color-stroke-weak") return null;
2790
+ for (const { prefix, floor } of FOREGROUNDS) {
2791
+ if (token.startsWith(prefix)) return floor;
2792
+ }
2793
+ return null;
2794
+ }
2795
+ function tokenFiles(projectRoot) {
2796
+ const read = (rel) => {
2797
+ try {
2798
+ return readFileSync13(join18(projectRoot, ...rel.split("/")), "utf8");
2799
+ } catch {
2800
+ return null;
2801
+ }
2802
+ };
2803
+ const statePath = join18(projectRoot, ".jig", "state.json");
2804
+ if (existsSync11(statePath)) {
2805
+ try {
2806
+ const state = JSON.parse(readFileSync13(statePath, "utf8"));
2807
+ const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
2808
+ const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
2809
+ if (out.length > 0) return out;
2810
+ } catch {
2811
+ }
2812
+ }
2813
+ const legacy = join18(projectRoot, ".jig", "tokens");
2814
+ if (!existsSync11(legacy)) return [];
2815
+ return readdirSync8(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync13(join18(legacy, f), "utf8") }));
2816
+ }
2817
+ function auditTokenLayer(projectRoot) {
2818
+ const files = tokenFiles(projectRoot);
2819
+ if (files.length === 0) return [];
2820
+ const scopes = collectScopes(files);
2821
+ const problems = [];
2822
+ for (const theme of ["light", "dark"]) {
2823
+ const scope = scopes[theme];
2824
+ const map = asTokenMap(scope);
2825
+ const base = extractColorComponents(scope["--color-bg-base"]?.value ?? "", map);
2826
+ const surfaces = [];
2827
+ for (const name of SURFACES) {
2828
+ const decl = scope[name];
2829
+ if (!decl) continue;
2830
+ const c = extractColorComponents(decl.value, map);
2831
+ if (!c) continue;
2832
+ if (c.alpha >= 0.999) surfaces.push({ name, rgb: c.rgb });
2833
+ else if (base && base.alpha >= 0.999) {
2834
+ surfaces.push({ name, rgb: composite(c.rgb, c.alpha, base.rgb) });
2835
+ }
2836
+ }
2837
+ if (surfaces.length === 0) continue;
2838
+ for (const [token, decl] of Object.entries(scope)) {
2839
+ const floor = floorFor(token);
2840
+ if (floor === null) continue;
2841
+ const fg = extractColorComponents(decl.value, map);
2842
+ if (!fg) continue;
2843
+ const failures = surfaces.map((surface) => {
2844
+ const solid = fg.alpha >= 0.999 ? fg.rgb : composite(fg.rgb, fg.alpha, surface.rgb);
2845
+ return { surface: surface.name, ratio: contrastRatio(solid, surface.rgb) };
2846
+ }).filter((f) => f.ratio < floor).sort((a, b) => a.ratio - b.ratio);
2847
+ if (failures.length === 0) continue;
2848
+ const worst = failures[0];
2849
+ const others = failures.length - 1;
2850
+ problems.push({
2851
+ token,
2852
+ surface: worst.surface,
2853
+ theme,
2854
+ ratio: Math.round(worst.ratio * 100) / 100,
2855
+ floor,
2856
+ file: decl.file,
2857
+ line: decl.line,
2858
+ message: `${token} is ${worst.ratio.toFixed(2)}:1 against ${worst.surface} in ${theme} mode, below the ${floor}:1 floor the token layer states for this role` + (others > 0 ? ` (and below it on ${others} other surface${others > 1 ? "s" : ""})` : "") + "."
2859
+ });
2860
+ }
2861
+ for (const { token, min, why } of NUMERIC_FLOORS) {
2862
+ const decl = scope[token];
2863
+ if (!decl) continue;
2864
+ const px = /^([\d.]+)px$/.exec(decl.value.trim());
2865
+ if (!px) continue;
2866
+ const value = Number(px[1]);
2867
+ if (value >= min) continue;
2868
+ problems.push({
2869
+ token,
2870
+ theme,
2871
+ value,
2872
+ floor: min,
2873
+ file: decl.file,
2874
+ line: decl.line,
2875
+ message: `${token} is ${value}px, below the ${min}px floor for ${why}. This is an accessibility limit, not a density setting.`
2876
+ });
2877
+ }
2878
+ }
2879
+ return problems;
2551
2880
  }
2552
2881
 
2553
2882
  // src/commands/check.ts
2554
- var LEGACY_INDEX_REL = join16(".jig", "rules.index.json");
2883
+ var LEGACY_INDEX_REL = join19(".jig", "rules.index.json");
2555
2884
  function resolveIndexPath(projectRoot) {
2556
- const legacy = join16(projectRoot, LEGACY_INDEX_REL);
2557
- if (existsSync9(legacy)) return legacy;
2558
- return join16(assetRoot(), "rules.index.json");
2885
+ const legacy = join19(projectRoot, LEGACY_INDEX_REL);
2886
+ if (existsSync12(legacy)) return legacy;
2887
+ return join19(assetRoot(), "rules.index.json");
2559
2888
  }
2560
2889
  function resolveMode(projectRoot) {
2561
2890
  try {
2562
- const config = JSON.parse(readFileSync11(join16(projectRoot, "jig.config.json"), "utf8"));
2891
+ const config = JSON.parse(readFileSync14(join19(projectRoot, "jig.config.json"), "utf8"));
2563
2892
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
2564
2893
  if (modes.length === 1) return modes[0];
2565
2894
  return modes.length > 1 ? "mixed" : "unknown";
@@ -2576,28 +2905,46 @@ function summariseUnscanned(files) {
2576
2905
  }
2577
2906
  function countSpecs() {
2578
2907
  try {
2579
- return loadSpecs(join16(assetRoot(), "rules")).length;
2908
+ return loadSpecs(join19(assetRoot(), "rules")).length;
2580
2909
  } catch {
2581
2910
  return 0;
2582
2911
  }
2583
2912
  }
2584
2913
  function check(opts) {
2585
2914
  const indexPath = resolveIndexPath(opts.projectRoot);
2586
- const index = validateIndex(JSON.parse(readFileSync11(indexPath, "utf8")));
2587
- const tokens = loadTokenMap(opts.projectRoot);
2915
+ const index = validateIndex(JSON.parse(readFileSync14(indexPath, "utf8")));
2588
2916
  const selection = selectFiles(opts.projectRoot, opts.all);
2589
- const { files } = selection;
2917
+ const exemptions = readExemptions(opts.projectRoot);
2918
+ const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
2590
2919
  const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
2591
2920
  const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
2921
+ const tokens = loadTokenMap(opts.projectRoot, stylesheets);
2592
2922
  const projectParticipates = stylesheets.some((f) => {
2593
2923
  if (!hasExtension(f, CSS_EXTENSIONS)) return false;
2594
2924
  try {
2595
- return participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
2925
+ return participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
2596
2926
  } catch {
2597
2927
  return false;
2598
2928
  }
2599
2929
  });
2600
2930
  const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
2931
+ const TOKEN_RULE = {
2932
+ "--text-prose": "B-75",
2933
+ "--size-touch-target": "E-51"
2934
+ };
2935
+ for (const problem of auditTokenLayer(opts.projectRoot)) {
2936
+ const ruleId = TOKEN_RULE[problem.token] ?? "C-19";
2937
+ const entry = index.find((e) => e.id === ruleId);
2938
+ findings.push({
2939
+ ruleId,
2940
+ detector: "token-layer",
2941
+ bucket: entry?.bucket ?? "mechanical",
2942
+ severity: entry?.severity ?? "error",
2943
+ file: problem.file,
2944
+ line: problem.line,
2945
+ message: problem.message
2946
+ });
2947
+ }
2601
2948
  const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
2602
2949
  const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
2603
2950
  const noTokenLayer = (
@@ -2605,7 +2952,7 @@ function check(opts) {
2605
2952
  // told to add a token @import it has nowhere to put.
2606
2953
  cssFiles.length > 0 && cssFiles.every((f) => {
2607
2954
  try {
2608
- return !participatesInTokenLayer(readFileSync11(join16(opts.projectRoot, f), "utf8"), tokens);
2955
+ return !participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
2609
2956
  } catch {
2610
2957
  return true;
2611
2958
  }
@@ -2620,18 +2967,20 @@ function check(opts) {
2620
2967
  version: opts.version,
2621
2968
  noTokenLayer,
2622
2969
  mode: resolveMode(opts.projectRoot),
2623
- unscanned: summariseUnscanned(files)
2970
+ unscanned: summariseUnscanned(files),
2971
+ exempt,
2972
+ exemptPatterns: byPattern
2624
2973
  });
2625
2974
  return { findings, report, hasError };
2626
2975
  }
2627
2976
 
2628
2977
  // src/commands/init.ts
2629
- import { existsSync as existsSync10, mkdirSync as mkdirSync6, readFileSync as readFileSync15, writeFileSync as writeFileSync6 } from "fs";
2630
- import { dirname as dirname7, join as join20 } from "path";
2978
+ import { existsSync as existsSync13, mkdirSync as mkdirSync5, readdirSync as readdirSync9, readFileSync as readFileSync18, writeFileSync as writeFileSync4 } from "fs";
2979
+ import { dirname as dirname6, isAbsolute, join as join23, resolve as resolve2, sep as sep3 } from "path";
2631
2980
 
2632
2981
  // src/init/detect.ts
2633
- import { readFileSync as readFileSync12 } from "fs";
2634
- import { join as join17 } from "path";
2982
+ import { readFileSync as readFileSync15 } from "fs";
2983
+ import { join as join20 } from "path";
2635
2984
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
2636
2985
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
2637
2986
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -2648,7 +2997,7 @@ var FRAMEWORK_DEPS = [
2648
2997
  function detectFramework(projectRoot) {
2649
2998
  let pkg;
2650
2999
  try {
2651
- pkg = JSON.parse(readFileSync12(join17(projectRoot, "package.json"), "utf8"));
3000
+ pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
2652
3001
  } catch {
2653
3002
  return void 0;
2654
3003
  }
@@ -2666,7 +3015,7 @@ function detect(projectRoot) {
2666
3015
  for (const f of cssFiles) {
2667
3016
  let source;
2668
3017
  try {
2669
- source = readFileSync12(join17(projectRoot, f), "utf8");
3018
+ source = readFileSync15(join20(projectRoot, f), "utf8");
2670
3019
  } catch {
2671
3020
  continue;
2672
3021
  }
@@ -2701,8 +3050,8 @@ function isIgnored(projectRoot, relPath) {
2701
3050
  }
2702
3051
 
2703
3052
  // src/init/derive.ts
2704
- import { readFileSync as readFileSync13 } from "fs";
2705
- import { join as join18 } from "path";
3053
+ import { readFileSync as readFileSync16 } from "fs";
3054
+ import { join as join21 } from "path";
2706
3055
  var DEFAULT_PROPOSAL = {
2707
3056
  h: 264,
2708
3057
  s: 0,
@@ -2735,7 +3084,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
2735
3084
  for (const file of cssFiles) {
2736
3085
  let source;
2737
3086
  try {
2738
- source = readFileSync13(join18(projectRoot, file), "utf8");
3087
+ source = readFileSync16(join21(projectRoot, file), "utf8");
2739
3088
  } catch {
2740
3089
  continue;
2741
3090
  }
@@ -2784,7 +3133,7 @@ function balancedBraceBody(source, openIdx) {
2784
3133
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
2785
3134
  let source;
2786
3135
  try {
2787
- source = readFileSync13(join18(projectRoot, tailwindConfigFile), "utf8");
3136
+ source = readFileSync16(join21(projectRoot, tailwindConfigFile), "utf8");
2788
3137
  } catch {
2789
3138
  return null;
2790
3139
  }
@@ -2840,7 +3189,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
2840
3189
  for (const file of cssFiles) {
2841
3190
  let source;
2842
3191
  try {
2843
- source = readFileSync13(join18(projectRoot, file), "utf8");
3192
+ source = readFileSync16(join21(projectRoot, file), "utf8");
2844
3193
  } catch {
2845
3194
  continue;
2846
3195
  }
@@ -3000,8 +3349,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
3000
3349
  }
3001
3350
 
3002
3351
  // src/init/project-name.ts
3003
- import { readFileSync as readFileSync14 } from "fs";
3004
- import { basename, join as join19 } from "path";
3352
+ import { readFileSync as readFileSync17 } from "fs";
3353
+ import { basename, join as join22 } from "path";
3005
3354
  function slugify(name) {
3006
3355
  const withoutScope = name.replace(/^@[^/]+\//, "");
3007
3356
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3009,7 +3358,7 @@ function slugify(name) {
3009
3358
  }
3010
3359
  function deriveProjectSlug(projectRoot) {
3011
3360
  try {
3012
- const pkg = JSON.parse(readFileSync14(join19(projectRoot, "package.json"), "utf8"));
3361
+ const pkg = JSON.parse(readFileSync17(join22(projectRoot, "package.json"), "utf8"));
3013
3362
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
3014
3363
  } catch {
3015
3364
  }
@@ -3075,7 +3424,7 @@ function parseSurfaces(answer, fallback) {
3075
3424
  return parsed.length > 0 ? parsed : fallback;
3076
3425
  }
3077
3426
  function fileState(projectRoot, absPath, relPath, initManifest) {
3078
- const existsOnDisk = existsSync10(absPath);
3427
+ const existsOnDisk = existsSync13(absPath);
3079
3428
  const tracked = !!initManifest && relPath in initManifest.files;
3080
3429
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
3081
3430
  return { existsOnDisk, tracked, modified };
@@ -3086,11 +3435,11 @@ function isValidSurfaceArray(v) {
3086
3435
  );
3087
3436
  }
3088
3437
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
3089
- if (!existsSync10(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3438
+ if (!existsSync13(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3090
3439
  try {
3091
- const parsed = JSON.parse(readFileSync15(configAbsPath, "utf8"));
3440
+ const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
3092
3441
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
3093
- const brandResolves = brandCandidate ? existsSync10(join20(projectRoot, ...brandCandidate.split("/"))) : false;
3442
+ const brandResolves = brandCandidate ? existsSync13(join23(projectRoot, ...brandCandidate.split("/"))) : false;
3094
3443
  return {
3095
3444
  brand: brandResolves ? brandCandidate : fallbackBrand,
3096
3445
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -3099,12 +3448,61 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
3099
3448
  return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3100
3449
  }
3101
3450
  }
3451
+ function configuredBrandPath(projectRoot, configAbsPath) {
3452
+ if (!existsSync13(configAbsPath)) return { path: null };
3453
+ let declared;
3454
+ try {
3455
+ declared = JSON.parse(readFileSync18(configAbsPath, "utf8")).brand;
3456
+ } catch {
3457
+ return { path: null };
3458
+ }
3459
+ if (typeof declared !== "string" || !declared.trim()) return { path: null };
3460
+ const rel = declared.trim().replace(/\\/g, "/");
3461
+ const abs = resolve2(projectRoot, rel);
3462
+ const root = resolve2(projectRoot);
3463
+ if (isAbsolute(rel) || abs !== root && !abs.startsWith(root + sep3)) {
3464
+ return { path: null, refused: rel };
3465
+ }
3466
+ if (!/\.css$/i.test(rel)) return { path: null, refused: rel };
3467
+ return { path: rel };
3468
+ }
3469
+ function defaultTokenDir(detection) {
3470
+ const target = findWireTarget(detection);
3471
+ if (target) return [...target.split("/").slice(0, -1), "jig"];
3472
+ const candidates = detection.cssFiles.filter((f) => !isCssModule(f) && !isTokenLayerFile(f));
3473
+ if (candidates.length > 1) {
3474
+ const dirs = new Set(candidates.map((f) => f.split("/").slice(0, -1).join("/")));
3475
+ if (dirs.size === 1) {
3476
+ const only = [...dirs][0];
3477
+ return only ? [...only.split("/"), "jig"] : ["jig"];
3478
+ }
3479
+ }
3480
+ return ["jig"];
3481
+ }
3482
+ function readConfiguredSurfaces(configAbsPath) {
3483
+ if (!existsSync13(configAbsPath)) return null;
3484
+ try {
3485
+ const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
3486
+ return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
3487
+ } catch {
3488
+ return null;
3489
+ }
3490
+ }
3102
3491
  function isCssModule(path) {
3103
3492
  return /\.module\.(css|scss)$/i.test(path);
3104
3493
  }
3494
+ function legacyBrandFile(projectRoot) {
3495
+ const dir = join23(projectRoot, ".jig", "tokens");
3496
+ if (!existsSync13(dir)) return void 0;
3497
+ const found = readdirSync9(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
3498
+ return found ? relKey(".jig", "tokens", found) : void 0;
3499
+ }
3500
+ function isTokenLayerFile(f) {
3501
+ return /(^|\/)(\.jig\/tokens|jig)\/[^/]+\.css$/.test(f);
3502
+ }
3105
3503
  function findWireTarget(d) {
3106
3504
  if (d.cssSystem === "tailwind-v4" && d.tailwindV4EntryFile) return d.tailwindV4EntryFile;
3107
- const candidates = d.cssFiles.filter((f) => !isCssModule(f));
3505
+ const candidates = d.cssFiles.filter((f) => !isCssModule(f) && !isTokenLayerFile(f));
3108
3506
  if (candidates.length === 1) return candidates[0];
3109
3507
  return null;
3110
3508
  }
@@ -3115,45 +3513,35 @@ function insertAfterCharset(content, insertion) {
3115
3513
  const charsetLen = m ? m[0].length : 0;
3116
3514
  return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
3117
3515
  }
3118
- function wireImport(absPath, brandImport, modeImport, mode) {
3119
- const content = existsSync10(absPath) ? readFileSync15(absPath, "utf8") : "";
3120
- const brandImportLine = `@import "${brandImport}";`;
3121
- const modeImportLine = `@import "${modeImport}";`;
3122
- const brandPresent = content.includes(brandImportLine);
3123
- const existingModeMatch = MODE_IMPORT_RE.exec(content);
3124
- if (brandPresent) {
3125
- if (existingModeMatch && existingModeMatch[0].trim() === modeImportLine) {
3126
- return { status: "already-present" };
3127
- }
3128
- if (existingModeMatch) {
3129
- const previousMode = existingModeMatch[2];
3130
- const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
3131
- ` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
3132
- writeFileSync6(absPath, next3, "utf8");
3133
- return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
3134
- }
3135
- const brandIdx = content.indexOf(brandImportLine);
3136
- let insertAt = brandIdx + brandImportLine.length;
3137
- if (content[insertAt] === "\r") insertAt++;
3138
- if (content[insertAt] === "\n") insertAt++;
3139
- const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
3140
- writeFileSync6(absPath, next2, "utf8");
3141
- return { status: "rewired", detail: `added missing mode import: ${mode}` };
3142
- }
3143
- const lines = `${brandImportLine}
3144
- ${modeImportLine}
3516
+ function barrelBody(fileName, brandFile, mode, version2) {
3517
+ return vendorHeader(fileName, version2, "css", null) + `/* The token layer for one surface, in one import.
3518
+ Brand declares the options; the mode selects from them. */
3519
+ @import "./${brandFile}";
3520
+ @import "./mode.${mode}.css";
3145
3521
  `;
3146
- const tailwindImportMatch = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
3147
- let next;
3148
- if (tailwindImportMatch) {
3149
- const idx = tailwindImportMatch.index + tailwindImportMatch[0].length;
3150
- next = content.slice(0, idx) + lines + content.slice(idx);
3151
- } else {
3152
- next = insertAfterCharset(content, content ? `${lines}
3153
- ` : lines);
3154
- }
3155
- mkdirSync6(dirname7(absPath), { recursive: true });
3156
- writeFileSync6(absPath, next, "utf8");
3522
+ }
3523
+ var LEGACY_PAIR_RE = new RegExp(
3524
+ `@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES.join("|")})\\.css["'];?\\r?\\n?`
3525
+ );
3526
+ function wireBarrel(absPath, barrelImport) {
3527
+ const content = existsSync13(absPath) ? readFileSync18(absPath, "utf8") : "";
3528
+ const line = `@import "${barrelImport}";`;
3529
+ if (content.includes(line)) return { status: "already-present" };
3530
+ const legacy = LEGACY_PAIR_RE.exec(content);
3531
+ if (legacy) {
3532
+ const next2 = content.slice(0, legacy.index) + `${line}
3533
+ ` + content.slice(legacy.index + legacy[0].length);
3534
+ writeFileSync4(absPath, next2, "utf8");
3535
+ return { status: "rewired", detail: "replaced the separate brand and mode imports with the barrel" };
3536
+ }
3537
+ const tailwind = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
3538
+ const next = tailwind ? content.slice(0, tailwind.index + tailwind[0].length) + `${line}
3539
+ ` + content.slice(tailwind.index + tailwind[0].length) : insertAfterCharset(content, content ? `${line}
3540
+
3541
+ ` : `${line}
3542
+ `);
3543
+ mkdirSync5(dirname6(absPath), { recursive: true });
3544
+ writeFileSync4(next === content ? absPath : absPath, next, "utf8");
3157
3545
  return { status: "wired" };
3158
3546
  }
3159
3547
  async function init(opts) {
@@ -3219,8 +3607,11 @@ async function init(opts) {
3219
3607
  }
3220
3608
  }
3221
3609
  if (opts.yes) {
3610
+ const declared = readConfiguredSurfaces(join23(opts.projectRoot, "jig.config.json"));
3611
+ const inEffect = declared ?? surfaces;
3612
+ const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
3222
3613
  log(
3223
- `Surface \u2192 mode: ${surfaces.map((s) => `'${s.match}' \u2192 ${s.mode}`).join(", ")} \u2014 the default, not inferred from this project. Mode sets density, type scale and control sizes, and it wins over an agent's own inference, so change it in jig.config.json if another mode fits (${MODES.join("/")}).`
3614
+ 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("/")}).`
3224
3615
  );
3225
3616
  }
3226
3617
  if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
@@ -3234,11 +3625,30 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
3234
3625
  }
3235
3626
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
3236
3627
  const projectSlug = deriveProjectSlug(opts.projectRoot);
3237
- const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
3238
- const brandAbsPath = join20(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
3239
3628
  const configRelPath = "jig.config.json";
3240
- const configAbsPath = join20(opts.projectRoot, configRelPath);
3241
- const initManifest = readInitManifest(opts.projectRoot);
3629
+ const configAbsPath = join23(opts.projectRoot, configRelPath);
3630
+ const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
3631
+ if (configuredBrand.refused) {
3632
+ log(
3633
+ `jig.config.json names brand '${configuredBrand.refused}', which is outside the project or is not a .css file \u2014 ignoring it and using the default location.`
3634
+ );
3635
+ }
3636
+ const priorManifest = readInitManifest(opts.projectRoot);
3637
+ const priorBrand = Object.keys(priorManifest?.files ?? {}).find((f) => /(^|\/)brand\.[\w.-]+\.css$/.test(f)) ?? // Also honour a brand file that is simply THERE. A project may hold one
3638
+ // init never recorded — written by hand, or by a version that predates the
3639
+ // sidecar — and writing a second one somewhere else would leave two token
3640
+ // layers, which is worse than either location.
3641
+ detection.cssFiles.find((f) => isTokenLayerFile(f) && /(^|\/)brand\./.test(f)) ?? // `.jig/` is a dotdir, so detection never walks into it. Probe it directly:
3642
+ // a project holding a legacy token layer must keep it rather than gain a
3643
+ // second one somewhere else.
3644
+ legacyBrandFile(opts.projectRoot);
3645
+ const brandRelPath = configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
3646
+ const brandAbsPath = join23(opts.projectRoot, ...brandRelPath.split("/"));
3647
+ const tokensRelDir = brandRelPath.split("/").slice(0, -1);
3648
+ log(
3649
+ `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.")
3650
+ );
3651
+ const initManifest = priorManifest;
3242
3652
  const files = { ...initManifest?.files ?? {} };
3243
3653
  const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
3244
3654
  const configState = fileState(opts.projectRoot, configAbsPath, configRelPath, initManifest);
@@ -3266,72 +3676,94 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3266
3676
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
3267
3677
  }
3268
3678
  if (brandAction === "written") {
3269
- const defaultCssRaw = readFileSync15(join20(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3679
+ const defaultCssRaw = readFileSync18(join23(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3270
3680
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
3271
- mkdirSync6(dirname7(brandAbsPath), { recursive: true });
3272
- writeFileSync6(brandAbsPath, content, "utf8");
3681
+ mkdirSync5(dirname6(brandAbsPath), { recursive: true });
3682
+ writeFileSync4(brandAbsPath, content, "utf8");
3273
3683
  files[brandRelPath] = checksum(content);
3274
3684
  }
3275
3685
  if (configAction === "written") {
3276
3686
  const config = { brand: brandRelPath, surfaces };
3277
3687
  const content = `${JSON.stringify(config, null, 2)}
3278
3688
  `;
3279
- writeFileSync6(configAbsPath, content, "utf8");
3689
+ writeFileSync4(configAbsPath, content, "utf8");
3280
3690
  files[configRelPath] = checksum(content);
3281
3691
  } else if (configAction === "merged") {
3282
3692
  let existing = {};
3283
3693
  try {
3284
- existing = JSON.parse(readFileSync15(configAbsPath, "utf8"));
3694
+ existing = JSON.parse(readFileSync18(configAbsPath, "utf8"));
3285
3695
  } catch {
3286
3696
  existing = {};
3287
3697
  }
3288
3698
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
3289
- const brandStillResolves = existingBrand ? existsSync10(join20(opts.projectRoot, ...existingBrand.split("/"))) : false;
3699
+ const brandStillResolves = existingBrand ? existsSync13(join23(opts.projectRoot, ...existingBrand.split("/"))) : false;
3290
3700
  const merged = {
3291
3701
  brand: brandStillResolves ? existingBrand : brandRelPath,
3292
3702
  surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
3293
3703
  };
3294
3704
  const content = `${JSON.stringify(merged, null, 2)}
3295
3705
  `;
3296
- writeFileSync6(configAbsPath, content, "utf8");
3706
+ writeFileSync4(configAbsPath, content, "utf8");
3297
3707
  files[configRelPath] = checksum(content);
3298
3708
  }
3299
3709
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
3300
3710
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
3301
- const wiringBrandAbsPath = join20(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3711
+ const wiringBrandAbsPath = join23(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3302
3712
  const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
3303
3713
  const modeAbsPaths = {};
3304
3714
  for (const mode of declaredModes) {
3305
3715
  const modeFileName = `mode.${mode}.css`;
3306
- const modeAbsPath2 = join20(opts.projectRoot, ".jig", "tokens", modeFileName);
3716
+ const modeAbsPath2 = join23(opts.projectRoot, ...tokensRelDir, modeFileName);
3307
3717
  modeAbsPaths[mode] = modeAbsPath2;
3308
- const modeRelPath = relKey(".jig", "tokens", modeFileName);
3718
+ const modeRelPath = relKey(...tokensRelDir, modeFileName);
3309
3719
  const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
3310
3720
  let modeAction = "written";
3311
3721
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
3312
3722
  else if (modeState.modified) modeAction = "skipped-edited";
3313
3723
  if (modeAction === "written") {
3314
- const modeSourceRaw = readFileSync15(join20(opts.packageRoot, "tokens", modeFileName), "utf8");
3724
+ const modeSourceRaw = readFileSync18(join23(opts.packageRoot, "tokens", modeFileName), "utf8");
3315
3725
  const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
3316
- mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
3317
- writeFileSync6(modeAbsPath2, content, "utf8");
3726
+ mkdirSync5(dirname6(modeAbsPath2), { recursive: true });
3727
+ writeFileSync4(modeAbsPath2, content, "utf8");
3318
3728
  files[modeRelPath] = checksum(content);
3319
3729
  } else {
3320
3730
  log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
3321
3731
  }
3322
3732
  }
3323
3733
  const modeAbsPath = modeAbsPaths[primaryMode];
3734
+ const brandFileOnly = brandRelPath.split("/").pop();
3735
+ const barrelFor = (mode) => mode === primaryMode ? "theme.css" : `theme.${mode}.css`;
3736
+ for (const mode of declaredModes) {
3737
+ const rel = relKey(...tokensRelDir, barrelFor(mode));
3738
+ const abs = join23(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
3739
+ const state = fileState(opts.projectRoot, abs, rel, initManifest);
3740
+ if (state.existsOnDisk && (!state.tracked || state.modified)) {
3741
+ log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
3742
+ continue;
3743
+ }
3744
+ const content = barrelBody(barrelFor(mode), brandFileOnly, mode, opts.version);
3745
+ mkdirSync5(dirname6(abs), { recursive: true });
3746
+ writeFileSync4(abs, content, "utf8");
3747
+ files[rel] = checksum(content);
3748
+ }
3749
+ if (declaredModes.length > 1) {
3750
+ log("\nOne barrel per surface. Import each at that route's entry point:");
3751
+ for (const surface of effectiveConfig.surfaces) {
3752
+ log(` '${surface.match}' \u2192 ${relKey(...tokensRelDir, barrelFor(surface.mode))}`);
3753
+ }
3754
+ }
3324
3755
  writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
3325
3756
  const wireTarget = findWireTarget(detection);
3326
3757
  let wiring;
3327
3758
  if (wireTarget) {
3328
- const targetAbsDir = dirname7(join20(opts.projectRoot, wireTarget));
3329
- const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
3330
- const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
3331
- const snippet = `@import "${brandImport}";
3332
- @import "${modeImport}";`;
3759
+ const targetAbsDir = dirname6(join23(opts.projectRoot, wireTarget));
3760
+ const barrelImport = relativeImportPath(
3761
+ targetAbsDir,
3762
+ join23(opts.projectRoot, ...tokensRelDir, "theme.css")
3763
+ );
3764
+ const snippet = `@import "${barrelImport}";`;
3333
3765
  try {
3334
- const outcome = wireImport(join20(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
3766
+ const outcome = wireBarrel(join23(opts.projectRoot, wireTarget), barrelImport);
3335
3767
  wiring = { target: wireTarget, status: outcome.status, snippet };
3336
3768
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
3337
3769
  log(`
@@ -3356,7 +3788,7 @@ Could not write to ${wireTarget}: ${err.message}`);
3356
3788
  if (pasteTargets.length > 0) {
3357
3789
  log("Paste the block for whichever of these is your global stylesheet:");
3358
3790
  for (const target of pasteTargets) {
3359
- const dir = dirname7(join20(opts.projectRoot, target));
3791
+ const dir = dirname6(join23(opts.projectRoot, target));
3360
3792
  const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
3361
3793
  @import "${relativeImportPath(dir, modeAbsPath)}";`;
3362
3794
  log(`
@@ -3397,7 +3829,7 @@ Could not write to ${wireTarget}: ${err.message}`);
3397
3829
 
3398
3830
  // src/index.ts
3399
3831
  var packageRoot = getPackageRoot();
3400
- var { version } = JSON.parse(readFileSync16(join21(packageRoot, "package.json"), "utf8"));
3832
+ var { version } = JSON.parse(readFileSync19(join24(packageRoot, "package.json"), "utf8"));
3401
3833
  function warnIfUnpublishedPin() {
3402
3834
  if (isPublishedBuild(packageRoot)) return;
3403
3835
  console.warn(
@@ -3434,9 +3866,9 @@ program.command("install").description("Install Jig rules and the agent skill fi
3434
3866
  process.exit(1);
3435
3867
  }
3436
3868
  });
3437
- program.command("explain").description("Print a rule's full text, its correction, and the version it was introduced in.").argument("<rule-id>", "a rule id, e.g. 'C-19' or 'P-02'").action((ruleId) => {
3869
+ 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) => {
3438
3870
  try {
3439
- console.log(explain({ ruleId, version }));
3871
+ console.log(explain({ ruleId: query ?? "", version, list: options.list }));
3440
3872
  } catch (err) {
3441
3873
  console.error(err.message);
3442
3874
  process.exit(1);