jig-ui 0.7.1 → 0.8.1

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
@@ -75,25 +75,63 @@ skill.
75
75
  npx jig-ui@latest init
76
76
  ```
77
77
 
78
- `init` is the only command that writes into your repo. It detects your CSS
79
- system (Tailwind v4, Tailwind v3, plain CSS), derives a brand colour from what
80
- your project already has — custom properties first, then a Tailwind config, then
81
- the most frequent literal colour — rather than interviewing you cold, validates
82
- that colour against the contrast and collision requirements in Jig's own brand
83
- file, writes the token files, wires the `@import`s into your stylesheet when
84
- there is one unambiguous place for them, and runs a baseline `check` so you have
85
- a number to move.
78
+ `init` is the only command that writes into your repo. It runs on a project
79
+ with nothing in it and on one with years of CSS, and behaves differently in
80
+ each — because the useful thing to do differs.
86
81
 
87
- A single-mode project ends up with four files, all of them yours:
82
+ ### An existing site
83
+
84
+ `init` reads what you already have. It detects the CSS system (Tailwind v4,
85
+ Tailwind v3, plain CSS), derives a brand colour from the project rather than
86
+ interviewing you cold — custom properties first, then a Tailwind config, then
87
+ the most frequent literal colour — and validates that colour against the
88
+ contrast and collision requirements in Jig's own brand file. It puts the token
89
+ layer **beside the stylesheet it wires**, and adds one import to it:
88
90
 
89
91
  ```
92
+ src/styles/app.css ← @import "./jig/theme.css"; added at the top
93
+ src/styles/jig/
94
+ brand.<project>.css your identity, edit freely
95
+ mode.<mode>.css a copy of Jig's mode file, refreshed by `update`
96
+ theme.css the barrel: brand + mode, in order
90
97
  jig.config.json route → mode map
91
- .jig/
92
- state.json bookkeeping — version, modes in use, checksums
93
- tokens/
94
- brand.<project>.css your identity, edit freely
95
- <mode>.css a copy of Jig's mode file, refreshed by `update`
98
+ .jig/state.json bookkeeping — version, modes, checksums
99
+ ```
100
+
101
+ Nothing you wrote is touched beyond that one import line. Re-running `init`
102
+ never overwrites a config or brand file you have edited.
103
+
104
+ ### A brand-new site
105
+
106
+ There is no CSS to read, so there is nothing to derive from and nowhere obvious
107
+ to wire. `init` says so rather than guessing:
108
+
96
109
  ```
