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 +35 -2
- package/dist/index.js +431 -94
- package/package.json +1 -1
- package/rules/02-tokens.md +33 -4
- package/templates/COMMAND.md.tmpl +36 -8
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
|
-
|
|
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
|
|
5
|
+
import { readFileSync as readFileSync19 } from "fs";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
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
|
|
52
|
+
const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
|
|
53
53
|
const hasDotDotSegment = relPath.split(/[\\/]/).includes("..");
|
|
54
|
-
if (
|
|
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
|
|
1233
|
-
import { join as
|
|
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 =
|
|
2883
|
+
var LEGACY_INDEX_REL = join19(".jig", "rules.index.json");
|
|
2650
2884
|
function resolveIndexPath(projectRoot) {
|
|
2651
|
-
const legacy =
|
|
2652
|
-
if (
|
|
2653
|
-
return
|
|
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(
|
|
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(
|
|
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(
|
|
2915
|
+
const index = validateIndex(JSON.parse(readFileSync14(indexPath, "utf8")));
|
|
2682
2916
|
const selection = selectFiles(opts.projectRoot, opts.all);
|
|
2683
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
2725
|
-
import { dirname as dirname6, join as
|
|
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
|
|
2729
|
-
import { join as
|
|
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(
|
|
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 =
|
|
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
|
|
2800
|
-
import { join as
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
3099
|
-
import { basename, join as
|
|
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(
|
|
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 =
|
|
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 (!
|
|
3438
|
+
if (!existsSync13(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
3185
3439
|
try {
|
|
3186
|
-
const parsed = JSON.parse(
|
|
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 ?
|
|
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
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
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
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
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: ${
|
|
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 =
|
|
3336
|
-
const
|
|
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 =
|
|
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(
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
3716
|
+
const modeAbsPath2 = join23(opts.projectRoot, ...tokensRelDir, modeFileName);
|
|
3402
3717
|
modeAbsPaths[mode] = modeAbsPath2;
|
|
3403
|
-
const modeRelPath = relKey(
|
|
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 =
|
|
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(
|
|
3424
|
-
const
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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.
|
|
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",
|
package/rules/02-tokens.md
CHANGED
|
@@ -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
|
|
383
|
+
**Tailwind v4** — wrap the barrel, nothing else changes
|
|
384
384
|
```css
|
|
385
385
|
@import "tailwindcss";
|
|
386
386
|
@theme {
|
|
387
|
-
@import "
|
|
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
|
-
**
|
|
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
|
-
|
|
15
|
-
and the
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|