jig-ui 0.5.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/README.md CHANGED
@@ -251,6 +251,19 @@ contrast below the floor (`C-19`), removed focus rings (`E-29`), gradient text
251
251
  (`A-02`), backdrop blur (`A-04`), pure black and white (`C-18`), and the
252
252
  violet-band hue check (`A-01`, which asks rather than fails).
253
253
 
254
+ **It also reads the token layer itself.** `.jig/tokens/*.css` is not application
255
+ code, so no detector scans it — but it is where a mistake costs most, since every
256
+ call site inherits it. `check` reads back what is declared there and holds it to
257
+ the floors the token layer claims: 4.5:1 for text roles, 3:1 for interface
258
+ strokes, **in both light and dark**, plus `--text-prose` at 18px and
259
+ `--size-touch-target` at 48px. Only floors, never density: `--size-control` at
260
+ 28px is a deliberate `operator` choice, and reporting it would teach you to
261
+ ignore the ones that matter.
262
+
263
+ This is what makes a hand-written or agent-written token layer safe to have.
264
+ `init` validates a colour once, when it writes it; without this, anything edited
265
+ afterwards was never looked at again.
266
+
254
267
  Two deliberate limits. A bare `p-4` is **not** a finding — it resolves through a
255
268
  scale, which is what a scale is for, and the scale is your project's decision.
256
269
  And a colour outside the framework's default palette is not resolved rather than
@@ -300,12 +313,32 @@ Drop this in the project root so mode selection does not require asking on every
300
313
  ```jsonc
301
314
  // jig.config.json
302
315
  {
303
- "brand": ".jig/tokens/brand.acme.css",
316
+ // Where the token layer lives. `init` writes the brand file here and puts
317
+ // the mode files beside it. Omit it and you get `.jig/tokens/`.
318
+ "brand": "src/styles/jig/brand.acme.css",
319
+
320
+ // One entry per surface. This outranks an agent's own reading of the
321
+ // project, so it is worth getting right before `init` runs.
304
322
  "surfaces": [
305
323
  { "match": "/", "mode": "editorial" },
306
324
  { "match": "/app/**", "mode": "product" },
307
325
  { "match": "/admin/**", "mode": "operator" }
308
- ]
326
+ ],
327
+
328
+ // Files that render OUTSIDE the cascade, where a literal is the only thing
329
+ // that works: an OG card serialised into an SVG `foreignObject` carries no
330
+ // stylesheet, and a PDF drawn by a React renderer never sees CSS.
331
+ //
332
+ // Prefer an exact path. An exemption is a claim about ONE file's rendering
333
+ // context, and that is usually literally true of one file. Reach for a glob
334
+ // only where the directory exists to hold them — `src/cv/pdf/**` is a fact
335
+ // about that tree; `**/*-card.tsx` is a naming coincidence that would also
336
+ // excuse every real card component you have.
337
+ //
338
+ // `check` names the pattern and its match count on every run, and says so
339
+ // when one is excusing enough files to look like a mistake. Nothing is ever
340
+ // exempt by default: this list is the only source.
341
+ "exempt": ["src/components/og-card.tsx", "src/cv/pdf/**"]
309
342
  }
310
343
  ```
311
344
 
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 readFileSync17 } from "fs";
5
+ import { readFileSync as readFileSync19 } from "fs";
6
6
  import { homedir } from "os";
7
- import { join as join22 } from "path";
7
+ import { join as join24 } from "path";
8
8
 
9
9
  // src/paths.ts
10
10
  import { existsSync } from "fs";
@@ -49,9 +49,9 @@ import { join as join7 } from "path";
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
  }
@@ -1229,8 +1229,8 @@ function sectionsOf(entries) {
1229
1229
  }
1230
1230
 
1231
1231
  // src/commands/check.ts
1232
- import { existsSync as existsSync10, readFileSync as readFileSync12 } from "fs";
1233
- import { join as join17 } from "path";
1232
+ import { existsSync as existsSync12, readFileSync as readFileSync14 } from "fs";
1233
+ import { join as join19 } from "path";
1234
1234
 