110
+ Detected: unknown
111
+ Token layer: jig/ — no stylesheet found to follow, so the project root.
112
+ Could not find a single unambiguous stylesheet to wire the import into.
113
+ Add this near the top of your global stylesheet:
114
+ @import "./jig/theme.css";
115
+ ```
116
+
117
+ The brand colour resolves to the unbranded near-black default, which ships a
118
+ coherent monochrome UI and makes the missing decision visible instead of
119
+ inventing a purple (`A-01`). Set `--brand-h/-s/-l` in the brand file when you
120
+ have decided, or tell your agent to ask you.
121
+
122
+ Add `--yes` to accept every derived default non-interactively — the mode CI and
123
+ agents run in.
124
+
125
+ ### Where the token layer goes
126
+
127
+ Beside the stylesheet it wires, so it sits with the rest of your CSS rather than
128
+ in a dotfolder next to your lockfile. `src/styles/jig/` in a project whose CSS
129
+ lives in `src/styles/`, `app/assets/stylesheets/jig/` in a Rails app, `jig/` at
130
+ the root when there is no stylesheet to follow. `init` prints the path it chose.
131
+
132
+ Set `brand` in `jig.config.json` to put it somewhere else. Projects set up
133
+ before 0.7.0 keep their `.jig/tokens/` layout; `update` does not move them, and
134
+ `init` offers to.
97
135
 
98
136
  The mode file is the one thing genuinely copied: a stylesheet `@import` is an
99
137
  edge in a build graph and has to resolve locally, on every machine that builds.
@@ -143,7 +181,7 @@ on the result — the CLI reports, the agent applies the judgment half.
143
181
  | Slash command | Equivalent |
144
182
  | --- | --- |
145
183
  | `/jig init` | `jig init` — then states the mode it chose and what it wired |
146
- | `/jig check` | `jig check` — then applies the 97 judgment rules and reports both halves |
184
+ | `/jig check` | `jig check` — then applies the 94 judgment rules and reports both halves |
147
185
  | `/jig explain C-19` | `jig explain C-19` — prints the rule as-is, without paraphrasing it |
148
186
  | `/jig explain contrast` | `jig explain contrast` — every rule matching a word, when you do not have an id |
149
187
  | `/jig install --agent cursor` | `jig install --agent cursor` |
@@ -224,7 +262,7 @@ In CI:
224
262
  code — nothing model-dependent, no network. As a pre-commit hook, plain `check`
225
263
  looks at changed files only.
226
264
 
227
- What you will not get from the CLI alone is the other 97 rules. `check` says so
265
+ What you will not get from the CLI alone is the other 94 rules. `check` says so
228
266
  rather than letting a narrow pass read as a broad one.
229
267
 
230
268
  ## What `check` covers
@@ -251,7 +289,7 @@ contrast below the floor (`C-19`), removed focus rings (`E-29`), gradient text
251
289
  (`A-02`), backdrop blur (`A-04`), pure black and white (`C-18`), and the
252
290
  violet-band hue check (`A-01`, which asks rather than fails).
253
291
 
254
- **It also reads the token layer itself.** `.jig/tokens/*.css` is not application
292
+ **It also reads the token layer itself.** The token layer is not application
255
293
  code, so no detector scans it — but it is where a mistake costs most, since every
256
294
  call site inherits it. `check` reads back what is declared there and holds it to
257
295
  the floors the token layer claims: 4.5:1 for text roles, 3:1 for interface
@@ -298,8 +336,9 @@ treatment.
298
336
  | `rules/03-patterns.md` | Component anatomy and behaviour |
299
337
  | `rules/04-principles.md` | Five frames + seven tiebreakers |
300
338
  | `rules/05-copy.md` | Interface text rules |
301
- | `.jig/tokens/brand.*.css` | Identity. One per project. |
302
- | `.jig/tokens/mode.*.css` | Density, scale, rhythm, motion |
339
+ | `<css dir>/jig/brand.*.css` | Identity. One per project. |
340
+ | `<css dir>/jig/mode.*.css` | Density, scale, rhythm, motion |
341
+ | `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. |
303
342
 
304
343
  `rules/*` and `rules.index.json` live beside your installed skill file, not
305
344
  in the project — see above.
@@ -314,7 +353,8 @@ Drop this in the project root so mode selection does not require asking on every
314
353
  // jig.config.json
315
354
  {
316
355
  // 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/`.
356
+ // the mode files beside it. Omit it and the layer follows your own
357
+ // layout — beside the stylesheet init wires, or the project root.
318
358
  "brand": "src/styles/jig/brand.acme.css",
319
359
 
320
360
  // One entry per surface. This outranks an agent's own reading of the
@@ -346,12 +386,56 @@ Without this file, follow the selection procedure in `rules/01-modes.md`: infer,
346
386
 
347
387
  ## Consuming tokens
348
388
 
389
+ ### Plain CSS, any framework
390
+
391
+ One import, the barrel:
392
+
349
393
  ```css
350
- @import ".jig/tokens/brand.acme.css"; /* one per project */
351
- @import ".jig/tokens/mode.product.css"; /* one per surface */
394
+ @import "./jig/theme.css";
352
395
  ```
353
396
 
354
- Then `var(--color-text-strong)`, `var(--spacing-card)`, `var(--text-body)` in any framework. For Tailwind v4, wrap both imports in `@theme` to generate utilities. See `rules/02-tokens.md`.
397
+ Then `var(--color-text-strong)`, `var(--spacing-card)`, `var(--text-body)`
398
+ anywhere — plain CSS, CSS modules, styled-components, Vue, Svelte, Rails. They
399
+ are ordinary custom properties and nothing takes a dependency on anything.
400
+
401
+ ### Tailwind v4
402
+
403
+ The same import. Jig needs nothing from Tailwind and Tailwind needs nothing from
404
+ Jig:
405
+
406
+ ```css
407
+ @import "tailwindcss";
408
+ @import "./jig/theme.css";
409
+ ```
410
+
411
+ That is what `init` wires, and it is enough. Every token is readable as
412
+ `var(--color-text-strong)` from any component.
413
+
414
+ **Optionally**, Tailwind can also generate utility classes from the tokens —
415
+ `p-card`, `rounded-surface`, `text-text-strong`. It only does that for names
416
+ declared in a `@theme` block, so `init` offers to generate one:
417
+
418
+ ```css
419
+ @import "tailwindcss";
420
+ @import "./jig/utilities.css"; /* the generated @theme block */
421
+ ```
422
+
423
+ `init` asks before writing it, because it changes how every component in the
424
+ project is written and both styles are correct. Under `--yes` it declines and
425
+ tells you how to get it.
426
+
427
+ One set of utilities serves every mode: the utility references the variable
428
+ rather than a resolved value, so whichever mode barrel a route loaded supplies
429
+ it. No `dark:` variants, nothing per-mode.
430
+
431
+ **Do not nest the import inside `@theme`.** Tailwind rejects it — *"@theme
432
+ blocks must only contain custom properties or @keyframes"* — and Jig's tokens
433
+ cannot move into one regardless, since they live in `:root` and are redeclared
434
+ under `[data-theme="dark"]` and a `prefers-color-scheme` query. That structure
435
+ is what makes dark mode work.
436
+
437
+ Full detail, including why a duplicate declaration in the compiled CSS is
438
+ correct and must not be "fixed": `rules/02-tokens.md`.
355
439
 
356
440
  ---
357
441
 
package/dist/index.js CHANGED
@@ -1013,15 +1013,26 @@ function parseRules(markdown, sourceFile) {
1013
1013
  title,
1014
1014
  wrong: "",
1015
1015
  correction: "",
1016
+ notes: [],
1017
+ preamble: [],
1016
1018
  source: `${sourceFile}#${id.toLowerCase()}`
