jig-ui 0.7.1 → 0.8.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/CHANGELOG.md +762 -0
- package/README.md +107 -23
- package/dist/index.js +298 -14
- package/package.json +3 -2
- package/rules/00-anti-patterns.md +24 -14
- package/rules/02-tokens.md +78 -3
- package/rules.index.json +12 -9
- package/templates/SKILL.md.tmpl +24 -1
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
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
|
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.**
|
|
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
|
-
|
|
|
302
|
-
|
|
|
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
|
|
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 "
|
|
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)`
|
|
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();
|
|
@@ -1140,8 +1151,16 @@ function renderRule(rule) {
|
|
|
1140
1151
|
return [
|
|
1141
1152
|
`${rule.id} ${rule.title}`,
|
|
1142
1153
|
"",
|
|
1154
|
+
// Source order: what the rule leads with comes first. C-49's table sets up
|
|
1155
|
+
// the distinction its pair then relies on, and reading them the other way
|
|
1156
|
+
// round is reading the answer before the question.
|
|
1157
|
+
...rule.preamble.length > 0 ? [...rule.preamble, ""] : [],
|
|
1143
1158
|
`\u274C ${rule.wrong}`,
|
|
1144
1159
|
`\u2705 ${rule.correction}`,
|
|
1160
|
+
// The reasoning, where the rule carries any. This was dropped at the parser
|
|
1161
|
+
// for three releases, so `explain` showed the pair and none of the argument
|
|
1162
|
+
// for it — which is the half that tells you when the rule does not apply.
|
|
1163
|
+
...rule.notes.length > 0 ? ["", ...rule.notes] : [],
|
|
1145
1164
|
"",
|
|
1146
1165
|
` ${rule.bucket} \xB7 ${rule.severity}${rule.detector ? ` \xB7 detector: ${rule.detector}` : ""}`,
|
|
1147
1166
|
` since ${rule.since} \xB7 ${rule.source}`
|
|
@@ -1410,7 +1429,9 @@ function formatReport(findings, meta) {
|
|
|
1410
1429
|
}
|
|
1411
1430
|
lines.push("");
|
|
1412
1431
|
} else {
|
|
1413
|
-
lines.push(
|
|
1432
|
+
lines.push(
|
|
1433
|
+
meta.withStyles === 0 && meta.scanned !== void 0 ? " Nothing inspected." : " No findings."
|
|
1434
|
+
);
|
|
1414
1435
|
lines.push("");
|
|
1415
1436
|
if (meta.noTokenLayer) {
|
|
1416
1437
|
lines.push(" No file references a Jig token, so H-47 (hard-coded values) was not run.");
|
|
@@ -1426,7 +1447,14 @@ function formatReport(findings, meta) {
|
|
|
1426
1447
|
if (notes > 0) summaryParts.push(plural(notes, "note"));
|
|
1427
1448
|
const rulesFired = new Set(findings.map((f) => f.ruleId)).size;
|
|
1428
1449
|
const scope = meta.totalSpecs ? `${meta.totalRules} rules (+ ${meta.totalSpecs} pattern and mode specs)` : `${meta.totalRules} rules`;
|
|
1429
|
-
|
|
1450
|
+
const examined = meta.scanned === void 0 ? "" : ` \xB7 ${plural(meta.scanned, "file")}, ${meta.withStyles ?? 0} with styles`;
|
|
1451
|
+
lines.push(` ${summaryParts.join(", ")} \xB7 ${scope}${examined}, ${rulesFired} fired`);
|
|
1452
|
+
if (meta.scanned !== void 0 && meta.withStyles === 0) {
|
|
1453
|
+
lines.push("");
|
|
1454
|
+
lines.push(
|
|
1455
|
+
` 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.`
|
|
1456
|
+
);
|
|
1457
|
+
}
|
|
1430
1458
|
if (meta.exemptPatterns && meta.exemptPatterns.length > 0) {
|
|
1431
1459
|
const n = meta.exempt?.length ?? 0;
|
|
1432
1460
|
lines.push(` ${n} file(s) exempt via jig.config.json and not scanned:`);
|
|
@@ -1448,7 +1476,7 @@ function formatReport(findings, meta) {
|
|
|
1448
1476
|
const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
1449
1477
|
const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
|
|
1450
1478
|
lines.push(
|
|
1451
|
-
` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run`
|
|
1479
|
+
` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run files=${meta.scanned ?? "unknown"} styled=${meta.withStyles ?? "unknown"}`
|
|
1452
1480
|
);
|
|
1453
1481
|
return lines.join("\n");
|
|
1454
1482
|
}
|
|
@@ -1622,7 +1650,7 @@ function splitRuleBlocks(source) {
|
|
|
1622
1650
|
}
|
|
1623
1651
|
function blankNested(body) {
|
|
1624
1652
|
const out = body.split("");
|
|
1625
|
-
const
|
|
1653
|
+
const blank3 = (from, to) => {
|
|
1626
1654
|
for (let j = from; j < to; j++) if (out[j] !== "\n") out[j] = " ";
|
|
1627
1655
|
};
|
|
1628
1656
|
let depth = 0;
|
|
@@ -1630,7 +1658,7 @@ function blankNested(body) {
|
|
|
1630
1658
|
for (let i = 0; i < body.length; i++) {
|
|
1631
1659
|
const ch = body[i];
|
|
1632
1660
|
if (ch === "{") {
|
|
1633
|
-
if (depth === 0)
|
|
1661
|
+
if (depth === 0) blank3(segmentStart, i);
|
|
1634
1662
|
depth++;
|
|
1635
1663
|
out[i] = " ";
|
|
1636
1664
|
} else if (ch === "}") {
|
|
@@ -1940,6 +1968,126 @@ var gradientText = {
|
|
|
1940
1968
|
}
|
|
1941
1969
|
};
|
|
1942
1970
|
|
|
1971
|
+
// src/check/detectors/text-scan.ts
|
|
1972
|
+
var blank2 = (m) => m.replace(/[^\n]/g, " ");
|
|
1973
|
+
function maskProseComments(raw) {
|
|
1974
|
+
return raw.replace(/\/\*[\s\S]*?\*\//g, blank2).replace(/<!--[\s\S]*?-->/g, blank2).replace(/(^|[^:@\w])\/\/[^\n]*/g, (m, lead) => lead + blank2(m.slice(lead.length)));
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// src/check/detectors/emoji-icon.ts
|
|
1978
|
+
var EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F2FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
|
|
1979
|
+
var TEXTUAL = /* @__PURE__ */ new Set(["\u2192", "\u2190", "\u2191", "\u2193", "\u2194", "\u21D2", "\u21D0", "\u2122", "\u2713", "\u2717", "\u2212", "\u2217", "\u2318", "\u2325", "\u23CE"]);
|
|
1980
|
+
var emojiIcon = {
|
|
1981
|
+
name: "emoji-icon",
|
|
1982
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
1983
|
+
run(_source, file, ctx) {
|
|
1984
|
+
const findings = [];
|
|
1985
|
+
const masked = maskProseComments(ctx.raw);
|
|
1986
|
+
const lines = masked.split("\n");
|
|
1987
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1988
|
+
for (const m of masked.matchAll(EMOJI_RE)) {
|
|
1989
|
+
if (TEXTUAL.has(m[0])) continue;
|
|
1990
|
+
const line = masked.slice(0, m.index).split("\n").length;
|
|
1991
|
+
if (seen.has(line)) continue;
|
|
1992
|
+
seen.add(line);
|
|
1993
|
+
findings.push(
|
|
1994
|
+
mkFinding(
|
|
1995
|
+
ctx,
|
|
1996
|
+
"emoji-icon",
|
|
1997
|
+
file,
|
|
1998
|
+
line,
|
|
1999
|
+
`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.`,
|
|
2000
|
+
lines[line - 1] ?? ""
|
|
2001
|
+
)
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
return findings;
|
|
2005
|
+
}
|
|
2006
|
+
};
|
|
2007
|
+
|
|
2008
|
+
// src/check/detectors/marketing-voice.ts
|
|
2009
|
+
var PHRASES = [
|
|
2010
|
+
"supercharge",
|
|
2011
|
+
"unleash",
|
|
2012
|
+
"revolutioni",
|
|
2013
|
+
"game-chang",
|
|
2014
|
+
"effortlessly",
|
|
2015
|
+
"seamlessly",
|
|
2016
|
+
"blazing fast",
|
|
2017
|
+
"take it to the next level",
|
|
2018
|
+
"unlock the power",
|
|
2019
|
+
"elevate your",
|
|
2020
|
+
"transform your workflow",
|
|
2021
|
+
"delight your users"
|
|
2022
|
+
];
|
|
2023
|
+
var RE = new RegExp(`\\b(${PHRASES.join("|")})`, "gi");
|
|
2024
|
+
var APPLIES_IN = /* @__PURE__ */ new Set(["product", "operator"]);
|
|
2025
|
+
var marketingVoice = {
|
|
2026
|
+
name: "marketing-voice",
|
|
2027
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
2028
|
+
run(_source, file, ctx) {
|
|
2029
|
+
if (!ctx.mode || !APPLIES_IN.has(ctx.mode)) return [];
|
|
2030
|
+
const findings = [];
|
|
2031
|
+
const masked = maskProseComments(ctx.raw);
|
|
2032
|
+
const lines = masked.split("\n");
|
|
2033
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2034
|
+
for (const m of masked.matchAll(RE)) {
|
|
2035
|
+
const line = masked.slice(0, m.index).split("\n").length;
|
|
2036
|
+
if (seen.has(line)) continue;
|
|
2037
|
+
seen.add(line);
|
|
2038
|
+
findings.push(
|
|
2039
|
+
mkFinding(
|
|
2040
|
+
ctx,
|
|
2041
|
+
"marketing-voice",
|
|
2042
|
+
file,
|
|
2043
|
+
line,
|
|
2044
|
+
`Marketing voice in a \`${ctx.mode}\` surface ("${m[0]}"). Someone using this every day wants to know what it does, not how it feels.`,
|
|
2045
|
+
lines[line - 1] ?? ""
|
|
2046
|
+
)
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
return findings;
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
|
|
2053
|
+
// src/check/detectors/placeholder-content.ts
|
|
2054
|
+
var PATTERNS = [
|
|
2055
|
+
{ re: /\blorem\s+ipsum\b/gi, what: "lorem ipsum" },
|
|
2056
|
+
{ re: /https?:\/\/(?:www\.)?example\.(?:com|org|net)\b/gi, what: "an example.com URL" },
|
|
2057
|
+
{ re: /\b(?:someone|user|test|foo|bar)@example\.(?:com|org|net)\b/gi, what: "a placeholder email" },
|
|
2058
|
+
{ re: /\bAcme\s+(?:Inc\.?|Corp\.?|Co\.?|Ltd\.?|LLC|Company)\b/gi, what: "a placeholder company" },
|
|
2059
|
+
{ re: /\byour\s+(?:company|brand|product)\s+name\b/gi, what: "a placeholder name" }
|
|
2060
|
+
];
|
|
2061
|
+
var placeholderContent = {
|
|
2062
|
+
name: "placeholder-content",
|
|
2063
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
2064
|
+
run(_source, file, ctx) {
|
|
2065
|
+
const findings = [];
|
|
2066
|
+
const masked = maskProseComments(ctx.raw);
|
|
2067
|
+
const lines = masked.split("\n");
|
|
2068
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2069
|
+
for (const { re, what } of PATTERNS) {
|
|
2070
|
+
re.lastIndex = 0;
|
|
2071
|
+
for (const m of masked.matchAll(re)) {
|
|
2072
|
+
const line = masked.slice(0, m.index).split("\n").length;
|
|
2073
|
+
if (seen.has(line)) continue;
|
|
2074
|
+
seen.add(line);
|
|
2075
|
+
findings.push(
|
|
2076
|
+
mkFinding(
|
|
2077
|
+
ctx,
|
|
2078
|
+
"placeholder-content",
|
|
2079
|
+
file,
|
|
2080
|
+
line,
|
|
2081
|
+
`Placeholder content shipped \u2014 ${what} ("${m[0]}"). Real content changes layout: names are longer, copy wraps, images are the wrong ratio.`,
|
|
2082
|
+
lines[line - 1] ?? ""
|
|
2083
|
+
)
|
|
2084
|
+
);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
return findings;
|
|
2088
|
+
}
|
|
2089
|
+
};
|
|
2090
|
+
|
|
1943
2091
|
// src/check/color.ts
|
|
1944
2092
|
var NAMED = {
|
|
1945
2093
|
white: { r: 255, g: 255, b: 255 },
|
|
@@ -2596,6 +2744,9 @@ var violetBandHue = {
|
|
|
2596
2744
|
// src/check/registry.ts
|
|
2597
2745
|
var DETECTORS = [
|
|
2598
2746
|
gradientText,
|
|
2747
|
+
emojiIcon,
|
|
2748
|
+
marketingVoice,
|
|
2749
|
+
placeholderContent,
|
|
2599
2750
|
backdropBlur,
|
|
2600
2751
|
pureBlackWhite,
|
|
2601
2752
|
contrastFloor,
|
|
@@ -2608,7 +2759,7 @@ function getDetector(name) {
|
|
|
2608
2759
|
}
|
|
2609
2760
|
|
|
2610
2761
|
// src/check/run.ts
|
|
2611
|
-
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false) {
|
|
2762
|
+
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode) {
|
|
2612
2763
|
const findings = [];
|
|
2613
2764
|
const active = index.filter(
|
|
2614
2765
|
(e) => !!e.detector && !!getDetector(e.detector)
|
|
@@ -2626,7 +2777,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
2626
2777
|
}
|
|
2627
2778
|
const source = maskComments(maskNonStyleRegions(raw, file));
|
|
2628
2779
|
for (const { entry, detector } of applicable) {
|
|
2629
|
-
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw };
|
|
2780
|
+
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode };
|
|
2630
2781
|
findings.push(...detector.run(source, file, ctx));
|
|
2631
2782
|
}
|
|
2632
2783
|
}
|
|
@@ -2927,7 +3078,16 @@ function check(opts) {
|
|
|
2927
3078
|
return false;
|
|
2928
3079
|
}
|
|
2929
3080
|
});
|
|
2930
|
-
const
|
|
3081
|
+
const withStyles = files.filter((f) => {
|
|
3082
|
+
try {
|
|
3083
|
+
const src = readFileSync14(join19(opts.projectRoot, f), "utf8");
|
|
3084
|
+
return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
|
|
3085
|
+
} catch {
|
|
3086
|
+
return false;
|
|
3087
|
+
}
|
|
3088
|
+
}).length;
|
|
3089
|
+
const resolvedMode = resolveMode(opts.projectRoot);
|
|
3090
|
+
const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode);
|
|
2931
3091
|
const TOKEN_RULE = {
|
|
2932
3092
|
"--text-prose": "B-75",
|
|
2933
3093
|
"--size-touch-target": "E-51"
|
|
@@ -2966,8 +3126,10 @@ function check(opts) {
|
|
|
2966
3126
|
totalSpecs: countSpecs(),
|
|
2967
3127
|
version: opts.version,
|
|
2968
3128
|
noTokenLayer,
|
|
2969
|
-
mode:
|
|
3129
|
+
mode: resolvedMode,
|
|
2970
3130
|
unscanned: summariseUnscanned(files),
|
|
3131
|
+
scanned: files.length,
|
|
3132
|
+
withStyles,
|
|
2971
3133
|
exempt,
|
|
2972
3134
|
exemptPatterns: byPattern
|
|
2973
3135
|
});
|
|
@@ -3365,6 +3527,77 @@ function deriveProjectSlug(projectRoot) {
|
|
|
3365
3527
|
return slugify(basename(projectRoot));
|
|
3366
3528
|
}
|
|
3367
3529
|
|
|
3530
|
+
// src/init/utilities.ts
|
|
3531
|
+
var TAILWIND_NAMESPACES = [
|
|
3532
|
+
"--color-",
|
|
3533
|
+
"--font-",
|
|
3534
|
+
"--text-",
|
|
3535
|
+
"--font-weight-",
|
|
3536
|
+
"--tracking-",
|
|
3537
|
+
"--leading-",
|
|
3538
|
+
"--breakpoint-",
|
|
3539
|
+
"--container-",
|
|
3540
|
+
"--spacing-",
|
|
3541
|
+
"--radius-",
|
|
3542
|
+
"--shadow-",
|
|
3543
|
+
"--inset-shadow-",
|
|
3544
|
+
"--drop-shadow-",
|
|
3545
|
+
"--blur-",
|
|
3546
|
+
"--perspective-",
|
|
3547
|
+
"--aspect-",
|
|
3548
|
+
"--ease-",
|
|
3549
|
+
"--animate-"
|
|
3550
|
+
];
|
|
3551
|
+
function tailwindNamespaced(names) {
|
|
3552
|
+
const kept = names.filter((n) => TAILWIND_NAMESPACES.some((ns) => n.startsWith(ns)));
|
|
3553
|
+
return [...new Set(kept)].sort();
|
|
3554
|
+
}
|
|
3555
|
+
function declaredTokenNames(css) {
|
|
3556
|
+
return [...css.matchAll(/(^|[;{\s])(--[\w-]+)\s*:/g)].map((m) => m[2]);
|
|
3557
|
+
}
|
|
3558
|
+
function utilitiesBody(names, version2) {
|
|
3559
|
+
if (names.length === 0) {
|
|
3560
|
+
throw new Error(
|
|
3561
|
+
"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."
|
|
3562
|
+
);
|
|
3563
|
+
}
|
|
3564
|
+
const width = Math.max(...names.map((n) => n.length));
|
|
3565
|
+
const lines = names.map((n) => ` ${n}:${" ".repeat(width - n.length)} var(${n});`);
|
|
3566
|
+
return `/* utilities.css \u2014 vendored from Jig v${version2}.
|
|
3567
|
+
Licensed Apache-2.0. LICENSE and NOTICE ship beside the jig skill.
|
|
3568
|
+
|
|
3569
|
+
Exposes the token layer to Tailwind v4 as utility classes: \`p-card\`,
|
|
3570
|
+
\`rounded-surface\`, \`text-text-strong\`. ONE per project, never one per mode \u2014
|
|
3571
|
+
the utility references the variable rather than a resolved value, so whichever
|
|
3572
|
+
mode barrel a route loads supplies it. One set of utilities serves every mode.
|
|
3573
|
+
|
|
3574
|
+
Import this once, in the stylesheet that imports Tailwind:
|
|
3575
|
+
|
|
3576
|
+
@import "tailwindcss";
|
|
3577
|
+
@import "./utilities.css";
|
|
3578
|
+
|
|
3579
|
+
YOU WILL SEE A DUPLICATE IN THE COMPILED CSS. That is correct:
|
|
3580
|
+
|
|
3581
|
+
@layer theme { :root,:host { --radius-surface: var(--radius-surface) } }
|
|
3582
|
+
:root { --radius-surface: var(--radius-md) }
|
|
3583
|
+
|
|
3584
|
+
The first is Tailwind's, inside \`@layer theme\`. The second is Jig's, unlayered.
|
|
3585
|
+
Unlayered declarations beat layered ones in the cascade regardless of source
|
|
3586
|
+
order, so Jig's value always wins. Do not "fix" it \u2014 deleting the alias
|
|
3587
|
+
removes the utility, deleting Jig's removes the value.
|
|
3588
|
+
|
|
3589
|
+
Only tokens in a Tailwind namespace are listed. \`--size-*\`, \`--measure-*\`,
|
|
3590
|
+
\`--focus-ring-*\` and \`--border-width-*\` have none, so they stay \`var()\`-only.
|
|
3591
|
+
|
|
3592
|
+
Regenerate with \`jig init\` after the token layer changes; a token missing here
|
|
3593
|
+
is a class that renders and matches nothing. */
|
|
3594
|
+
|
|
3595
|
+
@theme inline {
|
|
3596
|
+
${lines.join("\n")}
|
|
3597
|
+
}
|
|
3598
|
+
`;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3368
3601
|
// src/commands/init.ts
|
|
3369
3602
|
var MODES = ["editorial", "product", "operator"];
|
|
3370
3603
|
var DEFAULT_SURFACES = [{ match: "/", mode: "product" }];
|
|
@@ -3780,6 +4013,56 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
3780
4013
|
writeFileSync4(abs, content, "utf8");
|
|
3781
4014
|
files[rel] = checksum(content);
|
|
3782
4015
|
}
|
|
4016
|
+
if (detection.cssSystem === "tailwind-v4") {
|
|
4017
|
+
const declared = declaredTokenNames(
|
|
4018
|
+
[brandAbsPath, modeAbsPath].map((f) => {
|
|
4019
|
+
try {
|
|
4020
|
+
return readFileSync18(f, "utf8");
|
|
4021
|
+
} catch {
|
|
4022
|
+
return "";
|
|
4023
|
+
}
|
|
4024
|
+
}).join("\n")
|
|
4025
|
+
);
|
|
4026
|
+
const aliasable = tailwindNamespaced(declared);
|
|
4027
|
+
const rel = relKey(...tokensRelDir, "utilities.css");
|
|
4028
|
+
if (aliasable.length === 0) {
|
|
4029
|
+
log(`
|
|
4030
|
+
Tailwind v4 detected, but no token here matches a Tailwind namespace \u2014 skipping ${rel}.`);
|
|
4031
|
+
} else if (opts.yes) {
|
|
4032
|
+
log(`
|
|
4033
|
+
Tailwind v4 detected. Utility classes (\`p-card\`, \`rounded-surface\`) are`);
|
|
4034
|
+
log(` available but not set up: re-run without --yes to be offered ${rel},`);
|
|
4035
|
+
log(` or see "Optional: Tailwind utility classes" in 02-tokens.md.`);
|
|
4036
|
+
} else {
|
|
4037
|
+
log(`
|
|
4038
|
+
Tailwind v4 detected. Jig works as-is \u2014 every token reads as`);
|
|
4039
|
+
log(` var(--color-text-strong) from any component, and nothing further is needed.`);
|
|
4040
|
+
log(` Optionally, ${aliasable.length} tokens can also become utility classes`);
|
|
4041
|
+
log(` (\`p-card\`, \`rounded-surface\`, \`text-text-strong\`) via ${rel}.`);
|
|
4042
|
+
log(` This changes how components are written. Both styles are correct.`);
|
|
4043
|
+
const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
|
|
4044
|
+
if (answer === "y" || answer === "yes") {
|
|
4045
|
+
const abs = join23(opts.projectRoot, ...tokensRelDir, "utilities.css");
|
|
4046
|
+
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
4047
|
+
if (state.existsOnDisk && (!state.tracked || state.modified)) {
|
|
4048
|
+
log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
|
|
4049
|
+
} else {
|
|
4050
|
+
const content = utilitiesBody(aliasable, opts.version);
|
|
4051
|
+
mkdirSync5(dirname6(abs), { recursive: true });
|
|
4052
|
+
writeFileSync4(abs, content, "utf8");
|
|
4053
|
+
files[rel] = checksum(content);
|
|
4054
|
+
const target = findWireTarget(detection);
|
|
4055
|
+
const importPath = target ? relativeImportPath(
|
|
4056
|
+
dirname6(join23(opts.projectRoot, target)),
|
|
4057
|
+
join23(opts.projectRoot, ...tokensRelDir, "utilities.css")
|
|
4058
|
+
) : `./${rel}`;
|
|
4059
|
+
log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
|
|
4060
|
+
log(` @import "tailwindcss";`);
|
|
4061
|
+
log(` @import "${importPath}";`);
|
|
4062
|
+
}
|
|
4063
|
+
}
|
|
4064
|
+
}
|
|
4065
|
+
}
|
|
3783
4066
|
if (declaredModes.length > 1) {
|
|
3784
4067
|
log("\nOne barrel per surface. Import each at that route's entry point:");
|
|
3785
4068
|
for (const surface of effectiveConfig.surfaces) {
|
|
@@ -3836,10 +4119,11 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
3836
4119
|
log(` ${snippet.split("\n").join("\n ")}`);
|
|
3837
4120
|
}
|
|
3838
4121
|
} else {
|
|
3839
|
-
const
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
4122
|
+
const barrelImport = relativeImportPath(
|
|
4123
|
+
opts.projectRoot,
|
|
4124
|
+
join23(opts.projectRoot, ...tokensRelDir, "theme.css")
|
|
4125
|
+
);
|
|
4126
|
+
const snippet = `@import "${barrelImport}";`;
|
|
3843
4127
|
wiring = { target: null, status: "print-only", snippet };
|
|
3844
4128
|
log("\nCould not find a single unambiguous stylesheet to wire the import into.");
|
|
3845
4129
|
const pasteTargets = detection.cssFiles.filter((f) => !isCssModule(f));
|
|
@@ -3855,7 +4139,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
3855
4139
|
}
|
|
3856
4140
|
} else {
|
|
3857
4141
|
log(
|
|
3858
|
-
"Add this near the top of your global stylesheet. A CSS @import resolves relative to the file it sits in, so
|
|
4142
|
+
"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
4143
|
);
|
|
3860
4144
|
log(` ${snippet.split("\n").join("\n ")}`);
|
|
3861
4145
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -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"
|