1235
1235
  // src/check/files.ts
1236
1236
  import { execFileSync } from "child_process";
@@ -1427,6 +1427,17 @@ function formatReport(findings, meta) {
1427
1427
  const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
1428
1428
  const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
1429
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
+ }
1430
1441
  if (meta.unscanned && meta.unscanned.count > 0) {
1431
1442
  const exts = meta.unscanned.extensions.join(", ");
1432
1443
  lines.push("");
@@ -2447,13 +2458,38 @@ var COLOR_LITERAL_RE = /#[0-9a-fA-F]{3,8}\b|(?:rgb|hsl)a?\([^)]*\)/;
2447
2458
  var PX_RE = /(-?\d*\.?\d+)px/g;
2448
2459
  var EXCLUDED_PX = /* @__PURE__ */ new Set([0, 1, 2]);
2449
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
+ }
2450
2486
  var hardcodedValue = {
2451
2487
  name: "hardcoded-value",
2452
2488
  appliesTo: (file) => isStyleBearing(file),
2453
2489
  run(source, file, ctx) {
2490
+ const findings = primitiveConsumption(source, file, ctx);
2454
2491
  const participates = isStyleHost(file) ? ctx.projectParticipates : participatesInTokenLayer(source, ctx.tokens);
2455
- if (!participates) return [];
2456
- const findings = [];
2492
+ if (!participates) return findings;
2457
2493
  for (const block of leafBlocks(source)) {
2458
2494
  if (KEYFRAME_STEP_RE.test(block.selector.trim())) continue;
2459
2495
  DECL_RE2.lastIndex = 0;
@@ -2645,16 +2681,214 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
2645
2681
  return tokens;
2646
2682
  }
2647
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 = [];
2724
+ for (const file of files) {
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) {
2769
+ for (const block of splitRuleBlocks(source)) {
2770
+ if (block.body.includes("{")) continue;
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
+ };
2782
+ }
2783
+ }
2784
+ }
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;
2880
+ }
2881
+
2648
2882
  // src/commands/check.ts