1017
1019
  };
1018
1020
  continue;
1019
1021
  }
1020
1022
  if (!current) continue;
1023
+ if (line.startsWith("#")) {
1024
+ push();
1025
+ current = null;
1026
+ continue;
1027
+ }
1021
1028
  if (line.startsWith("\u274C") && !current.wrong) {
1022
1029
  current.wrong = line.slice(1).trim();
1023
1030
  } else if (line.startsWith("\u2705") && !current.correction) {
1024
1031
  current.correction = line.slice(1).trim();
1032
+ } else {
1033
+ const text = line.trim();
1034
+ if (!text || /^-{3,}$/.test(text)) continue;
1035
+ (current.correction ? current.notes : current.preamble).push(text);
1025
1036
  }
1026
1037
  }
1027
1038
  push();
@@ -1114,6 +1125,7 @@ function parseSpecs(markdown, sourceFile) {
1114
1125
  current = null;
1115
1126
  continue;
1116
1127
  }
1128
+ if (current && /^-{3,}$/.test(line.trim())) continue;
1117
1129
  if (current) body.push(line);
1118
1130
  }
1119
1131
  push();
@@ -1140,8 +1152,16 @@ function renderRule(rule) {
1140
1152
  return [
1141
1153
  `${rule.id} ${rule.title}`,
1142
1154
  "",
1155
+ // Source order: what the rule leads with comes first. C-49's table sets up
1156
+ // the distinction its pair then relies on, and reading them the other way
1157
+ // round is reading the answer before the question.
1158
+ ...rule.preamble.length > 0 ? [...rule.preamble, ""] : [],
1143
1159
  `\u274C ${rule.wrong}`,
1144
1160
  `\u2705 ${rule.correction}`,
1161
+ // The reasoning, where the rule carries any. This was dropped at the parser
1162
+ // for three releases, so `explain` showed the pair and none of the argument
1163
+ // for it — which is the half that tells you when the rule does not apply.
1164
+ ...rule.notes.length > 0 ? ["", ...rule.notes] : [],
1145
1165
  "",
1146
1166
  ` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
1147
1167
  ` since ${rule.since} \xB7 ${rule.source}`
@@ -1410,7 +1430,9 @@ function formatReport(findings, meta) {
1410
1430
  }
1411
1431
  lines.push("");
1412
1432
  } else {
1413
- lines.push(" No findings.");
1433
+ lines.push(
1434
+ meta.withStyles === 0 && meta.scanned !== void 0 ? " Nothing inspected." : " No findings."
1435
+ );
1414
1436
  lines.push("");
1415
1437
  if (meta.noTokenLayer) {
1416
1438
  lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
@@ -1426,7 +1448,14 @@ function formatReport(findings, meta) {
1426
1448
  if (notes > 0) summaryParts.push(plural(notes, "note"));
1427
1449
  const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
1428
1450
  const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
1429
- lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}, ${rulesFired} fired`);
1451
+ const examined = meta.scanned === void 0 ? "" : ` \xB7 ${plural(meta.scanned, "file")}, ${meta.withStyles ?? 0} with styles`;
1452
+ lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}${examined}, ${rulesFired} fired`);
1453
+ if (meta.scanned !== void 0 && meta.withStyles === 0) {
1454
+ lines.push("");
1455
+ lines.push(
1456
+ ` No file carried a style region, so the detectors examined nothing. This is not a pass \u2014 a project with no styling and a project with clean styling report the same findings, and only one of them has been checked.`
1457
+ );
1458
+ }
1430
1459
  if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
1431
1460
  const n = meta.exempt?.length ?? 0;
1432
1461
  lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
@@ -1448,7 +1477,7 @@ function formatReport(findings, meta) {
1448
1477
  const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
1449
1478
  const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
1450
1479
  lines.push(
1451
- ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
1480
+ ` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run files=${meta.scanned ?? "unknown"} styled=${meta.withStyles ?? "unknown"}`
1452
1481
  );
1453
1482
  return lines.join("\n");
1454
1483
  }
@@ -1622,7 +1651,7 @@ function splitRuleBlocks(source) {
1622
1651
  }
1623
1652
  function blankNested(body) {
1624
1653
  const out = body.split("");
1625
- const blank2 = (from, to) => {
1654
+ const blank3 = (from, to) => {
1626
1655
  for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
1627
1656
  };
1628
1657
  let depth = 0;
@@ -1630,7 +1659,7 @@ function blankNested(body) {
1630
1659
  for (let i = 0; i < body.length; i++) {
1631
1660
  const ch = body[i];
1632
1661
  if (ch === "{") {
1633
- if (depth === 0) blank2(segmentStart, i);
1662
+ if (depth === 0) blank3(segmentStart, i);
1634
1663
  depth++;
1635
1664
  out[i] = " ";
1636
1665
  } else if (ch === "}") {
@@ -1940,6 +1969,126 @@ var gradientText = {
1940
1969
  }
1941
1970
  };
1942
1971
 
1972
+ // src/check/detectors/text-scan.ts
1973
+ var blank2 = (m) => m.replace(/[^\n]/g, " ");
1974
+ function maskProseComments(raw) {
1975
+ return raw.replace(/\/\*[\s\S]*?\*\//g, blank2).replace(/<!--[\s\S]*?-->/g, blank2).replace(/(^|[^:@\w])\/\/[^\n]*/g, (m, lead) => lead + blank2(m.slice(lead.length)));
1976
+ }
1977
+
1978
+ // src/check/detectors/emoji-icon.ts
1979
+ var EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F2FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
1980
+ var TEXTUAL = /* @__PURE__ */ new Set(["\u2192", "\u2190", "\u2191", "\u2193", "\u2194", "\u21D2", "\u21D0", "\u2122", "\u2713", "\u2717", "\u2212", "\u2217", "\u2318", "\u2325", "\u23CE"]);
1981
+ var emojiIcon = {
1982
+ name: "emoji-icon",
1983
+ appliesTo: (file) => isStyleBearing(file),
1984
+ run(_source, file, ctx) {
1985
+ const findings = [];
1986
+ const masked = maskProseComments(ctx.raw);
1987
+ const lines = masked.split("\n");
1988
+ const seen = /* @__PURE__ */ new Set();
1989
+ for (const m of masked.matchAll(EMOJI_RE)) {
1990
+ if (TEXTUAL.has(m[0])) continue;
1991
+ const line = masked.slice(0, m.index).split("\n").length;
1992
+ if (seen.has(line)) continue;
1993
+ seen.add(line);
1994
+ findings.push(
1995
+ mkFinding(
1996
+ ctx,
1997
+ "emoji-icon",
1998
+ file,
1999
+ line,
2000
+ `Emoji used as interface iconography (${m[0]}). Emoji render differently on every platform, carry no consistent weight or colour, and cannot be styled \u2014 use an icon set, or words.`,
2001
+ lines[line - 1] ?? ""
2002
+ )
2003
+ );
2004
+ }
2005
+ return findings;
2006
+ }
2007
+ };
2008
+
2009
+ // src/check/detectors/marketing-voice.ts
2010
+ var PHRASES = [
2011
+ "supercharge",
2012
+ "unleash",
2013
+ "revolutioni",
2014
+ "game-chang",
2015
+ "effortlessly",
2016
+ "seamlessly",
2017
+ "blazing fast",
2018
+ "take it to the next level",
2019
+ "unlock the power",
2020
+ "elevate your",
2021
+ "transform your workflow",
2022
+ "delight your users"
2023
+ ];
2024
+ var RE = new RegExp(`\\b(${PHRASES.join("|")})`, "gi");
2025
+ var APPLIES_IN = /* @__PURE__ */ new Set(["product", "operator"]);
2026
+ var marketingVoice = {
2027
+ name: "marketing-voice",
2028
+ appliesTo: (file) => isStyleBearing(file),
2029
+ run(_source, file, ctx) {
2030
+ if (!ctx.mode || !APPLIES_IN.has(ctx.mode)) return [];
2031
+ const findings = [];
2032
+ const masked = maskProseComments(ctx.raw);
2033
+ const lines = masked.split("\n");
2034
+ const seen = /* @__PURE__ */ new Set();
2035
+ for (const m of masked.matchAll(RE)) {
2036
+ const line = masked.slice(0, m.index).split("\n").length;
2037
+ if (seen.has(line)) continue;
2038
+ seen.add(line);
2039
+ findings.push(
2040
+ mkFinding(
2041
+ ctx,
2042
+ "marketing-voice",
2043
+ file,
2044
+ line,
2045
+ `Marketing voice in a \`${ctx.mode}\` surface ("${m[0]}"). Someone using this every day wants to know what it does, not how it feels.`,
2046
+ lines[line - 1] ?? ""
2047
+ )
2048
+ );
2049
+ }
2050
+ return findings;
2051
+ }
2052
+ };
2053
+
2054
+ // src/check/detectors/placeholder-content.ts
2055
+ var PATTERNS = [
2056
+ { re: /\blorem\s+ipsum\b/gi, what: "lorem ipsum" },
2057
+ { re: /https?:\/\/(?:www\.)?example\.(?:com|org|net)\b/gi, what: "an example.com URL" },
2058
+ { re: /\b(?:someone|user|test|foo|bar)@example\.(?:com|org|net)\b/gi, what: "a placeholder email" },
2059
+ { re: /\bAcme\s+(?:Inc\.?|Corp\.?|Co\.?|Ltd\.?|LLC|Company)\b/gi, what: "a placeholder company" },
2060
+ { re: /\byour\s+(?:company|brand|product)\s+name\b/gi, what: "a placeholder name" }
2061
+ ];
2062
+ var placeholderContent = {
2063
+ name: "placeholder-content",
2064
+ appliesTo: (file) => isStyleBearing(file),
2065
+ run(_source, file, ctx) {
2066
+ const findings = [];
2067
+ const masked = maskProseComments(ctx.raw);
2068
+ const lines = masked.split("\n");
2069
+ const seen = /* @__PURE__ */ new Set();
2070
+ for (const { re, what } of PATTERNS) {
2071
+ re.lastIndex = 0;
2072
+ for (const m of masked.matchAll(re)) {
2073
+ const line = masked.slice(0, m.index).split("\n").length;
2074
+ if (seen.has(line)) continue;
2075
+ seen.add(line);
2076
+ findings.push(
2077
+ mkFinding(
2078
+ ctx,
2079
+ "placeholder-content",
2080
+ file,
2081
+ line,
2082
+ `Placeholder content shipped \u2014 ${what} ("${m[0]}"). Real content changes layout: names are longer, copy wraps, images are the wrong ratio.`,
2083
+ lines[line - 1] ?? ""
2084
+ )
2085
+ );
2086
+ }
2087
+ }
2088
+ return findings;
2089
+ }
2090
+ };
2091
+
1943
2092
  // src/check/color.ts
1944
2093
  var NAMED = {
1945
2094
  white: { r: 255, g: 255, b: 255 },
@@ -2596,6 +2745,9 @@ var violetBandHue = {
2596
2745
  // src/check/registry.ts
2597
2746
  var DETECTORS = [
2598
2747
  gradientText,
2748
+ emojiIcon,
2749
+ marketingVoice,
2750
+ placeholderContent,
2599
2751
  backdropBlur,
2600
2752
  pureBlackWhite,
2601
2753
  contrastFloor,
@@ -2608,7 +2760,7 @@ function getDetector(name) {
2608
2760
  }
2609
2761
 
2610
2762
  // src/check/run.ts
2611
- function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
2763
+ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode) {
2612
2764
  const findings = [];
2613
2765
  const active = index.filter(
2614
2766
  (e) => !!e.detector && !!getDetector(e.detector)
@@ -2626,7 +2778,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
2626
2778
  }
2627
2779
  const source = maskComments(maskNonStyleRegions(raw, file));
2628
2780
  for (const { entry, detector } of applicable) {
2629
- const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
2781
+ const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode };
2630
2782
  findings.push(...detector.run(source, file, ctx));
2631
2783
  }
2632
2784
  }
@@ -2927,7 +3079,16 @@ function check(opts) {
2927
3079
  return false;
2928
3080
  }
2929
3081
  });