2649
- var LEGACY_INDEX_REL = join17(".jig", "rules.index.json");
2883
+ var LEGACY_INDEX_REL = join19(".jig", "rules.index.json");
2650
2884
  function resolveIndexPath(projectRoot) {
2651
- const legacy = join17(projectRoot, LEGACY_INDEX_REL);
2652
- if (existsSync10(legacy)) return legacy;
2653
- return join17(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");
2654
2888
  }
2655
2889
  function resolveMode(projectRoot) {
2656
2890
  try {
2657
- const config = JSON.parse(readFileSync12(join17(projectRoot, "jig.config.json"), "utf8"));
2891
+ const config = JSON.parse(readFileSync14(join19(projectRoot, "jig.config.json"), "utf8"));
2658
2892
  const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
2659
2893
  if (modes.length === 1) return modes[0];
2660
2894
  return modes.length > 1 ? "mixed" : "unknown";
@@ -2671,28 +2905,46 @@ function summariseUnscanned(files) {
2671
2905
  }
2672
2906
  function countSpecs() {
2673
2907
  try {
2674
- return loadSpecs(join17(assetRoot(), "rules")).length;
2908
+ return loadSpecs(join19(assetRoot(), "rules")).length;
2675
2909
  } catch {
2676
2910
  return 0;
2677
2911
  }
2678
2912
  }
2679
2913
  function check(opts) {
2680
2914
  const indexPath = resolveIndexPath(opts.projectRoot);
2681
- const index = validateIndex(JSON.parse(readFileSync12(indexPath, "utf8")));
2915
+ const index = validateIndex(JSON.parse(readFileSync14(indexPath, "utf8")));
2682
2916
  const selection = selectFiles(opts.projectRoot, opts.all);
2683
- const { files } = selection;
2917
+ const exemptions = readExemptions(opts.projectRoot);
2918
+ const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
2684
2919
  const bucketFilter = opts.ci ? (b) => b === "mechanical" : void 0;
2685
2920
  const stylesheets = selection.mode === "all" ? files : selectFiles(opts.projectRoot, true).files;
2686
2921
  const tokens = loadTokenMap(opts.projectRoot, stylesheets);
2687
2922
  const projectParticipates = stylesheets.some((f) => {
2688
2923
  if (!hasExtension(f, CSS_EXTENSIONS)) return false;
2689
2924
  try {
2690
- return participatesInTokenLayer(readFileSync12(join17(opts.projectRoot, f), "utf8"), tokens);
2925
+ return participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
2691
2926
  } catch {
2692
2927
  return false;
2693
2928
  }
2694
2929
  });
2695
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
+ }
2696
2948
  const hasError = findings.some((f) => f.bucket === "mechanical" && f.severity === "error");
2697
2949
  const cssFiles = files.filter((f) => /\.(css|scss|less)$/i.test(f));
2698
2950
  const noTokenLayer = (
@@ -2700,7 +2952,7 @@ function check(opts) {
2700
2952
  // told to add a token @import it has nowhere to put.
2701
2953
  cssFiles.length > 0 && cssFiles.every((f) => {
2702
2954
  try {
2703
- return !participatesInTokenLayer(readFileSync12(join17(opts.projectRoot, f), "utf8"), tokens);
2955
+ return !participatesInTokenLayer(readFileSync14(join19(opts.projectRoot, f), "utf8"), tokens);
2704
2956
  } catch {
2705
2957
  return true;
2706
2958
  }
@@ -2715,18 +2967,20 @@ function check(opts) {
2715
2967
  version: opts.version,
2716
2968
  noTokenLayer,
2717
2969
  mode: resolveMode(opts.projectRoot),
2718
- unscanned: summariseUnscanned(files)
2970
+ unscanned: summariseUnscanned(files),
2971
+ exempt,
2972
+ exemptPatterns: byPattern
2719
2973
  });
2720
2974
  return { findings, report, hasError };
2721
2975
  }
2722
2976
 
2723
2977
  // src/commands/init.ts
2724
- import { existsSync as existsSync11, mkdirSync as mkdirSync5, readFileSync as readFileSync16, writeFileSync as writeFileSync4 } from "fs";
2725
- import { dirname as dirname6, join as join21 } from "path";
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";
2726
2980
 
2727
2981
  // src/init/detect.ts
2728
- import { readFileSync as readFileSync13 } from "fs";
2729
- import { join as join18 } from "path";
2982
+ import { readFileSync as readFileSync15 } from "fs";
2983
+ import { join as join20 } from "path";
2730
2984
  var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
2731
2985
  var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
2732
2986
  var TAILWIND_V4_THEME_RE = /@theme\b/;
@@ -2743,7 +2997,7 @@ var FRAMEWORK_DEPS = [
2743
2997
  function detectFramework(projectRoot) {
2744
2998
  let pkg;
2745
2999
  try {
2746
- pkg = JSON.parse(readFileSync13(join18(projectRoot, "package.json"), "utf8"));
3000
+ pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
2747
3001
  } catch {
2748
3002
  return void 0;
2749
3003
  }
@@ -2761,7 +3015,7 @@ function detect(projectRoot) {
2761
3015
  for (const f of cssFiles) {
2762
3016
  let source;
2763
3017
  try {
2764
- source = readFileSync13(join18(projectRoot, f), "utf8");
3018
+ source = readFileSync15(join20(projectRoot, f), "utf8");
2765
3019
  } catch {
2766
3020
  continue;
2767
3021
  }
@@ -2796,8 +3050,8 @@ function isIgnored(projectRoot, relPath) {
2796
3050
  }
2797
3051
 
2798
3052
  // src/init/derive.ts
2799
- import { readFileSync as readFileSync14 } from "fs";
2800
- import { join as join19 } from "path";
3053
+ import { readFileSync as readFileSync16 } from "fs";
3054
+ import { join as join21 } from "path";
2801
3055
  var DEFAULT_PROPOSAL = {
2802
3056
  h: 264,
2803
3057
  s: 0,
@@ -2830,7 +3084,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
2830
3084
  for (const file of cssFiles) {
2831
3085
  let source;
2832
3086
  try {
2833
- source = readFileSync14(join19(projectRoot, file), "utf8");
3087
+ source = readFileSync16(join21(projectRoot, file), "utf8");
2834
3088
  } catch {
2835
3089
  continue;
2836
3090
  }
@@ -2879,7 +3133,7 @@ function balancedBraceBody(source, openIdx) {
2879
3133
  function fromTailwindConfig(projectRoot, tailwindConfigFile) {
2880
3134
  let source;
2881
3135
  try {
2882
- source = readFileSync14(join19(projectRoot, tailwindConfigFile), "utf8");
3136
+ source = readFileSync16(join21(projectRoot, tailwindConfigFile), "utf8");
2883
3137
  } catch {
2884
3138
  return null;
2885
3139
  }
@@ -2935,7 +3189,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
2935
3189
  for (const file of cssFiles) {
2936
3190
  let source;
2937
3191
  try {
2938
- source = readFileSync14(join19(projectRoot, file), "utf8");
3192
+ source = readFileSync16(join21(projectRoot, file), "utf8");
2939
3193
  } catch {
2940
3194
  continue;
2941
3195
  }
@@ -3095,8 +3349,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
3095
3349
  }
3096
3350
 
3097
3351
  // src/init/project-name.ts
3098
- import { readFileSync as readFileSync15 } from "fs";
3099
- import { basename, join as join20 } from "path";
3352
+ import { readFileSync as readFileSync17 } from "fs";
3353
+ import { basename, join as join22 } from "path";
3100
3354
  function slugify(name) {
3101
3355
  const withoutScope = name.replace(/^@[^/]+\//, "");
3102
3356
  const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3104,7 +3358,7 @@ function slugify(name) {
3104
3358
  }
3105
3359
  function deriveProjectSlug(projectRoot) {
3106
3360
  try {
3107
- const pkg = JSON.parse(readFileSync15(join20(projectRoot, "package.json"), "utf8"));
3361
+ const pkg = JSON.parse(readFileSync17(join22(projectRoot, "package.json"), "utf8"));
3108
3362
  if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
3109
3363
  } catch {
3110
3364
  }
@@ -3170,7 +3424,7 @@ function parseSurfaces(answer, fallback) {
3170
3424
  return parsed.length > 0 ? parsed : fallback;
3171
3425
  }
3172
3426
  function fileState(projectRoot, absPath, relPath, initManifest) {
3173
- const existsOnDisk = existsSync11(absPath);
3427
+ const existsOnDisk = existsSync13(absPath);
3174
3428
  const tracked = !!initManifest && relPath in initManifest.files;
3175
3429
  const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
3176
3430
  return { existsOnDisk, tracked, modified };
@@ -3181,11 +3435,11 @@ function isValidSurfaceArray(v) {
3181
3435
  );
3182
3436
  }
3183
3437
  function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
3184
- if (!existsSync11(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3438
+ if (!existsSync13(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3185
3439
  try {
3186
- const parsed = JSON.parse(readFileSync16(configAbsPath, "utf8"));
3440
+ const parsed = JSON.parse(readFileSync18(configAbsPath, "utf8"));
3187
3441
  const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
3188
- const brandResolves = brandCandidate ? existsSync11(join21(projectRoot, ...brandCandidate.split("/"))) : false;
3442
+ const brandResolves = brandCandidate ? existsSync13(join23(projectRoot, ...brandCandidate.split("/"))) : false;
3189
3443
  return {
3190
3444
  brand: brandResolves ? brandCandidate : fallbackBrand,
3191
3445
  surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
@@ -3194,12 +3448,61 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
3194
3448
  return { brand: fallbackBrand, surfaces: fallbackSurfaces };
3195
3449
  }
3196
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
+ }
3197
3491
  function isCssModule(path) {
3198
3492
  return /\.module\.(css|scss)$/i.test(path);
3199
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
+ }
3200
3503
  function findWireTarget(d) {
3201
3504
  if (d.cssSystem === "tailwind-v4" && d.tailwindV4EntryFile) return d.tailwindV4EntryFile;
3202
- const candidates = d.cssFiles.filter((f) => !isCssModule(f));
3505
+ const candidates = d.cssFiles.filter((f) => !isCssModule(f) && !isTokenLayerFile(f));
3203
3506
  if (candidates.length === 1) return candidates[0];
3204
3507
  return null;
3205
3508
  }
@@ -3210,45 +3513,35 @@ function insertAfterCharset(content, insertion) {
3210
3513
  const charsetLen = m ? m[0].length : 0;
3211
3514
  return content.slice(0, charsetLen) + insertion + content.slice(charsetLen);
3212
3515
  }
3213
- function wireImport(absPath, brandImport, modeImport, mode) {
3214
- const content = existsSync11(absPath) ? readFileSync16(absPath, "utf8") : "";
3215
- const brandImportLine = `@import "${brandImport}";`;
3216
- const modeImportLine = `@import "${modeImport}";`;
3217
- const brandPresent = content.includes(brandImportLine);
3218
- const existingModeMatch = MODE_IMPORT_RE.exec(content);
3219
- if (brandPresent) {
3220
- if (existingModeMatch && existingModeMatch[0].trim() === modeImportLine) {
3221
- return { status: "already-present" };
3222
- }
3223
- if (existingModeMatch) {
3224
- const previousMode = existingModeMatch[2];
3225
- const next3 = content.slice(0, existingModeMatch.index) + `${modeImportLine}
3226
- ` + content.slice(existingModeMatch.index + existingModeMatch[0].length);
3227
- writeFileSync4(absPath, next3, "utf8");
3228
- return { status: "rewired", detail: `rewired mode: ${previousMode} \u2192 ${mode}` };
3229
- }
3230
- const brandIdx = content.indexOf(brandImportLine);
3231
- let insertAt = brandIdx + brandImportLine.length;
3232
- if (content[insertAt] === "\r") insertAt++;
3233
- if (content[insertAt] === "\n") insertAt++;
3234
- const next2 = content.slice(0, insertAt) + modeImportLine + "\n" + content.slice(insertAt);
3235
- writeFileSync4(absPath, next2, "utf8");
3236
- return { status: "rewired", detail: `added missing mode import: ${mode}` };
3237
- }
3238
- const lines = `${brandImportLine}
3239
- ${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";
3240
3521
  `;
3241
- const tailwindImportMatch = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
3242
- let next;
3243
- if (tailwindImportMatch) {
3244
- const idx = tailwindImportMatch.index + tailwindImportMatch[0].length;
3245
- next = content.slice(0, idx) + lines + content.slice(idx);
3246
- } else {
3247
- next = insertAfterCharset(content, content ? `${lines}
3248
- ` : lines);
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" };
3249
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
+ `);
3250
3543
  mkdirSync5(dirname6(absPath), { recursive: true });
3251
- writeFileSync4(absPath, next, "utf8");
3544
+ writeFileSync4(next === content ? absPath : absPath, next, "utf8");
3252
3545
  return { status: "wired" };
3253
3546
  }
3254
3547
  async function init(opts) {
@@ -3314,8 +3607,11 @@ async function init(opts) {
3314
3607
  }
3315
3608
  }
3316
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(", ");
3317
3613
  log(
3318
- `Surface \u2192 mode: ${surfaces.map((s) => `'${s.match}' \u2192 ${s.mode}`).join(", ")} \u2014 the default, not inferred from this project. Mode sets density, type scale and control sizes, and it wins over an agent's own inference, so change it in jig.config.json if another mode fits (${MODES.join("/")}).`
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("/")}).`
3319
3615
  );
3320
3616
  }
3321
3617
  if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
@@ -3329,11 +3625,30 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
3329
3625
  }
3330
3626
  const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
3331
3627
  const projectSlug = deriveProjectSlug(opts.projectRoot);
3332
- const brandRelPath = relKey(".jig", "tokens", brandFileName(projectSlug));
3333
- const brandAbsPath = join21(opts.projectRoot, ".jig", "tokens", brandFileName(projectSlug));
3334
3628
  const configRelPath = "jig.config.json";
3335
- const configAbsPath = join21(opts.projectRoot, configRelPath);
3336
- 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;
3337
3652
  const files = { ...initManifest?.files ?? {} };
3338
3653
  const brandState = fileState(opts.projectRoot, brandAbsPath, brandRelPath, initManifest);
3339
3654
  const configState = fileState(opts.projectRoot, configAbsPath, configRelPath, initManifest);
@@ -3361,7 +3676,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3361
3676
  if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
3362
3677
  }
3363
3678
  if (brandAction === "written") {
3364
- const defaultCssRaw = readFileSync16(join21(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3679
+ const defaultCssRaw = readFileSync18(join23(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
3365
3680
  const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
3366
3681
  mkdirSync5(dirname6(brandAbsPath), { recursive: true });
3367
3682
  writeFileSync4(brandAbsPath, content, "utf8");
@@ -3376,12 +3691,12 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3376
3691
  } else if (configAction === "merged") {
3377
3692
  let existing = {};
3378
3693
  try {
3379
- existing = JSON.parse(readFileSync16(configAbsPath, "utf8"));
3694
+ existing = JSON.parse(readFileSync18(configAbsPath, "utf8"));
3380
3695
  } catch {
3381
3696
  existing = {};
3382
3697
  }
3383
3698
  const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
3384
- const brandStillResolves = existingBrand ? existsSync11(join21(opts.projectRoot, ...existingBrand.split("/"))) : false;
3699
+ const brandStillResolves = existingBrand ? existsSync13(join23(opts.projectRoot, ...existingBrand.split("/"))) : false;
3385
3700
  const merged = {
3386
3701
  brand: brandStillResolves ? existingBrand : brandRelPath,
3387
3702
  surfaces: Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : surfaces
@@ -3393,20 +3708,20 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3393
3708
  }
3394
3709
  const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
3395
3710
  const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
3396
- const wiringBrandAbsPath = join21(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3711
+ const wiringBrandAbsPath = join23(opts.projectRoot, ...effectiveConfig.brand.split("/"));
3397
3712
  const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
3398
3713
  const modeAbsPaths = {};
3399
3714
  for (const mode of declaredModes) {
3400
3715
  const modeFileName = `mode.${mode}.css`;
3401
- const modeAbsPath2 = join21(opts.projectRoot, ".jig", "tokens", modeFileName);
3716
+ const modeAbsPath2 = join23(opts.projectRoot, ...tokensRelDir, modeFileName);
3402
3717
  modeAbsPaths[mode] = modeAbsPath2;
3403
- const modeRelPath = relKey(".jig", "tokens", modeFileName);
3718
+ const modeRelPath = relKey(...tokensRelDir, modeFileName);
3404
3719
  const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
3405
3720
  let modeAction = "written";
3406
3721
  if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
3407
3722
  else if (modeState.modified) modeAction = "skipped-edited";
3408
3723
  if (modeAction === "written") {
3409
- const modeSourceRaw = readFileSync16(join21(opts.packageRoot, "tokens", modeFileName), "utf8");
3724
+ const modeSourceRaw = readFileSync18(join23(opts.packageRoot, "tokens", modeFileName), "utf8");
3410
3725
  const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
3411
3726
  mkdirSync5(dirname6(modeAbsPath2), { recursive: true });
3412
3727
  writeFileSync4(modeAbsPath2, content, "utf8");
@@ -3416,17 +3731,39 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3416
3731
  }
3417
3732
  }
3418
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
+ }
3419
3755
  writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
3420
3756
  const wireTarget = findWireTarget(detection);
3421
3757
  let wiring;
3422
3758
  if (wireTarget) {
3423
- const targetAbsDir = dirname6(join21(opts.projectRoot, wireTarget));
3424
- const brandImport = relativeImportPath(targetAbsDir, wiringBrandAbsPath);
3425
- const modeImport = relativeImportPath(targetAbsDir, modeAbsPath);
3426
- const snippet = `@import "${brandImport}";
3427
- @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}";`;
3428
3765
  try {
3429
- const outcome = wireImport(join21(opts.projectRoot, wireTarget), brandImport, modeImport, primaryMode);
3766
+ const outcome = wireBarrel(join23(opts.projectRoot, wireTarget), barrelImport);
3430
3767
  wiring = { target: wireTarget, status: outcome.status, snippet };
3431
3768
  const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
3432
3769
  log(`
@@ -3451,7 +3788,7 @@ Could not write to ${wireTarget}: ${err.message}`);
3451
3788
  if (pasteTargets.length > 0) {
3452
3789
  log("Paste the block for whichever of these is your global stylesheet:");
3453
3790
  for (const target of pasteTargets) {
3454
- const dir = dirname6(join21(opts.projectRoot, target));
3791
+ const dir = dirname6(join23(opts.projectRoot, target));
3455
3792
  const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
3456
3793
  @import "${relativeImportPath(dir, modeAbsPath)}";`;
3457
3794
  log(`
@@ -3492,7 +3829,7 @@ Could not write to ${wireTarget}: ${err.message}`);
3492
3829
 
3493
3830
  // src/index.ts
3494
3831
  var packageRoot = getPackageRoot();
3495
- var { version } = JSON.parse(readFileSync17(join22(packageRoot, "package.json"), "utf8"));
3832
+ var { version } = JSON.parse(readFileSync19(join24(packageRoot, "package.json"), "utf8"));
3496
3833
  function warnIfUnpublishedPin() {
3497
3834
  if (isPublishedBuild(packageRoot)) return;
3498
3835
  console.warn(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A design system for coding agents. 104 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -380,14 +380,43 @@ In dark, elevated surfaces get **lighter**, not shadowed. Border-led elevation s
380
380
  }
381
381
  ```
382
382
 
383
- **Tailwind v4** — wrap the same files, nothing else changes
383
+ **Tailwind v4** — wrap the barrel, nothing else changes
384
384
  ```css
385
385
  @import "tailwindcss";
386
386
  @theme {
387
- @import ".jig/tokens/brand.default.css";
388
- @import ".jig/tokens/mode.product.css";
387
+ @import "./jig/theme.css";
389
388
  }
390
389
  ```
391
390
  Yields `bg-surface`, `rounded-surface`, `p-card`, `text-body` as utilities.
392
391
 
393
- **Multiple modes in one app** — scope by route, not by class. Each surface loads its own mode file at the layout or entry level. Do not attempt to nest two modes in one document (`01-modes.md`, seam rules).
392
+ **In a monorepo, add `@source` for every workspace package that uses these
393
+ utilities.** Tailwind v4's content detection does not cross package boundaries:
394
+ a package reached through a `node_modules` symlink is skipped by design, so a
395
+ utility used *only* inside `packages/ui` is never generated.
396
+
397
+ ```css
398
+ @source "../../../../packages/ui/src";
399
+ ```
400
+
401
+ The failure mode is the reason this is worth stating. Nothing errors — the
402
+ class lands on the element and no rule exists to match it, so the style simply
403
+ does not apply. Most utilities survive by coincidence, because the app happens
404
+ to use the same ones; the ones that do not are whatever only the shared package
405
+ uses, which tends to be its theme and state handling. Observed in a real
406
+ project as a theme switch that had silently never worked.
407
+
408
+ **One import per surface, through a barrel.** `init` writes `jig/theme.css`,
409
+ which imports the brand file and then one mode file, and wires that single line
410
+ into your stylesheet. Your stylesheet then never changes again: switching the
411
+ mode in `jig.config.json` rewrites the barrel, not your CSS.
412
+
413
+ A barrel holds exactly **one** mode, never a merge. The three mode files declare
414
+ the same token names with different values, so importing all three into one
415
+ document leaves only the last — the other two are inert. That is the mechanical
416
+ reason behind the seam rule below.
417
+
418
+ **Multiple modes in one app** — scope by route, not by class. Each surface imports its own
419
+ barrel — `jig/theme.css` for the primary surface, `jig/theme.<mode>.css` for the
420
+ others — at that route's layout or entry level. `init` names them and does not
421
+ wire them: which entry point serves `/admin/**` is your routing, which it cannot
422
+ see. Do not attempt to nest two modes in one document (`01-modes.md`, seam rules).
@@ -11,14 +11,42 @@ output — findings are ordered by severity, not position, so `head`, `tail`,
11
11
 
12
12
  ## init
13
13
 
14
- Report what it detected, the brand colour it derived and where that came from,
15
- and the mode it wrote. The mode is the load-bearing decision: `jig.config.json`
16
- outranks your own inference from then on, so if the project's signals point
17
- elsewhere — the mode table in `{{rules_path}}/01-modes.md` — say so and ask
18
- before leaving it.
19
-
20
- If the token layer does not exist yet, this is the command that creates it. Do
21
- not author token values yourself under any circumstances.
14
+ **Settle the surfaces before you run it.** Mode is the most consequential thing
15
+ `init` writes and the thing it is worst at choosing: with `--yes` it takes
16
+ `'/' → product` without reading the project at all, and `{{rules_path}}/01-modes.md`
17
+ rule 1 then makes that config outrank your own reading of the project from then
18
+ on. A default chosen in a second binds the project indefinitely, and density is
19
+ expensive to reverse.
20
+
21
+ You are the half that can fix this, because you are talking to someone who knows
22
+ the answer and the CLI is not. So, first:
23
+
24
+ 1. **Ask what the product is**, in their words rather than Jig's — a marketing
25
+ or content site, a signed-in application, an internal tool people use all
26
+ day? A project usually has more than one of these, and each is a surface.
27
+ 2. **Map each to a mode** using the table in `{{rules_path}}/01-modes.md`:
28
+ `editorial` for first-visit content, `product` for the signed-in app,
29
+ `operator` for dense daily tools. State the mapping in one line and let them
30
+ correct it.
31
+ 3. **Write `jig.config.json`** with those surfaces before running anything.
32
+ `init` reads an existing config and honours it — writing one mode file per
33
+ declared mode — so this is how a decision reaches the tokens.
34
+ 4. **Then run the command.**
35
+
36
+ Where there is genuinely one surface, say so and move on; the point is that the
37
+ mode was chosen rather than defaulted into.
38
+
39
+ Afterwards, report what it detected, the brand colour it derived and where that
40
+ came from, and the surfaces it used — the output says whether they came from
41
+ `jig.config.json` or from the default.
42
+
43
+ If the token layer does not exist yet, this is the command that creates it. **Do
44
+ not author token values yourself.** `init` derives the brand colour, checks it
45
+ against the contrast floor in both light and dark, and records a checksum for
46
+ each file it writes; a file you write by hand is untracked, so `jig update` can
47
+ never refresh it again. `jig check` now reads the token layer back and holds it
48
+ to those floors either way, so a hand-written value will be caught — but caught
49
+ late, and unmaintainable, is worse than derived.
22
50
 
23
51
  ## check
24
52