2930
- const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates);
3082
+ const withStyles = files.filter((f) => {
3083
+ try {
3084
+ const src = readFileSync14(join19(opts.projectRoot, f), "utf8");
3085
+ return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
3086
+ } catch {
3087
+ return false;
3088
+ }
3089
+ }).length;
3090
+ const resolvedMode = resolveMode(opts.projectRoot);
3091
+ const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode);
2931
3092
  const TOKEN_RULE = {
2932
3093
  "--text-prose": "B-75",
2933
3094
  "--size-touch-target": "E-51"
@@ -2966,8 +3127,10 @@ function check(opts) {
2966
3127
  totalSpecs: countSpecs(),
2967
3128
  version: opts.version,
2968
3129
  noTokenLayer,
2969
- mode: resolveMode(opts.projectRoot),
3130
+ mode: resolvedMode,
2970
3131
  unscanned: summariseUnscanned(files),
3132
+ scanned: files.length,
3133
+ withStyles,
2971
3134
  exempt,
2972
3135
  exemptPatterns: byPattern
2973
3136
  });
@@ -3365,6 +3528,77 @@ function deriveProjectSlug(projectRoot) {
3365
3528
  return slugify(basename(projectRoot));
3366
3529
  }
3367
3530
 
3531
+ // src/init/utilities.ts
3532
+ var TAILWIND_NAMESPACES = [
3533
+ "--color-",
3534
+ "--font-",
3535
+ "--text-",
3536
+ "--font-weight-",
3537
+ "--tracking-",
3538
+ "--leading-",
3539
+ "--breakpoint-",
3540
+ "--container-",
3541
+ "--spacing-",
3542
+ "--radius-",
3543
+ "--shadow-",
3544
+ "--inset-shadow-",
3545
+ "--drop-shadow-",
3546
+ "--blur-",
3547
+ "--perspective-",
3548
+ "--aspect-",
3549
+ "--ease-",
3550
+ "--animate-"
3551
+ ];
3552
+ function tailwindNamespaced(names) {
3553
+ const kept = names.filter((n) => TAILWIND_NAMESPACES.some((ns) => n.startsWith(ns)));
3554
+ return [...new Set(kept)].sort();
3555
+ }
3556
+ function declaredTokenNames(css) {
3557
+ return [...css.matchAll(/(^|[;{\s])(--[\w-]+)\s*:/g)].map((m) => m[2]);
3558
+ }
3559
+ function utilitiesBody(names, version2) {
3560
+ if (names.length === 0) {
3561
+ throw new Error(
3562
+ "Refusing to write an empty @theme block: it generates no utilities while looking like it should. No token in this layer matches a Tailwind namespace."
3563
+ );
3564
+ }
3565
+ const width = Math.max(...names.map((n) => n.length));
3566
+ const lines = names.map((n) => ` ${n}:${" ".repeat(width - n.length)} var(${n});`);
3567
+ return `/* utilities.css \u2014 vendored from Jig v${version2}.
3568
+ Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill.
3569
+
3570
+ Exposes the token layer to Tailwind v4 as utility classes: \`p-card\`,
3571
+ \`rounded-surface\`, \`text-text-strong\`. ONE per project, never one per mode \u2014
3572
+ the utility references the variable rather than a resolved value, so whichever
3573
+ mode barrel a route loads supplies it. One set of utilities serves every mode.
3574
+
3575
+ Import this once, in the stylesheet that imports Tailwind:
3576
+
3577
+ @import "tailwindcss";
3578
+ @import "./utilities.css";
3579
+
3580
+ YOU WILL SEE A DUPLICATE IN THE COMPILED CSS. That is correct:
3581
+
3582
+ @layer theme { :root,:host { --radius-surface: var(--radius-surface) } }
3583
+ :root { --radius-surface: var(--radius-md) }
3584
+
3585
+ The first is Tailwind's, inside \`@layer theme\`. The second is Jig's, unlayered.
3586
+ Unlayered declarations beat layered ones in the cascade regardless of source
3587
+ order, so Jig's value always wins. Do not "fix" it \u2014 deleting the alias
3588
+ removes the utility, deleting Jig's removes the value.
3589
+
3590
+ Only tokens in a Tailwind namespace are listed. \`--size-*\`, \`--measure-*\`,
3591
+ \`--focus-ring-*\` and \`--border-width-*\` have none, so they stay \`var()\`-only.
3592
+
3593
+ Regenerate with \`jig init\` after the token layer changes; a token missing here
3594
+ is a class that renders and matches nothing. */
3595
+
3596
+ @theme inline {
3597
+ ${lines.join("\n")}
3598
+ }
3599
+ `;
3600
+ }
3601
+
3368
3602
  // src/commands/init.ts
3369
3603
  var MODES = ["editorial", "product", "operator"];
3370
3604
  var DEFAULT_SURFACES = [{ match: "/", mode: "product" }];
@@ -3549,7 +3783,7 @@ async function init(opts) {
3549
3783
  const prompt = opts.prompt ?? defaultPrompt;
3550
3784
  if (!opts.yes && !opts.prompt && !process.stdin.isTTY) {
3551
3785
  throw new Error(
3552
- "'jig init' asks questions and stdin is not a terminal, so it cannot. Re-run with --yes to accept the derived defaults, or run it in a terminal. (To choose the mode without a terminal, write jig.config.json first \u2014 init honours it.)"
3786
+ "'jig init' asks questions and stdin is not a terminal, so it cannot. Re-run with --yes to accept the derived defaults, or run it in a terminal. A jig.config.json does not replace --yes, because this check runs before it is read. With both, init takes the mode from the config instead of deriving it."
3553
3787
  );
3554
3788
  }
3555
3789
  const migrateLegacy = async (report, describe) => {
@@ -3780,6 +4014,56 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
3780
4014
  writeFileSync4(abs, content, "utf8");
3781
4015
  files[rel] = checksum(content);
3782
4016
  }
4017
+ if (detection.cssSystem === "tailwind-v4") {
4018
+ const declared = declaredTokenNames(
4019
+ [brandAbsPath, modeAbsPath].map((f) => {
4020
+ try {
4021
+ return readFileSync18(f, "utf8");
4022
+ } catch {
4023
+ return "";
4024
+ }
4025
+ }).join("\n")
4026
+ );
4027
+ const aliasable = tailwindNamespaced(declared);
4028
+ const rel = relKey(...tokensRelDir, "utilities.css");
4029
+ if (aliasable.length === 0) {
4030
+ log(`
4031
+ Tailwind v4 detected, but no token here matches a Tailwind namespace \u2014 skipping ${rel}.`);
4032
+ } else if (opts.yes) {
4033
+ log(`
4034
+ Tailwind v4 detected. Utility classes (\`p-card\`, \`rounded-surface\`) are`);
4035
+ log(` available but not set up: re-run without --yes to be offered ${rel},`);
4036
+ log(` or see "Optional: Tailwind utility classes" in 02-tokens.md.`);
4037
+ } else {
4038
+ log(`
4039
+ Tailwind v4 detected. Jig works as-is \u2014 every token reads as`);
4040
+ log(` var(--color-text-strong) from any component, and nothing further is needed.`);
4041
+ log(` Optionally, ${aliasable.length} tokens can also become utility classes`);
4042
+ log(` (\`p-card\`, \`rounded-surface\`, \`text-text-strong\`) via ${rel}.`);
4043
+ log(` This changes how components are written. Both styles are correct.`);
4044
+ const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
4045
+ if (answer === "y" || answer === "yes") {
4046
+ const abs = join23(opts.projectRoot, ...tokensRelDir, "utilities.css");
4047
+ const state = fileState(opts.projectRoot, abs, rel, initManifest);
4048
+ if (state.existsOnDisk && (!state.tracked || state.modified)) {
4049
+ log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
4050
+ } else {
4051
+ const content = utilitiesBody(aliasable, opts.version);
4052
+ mkdirSync5(dirname6(abs), { recursive: true });
4053
+ writeFileSync4(abs, content, "utf8");
4054
+ files[rel] = checksum(content);
4055
+ const target = findWireTarget(detection);
4056
+ const importPath = target ? relativeImportPath(
4057
+ dirname6(join23(opts.projectRoot, target)),
4058
+ join23(opts.projectRoot, ...tokensRelDir, "utilities.css")
4059
+ ) : `./${rel}`;
4060
+ log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
4061
+ log(` @import "tailwindcss";`);
4062
+ log(` @import "${importPath}";`);
4063
+ }
4064
+ }
4065
+ }
4066
+ }
3783
4067
  if (declaredModes.length > 1) {
3784
4068
  log("\nOne barrel per surface. Import each at that route's entry point:");
3785
4069
  for (const surface of effectiveConfig.surfaces) {
@@ -3836,10 +4120,11 @@ Could not write to ${wireTarget}: ${err.message}`);
3836
4120
  log(` ${snippet.split("\n").join("\n ")}`);
3837
4121
  }
3838
4122
  } else {
3839
- const brandImport = relativeImportPath(opts.projectRoot, wiringBrandAbsPath);
3840
- const modeImport = relativeImportPath(opts.projectRoot, modeAbsPath);
3841
- const snippet = `@import "${brandImport}";
3842
- @import "${modeImport}";`;
4123
+ const barrelImport = relativeImportPath(
4124
+ opts.projectRoot,
4125
+ join23(opts.projectRoot, ...tokensRelDir, "theme.css")
4126
+ );
4127
+ const snippet = `@import "${barrelImport}";`;
3843
4128
  wiring = { target: null, status: "print-only", snippet };
3844
4129
  log("\nCould not find a single unambiguous stylesheet to wire the import into.");
3845
4130
  const pasteTargets = detection.cssFiles.filter((f) => !isCssModule(f));
@@ -3855,7 +4140,7 @@ Could not write to ${wireTarget}: ${err.message}`);
3855
4140
  }
3856
4141
  } else {
3857
4142
  log(
3858
- "Add this near the top of your global stylesheet. A CSS @import resolves relative to the file it sits in, so these project-root paths need a ../ per directory of depth:"
4143
+ "Add this near the top of your global stylesheet. A CSS @import resolves relative to the file it sits in, so this project-root path needs a ../ per directory of depth:"
3859
4144
  );
3860
4145
  log(` ${snippet.split("\n").join("\n ")}`);
3861
4146
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jig-ui",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
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",
@@ -16,7 +16,8 @@
16
16
  "rules.index.json",
17
17
  "LICENSE",
18
18
  "NOTICE",
19
- "README.md"
19
+ "README.md",
20
+ "CHANGELOG.md"
20
21
  ],
21
22
  "engines": {
22
23
  "node": ">=20"