jig-ui 0.9.0 → 0.11.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 +95 -0
- package/README.md +55 -12
- package/dist/index.js +1463 -159
- package/layers.json +100 -0
- package/package.json +3 -2
- package/rules/00-anti-patterns.md +80 -2
- package/rules/01-modes.md +6 -6
- package/rules/02-tokens.md +13 -10
- package/rules/03-patterns.md +44 -4
- package/rules/04-principles.md +12 -12
- package/rules/05-copy.md +1 -1
- package/rules.index.json +246 -95
- package/templates/COMMAND.md.tmpl +1060 -17
- package/templates/SKILL.md.tmpl +52 -13
- package/templates/command-metadata.json +43 -3
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 readFileSync28 } from "fs";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join34 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -42,11 +42,11 @@ function isPublishedBuild(packageRoot2) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// src/commands/install.ts
|
|
45
|
-
import { existsSync as existsSync5, readFileSync as readFileSync4 } from "fs";
|
|
45
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
|
|
46
46
|
import { join as join7 } from "path";
|
|
47
47
|
|
|
48
48
|
// src/adapters/types.ts
|
|
49
|
-
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up,
|
|
49
|
+
var COMMAND_DESCRIPTION = "Run a Jig design-system command: set the project up, plan and build one feature at a time (decide, spec, mockup, make, critique), check 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
52
|
const isAbsolute2 = relPath.startsWith("/") || /^[A-Za-z]:/.test(relPath);
|
|
@@ -390,7 +390,7 @@ function escapeTableCell(value) {
|
|
|
390
390
|
function renderCommandTable(metadata) {
|
|
391
391
|
const rows = Object.entries(metadata).map(([name, meta]) => {
|
|
392
392
|
const signature = meta.argumentHint ? `${name} ${meta.argumentHint}` : name;
|
|
393
|
-
const status = meta.status === "planned" ? "planned \u2014 not yet implemented" : "available";
|
|
393
|
+
const status = meta.status === "planned" ? "planned \u2014 not yet implemented" : meta.status === "agent" ? "agent procedure \u2014 no CLI to run" : "available";
|
|
394
394
|
return `| \`${escapeTableCell(signature)}\` | ${escapeTableCell(meta.description)} | ${status} |`;
|
|
395
395
|
});
|
|
396
396
|
return ["| Command | Description | Status |", "| --- | --- | --- |", ...rows].join("\n");
|
|
@@ -542,11 +542,17 @@ function buildCommandBody(packageRoot2, rulesPath, version2, argsPlaceholder) {
|
|
|
542
542
|
const metadata = JSON.parse(
|
|
543
543
|
readFileSync4(join7(packageRoot2, "templates", "command-metadata.json"), "utf8")
|
|
544
544
|
);
|
|
545
|
-
const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available").map(([name]) => name).sort();
|
|
545
|
+
const subcommands = Object.entries(metadata).filter(([, meta]) => meta.status === "available" || meta.status === "agent").map(([name]) => name).sort();
|
|
546
546
|
const templatePath = join7(packageRoot2, "templates", "COMMAND.md.tmpl");
|
|
547
547
|
if (!existsSync5(templatePath)) return { body: "", subcommands };
|
|
548
548
|
const template = readFileSync4(templatePath, "utf8");
|
|
549
|
-
const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath);
|
|
549
|
+
const body = template.replace(/\{\{command_prefix\}\}/g, "/jig ").replace(/\{\{args_placeholder\}\}/g, argsPlaceholder).replace(/\{\{subcommand_list\}\}/g, subcommands.join(", ")).replace(/\{\{scripts_path\}\}/g, `npx jig-ui@${version2}`).replace(/\{\{update_path\}\}/g, "npx jig-ui@latest").replace(/\{\{rules_path\}\}/g, rulesPath).replace(/\{\{config_file\}\}/g, "jig.config.json");
|
|
550
|
+
const unresolved = [...new Set(body.match(/\{\{[a-z_]+\}\}/g) ?? [])];
|
|
551
|
+
if (unresolved.length > 0) {
|
|
552
|
+
throw new Error(
|
|
553
|
+
`COMMAND.md.tmpl has placeholders nothing substitutes: ${unresolved.join(", ")}. Add them to the replace chain in buildCommandBody, or remove them from the template.`
|
|
554
|
+
);
|
|
555
|
+
}
|
|
550
556
|
return { body, subcommands };
|
|
551
557
|
}
|
|
552
558
|
function rulesPathFor(referenceDir, scope) {
|
|
@@ -653,7 +659,36 @@ function install(opts) {
|
|
|
653
659
|
};
|
|
654
660
|
writeManifest(installRoot, manifest, referenceDir);
|
|
655
661
|
written.push(relKey(referenceDir, "manifest.json"));
|
|
656
|
-
|
|
662
|
+
const stopHook = opts.hook && opts.agent === "claude" && opts.scope === "project" ? installStopHook(installRoot, opts.version) : void 0;
|
|
663
|
+
return { written, skipped, stopHook };
|
|
664
|
+
}
|
|
665
|
+
function hasStopHook(projectRoot) {
|
|
666
|
+
try {
|
|
667
|
+
const settings = readFileSync4(join7(projectRoot, ".claude", "settings.json"), "utf8");
|
|
668
|
+
return /\bjig-ui@[^\s"]+ gate\b/.test(settings);
|
|
669
|
+
} catch {
|
|
670
|
+
return false;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
function installStopHook(projectRoot, version2) {
|
|
674
|
+
const path = join7(projectRoot, ".claude", "settings.json");
|
|
675
|
+
let settings = {};
|
|
676
|
+
if (existsSync5(path)) {
|
|
677
|
+
try {
|
|
678
|
+
settings = JSON.parse(readFileSync4(path, "utf8"));
|
|
679
|
+
} catch {
|
|
680
|
+
return false;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
const command = `npx --yes jig-ui@${version2} gate`;
|
|
684
|
+
const hooks = settings.hooks ?? {};
|
|
685
|
+
const isJig = (h) => /\bjig-ui@[^\s]+ gate\b/.test(h.command ?? "");
|
|
686
|
+
const stop = (hooks.Stop ?? []).map((group) => ({ ...group, hooks: group.hooks.filter((h) => !isJig(h)) })).filter((group) => group.hooks.length > 0);
|
|
687
|
+
stop.push({ hooks: [{ type: "command", command }] });
|
|
688
|
+
settings.hooks = { ...hooks, Stop: stop };
|
|
689
|
+
mkdirSync4(join7(projectRoot, ".claude"), { recursive: true });
|
|
690
|
+
writeFileSync3(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
691
|
+
return true;
|
|
657
692
|
}
|
|
658
693
|
|
|
659
694
|
// src/commands/update.ts
|
|
@@ -701,7 +736,7 @@ function resolveAllInstalled(projectRoot, homeDir) {
|
|
|
701
736
|
}
|
|
702
737
|
|
|
703
738
|
// src/init/state.ts
|
|
704
|
-
import { existsSync as existsSync6, mkdirSync as
|
|
739
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync5 } from "fs";
|
|
705
740
|
import { dirname as dirname5, join as join8 } from "path";
|
|
706
741
|
var INIT_MANIFEST_REL = join8(".jig", "state.json");
|
|
707
742
|
function readInitManifest(projectRoot) {
|
|
@@ -724,7 +759,7 @@ function readInitManifest(projectRoot) {
|
|
|
724
759
|
var MERGE_ATTEMPTS2 = 5;
|
|
725
760
|
function writeInitManifest(projectRoot, m) {
|
|
726
761
|
const path = join8(projectRoot, INIT_MANIFEST_REL);
|
|
727
|
-
|
|
762
|
+
mkdirSync5(dirname5(path), { recursive: true });
|
|
728
763
|
for (let attempt = 0; attempt < MERGE_ATTEMPTS2; attempt++) {
|
|
729
764
|
const onDisk = readInitManifest(projectRoot);
|
|
730
765
|
const merged = {
|
|
@@ -884,6 +919,9 @@ function update(opts) {
|
|
|
884
919
|
function updateTarget(opts, resolved) {
|
|
885
920
|
const { installRoot, scope: discoveredScope, manifest: existing, referenceDir } = resolved;
|
|
886
921
|
const adapter = getAdapter(existing.agent);
|
|
922
|
+
if (existing.agent === "claude" && discoveredScope === "project" && hasStopHook(installRoot)) {
|
|
923
|
+
installStopHook(installRoot, opts.version);
|
|
924
|
+
}
|
|
887
925
|
const updated = [];
|
|
888
926
|
const skipped = [];
|
|
889
927
|
const writer = createWriter(installRoot, existing.files);
|
|
@@ -985,7 +1023,7 @@ function updateInitFiles(opts) {
|
|
|
985
1023
|
}
|
|
986
1024
|
|
|
987
1025
|
// src/commands/explain.ts
|
|
988
|
-
import { join as
|
|
1026
|
+
import { join as join15 } from "path";
|
|
989
1027
|
|
|
990
1028
|
// src/rules/load.ts
|
|
991
1029
|
import { readFileSync as readFileSync8, readdirSync as readdirSync4 } from "fs";
|
|
@@ -1041,6 +1079,7 @@ function parseRules(markdown, sourceFile) {
|
|
|
1041
1079
|
|
|
1042
1080
|
// src/rules/schema.ts
|
|
1043
1081
|
var BUCKETS = ["mechanical", "judgment", "hybrid"];
|
|
1082
|
+
var PASSES = ["code", "screen"];
|
|
1044
1083
|
var SEVERITIES = ["error", "warning", "note"];
|
|
1045
1084
|
function validateIndex(entries) {
|
|
1046
1085
|
if (!Array.isArray(entries)) throw new Error("rules.index.json must be an array");
|
|
@@ -1059,13 +1098,22 @@ function validateIndex(entries) {
|
|
|
1059
1098
|
if (typeof e.since !== "string") {
|
|
1060
1099
|
throw new Error(`${at} (${e.id}): since is required`);
|
|
1061
1100
|
}
|
|
1101
|
+
if (e.bucket === "judgment" && !PASSES.includes(e.pass)) {
|
|
1102
|
+
throw new Error(
|
|
1103
|
+
`${at} (${e.id}): judgment rules need pass: ${PASSES.join(" or ")} \u2014 'code' if it can be decided by reading files, 'screen' if it needs the rendered page`
|
|
1104
|
+
);
|
|
1105
|
+
}
|
|
1106
|
+
if (e.pass !== void 0 && !PASSES.includes(e.pass)) {
|
|
1107
|
+
throw new Error(`${at} (${e.id}): pass must be one of ${PASSES.join(", ")}`);
|
|
1108
|
+
}
|
|
1062
1109
|
return {
|
|
1063
1110
|
id: e.id,
|
|
1064
1111
|
bucket: e.bucket,
|
|
1065
1112
|
severity: e.severity,
|
|
1066
1113
|
detector: typeof e.detector === "string" ? e.detector : void 0,
|
|
1067
1114
|
fix: typeof e.fix === "string" ? e.fix : void 0,
|
|
1068
|
-
since: e.since
|
|
1115
|
+
since: e.since,
|
|
1116
|
+
pass: e.pass
|
|
1069
1117
|
};
|
|
1070
1118
|
});
|
|
1071
1119
|
}
|
|
@@ -1101,7 +1149,26 @@ function loadRules(rulesDir, indexPath) {
|
|
|
1101
1149
|
// src/rules/specs.ts
|
|
1102
1150
|
import { readFileSync as readFileSync9, readdirSync as readdirSync5 } from "fs";
|
|
1103
1151
|
import { join as join12 } from "path";
|
|
1104
|
-
var
|
|
1152
|
+
var KINDS = {
|
|
1153
|
+
P: "pattern",
|
|
1154
|
+
M: "mode",
|
|
1155
|
+
L: "method",
|
|
1156
|
+
// `R` — principles. Both halves of `04-principles.md`: the five frames, which
|
|
1157
|
+
// are generative (how to recognise a failure no rule covers yet), and the
|
|
1158
|
+
// seven tiebreakers, which are adjudicative (which rule yields when two
|
|
1159
|
+
// conflict). The file itself was 0% addressable, so `04` could be cited by
|
|
1160
|
+
// nothing and loaded only as a whole — and the protocol says to load it only
|
|
1161
|
+
// when two rules conflict, which is exactly when a reader wants one entry and
|
|
1162
|
+
// not 141 lines.
|
|
1163
|
+
R: "principle",
|
|
1164
|
+
// `T` — token guidance. The contracts and conventions around the token layer:
|
|
1165
|
+
// what the names mean, what the contrast floor is, how dark mode resolves.
|
|
1166
|
+
// 02-tokens.md was 517 lines at 0% addressable, the largest single block in
|
|
1167
|
+
// the corpus, and the protocol sends readers there "for setup or when adding a
|
|
1168
|
+
// token" — both of which want one contract, not the file.
|
|
1169
|
+
T: "token"
|
|
1170
|
+
};
|
|
1171
|
+
var HEADING2 = /^##\s+([PMLRT]-\d+)\s*(?:·\s*)?(.*?)\s*$/;
|
|
1105
1172
|
function parseSpecs(markdown, sourceFile) {
|
|
1106
1173
|
const lines = markdown.split("\n");
|
|
1107
1174
|
const specs = [];
|
|
@@ -1117,7 +1184,13 @@ function parseSpecs(markdown, sourceFile) {
|
|
|
1117
1184
|
if (heading) {
|
|
1118
1185
|
push();
|
|
1119
1186
|
const [, id, title] = heading;
|
|
1120
|
-
current = {
|
|
1187
|
+
current = {
|
|
1188
|
+
id,
|
|
1189
|
+
title,
|
|
1190
|
+
kind: KINDS[id[0]],
|
|
1191
|
+
body: "",
|
|
1192
|
+
source: `${sourceFile}#${id.toLowerCase()}`
|
|
1193
|
+
};
|
|
1121
1194
|
continue;
|
|
1122
1195
|
}
|
|
1123
1196
|
if (current && /^##\s/.test(line)) {
|
|
@@ -1139,6 +1212,61 @@ function loadSpecs(rulesDir) {
|
|
|
1139
1212
|
return specs;
|
|
1140
1213
|
}
|
|
1141
1214
|
|
|
1215
|
+
// src/rules/sections.ts
|
|
1216
|
+
import { readFileSync as readFileSync10, readdirSync as readdirSync6 } from "fs";
|
|
1217
|
+
import { join as join13 } from "path";
|
|
1218
|
+
var RULE_HEADING = /^###\s+[A-Z]-\d+\b/;
|
|
1219
|
+
var SPEC_HEADING = /^##\s+[PMLRT]-\d+\b/;
|
|
1220
|
+
var ANY_HEADING = /^(#{1,6})\s+(.*?)\s*$/;
|
|
1221
|
+
function anchorOf(file, heading) {
|
|
1222
|
+
const slug = heading.toLowerCase().replace(/[`*_]/g, "").replace(/[^a-z0-9\s-]/g, "").trim().replace(/\s+/g, "-").replace(/-+/g, "-");
|
|
1223
|
+
return `${file}#${slug}`;
|
|
1224
|
+
}
|
|
1225
|
+
function parseSections(markdown, file) {
|
|
1226
|
+
const sections = [];
|
|
1227
|
+
let owner = null;
|
|
1228
|
+
let heading = "";
|
|
1229
|
+
let body = [];
|
|
1230
|
+
const push = () => {
|
|
1231
|
+
if (owner !== "section") return;
|
|
1232
|
+
const text = body.map((l) => l.trim()).filter((l) => !/^-{3,}$/.test(l)).join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
1233
|
+
if (text) sections.push({ file, heading, anchor: anchorOf(file, heading), body: text });
|
|
1234
|
+
};
|
|
1235
|
+
for (const line of markdown.split("\n")) {
|
|
1236
|
+
const h = ANY_HEADING.exec(line);
|
|
1237
|
+
if (h) {
|
|
1238
|
+
const level = h[1].length;
|
|
1239
|
+
if (owner === "spec" && level >= 3) continue;
|
|
1240
|
+
push();
|
|
1241
|
+
body = [];
|
|
1242
|
+
if (RULE_HEADING.test(line)) owner = "rule";
|
|
1243
|
+
else if (SPEC_HEADING.test(line)) owner = "spec";
|
|
1244
|
+
else {
|
|
1245
|
+
owner = "section";
|
|
1246
|
+
heading = h[2];
|
|
1247
|
+
}
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (owner === "section") body.push(line);
|
|
1251
|
+
}
|
|
1252
|
+
push();
|
|
1253
|
+
return sections;
|
|
1254
|
+
}
|
|
1255
|
+
function loadSections(rulesDir) {
|
|
1256
|
+
const out = [];
|
|
1257
|
+
for (const file of readdirSync6(rulesDir).filter((f) => f.endsWith(".md")).sort()) {
|
|
1258
|
+
out.push(...parseSections(readFileSync10(join13(rulesDir, file), "utf8"), file));
|
|
1259
|
+
}
|
|
1260
|
+
return out;
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// src/rules/layers.ts
|
|
1264
|
+
import { readFileSync as readFileSync11 } from "fs";
|
|
1265
|
+
import { join as join14 } from "path";
|
|
1266
|
+
function loadLayers(packageRoot2) {
|
|
1267
|
+
return JSON.parse(readFileSync11(join14(packageRoot2, "layers.json"), "utf8"));
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1142
1270
|
// src/commands/explain.ts
|
|
1143
1271
|
var ID_SHAPE = /^([A-Z])-?(\d+)$/;
|
|
1144
1272
|
var SECTION_SHAPE = /^([A-Z])$/;
|
|
@@ -1167,28 +1295,97 @@ function renderRule(rule) {
|
|
|
1167
1295
|
` since ${rule.since} \xB7 ${rule.source}`
|
|
1168
1296
|
].join("\n");
|
|
1169
1297
|
}
|
|
1298
|
+
var KIND_NOTE = {
|
|
1299
|
+
pattern: [
|
|
1300
|
+
"specification \u2014 component anatomy and behaviour, not a single rule,",
|
|
1301
|
+
"so it carries no \u274C/\u2705 pair and no detector."
|
|
1302
|
+
],
|
|
1303
|
+
mode: [
|
|
1304
|
+
"mode profile \u2014 the character a surface inherits, not a single rule,",
|
|
1305
|
+
"so it carries no \u274C/\u2705 pair and no detector."
|
|
1306
|
+
],
|
|
1307
|
+
method: [
|
|
1308
|
+
"method \u2014 a procedure to follow before building, not a component to build.",
|
|
1309
|
+
"It applies to any screen, so nothing selects it for you: run it."
|
|
1310
|
+
],
|
|
1311
|
+
principle: [
|
|
1312
|
+
"principle \u2014 for the case no rule covers, or the case two rules answer",
|
|
1313
|
+
"differently. Not a rule: it has no \u274C/\u2705 pair and nothing detects it."
|
|
1314
|
+
],
|
|
1315
|
+
token: [
|
|
1316
|
+
"token contract \u2014 what the token layer guarantees and what it expects back.",
|
|
1317
|
+
"Values live in the token files; this is what they mean and how they resolve."
|
|
1318
|
+
]
|
|
1319
|
+
};
|
|
1170
1320
|
function renderSpec(spec) {
|
|
1321
|
+
const [what, more] = KIND_NOTE[spec.kind];
|
|
1171
1322
|
return [
|
|
1172
1323
|
`${spec.id} ${spec.title}`,
|
|
1173
1324
|
"",
|
|
1174
1325
|
spec.body,
|
|
1175
1326
|
"",
|
|
1176
|
-
`
|
|
1177
|
-
`
|
|
1327
|
+
` ${what}`,
|
|
1328
|
+
` ${more}`,
|
|
1178
1329
|
` ${spec.source}`
|
|
1179
1330
|
].join("\n");
|
|
1180
1331
|
}
|
|
1332
|
+
function searchEntries(rules, specs, sections = []) {
|
|
1333
|
+
return [
|
|
1334
|
+
...rules.map((r) => ({
|
|
1335
|
+
id: r.id,
|
|
1336
|
+
title: r.title,
|
|
1337
|
+
text: [...r.preamble, r.wrong, r.correction, ...r.notes].join("\n")
|
|
1338
|
+
})),
|
|
1339
|
+
...specs.map((s) => ({ id: s.id, title: s.title, text: s.body })),
|
|
1340
|
+
...sections.map((s) => ({ id: s.anchor, title: s.heading, text: s.body, section: s }))
|
|
1341
|
+
];
|
|
1342
|
+
}
|
|
1343
|
+
function renderSection(s) {
|
|
1344
|
+
return [s.heading, "", s.body, "", ` ${s.anchor}`].join("\n");
|
|
1345
|
+
}
|
|
1181
1346
|
function explain(opts) {
|
|
1182
1347
|
const raw = opts.ruleId.trim().toUpperCase();
|
|
1183
1348
|
const root = opts.packageRoot ?? assetRoot();
|
|
1184
|
-
const rulesDir =
|
|
1185
|
-
const rules = loadRules(rulesDir,
|
|
1349
|
+
const rulesDir = join15(root, "rules");
|
|
1350
|
+
const rules = loadRules(rulesDir, join15(root, "rules.index.json"));
|
|
1186
1351
|
const specs = loadSpecs(rulesDir);
|
|
1187
|
-
const
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1352
|
+
const sections = loadSections(rulesDir);
|
|
1353
|
+
const entries = searchEntries(rules, specs, sections);
|
|
1354
|
+
if (opts.layer) {
|
|
1355
|
+
const l = loadLayers(root);
|
|
1356
|
+
if (!raw) {
|
|
1357
|
+
const rows = Object.entries(l.layers).map(
|
|
1358
|
+
([name2, layer2]) => `${name2.padEnd(14)} ${layer2.ids.length === 0 ? String(rules.length).padStart(3) : String(layer2.ids.length).padStart(3)} ${layer2.question}`
|
|
1359
|
+
);
|
|
1360
|
+
const extra = Object.entries(l.not_a_layer).filter(([, g]) => Array.isArray(g.ids)).map(([name2, g]) => `${name2.padEnd(14)} ${String(g.ids.length).padStart(3)} ${g.question ?? ""}`);
|
|
1361
|
+
return [
|
|
1362
|
+
"The six layers \u2014 a view over the corpus, not where anything is stored.",
|
|
1363
|
+
"",
|
|
1364
|
+
...rows,
|
|
1365
|
+
"",
|
|
1366
|
+
"Not one of the six \u2014 the machinery, rather than the design knowledge:",
|
|
1367
|
+
"",
|
|
1368
|
+
...extra,
|
|
1369
|
+
"",
|
|
1370
|
+
"Run 'jig explain --layer <name>' for one, or 'jig explain <id>' for an entry."
|
|
1371
|
+
].join("\n");
|
|
1372
|
+
}
|
|
1373
|
+
const name = raw.toLowerCase();
|
|
1374
|
+
const layer = l.layers[name] ?? l.not_a_layer[name];
|
|
1375
|
+
if (!layer) {
|
|
1376
|
+
throw new Error(
|
|
1377
|
+
`No layer '${raw}'. The six are: ${Object.keys(l.layers).join(", ")}; plus ${Object.keys(l.not_a_layer).filter((k) => !k.startsWith("_")).join(" and ")}.`
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
const members = "ids" in layer && layer.ids && layer.ids.length > 0 ? entries.filter((e) => layer.ids.includes(e.id)) : entries.filter((e) => rules.some((r) => r.id === e.id));
|
|
1381
|
+
return [
|
|
1382
|
+
`${name} \u2014 ${members.length} entries`,
|
|
1383
|
+
..."question" in layer && layer.question ? ["", layer.question] : [],
|
|
1384
|
+
..."note" in layer && layer.note ? ["", layer.note] : [],
|
|
1385
|
+
"",
|
|
1386
|
+
...members.sort((a, b) => byNumber(a.id, b.id)).map((e) => `${e.id.padEnd(6)} ${e.title}`)
|
|
1387
|
+
].join("\n");
|
|
1388
|
+
}
|
|
1192
1389
|
if (opts.list) {
|
|
1193
1390
|
const section = SECTION_SHAPE.exec(raw)?.[1];
|
|
1194
1391
|
const shown = entries.filter((e) => !section || e.id.startsWith(`${section}-`)).sort((a, b) => byNumber(a.id, b.id));
|
|
@@ -1219,11 +1416,19 @@ ${r.correction}` })),
|
|
|
1219
1416
|
);
|
|
1220
1417
|
}
|
|
1221
1418
|
const needle = opts.ruleId.trim();
|
|
1222
|
-
const
|
|
1223
|
-
|
|
1419
|
+
const byAnchor = sections.find((sec) => sec.anchor === needle.toLowerCase());
|
|
1420
|
+
if (byAnchor) return renderSection(byAnchor);
|
|
1421
|
+
const ids = entries.filter((e) => !e.section);
|
|
1422
|
+
const secs = entries.filter((e) => e.section);
|
|
1423
|
+
const idTitle = ids.filter((e) => matches(e.title, needle) || matches(e.id, needle));
|
|
1424
|
+
const idBody = ids.filter((e) => !idTitle.includes(e) && matches(e.text, needle));
|
|
1425
|
+
const secTitle = secs.filter((e) => matches(e.title, needle));
|
|
1426
|
+
const secBody = secs.filter((e) => !secTitle.includes(e) && matches(e.text, needle));
|
|
1224
1427
|
const hits = [
|
|
1225
|
-
...
|
|
1226
|
-
...
|
|
1428
|
+
...idTitle.sort((a, b) => byNumber(a.id, b.id)),
|
|
1429
|
+
...idBody.sort((a, b) => byNumber(a.id, b.id)),
|
|
1430
|
+
...secTitle,
|
|
1431
|
+
...secBody
|
|
1227
1432
|
];
|
|
1228
1433
|
if (hits.length === 0) {
|
|
1229
1434
|
throw new Error(
|
|
@@ -1231,6 +1436,7 @@ ${r.correction}` })),
|
|
|
1231
1436
|
);
|
|
1232
1437
|
}
|
|
1233
1438
|
if (hits.length === 1) {
|
|
1439
|
+
if (hits[0].section) return renderSection(hits[0].section);
|
|
1234
1440
|
const rule = rules.find((r) => r.id === hits[0].id);
|
|
1235
1441
|
return rule ? renderRule(rule) : renderSpec(specs.find((s) => s.id === hits[0].id));
|
|
1236
1442
|
}
|
|
@@ -1239,7 +1445,7 @@ ${r.correction}` })),
|
|
|
1239
1445
|
// otherwise C-19 sitting above A-02 reads as a sorting bug.
|
|
1240
1446
|
`${hits.length} entries match '${needle}' \u2014 title matches first:`,
|
|
1241
1447
|
"",
|
|
1242
|
-
...hits.map((e) => `${e.id.padEnd(6)} ${e.title}`),
|
|
1448
|
+
...hits.map((e) => e.section ? `${e.id} ${e.title}` : `${e.id.padEnd(6)} ${e.title}`),
|
|
1243
1449
|
"",
|
|
1244
1450
|
`Run 'jig explain <id>' for any of them.`
|
|
1245
1451
|
].join("\n");
|
|
@@ -1249,13 +1455,13 @@ function sectionsOf(entries) {
|
|
|
1249
1455
|
}
|
|
1250
1456
|
|
|
1251
1457
|
// src/commands/check.ts
|
|
1252
|
-
import { existsSync as
|
|
1253
|
-
import { join as
|
|
1458
|
+
import { existsSync as existsSync14, readFileSync as readFileSync18 } from "fs";
|
|
1459
|
+
import { join as join23 } from "path";
|
|
1254
1460
|
|
|
1255
1461
|
// src/check/files.ts
|
|
1256
1462
|
import { execFileSync } from "child_process";
|
|
1257
|
-
import { readdirSync as
|
|
1258
|
-
import { join as
|
|
1463
|
+
import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
|
|
1464
|
+
import { join as join16, relative, sep } from "path";
|
|
1259
1465
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
1260
1466
|
"node_modules",
|
|
1261
1467
|
"dist",
|
|
@@ -1314,13 +1520,13 @@ function isExcluded(relPath) {
|
|
|
1314
1520
|
function walk(root, dir, out, seen) {
|
|
1315
1521
|
let entries;
|
|
1316
1522
|
try {
|
|
1317
|
-
entries =
|
|
1523
|
+
entries = readdirSync7(dir, { withFileTypes: true });
|
|
1318
1524
|
} catch {
|
|
1319
1525
|
return;
|
|
1320
1526
|
}
|
|
1321
1527
|
for (const entry of entries) {
|
|
1322
1528
|
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
1323
|
-
const abs =
|
|
1529
|
+
const abs = join16(dir, entry.name);
|
|
1324
1530
|
let isDir = entry.isDirectory();
|
|
1325
1531
|
let isFile = entry.isFile();
|
|
1326
1532
|
if (entry.isSymbolicLink()) {
|
|
@@ -1441,7 +1647,7 @@ function formatReport(findings, meta) {
|
|
|
1441
1647
|
}
|
|
1442
1648
|
}
|
|
1443
1649
|
const errors = findings.filter((f) => f.severity === "error").length;
|
|
1444
|
-
const warnings = findings.filter((f) => f.severity === "warning").length;
|
|
1650
|
+
const warnings = findings.filter((f) => f.severity === "warning").length + (meta.modeUnwired?.length ?? 0);
|
|
1445
1651
|
const notes = findings.filter((f) => f.severity === "note").length;
|
|
1446
1652
|
const summaryParts = [plural(errors, "error")];
|
|
1447
1653
|
if (warnings > 0) summaryParts.push(plural(warnings, "warning"));
|
|
@@ -1480,10 +1686,19 @@ function formatReport(findings, meta) {
|
|
|
1480
1686
|
` ${meta.unscanned.count} file(s) were not scanned (${exts}) \u2014 no style extraction exists for them, so any CSS they carry is invisible to this check and a clean result above does not cover it.`
|
|
1481
1687
|
);
|
|
1482
1688
|
}
|
|
1689
|
+
if (meta.modeUnwired && meta.modeUnwired.length > 0) {
|
|
1690
|
+
lines.push("");
|
|
1691
|
+
for (const p of meta.modeUnwired) lines.push(` \u26A0 ${p.message}.`);
|
|
1692
|
+
if (meta.modeUnwired.some((p) => p.barrel === "jig.config.json")) {
|
|
1693
|
+
lines.push(` Fix jig.config.json by hand \u2014 init leaves an edited config alone \u2014 then run 'jig init' again.`);
|
|
1694
|
+
} else {
|
|
1695
|
+
lines.push(` Run 'jig init' again: it rewrites the token layer for the modes jig.config.json declares.`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1483
1698
|
const mechanicalErrors = findings.filter((f) => f.bucket === "mechanical" && f.severity === "error").length;
|
|
1484
1699
|
const mechStatus = `${mechanicalErrors > 0 ? "fail" : "pass"}:${mechanicalErrors}`;
|
|
1485
1700
|
lines.push(
|
|
1486
|
-
` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} judgment=not-run files=${meta.scanned ?? "unknown"} styled=${meta.withStyles ?? "unknown"}`
|
|
1701
|
+
` JIG_CHECK: version=${meta.version} mode=${meta.mode ?? "unknown"} mechanical=${mechStatus} warnings=${warnings} judgment=not-run files=${meta.scanned ?? "unknown"} styled=${meta.withStyles ?? "unknown"}`
|
|
1487
1702
|
);
|
|
1488
1703
|
return lines.join("\n");
|
|
1489
1704
|
}
|
|
@@ -1583,8 +1798,8 @@ function isStyleBearing(file) {
|
|
|
1583
1798
|
}
|
|
1584
1799
|
|
|
1585
1800
|
// src/check/run.ts
|
|
1586
|
-
import { readFileSync as
|
|
1587
|
-
import { join as
|
|
1801
|
+
import { readFileSync as readFileSync13 } from "fs";
|
|
1802
|
+
import { join as join18 } from "path";
|
|
1588
1803
|
|
|
1589
1804
|
// src/check/css.ts
|
|
1590
1805
|
function maskComments(source) {
|
|
@@ -2772,6 +2987,318 @@ var violetBandHue = {
|
|
|
2772
2987
|
}
|
|
2773
2988
|
};
|
|
2774
2989
|
|
|
2990
|
+
// src/check/detectors/fixed-width.ts
|
|
2991
|
+
var PHONE_WIDTH_PX = 480;
|
|
2992
|
+
var GRID_COLUMNS_RE = /\bgrid-template-columns\s*:\s*([^;}]+)/i;
|
|
2993
|
+
var FLEX_RE = /\bdisplay\s*:\s*(?:inline-)?flex\b/i;
|
|
2994
|
+
var COLUMN_DIRECTION_RE = /\bflex-direction\s*:\s*column(?:-reverse)?\b|\bflex-flow\s*:\s*column\b/i;
|
|
2995
|
+
var FIXED_WIDTH_RE = /(?<![-\w])(?:min-)?width\s*:\s*(\d+(?:\.\d+)?)px\b/i;
|
|
2996
|
+
function isMultiTrack(value) {
|
|
2997
|
+
const v = value.trim();
|
|
2998
|
+
const repeat = /repeat\s*\(\s*(\d+)\s*,/i.exec(v);
|
|
2999
|
+
if (repeat) return Number(repeat[1]) > 1;
|
|
3000
|
+
const tracks = v.replace(/\([^)]*\)/g, "()").split(/\s+/).filter(Boolean);
|
|
3001
|
+
return tracks.length > 1;
|
|
3002
|
+
}
|
|
3003
|
+
var fixedWidth = {
|
|
3004
|
+
name: "fixed-width",
|
|
3005
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3006
|
+
run(source, file, ctx) {
|
|
3007
|
+
if (ctx.projectResponsive !== false) return [];
|
|
3008
|
+
const findings = [];
|
|
3009
|
+
for (const block of leafBlocks(source)) {
|
|
3010
|
+
const body = block.body;
|
|
3011
|
+
let offset = -1;
|
|
3012
|
+
let message = "";
|
|
3013
|
+
const grid = GRID_COLUMNS_RE.exec(body);
|
|
3014
|
+
const width = FIXED_WIDTH_RE.exec(body);
|
|
3015
|
+
if (grid && isMultiTrack(grid[1])) {
|
|
3016
|
+
offset = grid.index;
|
|
3017
|
+
message = "multi-column grid, and nothing in the project changes it at a narrow width";
|
|
3018
|
+
} else if (FLEX_RE.test(body) && !COLUMN_DIRECTION_RE.test(body)) {
|
|
3019
|
+
offset = FLEX_RE.exec(body).index;
|
|
3020
|
+
message = "row of flex items with no wrap, and nothing in the project changes it at a narrow width";
|
|
3021
|
+
} else if (width && Number(width[1]) > PHONE_WIDTH_PX) {
|
|
3022
|
+
offset = width.index;
|
|
3023
|
+
message = `fixed ${width[1]}px width, wider than a phone, and nothing in the project adapts it`;
|
|
3024
|
+
}
|
|
3025
|
+
if (offset < 0) continue;
|
|
3026
|
+
const line = lineOfOffset(block, offset);
|
|
3027
|
+
findings.push(mkFinding(ctx, "fixed-width", file, line, message, sourceLine(source, line)));
|
|
3028
|
+
}
|
|
3029
|
+
return findings;
|
|
3030
|
+
}
|
|
3031
|
+
};
|
|
3032
|
+
|
|
3033
|
+
// src/check/detectors/viewport-height.ts
|
|
3034
|
+
var VH_DECL_RE = /(?<![-\w])((?:min-|max-)?height)\s*:\s*([^;}]*\b100vh\b[^;}]*)/gi;
|
|
3035
|
+
var MODERN_UNIT_RE = /\b\d*\.?\d+(?:dvh|svh|lvh)\b/i;
|
|
3036
|
+
var viewportHeight = {
|
|
3037
|
+
name: "viewport-height",
|
|
3038
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3039
|
+
run(source, file, ctx) {
|
|
3040
|
+
if (MODERN_UNIT_RE.test(source)) return [];
|
|
3041
|
+
const findings = [];
|
|
3042
|
+
for (const block of leafBlocks(source)) {
|
|
3043
|
+
VH_DECL_RE.lastIndex = 0;
|
|
3044
|
+
let m;
|
|
3045
|
+
while (m = VH_DECL_RE.exec(block.body)) {
|
|
3046
|
+
const line = lineOfOffset(block, m.index);
|
|
3047
|
+
findings.push(
|
|
3048
|
+
mkFinding(
|
|
3049
|
+
ctx,
|
|
3050
|
+
"viewport-height",
|
|
3051
|
+
file,
|
|
3052
|
+
line,
|
|
3053
|
+
`${m[1]} uses 100vh, which is taller than the visible area on a phone while its browser bars show`,
|
|
3054
|
+
sourceLine(source, line)
|
|
3055
|
+
)
|
|
3056
|
+
);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
return findings;
|
|
3060
|
+
}
|
|
3061
|
+
};
|
|
3062
|
+
|
|
3063
|
+
// src/check/detectors/input-zoom.ts
|
|
3064
|
+
var SELECTOR_RE = /(?:^|[\s,>+~(])(?:input|select|textarea)\b(?![-\w])|\[contenteditable/i;
|
|
3065
|
+
var SIZE_RE = /(?<![-\w])font-size\s*:\s*([^;}]+)/gi;
|
|
3066
|
+
var TOUCH_QUERY_RE = /@media[^{]*\b(?:pointer\s*:\s*coarse|hover\s*:\s*none)\b/i;
|
|
3067
|
+
var ROOT_PX = 16;
|
|
3068
|
+
function pixels(value, mode) {
|
|
3069
|
+
const v = value.trim().replace(/\s*!important$/i, "");
|
|
3070
|
+
const unit = /^(\d*\.?\d+)(px|rem|em)$/i.exec(v);
|
|
3071
|
+
if (unit) return unit[2].toLowerCase() === "px" ? Number(unit[1]) : Number(unit[1]) * ROOT_PX;
|
|
3072
|
+
if (/^var\(\s*--text-caption\s*\)$/i.test(v)) return 14;
|
|
3073
|
+
if (/^var\(\s*--text-body\s*\)$/i.test(v) && mode === "operator") return 14;
|
|
3074
|
+
return void 0;
|
|
3075
|
+
}
|
|
3076
|
+
var inputZoom = {
|
|
3077
|
+
name: "input-zoom",
|
|
3078
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3079
|
+
run(source, file, ctx) {
|
|
3080
|
+
if (TOUCH_QUERY_RE.test(source)) return [];
|
|
3081
|
+
const findings = [];
|
|
3082
|
+
for (const block of leafBlocks(source)) {
|
|
3083
|
+
if (!SELECTOR_RE.test(block.selector)) continue;
|
|
3084
|
+
SIZE_RE.lastIndex = 0;
|
|
3085
|
+
let m;
|
|
3086
|
+
while (m = SIZE_RE.exec(block.body)) {
|
|
3087
|
+
const px = pixels(m[1], ctx.mode);
|
|
3088
|
+
if (px === void 0 || px >= ROOT_PX) continue;
|
|
3089
|
+
const line = lineOfOffset(block, m.index);
|
|
3090
|
+
findings.push(
|
|
3091
|
+
mkFinding(
|
|
3092
|
+
ctx,
|
|
3093
|
+
"input-zoom",
|
|
3094
|
+
file,
|
|
3095
|
+
line,
|
|
3096
|
+
`form control text resolves to ${px}px; iOS zooms the page on focus below 16px`,
|
|
3097
|
+
sourceLine(source, line)
|
|
3098
|
+
)
|
|
3099
|
+
);
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
return findings;
|
|
3103
|
+
}
|
|
3104
|
+
};
|
|
3105
|
+
|
|
3106
|
+
// src/check/detectors/safe-area.ts
|
|
3107
|
+
var PINNED_RE = /\bposition\s*:\s*(?:fixed|sticky)\b/i;
|
|
3108
|
+
var EDGE_RE = /(?<![-\w])(top|bottom|left|right|inset(?:-block|-inline)?(?:-start|-end)?)\s*:\s*0(?:px|rem|em)?\s*(?:[;}]|$)/i;
|
|
3109
|
+
var INSET_RE = /\b(?:env|constant)\(\s*safe-area-inset-/i;
|
|
3110
|
+
var safeArea = {
|
|
3111
|
+
name: "safe-area",
|
|
3112
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3113
|
+
run(source, file, ctx) {
|
|
3114
|
+
if (ctx.viewportFitCover !== true) return [];
|
|
3115
|
+
const findings = [];
|
|
3116
|
+
for (const block of leafBlocks(source)) {
|
|
3117
|
+
const body = block.body;
|
|
3118
|
+
if (!PINNED_RE.test(body) || INSET_RE.test(body)) continue;
|
|
3119
|
+
const edge = EDGE_RE.exec(body);
|
|
3120
|
+
if (!edge) continue;
|
|
3121
|
+
const line = lineOfOffset(block, PINNED_RE.exec(body).index);
|
|
3122
|
+
findings.push(
|
|
3123
|
+
mkFinding(
|
|
3124
|
+
ctx,
|
|
3125
|
+
"safe-area",
|
|
3126
|
+
file,
|
|
3127
|
+
line,
|
|
3128
|
+
`pinned to the ${edge[1]} edge of a page that extends under the notch, with no safe-area inset`,
|
|
3129
|
+
sourceLine(source, line)
|
|
3130
|
+
)
|
|
3131
|
+
);
|
|
3132
|
+
}
|
|
3133
|
+
return findings;
|
|
3134
|
+
}
|
|
3135
|
+
};
|
|
3136
|
+
|
|
3137
|
+
// src/check/detectors/menu-state.ts
|
|
3138
|
+
var HIDDEN_RE = /(?<![-\w])display\s*:\s*none\b/i;
|
|
3139
|
+
var NAV_SELECTOR_RE = /(?:^|[\s>+~.#])(?:nav|[\w-]*(?:nav|navigation|menu)[\w-]*)(?:$|[\s.#:[>+~])/i;
|
|
3140
|
+
var SKIP_RE = /sub-?menu|dropdown|flyout|mega|button|btn|toggle|trigger|burger|icon/i;
|
|
3141
|
+
var NESTED_LIST_RE = /\bli\b[^,]*\b(?:ul|ol)\b/i;
|
|
3142
|
+
function navSelectors(selectorList) {
|
|
3143
|
+
return selectorList.split(",").map((s) => s.trim()).filter((s) => s && !SKIP_RE.test(s) && !NESTED_LIST_RE.test(s) && !/:hover|:focus/i.test(s)).filter((s) => s.split(/\s+|>|\+|~/).some((c) => NAV_SELECTOR_RE.test(c)));
|
|
3144
|
+
}
|
|
3145
|
+
var MENU_BUTTON_RE = /<button\b([^>]*)>([\s\S]{0,200}?)<\/button>/gi;
|
|
3146
|
+
var MENU_NAME_RE = /^\s*(?:open |toggle |show )?(?:the )?(?:site |main )?(?:menu|navigation|nav)\s*$/i;
|
|
3147
|
+
function deadMenuButtons(raw) {
|
|
3148
|
+
const offsets = [];
|
|
3149
|
+
for (const m of raw.matchAll(MENU_BUTTON_RE)) {
|
|
3150
|
+
const label = /\baria-label\s*=\s*["']([^"']*)["']/i.exec(m[1])?.[1];
|
|
3151
|
+
const text = m[2].replace(/<[^>]*>/g, "").trim();
|
|
3152
|
+
if (MENU_NAME_RE.test(label ?? "") || MENU_NAME_RE.test(text)) offsets.push(m.index);
|
|
3153
|
+
}
|
|
3154
|
+
return offsets;
|
|
3155
|
+
}
|
|
3156
|
+
var menuState = {
|
|
3157
|
+
name: "menu-state",
|
|
3158
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3159
|
+
run(source, file, ctx) {
|
|
3160
|
+
if (ctx.projectMenuToggle !== false) return [];
|
|
3161
|
+
const findings = [];
|
|
3162
|
+
if (/\.(html?|vue|svelte|astro|jsx|tsx|php|erb|twig|hbs)$/i.test(file)) {
|
|
3163
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3164
|
+
for (const offset of deadMenuButtons(ctx.raw)) {
|
|
3165
|
+
const line = lineForOffset(starts, offset);
|
|
3166
|
+
findings.push(
|
|
3167
|
+
mkFinding(
|
|
3168
|
+
ctx,
|
|
3169
|
+
"menu-state",
|
|
3170
|
+
file,
|
|
3171
|
+
line,
|
|
3172
|
+
"a Menu button that nothing opens \u2014 no script or binding ever changes aria-expanded, and no <details>",
|
|
3173
|
+
sourceLine(ctx.raw, line)
|
|
3174
|
+
)
|
|
3175
|
+
);
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
for (const block of leafBlocks(source)) {
|
|
3179
|
+
const hidden = HIDDEN_RE.exec(block.body);
|
|
3180
|
+
if (!hidden) continue;
|
|
3181
|
+
const names = navSelectors(block.selector.replace(/^[\s\S]*[;}]/, ""));
|
|
3182
|
+
if (names.length === 0) continue;
|
|
3183
|
+
const line = lineOfOffset(block, hidden.index);
|
|
3184
|
+
findings.push(
|
|
3185
|
+
mkFinding(
|
|
3186
|
+
ctx,
|
|
3187
|
+
"menu-state",
|
|
3188
|
+
file,
|
|
3189
|
+
line,
|
|
3190
|
+
`${names[0]} is hidden, and nothing in the project opens it with aria-expanded or <details>`,
|
|
3191
|
+
sourceLine(source, line)
|
|
3192
|
+
)
|
|
3193
|
+
);
|
|
3194
|
+
}
|
|
3195
|
+
return findings;
|
|
3196
|
+
}
|
|
3197
|
+
};
|
|
3198
|
+
|
|
3199
|
+
// src/check/declared-properties.ts
|
|
3200
|
+
import { existsSync as existsSync9, readFileSync as readFileSync12 } from "fs";
|
|
3201
|
+
import { join as join17 } from "path";
|
|
3202
|
+
var DECLARATION_RE = /(?<![\w-])['"]?(--[A-Za-z_][\w-]*)['"]?\s*:/g;
|
|
3203
|
+
var AT_PROPERTY_RE = /@property\s+(--[\w-]+)/g;
|
|
3204
|
+
var SET_PROPERTY_RE = /setProperty\(\s*['"`](--[\w-]+)/g;
|
|
3205
|
+
var IMPORT_RE2 = /@import\s+(?:url\(\s*)?["']([^"')]+)["']/g;
|
|
3206
|
+
var TAILWIND_RE = /@import\s+["']tailwindcss["']/;
|
|
3207
|
+
function addAll(names, text) {
|
|
3208
|
+
for (const re of [DECLARATION_RE, AT_PROPERTY_RE, SET_PROPERTY_RE]) {
|
|
3209
|
+
for (const m of text.matchAll(re)) names.add(m[1]);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
function readPackageImport(projectRoot, spec) {
|
|
3213
|
+
const base = join17(projectRoot, "node_modules", spec);
|
|
3214
|
+
for (const candidate of [base, `${base}.css`]) {
|
|
3215
|
+
try {
|
|
3216
|
+
if (existsSync9(candidate) && candidate.endsWith(".css")) return readFileSync12(candidate, "utf8");
|
|
3217
|
+
} catch {
|
|
3218
|
+
}
|
|
3219
|
+
}
|
|
3220
|
+
try {
|
|
3221
|
+
const pkg = JSON.parse(readFileSync12(join17(base, "package.json"), "utf8"));
|
|
3222
|
+
if (pkg.style) return readFileSync12(join17(base, pkg.style), "utf8");
|
|
3223
|
+
} catch {
|
|
3224
|
+
}
|
|
3225
|
+
return void 0;
|
|
3226
|
+
}
|
|
3227
|
+
function collectDeclaredProperties(projectRoot, files, tokenNames = []) {
|
|
3228
|
+
const names = new Set(tokenNames.map((n) => `--${n}`));
|
|
3229
|
+
let tailwind = false;
|
|
3230
|
+
let complete = true;
|
|
3231
|
+
for (const file of files) {
|
|
3232
|
+
let raw;
|
|
3233
|
+
try {
|
|
3234
|
+
raw = readFileSync12(join17(projectRoot, file), "utf8");
|
|
3235
|
+
} catch {
|
|
3236
|
+
continue;
|
|
3237
|
+
}
|
|
3238
|
+
addAll(names, raw);
|
|
3239
|
+
if (TAILWIND_RE.test(raw)) tailwind = true;
|
|
3240
|
+
for (const m of raw.matchAll(IMPORT_RE2)) {
|
|
3241
|
+
const spec = m[1];
|
|
3242
|
+
if (spec === "tailwindcss" || /^(\.|\/|https?:)/.test(spec)) continue;
|
|
3243
|
+
const imported = readPackageImport(projectRoot, spec);
|
|
3244
|
+
if (imported === void 0) complete = false;
|
|
3245
|
+
else addAll(names, imported);
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
return { names, tailwind, complete };
|
|
3249
|
+
}
|
|
3250
|
+
var PALETTE2 = "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|mauve|olive|mist|taupe";
|
|
3251
|
+
var SIZE = "3xs|2xs|xs|sm|md|lg|xl|[2-9]xl|base";
|
|
3252
|
+
var TAILWIND_DEFAULT_RE = new RegExp(
|
|
3253
|
+
`^--(?:tw-[\\w-]+|color-(?:(?:${PALETTE2})-(?:50|[1-9]00|950)|black|white)|spacing|container-(?:${SIZE})|text-(?:${SIZE})(?:--line-height|--letter-spacing|--font-weight)?|font-(?:sans|serif|mono)|font-weight-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black)|tracking-(?:tighter|tight|normal|wide|wider|widest)|leading-(?:tight|snug|normal|relaxed|loose)|breakpoint-(?:${SIZE})|radius-(?:${SIZE})|(?:inset-shadow|drop-shadow|text-shadow|shadow|blur|perspective|ease|animate|aspect|default)-[\\w-]+|shadow|blur)$`
|
|
3254
|
+
);
|
|
3255
|
+
function isTailwindDefault(name) {
|
|
3256
|
+
return TAILWIND_DEFAULT_RE.test(name);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
// src/check/detectors/undeclared-token.ts
|
|
3260
|
+
var VAR_RE2 = /var\(\s*(--[\w-]+)\s*([,)])/g;
|
|
3261
|
+
var CLASS_ATTR_RE = /\bclass(?:Name)?\s*=\s*(?:"[^"]*"|'[^']*'|\{`[^`]*`\})/g;
|
|
3262
|
+
var CLASS_REF_RE = /(?:-\(\s*|var\(\s*)(--[\w-]+)\s*\)/g;
|
|
3263
|
+
var undeclaredToken = {
|
|
3264
|
+
name: "undeclared-token",
|
|
3265
|
+
appliesTo: (file) => isStyleBearing(file),
|
|
3266
|
+
run(source, file, ctx) {
|
|
3267
|
+
const declared = ctx.declaredProperties;
|
|
3268
|
+
if (!declared || !declared.complete) return [];
|
|
3269
|
+
const known = (name) => declared.names.has(name) || declared.tailwind && isTailwindDefault(name) || name.startsWith("--tw-");
|
|
3270
|
+
const hits = /* @__PURE__ */ new Map();
|
|
3271
|
+
const note = (name, offset, text) => {
|
|
3272
|
+
if (known(name)) return;
|
|
3273
|
+
const key = `${name}@${offset}`;
|
|
3274
|
+
if (!hits.has(key)) hits.set(key, { name, offset, text });
|
|
3275
|
+
};
|
|
3276
|
+
for (const m of source.matchAll(VAR_RE2)) {
|
|
3277
|
+
if (m[2] === ",") continue;
|
|
3278
|
+
note(m[1], m.index, source);
|
|
3279
|
+
}
|
|
3280
|
+
for (const attr of ctx.raw.matchAll(CLASS_ATTR_RE)) {
|
|
3281
|
+
for (const m of attr[0].matchAll(CLASS_REF_RE)) note(m[1], attr.index + m.index, ctx.raw);
|
|
3282
|
+
}
|
|
3283
|
+
const lines = buildLineIndex(ctx.raw);
|
|
3284
|
+
const findings = [];
|
|
3285
|
+
for (const { name, offset } of hits.values()) {
|
|
3286
|
+
const line = lineForOffset(lines, offset);
|
|
3287
|
+
findings.push(
|
|
3288
|
+
mkFinding(
|
|
3289
|
+
ctx,
|
|
3290
|
+
"undeclared-token",
|
|
3291
|
+
file,
|
|
3292
|
+
line,
|
|
3293
|
+
`${name} is not declared anywhere in the project, so every property using it is dropped`,
|
|
3294
|
+
sourceLine(ctx.raw, line)
|
|
3295
|
+
)
|
|
3296
|
+
);
|
|
3297
|
+
}
|
|
3298
|
+
return findings.sort((a, b) => a.line - b.line);
|
|
3299
|
+
}
|
|
3300
|
+
};
|
|
3301
|
+
|
|
2775
3302
|
// src/check/registry.ts
|
|
2776
3303
|
var DETECTORS = [
|
|
2777
3304
|
gradientText,
|
|
@@ -2783,14 +3310,20 @@ var DETECTORS = [
|
|
|
2783
3310
|
contrastFloor,
|
|
2784
3311
|
focusRemoved,
|
|
2785
3312
|
hardcodedValue,
|
|
2786
|
-
violetBandHue
|
|
3313
|
+
violetBandHue,
|
|
3314
|
+
fixedWidth,
|
|
3315
|
+
viewportHeight,
|
|
3316
|
+
inputZoom,
|
|
3317
|
+
safeArea,
|
|
3318
|
+
menuState,
|
|
3319
|
+
undeclaredToken
|
|
2787
3320
|
];
|
|
2788
3321
|
function getDetector(name) {
|
|
2789
3322
|
return DETECTORS.find((d) => d.name === name);
|
|
2790
3323
|
}
|
|
2791
3324
|
|
|
2792
3325
|
// src/check/run.ts
|
|
2793
|
-
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode) {
|
|
3326
|
+
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties) {
|
|
2794
3327
|
const findings = [];
|
|
2795
3328
|
const active = index.filter(
|
|
2796
3329
|
(e) => !!e.detector && !!getDetector(e.detector)
|
|
@@ -2802,13 +3335,13 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
2802
3335
|
if (applicable.length === 0) continue;
|
|
2803
3336
|
let raw;
|
|
2804
3337
|
try {
|
|
2805
|
-
raw =
|
|
3338
|
+
raw = readFileSync13(join18(projectRoot, file), "utf8");
|
|
2806
3339
|
} catch {
|
|
2807
3340
|
continue;
|
|
2808
3341
|
}
|
|
2809
3342
|
const source = maskComments(maskNonStyleRegions(raw, file));
|
|
2810
3343
|
for (const { entry, detector } of applicable) {
|
|
2811
|
-
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode };
|
|
3344
|
+
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties };
|
|
2812
3345
|
findings.push(...detector.run(source, file, ctx));
|
|
2813
3346
|
}
|
|
2814
3347
|
}
|
|
@@ -2816,8 +3349,8 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
2816
3349
|
}
|
|
2817
3350
|
|
|
2818
3351
|
// src/check/tokens.ts
|
|
2819
|
-
import { existsSync as
|
|
2820
|
-
import { join as
|
|
3352
|
+
import { existsSync as existsSync10, readdirSync as readdirSync8, readFileSync as readFileSync14 } from "fs";
|
|
3353
|
+
import { join as join19 } from "path";
|
|
2821
3354
|
function rootDeclarations(source) {
|
|
2822
3355
|
const out = {};
|
|
2823
3356
|
for (const block of splitRuleBlocks(source)) {
|
|
@@ -2832,20 +3365,20 @@ function rootDeclarations(source) {
|
|
|
2832
3365
|
}
|
|
2833
3366
|
function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
2834
3367
|
const jig = {};
|
|
2835
|
-
const tokensDir =
|
|
2836
|
-
if (
|
|
2837
|
-
for (const file of
|
|
2838
|
-
Object.assign(jig, rootDeclarations(
|
|
3368
|
+
const tokensDir = join19(projectRoot, ".jig", "tokens");
|
|
3369
|
+
if (existsSync10(tokensDir)) {
|
|
3370
|
+
for (const file of readdirSync8(tokensDir).filter((f) => f.endsWith(".css")).sort()) {
|
|
3371
|
+
Object.assign(jig, rootDeclarations(readFileSync14(join19(tokensDir, file), "utf8")));
|
|
2839
3372
|
}
|
|
2840
3373
|
}
|
|
2841
3374
|
const consumer = {};
|
|
2842
3375
|
const ambiguous = /* @__PURE__ */ new Set();
|
|
2843
|
-
const vendored =
|
|
3376
|
+
const vendored = join19(".jig", "tokens");
|
|
2844
3377
|
for (const file of consumerFiles) {
|
|
2845
3378
|
if (file.includes(vendored)) continue;
|
|
2846
3379
|
let source;
|
|
2847
3380
|
try {
|
|
2848
|
-
source =
|
|
3381
|
+
source = readFileSync14(join19(projectRoot, file), "utf8");
|
|
2849
3382
|
} catch {
|
|
2850
3383
|
continue;
|
|
2851
3384
|
}
|
|
@@ -2864,8 +3397,8 @@ function loadTokenMap(projectRoot, consumerFiles = []) {
|
|
|
2864
3397
|
}
|
|
2865
3398
|
|
|
2866
3399
|
// src/check/exempt.ts
|
|
2867
|
-
import { existsSync as
|
|
2868
|
-
import { join as
|
|
3400
|
+
import { existsSync as existsSync11, readFileSync as readFileSync15 } from "fs";
|
|
3401
|
+
import { join as join20 } from "path";
|
|
2869
3402
|
function globToRegExp(glob) {
|
|
2870
3403
|
let out = "";
|
|
2871
3404
|
for (let i = 0; i < glob.length; i++) {
|
|
@@ -2884,11 +3417,11 @@ function globToRegExp(glob) {
|
|
|
2884
3417
|
return new RegExp(`^${out}$`);
|
|
2885
3418
|
}
|
|
2886
3419
|
function readExemptions(projectRoot) {
|
|
2887
|
-
const configPath =
|
|
2888
|
-
if (!
|
|
3420
|
+
const configPath = join20(projectRoot, "jig.config.json");
|
|
3421
|
+
if (!existsSync11(configPath)) return [];
|
|
2889
3422
|
let declared;
|
|
2890
3423
|
try {
|
|
2891
|
-
declared = JSON.parse(
|
|
3424
|
+
declared = JSON.parse(readFileSync15(configPath, "utf8")).exempt;
|
|
2892
3425
|
} catch {
|
|
2893
3426
|
return [];
|
|
2894
3427
|
}
|
|
@@ -2919,9 +3452,114 @@ function applyExemptions(files, patterns) {
|
|
|
2919
3452
|
};
|
|
2920
3453
|
}
|
|
2921
3454
|
|
|
3455
|
+
// src/check/responsive.ts
|
|
3456
|
+
var MEDIA_RE = /@media\b([^{]*)\{/gi;
|
|
3457
|
+
var VIEWPORT_FEATURE_RE = /\b(?:min-|max-)?(?:width|height|aspect-ratio|orientation|resolution)\b|\b(?:any-)?(?:pointer|hover)\b/i;
|
|
3458
|
+
var INTRINSIC_RE = [
|
|
3459
|
+
/@container\b/i,
|
|
3460
|
+
/\bminmax\s*\(/i,
|
|
3461
|
+
/\bauto-(?:fit|fill)\b/i,
|
|
3462
|
+
/\bflex-wrap\s*:\s*wrap(?:-reverse)?\b/i,
|
|
3463
|
+
/\bclamp\s*\(/i
|
|
3464
|
+
];
|
|
3465
|
+
var TAILWIND_BREAKPOINT_RE = /(?:^|[\s"'`{])(?:max-)?(?:sm|md|lg|xl|2xl|@(?:xs|sm|md|lg|xl))(?:\/[\w-]+)?:[\w[-]/;
|
|
3466
|
+
function isResponsive(source, raw) {
|
|
3467
|
+
MEDIA_RE.lastIndex = 0;
|
|
3468
|
+
let m;
|
|
3469
|
+
while (m = MEDIA_RE.exec(source)) {
|
|
3470
|
+
if (VIEWPORT_FEATURE_RE.test(m[1])) return true;
|
|
3471
|
+
}
|
|
3472
|
+
if (INTRINSIC_RE.some((re) => re.test(source))) return true;
|
|
3473
|
+
return TAILWIND_BREAKPOINT_RE.test(raw);
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
// src/check/menu-toggle.ts
|
|
3477
|
+
var NAV_REGION_RE = /<(nav|header)\b[\s\S]*?<\/\1\s*>/gi;
|
|
3478
|
+
var DETAILS_RE = /<details\b/i;
|
|
3479
|
+
var BOUND_RE = /(?::|x-bind:|\[attr\.)aria-expanded\b|\baria-expanded\s*=\s*(?:\{|["'][^"']*[{$@])/i;
|
|
3480
|
+
var EXPANDED_TAG_RE = /<[a-z][\w-]*\b[^>]*\baria-expanded\b[^>]*>/gi;
|
|
3481
|
+
var NAMES_MENU_RE = /menu|nav/i;
|
|
3482
|
+
var SCRIPT_STATE_RE = /setAttribute\(\s*['"`]aria-expanded['"`]|toggleAttribute\(\s*['"`]aria-expanded|\.ariaExpanded\s*=|dataset\.expanded|\[['"]aria-expanded['"]\]\s*=/;
|
|
3483
|
+
function hasMenuToggle(raw) {
|
|
3484
|
+
const scripted = SCRIPT_STATE_RE.test(raw);
|
|
3485
|
+
for (const region of raw.match(NAV_REGION_RE) ?? []) {
|
|
3486
|
+
if (DETAILS_RE.test(region)) return true;
|
|
3487
|
+
if (/\baria-expanded\b/i.test(region) && (scripted || BOUND_RE.test(region))) return true;
|
|
3488
|
+
}
|
|
3489
|
+
for (const tag of raw.match(EXPANDED_TAG_RE) ?? []) {
|
|
3490
|
+
if (!NAMES_MENU_RE.test(tag.replace(/\baria-expanded\b/i, ""))) continue;
|
|
3491
|
+
if (scripted || BOUND_RE.test(tag)) return true;
|
|
3492
|
+
}
|
|
3493
|
+
return scripted;
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
// src/check/mode-wiring.ts
|
|
3497
|
+
import { existsSync as existsSync12, readFileSync as readFileSync16 } from "fs";
|
|
3498
|
+
import { dirname as dirname6, join as join21 } from "path";
|
|
3499
|
+
var MODES = ["editorial", "product", "operator"];
|
|
3500
|
+
function configProblems(config) {
|
|
3501
|
+
const problems = [];
|
|
3502
|
+
const at = "jig.config.json";
|
|
3503
|
+
if (config.brand !== void 0) {
|
|
3504
|
+
if (typeof config.brand !== "string" || !config.brand.endsWith(".css")) {
|
|
3505
|
+
problems.push({ mode: "", barrel: at, message: `jig.config.json "brand" is ${JSON.stringify(config.brand)}, which is not a brand .css file in this project` });
|
|
3506
|
+
}
|
|
3507
|
+
}
|
|
3508
|
+
if (config.surfaces !== void 0) {
|
|
3509
|
+
const ok = Array.isArray(config.surfaces) && config.surfaces.every(
|
|
3510
|
+
(x) => x && typeof x === "object" && typeof x.match === "string" && MODES.includes(x.mode ?? "")
|
|
3511
|
+
);
|
|
3512
|
+
if (!ok) {
|
|
3513
|
+
problems.push({ mode: "", barrel: at, message: `jig.config.json "surfaces" must be a list of {"match": "/", "mode": "${MODES.join("|")}"} \u2014 as written, no mode applies and mode-gated rules are silent` });
|
|
3514
|
+
}
|
|
3515
|
+
}
|
|
3516
|
+
return problems;
|
|
3517
|
+
}
|
|
3518
|
+
function modeWiringProblems(projectRoot) {
|
|
3519
|
+
let config;
|
|
3520
|
+
const path = join21(projectRoot, "jig.config.json");
|
|
3521
|
+
if (!existsSync12(path)) return [];
|
|
3522
|
+
try {
|
|
3523
|
+
config = JSON.parse(readFileSync16(path, "utf8"));
|
|
3524
|
+
} catch {
|
|
3525
|
+
return [{ mode: "", barrel: "jig.config.json", message: "jig.config.json is not valid JSON, so nothing in it applies" }];
|
|
3526
|
+
}
|
|
3527
|
+
const invalid = configProblems(config);
|
|
3528
|
+
if (invalid.length > 0) return invalid;
|
|
3529
|
+
if (typeof config.brand !== "string" || !Array.isArray(config.surfaces)) return [];
|
|
3530
|
+
const modes = [...new Set(config.surfaces.map((s) => s?.mode).filter((m) => typeof m === "string"))];
|
|
3531
|
+
if (modes.length === 0) return [];
|
|
3532
|
+
const dir = dirname6(config.brand);
|
|
3533
|
+
const rel = (f) => dir === "." ? f : `${dir}/${f}`;
|
|
3534
|
+
if (!existsSync12(join21(projectRoot, rel("theme.css")))) return [];
|
|
3535
|
+
const problems = [];
|
|
3536
|
+
modes.forEach((mode, i) => {
|
|
3537
|
+
const barrel = rel(i === 0 ? "theme.css" : `theme.${mode}.css`);
|
|
3538
|
+
const modeFile = `mode.${mode}.css`;
|
|
3539
|
+
let body;
|
|
3540
|
+
try {
|
|
3541
|
+
body = readFileSync16(join21(projectRoot, barrel), "utf8");
|
|
3542
|
+
} catch {
|
|
3543
|
+
problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${barrel} does not exist` });
|
|
3544
|
+
return;
|
|
3545
|
+
}
|
|
3546
|
+
const imported = /@import\s+["'][^"']*mode\.(\w+)\.css["']/.exec(body)?.[1];
|
|
3547
|
+
if (imported !== mode) {
|
|
3548
|
+
problems.push({
|
|
3549
|
+
mode,
|
|
3550
|
+
barrel,
|
|
3551
|
+
message: `jig.config.json declares ${mode}, but ${barrel} imports ${imported ? `mode.${imported}.css` : "no mode file"} \u2014 the page is styled at ${imported ?? "no"} density`
|
|
3552
|
+
});
|
|
3553
|
+
} else if (!existsSync12(join21(projectRoot, rel(modeFile)))) {
|
|
3554
|
+
problems.push({ mode, barrel, message: `jig.config.json declares ${mode}, but ${rel(modeFile)} does not exist` });
|
|
3555
|
+
}
|
|
3556
|
+
});
|
|
3557
|
+
return problems;
|
|
3558
|
+
}
|
|
3559
|
+
|
|
2922
3560
|
// src/check/token-audit.ts
|
|
2923
|
-
import { existsSync as
|
|
2924
|
-
import { join as
|
|
3561
|
+
import { existsSync as existsSync13, readdirSync as readdirSync9, readFileSync as readFileSync17 } from "fs";
|
|
3562
|
+
import { join as join22 } from "path";
|
|
2925
3563
|
var TEXT_FLOOR = 4.5;
|
|
2926
3564
|
var UI_FLOOR = 3;
|
|
2927
3565
|
var FOREGROUNDS = [
|
|
@@ -2977,24 +3615,24 @@ function floorFor(token) {
|
|
|
2977
3615
|
function tokenFiles(projectRoot) {
|
|
2978
3616
|
const read = (rel) => {
|
|
2979
3617
|
try {
|
|
2980
|
-
return
|
|
3618
|
+
return readFileSync17(join22(projectRoot, ...rel.split("/")), "utf8");
|
|
2981
3619
|
} catch {
|
|
2982
3620
|
return null;
|
|
2983
3621
|
}
|
|
2984
3622
|
};
|
|
2985
|
-
const statePath =
|
|
2986
|
-
if (
|
|
3623
|
+
const statePath = join22(projectRoot, ".jig", "state.json");
|
|
3624
|
+
if (existsSync13(statePath)) {
|
|
2987
3625
|
try {
|
|
2988
|
-
const state = JSON.parse(
|
|
3626
|
+
const state = JSON.parse(readFileSync17(statePath, "utf8"));
|
|
2989
3627
|
const recorded = Object.keys(state.files ?? {}).filter((f) => f.endsWith(".css")).sort();
|
|
2990
3628
|
const out = recorded.map((path) => ({ path, source: read(path) })).filter((f) => f.source !== null);
|
|
2991
3629
|
if (out.length > 0) return out;
|
|
2992
3630
|
} catch {
|
|
2993
3631
|
}
|
|
2994
3632
|
}
|
|
2995
|
-
const legacy =
|
|
2996
|
-
if (!
|
|
2997
|
-
return
|
|
3633
|
+
const legacy = join22(projectRoot, ".jig", "tokens");
|
|
3634
|
+
if (!existsSync13(legacy)) return [];
|
|
3635
|
+
return readdirSync9(legacy).filter((f) => f.endsWith(".css")).sort().map((f) => ({ path: `.jig/tokens/${f}`, source: readFileSync17(join22(legacy, f), "utf8") }));
|
|
2998
3636
|
}
|
|
2999
3637
|
function auditTokenLayer(projectRoot) {
|
|
3000
3638
|
const files = tokenFiles(projectRoot);
|
|
@@ -3062,15 +3700,15 @@ function auditTokenLayer(projectRoot) {
|
|
|
3062
3700
|
}
|
|
3063
3701
|
|
|
3064
3702
|
// src/commands/check.ts
|
|
3065
|
-
var LEGACY_INDEX_REL =
|
|
3703
|
+
var LEGACY_INDEX_REL = join23(".jig", "rules.index.json");
|
|
3066
3704
|
function resolveIndexPath(projectRoot) {
|
|
3067
|
-
const legacy =
|
|
3068
|
-
if (
|
|
3069
|
-
return
|
|
3705
|
+
const legacy = join23(projectRoot, LEGACY_INDEX_REL);
|
|
3706
|
+
if (existsSync14(legacy)) return legacy;
|
|
3707
|
+
return join23(assetRoot(), "rules.index.json");
|
|
3070
3708
|
}
|
|
3071
3709
|
function resolveMode(projectRoot) {
|
|
3072
3710
|
try {
|
|
3073
|
-
const config = JSON.parse(
|
|
3711
|
+
const config = JSON.parse(readFileSync18(join23(projectRoot, "jig.config.json"), "utf8"));
|
|
3074
3712
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
3075
3713
|
if (modes.length === 1) return modes[0];
|
|
3076
3714
|
return modes.length > 1 ? "mixed" : "unknown";
|
|
@@ -3087,14 +3725,14 @@ function summariseUnscanned(files) {
|
|
|
3087
3725
|
}
|
|
3088
3726
|
function countSpecs() {
|
|
3089
3727
|
try {
|
|
3090
|
-
return loadSpecs(
|
|
3728
|
+
return loadSpecs(join23(assetRoot(), "rules")).length;
|
|
3091
3729
|
} catch {
|
|
3092
3730
|
return 0;
|
|
3093
3731
|
}
|
|
3094
3732
|
}
|
|
3095
3733
|
function check(opts) {
|
|
3096
3734
|
const indexPath = resolveIndexPath(opts.projectRoot);
|
|
3097
|
-
const index = validateIndex(JSON.parse(
|
|
3735
|
+
const index = validateIndex(JSON.parse(readFileSync18(indexPath, "utf8")));
|
|
3098
3736
|
const selection = selectFiles(opts.projectRoot, opts.all);
|
|
3099
3737
|
const exemptions = readExemptions(opts.projectRoot);
|
|
3100
3738
|
const { scanned: files, exempt, byPattern } = applyExemptions(selection.files, exemptions);
|
|
@@ -3104,21 +3742,46 @@ function check(opts) {
|
|
|
3104
3742
|
const projectParticipates = stylesheets.some((f) => {
|
|
3105
3743
|
if (!hasExtension(f, CSS_EXTENSIONS)) return false;
|
|
3106
3744
|
try {
|
|
3107
|
-
return participatesInTokenLayer(
|
|
3745
|
+
return participatesInTokenLayer(readFileSync18(join23(opts.projectRoot, f), "utf8"), tokens);
|
|
3746
|
+
} catch {
|
|
3747
|
+
return false;
|
|
3748
|
+
}
|
|
3749
|
+
});
|
|
3750
|
+
const projectResponsive = stylesheets.some((f) => {
|
|
3751
|
+
if (!isStyleBearing(f)) return false;
|
|
3752
|
+
try {
|
|
3753
|
+
const raw = readFileSync18(join23(opts.projectRoot, f), "utf8");
|
|
3754
|
+
return isResponsive(maskComments(maskNonStyleRegions(raw, f)), raw);
|
|
3755
|
+
} catch {
|
|
3756
|
+
return false;
|
|
3757
|
+
}
|
|
3758
|
+
});
|
|
3759
|
+
const viewportFitCover = stylesheets.some((f) => {
|
|
3760
|
+
if (!isStyleBearing(f)) return false;
|
|
3761
|
+
try {
|
|
3762
|
+
return /viewport-fit\s*=\s*cover|viewportFit\s*:\s*['"]cover['"]/i.test(readFileSync18(join23(opts.projectRoot, f), "utf8"));
|
|
3763
|
+
} catch {
|
|
3764
|
+
return false;
|
|
3765
|
+
}
|
|
3766
|
+
});
|
|
3767
|
+
const projectMenuToggle = stylesheets.some((f) => {
|
|
3768
|
+
try {
|
|
3769
|
+
return hasMenuToggle(readFileSync18(join23(opts.projectRoot, f), "utf8"));
|
|
3108
3770
|
} catch {
|
|
3109
3771
|
return false;
|
|
3110
3772
|
}
|
|
3111
3773
|
});
|
|
3774
|
+
const declaredProperties = collectDeclaredProperties(opts.projectRoot, stylesheets, Object.keys(tokens));
|
|
3112
3775
|
const withStyles = files.filter((f) => {
|
|
3113
3776
|
try {
|
|
3114
|
-
const src =
|
|
3777
|
+
const src = readFileSync18(join23(opts.projectRoot, f), "utf8");
|
|
3115
3778
|
return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
|
|
3116
3779
|
} catch {
|
|
3117
3780
|
return false;
|
|
3118
3781
|
}
|
|
3119
3782
|
}).length;
|
|
3120
3783
|
const resolvedMode = resolveMode(opts.projectRoot);
|
|
3121
|
-
const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode);
|
|
3784
|
+
const findings = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties);
|
|
3122
3785
|
const TOKEN_RULE = {
|
|
3123
3786
|
"--text-prose": "B-75",
|
|
3124
3787
|
"--size-touch-target": "E-51"
|
|
@@ -3143,7 +3806,7 @@ function check(opts) {
|
|
|
3143
3806
|
// told to add a token @import it has nowhere to put.
|
|
3144
3807
|
cssFiles.length > 0 && cssFiles.every((f) => {
|
|
3145
3808
|
try {
|
|
3146
|
-
return !participatesInTokenLayer(
|
|
3809
|
+
return !participatesInTokenLayer(readFileSync18(join23(opts.projectRoot, f), "utf8"), tokens);
|
|
3147
3810
|
} catch {
|
|
3148
3811
|
return true;
|
|
3149
3812
|
}
|
|
@@ -3163,18 +3826,19 @@ function check(opts) {
|
|
|
3163
3826
|
withStyles,
|
|
3164
3827
|
exempt,
|
|
3165
3828
|
exemptPatterns: byPattern,
|
|
3166
|
-
scope: selection.mode
|
|
3829
|
+
scope: selection.mode,
|
|
3830
|
+
modeUnwired: modeWiringProblems(opts.projectRoot)
|
|
3167
3831
|
});
|
|
3168
3832
|
return { findings, report, hasError };
|
|
3169
3833
|
}
|
|
3170
3834
|
|
|
3171
3835
|
// src/commands/init.ts
|
|
3172
|
-
import { existsSync as
|
|
3173
|
-
import { dirname as
|
|
3836
|
+
import { existsSync as existsSync15, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync22, rmSync as rmSync2, writeFileSync as writeFileSync5 } from "fs";
|
|
3837
|
+
import { dirname as dirname7, isAbsolute, join as join27, resolve as resolve2, sep as sep3 } from "path";
|
|
3174
3838
|
|
|
3175
3839
|
// src/init/detect.ts
|
|
3176
|
-
import { readFileSync as
|
|
3177
|
-
import { join as
|
|
3840
|
+
import { readFileSync as readFileSync19 } from "fs";
|
|
3841
|
+
import { join as join24 } from "path";
|
|
3178
3842
|
var TAILWIND_CONFIG_RE = /^tailwind\.config\.(js|ts|mjs|cjs)$/;
|
|
3179
3843
|
var TAILWIND_V4_IMPORT_RE = /@import\s+["']tailwindcss["']/;
|
|
3180
3844
|
var TAILWIND_V4_THEME_RE = /@theme\b/;
|
|
@@ -3191,7 +3855,7 @@ var FRAMEWORK_DEPS = [
|
|
|
3191
3855
|
function detectFramework(projectRoot) {
|
|
3192
3856
|
let pkg;
|
|
3193
3857
|
try {
|
|
3194
|
-
pkg = JSON.parse(
|
|
3858
|
+
pkg = JSON.parse(readFileSync19(join24(projectRoot, "package.json"), "utf8"));
|
|
3195
3859
|
} catch {
|
|
3196
3860
|
return void 0;
|
|
3197
3861
|
}
|
|
@@ -3209,7 +3873,7 @@ function detect(projectRoot) {
|
|
|
3209
3873
|
for (const f of cssFiles) {
|
|
3210
3874
|
let source;
|
|
3211
3875
|
try {
|
|
3212
|
-
source =
|
|
3876
|
+
source = readFileSync19(join24(projectRoot, f), "utf8");
|
|
3213
3877
|
} catch {
|
|
3214
3878
|
continue;
|
|
3215
3879
|
}
|
|
@@ -3244,8 +3908,8 @@ function isIgnored(projectRoot, relPath) {
|
|
|
3244
3908
|
}
|
|
3245
3909
|
|
|
3246
3910
|
// src/init/derive.ts
|
|
3247
|
-
import { readFileSync as
|
|
3248
|
-
import { join as
|
|
3911
|
+
import { readFileSync as readFileSync20 } from "fs";
|
|
3912
|
+
import { join as join25 } from "path";
|
|
3249
3913
|
var DEFAULT_PROPOSAL = {
|
|
3250
3914
|
h: 264,
|
|
3251
3915
|
s: 0,
|
|
@@ -3278,7 +3942,7 @@ function fromCssCustomProperties(projectRoot, cssFiles) {
|
|
|
3278
3942
|
for (const file of cssFiles) {
|
|
3279
3943
|
let source;
|
|
3280
3944
|
try {
|
|
3281
|
-
source =
|
|
3945
|
+
source = readFileSync20(join25(projectRoot, file), "utf8");
|
|
3282
3946
|
} catch {
|
|
3283
3947
|
continue;
|
|
3284
3948
|
}
|
|
@@ -3327,7 +3991,7 @@ function balancedBraceBody(source, openIdx) {
|
|
|
3327
3991
|
function fromTailwindConfig(projectRoot, tailwindConfigFile) {
|
|
3328
3992
|
let source;
|
|
3329
3993
|
try {
|
|
3330
|
-
source =
|
|
3994
|
+
source = readFileSync20(join25(projectRoot, tailwindConfigFile), "utf8");
|
|
3331
3995
|
} catch {
|
|
3332
3996
|
return null;
|
|
3333
3997
|
}
|
|
@@ -3383,7 +4047,7 @@ function fromLiteralFrequency(projectRoot, cssFiles) {
|
|
|
3383
4047
|
for (const file of cssFiles) {
|
|
3384
4048
|
let source;
|
|
3385
4049
|
try {
|
|
3386
|
-
source =
|
|
4050
|
+
source = readFileSync20(join25(projectRoot, file), "utf8");
|
|
3387
4051
|
} catch {
|
|
3388
4052
|
continue;
|
|
3389
4053
|
}
|
|
@@ -3543,8 +4207,8 @@ function relativeImportPath(fromAbsDir, toAbsFile) {
|
|
|
3543
4207
|
}
|
|
3544
4208
|
|
|
3545
4209
|
// src/init/project-name.ts
|
|
3546
|
-
import { readFileSync as
|
|
3547
|
-
import { basename, join as
|
|
4210
|
+
import { readFileSync as readFileSync21 } from "fs";
|
|
4211
|
+
import { basename, join as join26 } from "path";
|
|
3548
4212
|
function slugify(name) {
|
|
3549
4213
|
const withoutScope = name.replace(/^@[^/]+\//, "");
|
|
3550
4214
|
const slug = withoutScope.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -3552,7 +4216,7 @@ function slugify(name) {
|
|
|
3552
4216
|
}
|
|
3553
4217
|
function deriveProjectSlug(projectRoot) {
|
|
3554
4218
|
try {
|
|
3555
|
-
const pkg = JSON.parse(
|
|
4219
|
+
const pkg = JSON.parse(readFileSync21(join26(projectRoot, "package.json"), "utf8"));
|
|
3556
4220
|
if (typeof pkg.name === "string" && pkg.name.trim()) return slugify(pkg.name);
|
|
3557
4221
|
} catch {
|
|
3558
4222
|
}
|
|
@@ -3635,7 +4299,7 @@ ${lines.join("\n")}
|
|
|
3635
4299
|
}
|
|
3636
4300
|
|
|
3637
4301
|
// src/commands/init.ts
|
|
3638
|
-
var
|
|
4302
|
+
var MODES2 = ["editorial", "product", "operator"];
|
|
3639
4303
|
var DEFAULT_SURFACES = [{ match: "/", mode: "product" }];
|
|
3640
4304
|
async function defaultPrompt(question) {
|
|
3641
4305
|
const { createInterface } = await import("readline/promises");
|
|
@@ -3687,28 +4351,28 @@ function parseSurfaces(answer, fallback) {
|
|
|
3687
4351
|
if (idx === -1) return fallback;
|
|
3688
4352
|
const match = part.slice(0, idx).trim();
|
|
3689
4353
|
const mode = part.slice(idx + 1).trim();
|
|
3690
|
-
if (!match || !
|
|
4354
|
+
if (!match || !MODES2.includes(mode)) return fallback;
|
|
3691
4355
|
parsed.push({ match, mode });
|
|
3692
4356
|
}
|
|
3693
4357
|
return parsed.length > 0 ? parsed : fallback;
|
|
3694
4358
|
}
|
|
3695
4359
|
function fileState(projectRoot, absPath, relPath, initManifest) {
|
|
3696
|
-
const existsOnDisk =
|
|
4360
|
+
const existsOnDisk = existsSync15(absPath);
|
|
3697
4361
|
const tracked = !!initManifest && relPath in initManifest.files;
|
|
3698
4362
|
const modified = tracked && isInitFileModified(projectRoot, relPath, initManifest);
|
|
3699
4363
|
return { existsOnDisk, tracked, modified };
|
|
3700
4364
|
}
|
|
3701
4365
|
function isValidSurfaceArray(v) {
|
|
3702
4366
|
return Array.isArray(v) && v.length > 0 && v.every(
|
|
3703
|
-
(s) => typeof s === "object" && s !== null && typeof s.match === "string" &&
|
|
4367
|
+
(s) => typeof s === "object" && s !== null && typeof s.match === "string" && MODES2.includes(s.mode)
|
|
3704
4368
|
);
|
|
3705
4369
|
}
|
|
3706
4370
|
function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallbackSurfaces) {
|
|
3707
|
-
if (!
|
|
4371
|
+
if (!existsSync15(configAbsPath)) return { brand: fallbackBrand, surfaces: fallbackSurfaces };
|
|
3708
4372
|
try {
|
|
3709
|
-
const parsed = JSON.parse(
|
|
4373
|
+
const parsed = JSON.parse(readFileSync22(configAbsPath, "utf8"));
|
|
3710
4374
|
const brandCandidate = typeof parsed.brand === "string" && parsed.brand ? parsed.brand : void 0;
|
|
3711
|
-
const brandResolves = brandCandidate ?
|
|
4375
|
+
const brandResolves = brandCandidate ? existsSync15(join27(projectRoot, ...brandCandidate.split("/"))) : false;
|
|
3712
4376
|
return {
|
|
3713
4377
|
brand: brandResolves ? brandCandidate : fallbackBrand,
|
|
3714
4378
|
surfaces: isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : fallbackSurfaces
|
|
@@ -3718,10 +4382,10 @@ function loadEffectiveConfig(projectRoot, configAbsPath, fallbackBrand, fallback
|
|
|
3718
4382
|
}
|
|
3719
4383
|
}
|
|
3720
4384
|
function configuredBrandPath(projectRoot, configAbsPath) {
|
|
3721
|
-
if (!
|
|
4385
|
+
if (!existsSync15(configAbsPath)) return { path: null };
|
|
3722
4386
|
let declared;
|
|
3723
4387
|
try {
|
|
3724
|
-
declared = JSON.parse(
|
|
4388
|
+
declared = JSON.parse(readFileSync22(configAbsPath, "utf8")).brand;
|
|
3725
4389
|
} catch {
|
|
3726
4390
|
return { path: null };
|
|
3727
4391
|
}
|
|
@@ -3749,9 +4413,9 @@ function defaultTokenDir(detection) {
|
|
|
3749
4413
|
return ["jig"];
|
|
3750
4414
|
}
|
|
3751
4415
|
function readConfiguredSurfaces(configAbsPath) {
|
|
3752
|
-
if (!
|
|
4416
|
+
if (!existsSync15(configAbsPath)) return null;
|
|
3753
4417
|
try {
|
|
3754
|
-
const parsed = JSON.parse(
|
|
4418
|
+
const parsed = JSON.parse(readFileSync22(configAbsPath, "utf8"));
|
|
3755
4419
|
return isValidSurfaceArray(parsed.surfaces) ? parsed.surfaces : null;
|
|
3756
4420
|
} catch {
|
|
3757
4421
|
return null;
|
|
@@ -3761,9 +4425,9 @@ function isCssModule(path) {
|
|
|
3761
4425
|
return /\.module\.(css|scss)$/i.test(path);
|
|
3762
4426
|
}
|
|
3763
4427
|
function legacyBrandFile(projectRoot) {
|
|
3764
|
-
const dir =
|
|
3765
|
-
if (!
|
|
3766
|
-
const found =
|
|
4428
|
+
const dir = join27(projectRoot, ".jig", "tokens");
|
|
4429
|
+
if (!existsSync15(dir)) return void 0;
|
|
4430
|
+
const found = readdirSync10(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
|
|
3767
4431
|
return found ? relKey(".jig", "tokens", found) : void 0;
|
|
3768
4432
|
}
|
|
3769
4433
|
function isTokenLayerFile(f) {
|
|
@@ -3775,7 +4439,7 @@ function findWireTarget(d) {
|
|
|
3775
4439
|
if (candidates.length === 1) return candidates[0];
|
|
3776
4440
|
return null;
|
|
3777
4441
|
}
|
|
3778
|
-
var MODE_IMPORT_RE = new RegExp(`@import\\s+["']([^"']*/)?mode\\.(${
|
|
4442
|
+
var MODE_IMPORT_RE = new RegExp(`@import\\s+["']([^"']*/)?mode\\.(${MODES2.join("|")})\\.css["'];?\\r?\\n?`);
|
|
3779
4443
|
var CHARSET_RE = /^@charset\s+["'][^"']*["'];\r?\n?/;
|
|
3780
4444
|
function insertAfterCharset(content, insertion) {
|
|
3781
4445
|
const m = CHARSET_RE.exec(content);
|
|
@@ -3790,17 +4454,17 @@ function barrelBody(fileName, brandFile, mode, version2) {
|
|
|
3790
4454
|
`;
|
|
3791
4455
|
}
|
|
3792
4456
|
var LEGACY_PAIR_RE = new RegExp(
|
|
3793
|
-
`@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${
|
|
4457
|
+
`@import\\s+["'][^"']*brand\\.[\\w.-]+\\.css["'];?\\r?\\n?\\s*@import\\s+["'][^"']*mode\\.(?:${MODES2.join("|")})\\.css["'];?\\r?\\n?`
|
|
3794
4458
|
);
|
|
3795
4459
|
function wireBarrel(absPath, barrelImport) {
|
|
3796
|
-
const content =
|
|
4460
|
+
const content = existsSync15(absPath) ? readFileSync22(absPath, "utf8") : "";
|
|
3797
4461
|
const line = `@import "${barrelImport}";`;
|
|
3798
4462
|
if (content.includes(line)) return { status: "already-present" };
|
|
3799
4463
|
const legacy = LEGACY_PAIR_RE.exec(content);
|
|
3800
4464
|
if (legacy) {
|
|
3801
4465
|
const next2 = content.slice(0, legacy.index) + `${line}
|
|
3802
4466
|
` + content.slice(legacy.index + legacy[0].length);
|
|
3803
|
-
|
|
4467
|
+
writeFileSync5(absPath, next2, "utf8");
|
|
3804
4468
|
return { status: "rewired", detail: "replaced the separate brand and mode imports with the barrel" };
|
|
3805
4469
|
}
|
|
3806
4470
|
const tailwind = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
|
|
@@ -3809,8 +4473,8 @@ function wireBarrel(absPath, barrelImport) {
|
|
|
3809
4473
|
|
|
3810
4474
|
` : `${line}
|
|
3811
4475
|
`);
|
|
3812
|
-
|
|
3813
|
-
|
|
4476
|
+
mkdirSync6(dirname7(absPath), { recursive: true });
|
|
4477
|
+
writeFileSync5(next === content ? absPath : absPath, next, "utf8");
|
|
3814
4478
|
return { status: "wired" };
|
|
3815
4479
|
}
|
|
3816
4480
|
async function init(opts) {
|
|
@@ -3871,21 +4535,21 @@ async function init(opts) {
|
|
|
3871
4535
|
for (const line of describeValidation(validation)) log(line);
|
|
3872
4536
|
}
|
|
3873
4537
|
const surfaceAnswer = await prompt(
|
|
3874
|
-
`Surface \u2192 mode mapping. Enter to accept '/' \u2192 product, or 'match:mode,match:mode' (modes: ${
|
|
4538
|
+
`Surface \u2192 mode mapping. Enter to accept '/' \u2192 product, or 'match:mode,match:mode' (modes: ${MODES2.join("/")}): `
|
|
3875
4539
|
);
|
|
3876
4540
|
surfaces = parseSurfaces(surfaceAnswer, DEFAULT_SURFACES);
|
|
3877
4541
|
if (surfaceAnswer.trim() && surfaces === DEFAULT_SURFACES) {
|
|
3878
4542
|
log(
|
|
3879
|
-
` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${
|
|
4543
|
+
` Could not parse '${surfaceAnswer}' as 'match:mode,match:mode' (modes: ${MODES2.join("/")}) \u2014 using the default ('/' \u2192 product) instead.`
|
|
3880
4544
|
);
|
|
3881
4545
|
}
|
|
3882
4546
|
}
|
|
3883
4547
|
if (opts.yes) {
|
|
3884
|
-
const declared = readConfiguredSurfaces(
|
|
4548
|
+
const declared = readConfiguredSurfaces(join27(opts.projectRoot, "jig.config.json"));
|
|
3885
4549
|
const inEffect = declared ?? surfaces;
|
|
3886
4550
|
const map = inEffect.map((x) => `'${x.match}' \u2192 ${x.mode}`).join(", ");
|
|
3887
4551
|
log(
|
|
3888
|
-
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:
|
|
4552
|
+
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: not declared. jig.config.json records no mapping, because --yes has nobody to ask and a guess written there cannot be told from a decision. Mode-gated rules stay silent until you declare one. Token files are written at ${inEffect[0]?.mode ?? "product"} as a baseline. Add "surfaces": [{"match": "/", "mode": "..."}] to jig.config.json and run jig init again \u2014 the config alone changes what agents read, not the token files the page imports \u2014 or run jig init without --yes (modes: ${MODES2.join("/")}).`
|
|
3889
4553
|
);
|
|
3890
4554
|
}
|
|
3891
4555
|
if (!validation.passesContrast && validation.nearestPassingLightness !== void 0) {
|
|
@@ -3900,7 +4564,7 @@ Brand colour adjusted to clear the 4.5:1 contrast floor: l:${originalL}% \u2192
|
|
|
3900
4564
|
const finalColor = { h: proposal.h, s: proposal.s, l: proposal.l };
|
|
3901
4565
|
const projectSlug = deriveProjectSlug(opts.projectRoot);
|
|
3902
4566
|
const configRelPath = "jig.config.json";
|
|
3903
|
-
const configAbsPath =
|
|
4567
|
+
const configAbsPath = join27(opts.projectRoot, configRelPath);
|
|
3904
4568
|
const configuredBrand = configuredBrandPath(opts.projectRoot, configAbsPath);
|
|
3905
4569
|
if (configuredBrand.refused) {
|
|
3906
4570
|
log(
|
|
@@ -3934,7 +4598,7 @@ The token layer is at ${currentDir}/, and this project's layout suggests ${would
|
|
|
3934
4598
|
}
|
|
3935
4599
|
}
|
|
3936
4600
|
const brandRelPath = relocateTo ?? configuredBrand.path ?? priorBrand ?? relKey(...defaultTokenDir(detection), brandFileName(projectSlug));
|
|
3937
|
-
const brandAbsPath =
|
|
4601
|
+
const brandAbsPath = join27(opts.projectRoot, ...brandRelPath.split("/"));
|
|
3938
4602
|
const tokensRelDir = brandRelPath.split("/").slice(0, -1);
|
|
3939
4603
|
log(
|
|
3940
4604
|
`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.")
|
|
@@ -3967,18 +4631,18 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
3967
4631
|
if (configAction !== "written") log(` ${configRelPath} exists and is not jig-tracked \u2014 leaving it alone (re-run without --yes to refresh).`);
|
|
3968
4632
|
}
|
|
3969
4633
|
if (brandAction === "written") {
|
|
3970
|
-
const defaultCssRaw =
|
|
4634
|
+
const defaultCssRaw = readFileSync22(join27(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
3971
4635
|
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
3972
|
-
|
|
3973
|
-
|
|
4636
|
+
mkdirSync6(dirname7(brandAbsPath), { recursive: true });
|
|
4637
|
+
writeFileSync5(brandAbsPath, content, "utf8");
|
|
3974
4638
|
files[brandRelPath] = checksum(content);
|
|
3975
4639
|
}
|
|
3976
4640
|
if (relocateTo && configAction !== "written") {
|
|
3977
4641
|
try {
|
|
3978
|
-
const existing = JSON.parse(
|
|
4642
|
+
const existing = JSON.parse(readFileSync22(configAbsPath, "utf8"));
|
|
3979
4643
|
const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
|
|
3980
4644
|
`;
|
|
3981
|
-
|
|
4645
|
+
writeFileSync5(configAbsPath, content, "utf8");
|
|
3982
4646
|
files[configRelPath] = checksum(content);
|
|
3983
4647
|
log(` Updated ${configRelPath} to point at the new location.`);
|
|
3984
4648
|
} catch {
|
|
@@ -3986,37 +4650,40 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
3986
4650
|
}
|
|
3987
4651
|
}
|
|
3988
4652
|
if (configAction === "written") {
|
|
3989
|
-
const config = { brand: brandRelPath, surfaces };
|
|
4653
|
+
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
|
|
3990
4654
|
const content = `${JSON.stringify(config, null, 2)}
|
|
3991
4655
|
`;
|
|
3992
|
-
|
|
4656
|
+
writeFileSync5(configAbsPath, content, "utf8");
|
|
3993
4657
|
files[configRelPath] = checksum(content);
|
|
3994
4658
|
} else if (configAction === "merged") {
|
|
3995
4659
|
let existing = {};
|
|
3996
4660
|
try {
|
|
3997
|
-
existing = JSON.parse(
|
|
4661
|
+
existing = JSON.parse(readFileSync22(configAbsPath, "utf8"));
|
|
3998
4662
|
} catch {
|
|
3999
4663
|
existing = {};
|
|
4000
4664
|
}
|
|
4001
4665
|
const existingBrand = typeof existing.brand === "string" ? existing.brand : void 0;
|
|
4002
|
-
const brandStillResolves = existingBrand ?
|
|
4666
|
+
const brandStillResolves = existingBrand ? existsSync15(join27(opts.projectRoot, ...existingBrand.split("/"))) : false;
|
|
4667
|
+
const existingSurfaces = Array.isArray(existing.surfaces) && existing.surfaces.length > 0 ? existing.surfaces : void 0;
|
|
4003
4668
|
const merged = {
|
|
4004
4669
|
brand: brandStillResolves ? existingBrand : brandRelPath,
|
|
4005
|
-
|
|
4670
|
+
// Same contract as the 'written' branch: never invent a mapping under
|
|
4671
|
+
// `--yes`. An existing declaration is always kept.
|
|
4672
|
+
...existingSurfaces ? { surfaces: existingSurfaces } : opts.yes ? {} : { surfaces }
|
|
4006
4673
|
};
|
|
4007
4674
|
const content = `${JSON.stringify(merged, null, 2)}
|
|
4008
4675
|
`;
|
|
4009
|
-
|
|
4676
|
+
writeFileSync5(configAbsPath, content, "utf8");
|
|
4010
4677
|
files[configRelPath] = checksum(content);
|
|
4011
4678
|
}
|
|
4012
4679
|
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
4013
4680
|
const primaryMode = effectiveConfig.surfaces[0]?.mode ?? "product";
|
|
4014
|
-
const wiringBrandAbsPath =
|
|
4681
|
+
const wiringBrandAbsPath = join27(opts.projectRoot, ...effectiveConfig.brand.split("/"));
|
|
4015
4682
|
const declaredModes = [...new Set(effectiveConfig.surfaces.map((s) => s.mode))];
|
|
4016
4683
|
const modeAbsPaths = {};
|
|
4017
4684
|
for (const mode of declaredModes) {
|
|
4018
4685
|
const modeFileName = `mode.${mode}.css`;
|
|
4019
|
-
const modeAbsPath2 =
|
|
4686
|
+
const modeAbsPath2 = join27(opts.projectRoot, ...tokensRelDir, modeFileName);
|
|
4020
4687
|
modeAbsPaths[mode] = modeAbsPath2;
|
|
4021
4688
|
const modeRelPath = relKey(...tokensRelDir, modeFileName);
|
|
4022
4689
|
const modeState = fileState(opts.projectRoot, modeAbsPath2, modeRelPath, initManifest);
|
|
@@ -4024,10 +4691,10 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
4024
4691
|
if (modeState.existsOnDisk && !modeState.tracked) modeAction = "skipped-untracked";
|
|
4025
4692
|
else if (modeState.modified) modeAction = "skipped-edited";
|
|
4026
4693
|
if (modeAction === "written") {
|
|
4027
|
-
const modeSourceRaw =
|
|
4694
|
+
const modeSourceRaw = readFileSync22(join27(opts.packageRoot, "tokens", modeFileName), "utf8");
|
|
4028
4695
|
const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
|
|
4029
|
-
|
|
4030
|
-
|
|
4696
|
+
mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
|
|
4697
|
+
writeFileSync5(modeAbsPath2, content, "utf8");
|
|
4031
4698
|
files[modeRelPath] = checksum(content);
|
|
4032
4699
|
} else {
|
|
4033
4700
|
log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
|
|
@@ -4038,22 +4705,22 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
4038
4705
|
const barrelFor = (mode) => mode === primaryMode ? "theme.css" : `theme.${mode}.css`;
|
|
4039
4706
|
for (const mode of declaredModes) {
|
|
4040
4707
|
const rel = relKey(...tokensRelDir, barrelFor(mode));
|
|
4041
|
-
const abs =
|
|
4708
|
+
const abs = join27(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
|
|
4042
4709
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
4043
4710
|
if (state.existsOnDisk && (!state.tracked || state.modified)) {
|
|
4044
4711
|
log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
|
|
4045
4712
|
continue;
|
|
4046
4713
|
}
|
|
4047
4714
|
const content = barrelBody(barrelFor(mode), brandFileOnly, mode, opts.version);
|
|
4048
|
-
|
|
4049
|
-
|
|
4715
|
+
mkdirSync6(dirname7(abs), { recursive: true });
|
|
4716
|
+
writeFileSync5(abs, content, "utf8");
|
|
4050
4717
|
files[rel] = checksum(content);
|
|
4051
4718
|
}
|
|
4052
4719
|
if (detection.cssSystem === "tailwind-v4") {
|
|
4053
4720
|
const declared = declaredTokenNames(
|
|
4054
4721
|
[brandAbsPath, modeAbsPath].map((f) => {
|
|
4055
4722
|
try {
|
|
4056
|
-
return
|
|
4723
|
+
return readFileSync22(f, "utf8");
|
|
4057
4724
|
} catch {
|
|
4058
4725
|
return "";
|
|
4059
4726
|
}
|
|
@@ -4078,19 +4745,19 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
4078
4745
|
log(` This changes how components are written. Both styles are correct.`);
|
|
4079
4746
|
const answer = (await prompt(" Generate it? [y/N]: ")).toLowerCase();
|
|
4080
4747
|
if (answer === "y" || answer === "yes") {
|
|
4081
|
-
const abs =
|
|
4748
|
+
const abs = join27(opts.projectRoot, ...tokensRelDir, "utilities.css");
|
|
4082
4749
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
4083
4750
|
if (state.existsOnDisk && (!state.tracked || state.modified)) {
|
|
4084
4751
|
log(` ${rel} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone.`);
|
|
4085
4752
|
} else {
|
|
4086
4753
|
const content = utilitiesBody(aliasable, opts.version);
|
|
4087
|
-
|
|
4088
|
-
|
|
4754
|
+
mkdirSync6(dirname7(abs), { recursive: true });
|
|
4755
|
+
writeFileSync5(abs, content, "utf8");
|
|
4089
4756
|
files[rel] = checksum(content);
|
|
4090
4757
|
const target = findWireTarget(detection);
|
|
4091
4758
|
const importPath = target ? relativeImportPath(
|
|
4092
|
-
|
|
4093
|
-
|
|
4759
|
+
dirname7(join27(opts.projectRoot, target)),
|
|
4760
|
+
join27(opts.projectRoot, ...tokensRelDir, "utilities.css")
|
|
4094
4761
|
) : `./${rel}`;
|
|
4095
4762
|
log(` Wrote ${rel}. Import it in the stylesheet that imports Tailwind:`);
|
|
4096
4763
|
log(` @import "tailwindcss";`);
|
|
@@ -4108,11 +4775,11 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
4108
4775
|
writeInitManifest(opts.projectRoot, { version: opts.version, modes: declaredModes, files });
|
|
4109
4776
|
if (relocateTo && currentBrand) {
|
|
4110
4777
|
const oldDir = currentBrand.split("/").slice(0, -1);
|
|
4111
|
-
const oldAbsDir =
|
|
4112
|
-
if (
|
|
4113
|
-
for (const name of
|
|
4778
|
+
const oldAbsDir = join27(opts.projectRoot, ...oldDir);
|
|
4779
|
+
if (existsSync15(oldAbsDir)) {
|
|
4780
|
+
for (const name of readdirSync10(oldAbsDir).filter((f) => f.endsWith(".css"))) {
|
|
4114
4781
|
const rel = relKey(...oldDir, name);
|
|
4115
|
-
const abs =
|
|
4782
|
+
const abs = join27(oldAbsDir, name);
|
|
4116
4783
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
4117
4784
|
if (!state.tracked || state.modified) {
|
|
4118
4785
|
log(` ${rel} was edited \u2014 left in place. Delete it yourself once you have moved anything you need.`);
|
|
@@ -4126,21 +4793,21 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
4126
4793
|
const wireTarget = findWireTarget(detection);
|
|
4127
4794
|
let wiring;
|
|
4128
4795
|
if (wireTarget) {
|
|
4129
|
-
const targetAbsDir =
|
|
4796
|
+
const targetAbsDir = dirname7(join27(opts.projectRoot, wireTarget));
|
|
4130
4797
|
const barrelImport = relativeImportPath(
|
|
4131
4798
|
targetAbsDir,
|
|
4132
|
-
|
|
4799
|
+
join27(opts.projectRoot, ...tokensRelDir, "theme.css")
|
|
4133
4800
|
);
|
|
4134
4801
|
const snippet = `@import "${barrelImport}";`;
|
|
4135
4802
|
try {
|
|
4136
4803
|
if (relocateTo && currentBrand) {
|
|
4137
|
-
const abs =
|
|
4804
|
+
const abs = join27(opts.projectRoot, wireTarget);
|
|
4138
4805
|
const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
|
|
4139
|
-
const before =
|
|
4806
|
+
const before = existsSync15(abs) ? readFileSync22(abs, "utf8") : "";
|
|
4140
4807
|
const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
|
|
4141
|
-
if (after !== before)
|
|
4808
|
+
if (after !== before) writeFileSync5(abs, after, "utf8");
|
|
4142
4809
|
}
|
|
4143
|
-
const outcome = wireBarrel(
|
|
4810
|
+
const outcome = wireBarrel(join27(opts.projectRoot, wireTarget), barrelImport);
|
|
4144
4811
|
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
4145
4812
|
const verb = outcome.status === "wired" ? "Wired" : outcome.status === "rewired" ? "Rewired" : "Already present in";
|
|
4146
4813
|
log(`
|
|
@@ -4157,7 +4824,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4157
4824
|
} else {
|
|
4158
4825
|
const barrelImport = relativeImportPath(
|
|
4159
4826
|
opts.projectRoot,
|
|
4160
|
-
|
|
4827
|
+
join27(opts.projectRoot, ...tokensRelDir, "theme.css")
|
|
4161
4828
|
);
|
|
4162
4829
|
const snippet = `@import "${barrelImport}";`;
|
|
4163
4830
|
wiring = { target: null, status: "print-only", snippet };
|
|
@@ -4166,7 +4833,7 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4166
4833
|
if (pasteTargets.length > 0) {
|
|
4167
4834
|
log("Paste the block for whichever of these is your global stylesheet:");
|
|
4168
4835
|
for (const target of pasteTargets) {
|
|
4169
|
-
const dir =
|
|
4836
|
+
const dir = dirname7(join27(opts.projectRoot, target));
|
|
4170
4837
|
const forTarget = `@import "${relativeImportPath(dir, wiringBrandAbsPath)}";
|
|
4171
4838
|
@import "${relativeImportPath(dir, modeAbsPath)}";`;
|
|
4172
4839
|
log(`
|
|
@@ -4180,9 +4847,16 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4180
4847
|
log(` ${snippet.split("\n").join("\n ")}`);
|
|
4181
4848
|
}
|
|
4182
4849
|
}
|
|
4850
|
+
const tokenDirRel = tokensRelDir.join("/");
|
|
4851
|
+
if (isIgnored(opts.projectRoot, tokenDirRel)) {
|
|
4852
|
+
log(
|
|
4853
|
+
`
|
|
4854
|
+
WARNING: ${tokenDirRel}/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the ${tokenDirRel}/ rule from .gitignore, or commit the directory explicitly.`
|
|
4855
|
+
);
|
|
4856
|
+
}
|
|
4183
4857
|
if (isIgnored(opts.projectRoot, ".jig")) {
|
|
4184
4858
|
log(
|
|
4185
|
-
"\n WARNING: .jig/ is gitignored
|
|
4859
|
+
"\n WARNING: .jig/ is gitignored. It holds state.json \u2014 which `update` reads to leave your edited files alone \u2014 and the specs, mockups and critique verdicts this project agreed. Ignored, update cannot tell your edits from its own writes, and every spec is invisible to the next agent. Commit it."
|
|
4186
4860
|
);
|
|
4187
4861
|
}
|
|
4188
4862
|
const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
|
|
@@ -4205,9 +4879,565 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4205
4879
|
};
|
|
4206
4880
|
}
|
|
4207
4881
|
|
|
4882
|
+
// src/commands/verdicts.ts
|
|
4883
|
+
import { existsSync as existsSync18, readFileSync as readFileSync25 } from "fs";
|
|
4884
|
+
import { join as join31, resolve as resolve4 } from "path";
|
|
4885
|
+
|
|
4886
|
+
// src/rules/citations.ts
|
|
4887
|
+
import { join as join28 } from "path";
|
|
4888
|
+
function citableIds(packageRoot2) {
|
|
4889
|
+
const rulesDir = join28(packageRoot2, "rules");
|
|
4890
|
+
const ids = [
|
|
4891
|
+
...loadRules(rulesDir, join28(packageRoot2, "rules.index.json")).map((r) => r.id),
|
|
4892
|
+
...loadSpecs(rulesDir).map((s) => s.id)
|
|
4893
|
+
];
|
|
4894
|
+
return [...new Set(ids)].sort();
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
// src/probe/check.ts
|
|
4898
|
+
import { existsSync as existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
|
|
4899
|
+
import { join as join30 } from "path";
|
|
4900
|
+
|
|
4901
|
+
// src/probe/save.ts
|
|
4902
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
|
|
4903
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4904
|
+
import { join as join29, relative as relative3, resolve as resolve3 } from "path";
|
|
4905
|
+
|
|
4906
|
+
// src/probe/script.ts
|
|
4907
|
+
var PROBE_VERSION = 2;
|
|
4908
|
+
var PROBE_SCRIPT = `(async () => {
|
|
4909
|
+
const doc = document.documentElement;
|
|
4910
|
+
const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
|
|
4911
|
+
const probe = document.createElement('div');
|
|
4912
|
+
probe.style.all = 'initial';
|
|
4913
|
+
document.body.appendChild(probe);
|
|
4914
|
+
const defaultFont = getComputedStyle(probe).fontFamily;
|
|
4915
|
+
probe.remove();
|
|
4916
|
+
const text = document.body.innerText || '';
|
|
4917
|
+
const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
|
|
4918
|
+
const unresolved = new Set();
|
|
4919
|
+
for (const sheet of document.styleSheets) {
|
|
4920
|
+
let rules; try { rules = sheet.cssRules; } catch (e) { continue; }
|
|
4921
|
+
for (const rule of rules) {
|
|
4922
|
+
for (const m of (rule.cssText || '').matchAll(/var\\(\\s*(--[\\w-]+)\\s*\\)/g)) {
|
|
4923
|
+
if (!getComputedStyle(doc).getPropertyValue(m[1]).trim()) unresolved.add(m[1]);
|
|
4924
|
+
}
|
|
4925
|
+
}
|
|
4926
|
+
}
|
|
4927
|
+
const navLinks = () => [...document.querySelectorAll('nav a, header a, [role=navigation] a')].filter(vis).length;
|
|
4928
|
+
// Measured before anything is clicked: what a reader sees on arrival.
|
|
4929
|
+
const navAtRest = navLinks();
|
|
4930
|
+
// The menu control: inside the header or navigation, or named as the menu.
|
|
4931
|
+
// Not any disclosure \u2014 an FAQ <summary> is not a menu.
|
|
4932
|
+
const inChrome = (b) => !!b.closest('header, nav, [role=navigation], [role=banner]');
|
|
4933
|
+
const named = (b) => /\b(menu|navigation)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + b.textContent);
|
|
4934
|
+
const toggle = [...document.querySelectorAll('button, summary, [role=button]')].find((b) => vis(b) &&
|
|
4935
|
+
(named(b) || (inChrome(b) && (b.tagName === 'SUMMARY' || b.hasAttribute('aria-expanded') || b.hasAttribute('aria-controls')))));
|
|
4936
|
+
let menu = null;
|
|
4937
|
+
if (toggle) {
|
|
4938
|
+
const name = () => ((toggle.getAttribute('aria-label') || '') + ' ' + (toggle.textContent || '')).replace(/\\s+/g, ' ').trim();
|
|
4939
|
+
// Opening is judged by what becomes visible anywhere: a menu panel is often
|
|
4940
|
+
// a sibling of the header, not inside it (arm test 3, tw-1).
|
|
4941
|
+
const allLinks = () => [...document.querySelectorAll('a')].filter(vis).length;
|
|
4942
|
+
const controlled = () => { const id = toggle.getAttribute('aria-controls'); const el = id && document.getElementById(id); return el ? vis(el) : null; };
|
|
4943
|
+
const before = { links: allLinks(), controlled: controlled(), expanded: toggle.getAttribute('aria-expanded'), name: name(), html: toggle.innerHTML };
|
|
4944
|
+
toggle.click();
|
|
4945
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
4946
|
+
const after = { links: allLinks(), controlled: controlled(), expanded: toggle.getAttribute('aria-expanded'), name: name(), htmlChanged: toggle.innerHTML !== before.html };
|
|
4947
|
+
toggle.focus();
|
|
4948
|
+
toggle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
4949
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
4950
|
+
await new Promise((r) => setTimeout(r, 350));
|
|
4951
|
+
menu = {
|
|
4952
|
+
linksBefore: before.links, linksAfter: after.links,
|
|
4953
|
+
expandedBefore: before.expanded, expandedAfter: after.expanded,
|
|
4954
|
+
opened: after.links > before.links || (before.controlled === false && after.controlled === true),
|
|
4955
|
+
labelChanged: after.name !== before.name || after.htmlChanged,
|
|
4956
|
+
escapeCloses: toggle.getAttribute('aria-expanded') !== 'true' && allLinks() <= before.links,
|
|
4957
|
+
focusReturned: document.activeElement === toggle,
|
|
4958
|
+
};
|
|
4959
|
+
}
|
|
4960
|
+
return JSON.stringify({
|
|
4961
|
+
jigProbe: ${PROBE_VERSION},
|
|
4962
|
+
url: location.href,
|
|
4963
|
+
width: innerWidth,
|
|
4964
|
+
scrollWidth: doc.scrollWidth,
|
|
4965
|
+
clientWidth: doc.clientWidth,
|
|
4966
|
+
sidewaysScroll: doc.scrollWidth > doc.clientWidth,
|
|
4967
|
+
bodyFont: getComputedStyle(document.body).fontFamily,
|
|
4968
|
+
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
4969
|
+
unresolvedTokens: [...unresolved],
|
|
4970
|
+
junkText: junk,
|
|
4971
|
+
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
4972
|
+
navLinksVisible: navAtRest,
|
|
4973
|
+
menu,
|
|
4974
|
+
});
|
|
4975
|
+
})()`;
|
|
4976
|
+
|
|
4977
|
+
// src/probe/save.ts
|
|
4978
|
+
function saveProbe(opts) {
|
|
4979
|
+
let probe;
|
|
4980
|
+
try {
|
|
4981
|
+
probe = JSON.parse(opts.json);
|
|
4982
|
+
} catch (e) {
|
|
4983
|
+
throw new Error(`That is not the probe's output: ${e.message}. Evaluate \`jig probe\` in the browser and pipe what it returns straight in.`);
|
|
4984
|
+
}
|
|
4985
|
+
if (probe.jigProbe !== PROBE_VERSION) {
|
|
4986
|
+
throw new Error(`That JSON is not version ${PROBE_VERSION} probe output (jigProbe: ${JSON.stringify(probe.jigProbe)}). Re-read \`jig probe\` and evaluate it; do not write the file yourself.`);
|
|
4987
|
+
}
|
|
4988
|
+
const width = typeof probe.width === "number" ? probe.width : NaN;
|
|
4989
|
+
if (!Number.isFinite(width)) throw new Error("The probe output has no width. Evaluate it in a browser, at the width you are judging.");
|
|
4990
|
+
const url = typeof probe.url === "string" ? probe.url : "";
|
|
4991
|
+
const page = pageFile(opts.projectRoot, url);
|
|
4992
|
+
if (!page) {
|
|
4993
|
+
throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built \u2014 a file:// URL under the project \u2014 and probe that.`);
|
|
4994
|
+
}
|
|
4995
|
+
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
4996
|
+
probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
|
|
4997
|
+
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4998
|
+
const dir = join29(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
4999
|
+
mkdirSync7(dir, { recursive: true });
|
|
5000
|
+
const path = join29(dir, `probe-${width}.json`);
|
|
5001
|
+
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
5002
|
+
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
5003
|
+
}
|
|
5004
|
+
function pageFile(projectRoot, url) {
|
|
5005
|
+
if (!url.startsWith("file://")) return void 0;
|
|
5006
|
+
let path;
|
|
5007
|
+
try {
|
|
5008
|
+
path = fileURLToPath2(url.split("#")[0].split("?")[0]);
|
|
5009
|
+
} catch {
|
|
5010
|
+
return void 0;
|
|
5011
|
+
}
|
|
5012
|
+
const abs = resolve3(path);
|
|
5013
|
+
if (!abs.startsWith(resolve3(projectRoot))) return void 0;
|
|
5014
|
+
return existsSync16(abs) ? abs : void 0;
|
|
5015
|
+
}
|
|
5016
|
+
|
|
5017
|
+
// src/probe/check.ts
|
|
5018
|
+
function stampProblem(projectRoot, file, p) {
|
|
5019
|
+
if (!p.pageChecksum || !p.pageFile) {
|
|
5020
|
+
return `${file} was not written by \`jig probe --save\`, so nothing measured it. Evaluate \`jig probe\` in the browser and pipe its output into \`jig probe --save <surface>\`.`;
|
|
5021
|
+
}
|
|
5022
|
+
const page = pageFile(projectRoot, p.url ?? "");
|
|
5023
|
+
if (!page) return `${file} names a page outside this project (${p.url ?? "no url"}).`;
|
|
5024
|
+
let current;
|
|
5025
|
+
try {
|
|
5026
|
+
current = readFileSync24(page, "utf8");
|
|
5027
|
+
} catch {
|
|
5028
|
+
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
5029
|
+
}
|
|
5030
|
+
if (checksum(current) !== p.pageChecksum) {
|
|
5031
|
+
return `${file} was taken on an older ${p.pageFile} \u2014 the page changed after it. Render and probe again at that width.`;
|
|
5032
|
+
}
|
|
5033
|
+
return void 0;
|
|
5034
|
+
}
|
|
5035
|
+
function readProbes(projectRoot, dir, errors) {
|
|
5036
|
+
if (!existsSync17(dir)) return [];
|
|
5037
|
+
const probes = [];
|
|
5038
|
+
for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
5039
|
+
try {
|
|
5040
|
+
const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
|
|
5041
|
+
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
5042
|
+
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
5043
|
+
continue;
|
|
5044
|
+
}
|
|
5045
|
+
const stale = stampProblem(projectRoot, f, p);
|
|
5046
|
+
if (stale) {
|
|
5047
|
+
errors.push(stale);
|
|
5048
|
+
continue;
|
|
5049
|
+
}
|
|
5050
|
+
probes.push(p);
|
|
5051
|
+
} catch (e) {
|
|
5052
|
+
errors.push(`${f}: not valid JSON (${e.message}).`);
|
|
5053
|
+
}
|
|
5054
|
+
}
|
|
5055
|
+
return probes;
|
|
5056
|
+
}
|
|
5057
|
+
function probeContradictions(probes, verdictOf) {
|
|
5058
|
+
const errors = [];
|
|
5059
|
+
const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
|
|
5060
|
+
const at = (p) => `probe-${p.width}.json`;
|
|
5061
|
+
for (const p of probes) {
|
|
5062
|
+
if (p.sidewaysScroll && clean("D-115")) {
|
|
5063
|
+
errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
|
|
5064
|
+
}
|
|
5065
|
+
if (p.defaultFont) {
|
|
5066
|
+
errors.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
|
|
5067
|
+
}
|
|
5068
|
+
if (p.unresolvedTokens.length) {
|
|
5069
|
+
errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
5070
|
+
}
|
|
5071
|
+
if (p.junkText.length) {
|
|
5072
|
+
errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
|
|
5073
|
+
}
|
|
5074
|
+
}
|
|
5075
|
+
const navClean = clean("P-14") || clean("E-61");
|
|
5076
|
+
const cite = [clean("P-14") && `P-14 is "${verdictOf("P-14")}"`, clean("E-61") && `E-61 is "${verdictOf("E-61")}"`].filter(Boolean).join(" and ");
|
|
5077
|
+
for (const p of probes.filter((x) => x.width >= 768)) {
|
|
5078
|
+
if (!navClean) continue;
|
|
5079
|
+
const m = p.menu;
|
|
5080
|
+
if (m && p.navLinksVisible > 0) {
|
|
5081
|
+
errors.push(`${cite}, but at ${p.width}px ${at(p)} found a menu button beside navigation links that already show \u2014 ${m.opened ? "it opens a second copy of them" : "it opens nothing"}. At a width where the links show, there is no menu button.`);
|
|
5082
|
+
} else if (m && m.opened && m.linksAfter - m.linksBefore <= 8) {
|
|
5083
|
+
errors.push(`${cite}, but at ${p.width}px ${at(p)} found the navigation behind a menu \u2014 ${m.linksAfter - m.linksBefore} link(s) that fit on one row at this width. Show them.`);
|
|
5084
|
+
} else if (p.navLinksVisible === 0 && !(m && m.opened)) {
|
|
5085
|
+
errors.push(`${cite}, but at ${p.width}px ${at(p)} found no visible navigation links and no menu that opens.`);
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
const phone = probes.filter((p) => p.width <= 480).sort((a, b) => a.width - b.width)[0];
|
|
5089
|
+
if (phone && clean("P-14")) {
|
|
5090
|
+
const m = phone.menu;
|
|
5091
|
+
if (!m && phone.navLinksVisible === 0) {
|
|
5092
|
+
errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} found no visible navigation links and no menu control.`);
|
|
5093
|
+
} else if (m) {
|
|
5094
|
+
const broken = [
|
|
5095
|
+
!m.opened && `tapping it showed no more links (${m.linksBefore} \u2192 ${m.linksAfter})`,
|
|
5096
|
+
m.expandedAfter !== "true" && `aria-expanded was ${JSON.stringify(m.expandedAfter)} after opening`,
|
|
5097
|
+
!m.labelChanged && "its label and icon did not change to close",
|
|
5098
|
+
!m.escapeCloses && "Escape did not close it"
|
|
5099
|
+
].filter(Boolean);
|
|
5100
|
+
if (broken.length) errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} operated the menu: ${broken.join("; ")}.`);
|
|
5101
|
+
}
|
|
5102
|
+
}
|
|
5103
|
+
return errors;
|
|
5104
|
+
}
|
|
5105
|
+
|
|
5106
|
+
// src/commands/verdicts.ts
|
|
5107
|
+
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5108
|
+
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
5109
|
+
function readJson(path, errors) {
|
|
5110
|
+
if (!existsSync18(path)) return null;
|
|
5111
|
+
try {
|
|
5112
|
+
return JSON.parse(readFileSync25(path, "utf8"));
|
|
5113
|
+
} catch (e) {
|
|
5114
|
+
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
5115
|
+
return {};
|
|
5116
|
+
}
|
|
5117
|
+
}
|
|
5118
|
+
function specNeedsNav(projectRoot, surface) {
|
|
5119
|
+
const path = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5120
|
+
if (!existsSync18(path)) return false;
|
|
5121
|
+
const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5122
|
+
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5123
|
+
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
5124
|
+
return navField || navRegion;
|
|
5125
|
+
}
|
|
5126
|
+
function checkArm(name, file, required, otherPass, extraAllowed, extraRequired, errors) {
|
|
5127
|
+
const total = required.length + extraRequired.length;
|
|
5128
|
+
if (file === null) {
|
|
5129
|
+
errors.push(`${name}.json is missing \u2014 the ${name} arm has not run, or did not write its verdicts.`);
|
|
5130
|
+
return { state: "missing", judged: 0, total, findings: 0 };
|
|
5131
|
+
}
|
|
5132
|
+
const list = Array.isArray(file.verdicts) ? file.verdicts : [];
|
|
5133
|
+
if (!Array.isArray(file.verdicts)) errors.push(`${name}.json has no "verdicts" array.`);
|
|
5134
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5135
|
+
let findings = 0;
|
|
5136
|
+
for (const v of list) {
|
|
5137
|
+
const written = typeof v.id === "string" ? v.id.trim() : "";
|
|
5138
|
+
const id = written.toUpperCase();
|
|
5139
|
+
if (!id) {
|
|
5140
|
+
errors.push(`${name}.json: a verdict has no id.`);
|
|
5141
|
+
continue;
|
|
5142
|
+
}
|
|
5143
|
+
if (seen.has(id)) {
|
|
5144
|
+
errors.push(`${name}.json: ${id} is judged more than once.`);
|
|
5145
|
+
continue;
|
|
5146
|
+
}
|
|
5147
|
+
seen.add(id);
|
|
5148
|
+
const pass = otherPass.get(id);
|
|
5149
|
+
if (!required.includes(id) && !extraAllowed.has(id)) {
|
|
5150
|
+
errors.push(pass === "mechanical" ? `${name}.json: ${id} is a mechanical rule \u2014 \`jig check\` decides it, so it has no verdict here. Remove it.` : pass ? `${name}.json: ${id} is a pass: ${pass} rule \u2014 it belongs to the other arm.` : `${name}.json: ${written} is not a rule or spec in this corpus. Run \`jig explain ${written}\`; an id that does not resolve is not a verdict.`);
|
|
5151
|
+
continue;
|
|
5152
|
+
}
|
|
5153
|
+
if (typeof v.verdict !== "string" || !VERDICTS.includes(v.verdict)) {
|
|
5154
|
+
errors.push(`${name}.json: ${id} has verdict ${JSON.stringify(v.verdict)} \u2014 it must be ok, finding or n/a.`);
|
|
5155
|
+
continue;
|
|
5156
|
+
}
|
|
5157
|
+
const reason = typeof v.reason === "string" ? v.reason.trim() : "";
|
|
5158
|
+
if (!reason) errors.push(`${name}.json: ${id} has no reason.`);
|
|
5159
|
+
else if (ABSENCE.test(reason)) errors.push(`${name}.json: ${id} \u2014 "${reason}" says the rule was not read. Read it with \`jig explain ${id}\` and judge it.`);
|
|
5160
|
+
if (v.verdict === "finding") findings++;
|
|
5161
|
+
}
|
|
5162
|
+
const missing = [...required, ...extraRequired].filter((id) => !seen.has(id));
|
|
5163
|
+
if (missing.length) {
|
|
5164
|
+
errors.push(`${name}.json: ${missing.length} of ${total} ids have no verdict: ${missing.join(", ")}. Re-run the arm; never report a short pass.`);
|
|
5165
|
+
}
|
|
5166
|
+
const judged = total - missing.length;
|
|
5167
|
+
return { state: missing.length ? "incomplete" : "ran", judged, total, findings };
|
|
5168
|
+
}
|
|
5169
|
+
function verifyVerdicts(opts) {
|
|
5170
|
+
const root = opts.packageRoot ?? assetRoot();
|
|
5171
|
+
const errors = [];
|
|
5172
|
+
const index = JSON.parse(readFileSync25(join31(root, "rules.index.json"), "utf8"));
|
|
5173
|
+
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5174
|
+
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5175
|
+
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
5176
|
+
const passOf = new Map([
|
|
5177
|
+
...index.filter((r) => r.bucket === "mechanical").map((r) => [r.id, "mechanical"]),
|
|
5178
|
+
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5179
|
+
]);
|
|
5180
|
+
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5181
|
+
const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5182
|
+
const screenFile = readJson(join31(dir, "screen.json"), errors);
|
|
5183
|
+
const codeFile = readJson(join31(dir, "code.json"), errors);
|
|
5184
|
+
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5185
|
+
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5186
|
+
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5187
|
+
let rendered = false;
|
|
5188
|
+
if (screenFile && screenFile.rendered === true) {
|
|
5189
|
+
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5190
|
+
const absent = artefacts.filter((a) => !existsSync18(resolve4(opts.projectRoot, a)));
|
|
5191
|
+
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5192
|
+
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5193
|
+
else rendered = true;
|
|
5194
|
+
}
|
|
5195
|
+
const probes = readProbes(opts.projectRoot, dir, errors);
|
|
5196
|
+
if (screenFile && screenFile.rendered === true) {
|
|
5197
|
+
const widths = new Set(probes.map((p) => p.width));
|
|
5198
|
+
const missing = [360, 768, 1280].filter((w) => !widths.has(w));
|
|
5199
|
+
if (missing.length) {
|
|
5200
|
+
errors.push(`screen.json says rendered: true, but there is no probe at ${missing.join(", ")}px. At each width run \`jig probe\` in the browser and save its output as probe-<width>.json.`);
|
|
5201
|
+
rendered = false;
|
|
5202
|
+
}
|
|
5203
|
+
}
|
|
5204
|
+
const screenVerdicts = Array.isArray(screenFile?.verdicts) ? screenFile.verdicts : [];
|
|
5205
|
+
const verdictOf = (id) => {
|
|
5206
|
+
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
5207
|
+
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
5208
|
+
};
|
|
5209
|
+
errors.push(...probeContradictions(probes, verdictOf));
|
|
5210
|
+
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
5211
|
+
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
5212
|
+
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings}`;
|
|
5213
|
+
return { ok: errors.length === 0, errors, screen, code, rendered, line };
|
|
5214
|
+
}
|
|
5215
|
+
|
|
5216
|
+
// src/commands/gate.ts
|
|
5217
|
+
import { existsSync as existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
|
|
5218
|
+
import { join as join33 } from "path";
|
|
5219
|
+
|
|
5220
|
+
// src/check/spec-shape.ts
|
|
5221
|
+
import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
|
|
5222
|
+
import { join as join32 } from "path";
|
|
5223
|
+
function newestSpec(projectRoot) {
|
|
5224
|
+
const dir = join32(projectRoot, ".jig", "specs");
|
|
5225
|
+
if (!existsSync19(dir)) return void 0;
|
|
5226
|
+
const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
|
|
5227
|
+
if (files.length === 0) return void 0;
|
|
5228
|
+
const newest = files.map((f) => ({ f, at: statSync2(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
5229
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join32(dir, newest), "utf8") };
|
|
5230
|
+
}
|
|
5231
|
+
function specProblems(spec) {
|
|
5232
|
+
const parts = spec.body.split(/^---\s*$/m);
|
|
5233
|
+
const front = parts.length >= 3 ? parts[1] : "";
|
|
5234
|
+
if (!front.trim()) {
|
|
5235
|
+
return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
|
|
5236
|
+
}
|
|
5237
|
+
const problems = [];
|
|
5238
|
+
const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
|
|
5239
|
+
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
5240
|
+
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
5241
|
+
}
|
|
5242
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5243
|
+
for (const size of ["phone", "tablet", "desktop"]) {
|
|
5244
|
+
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
5245
|
+
if (!line) {
|
|
5246
|
+
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
5247
|
+
continue;
|
|
5248
|
+
}
|
|
5249
|
+
if (line[1].trim()) {
|
|
5250
|
+
problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
|
|
5251
|
+
continue;
|
|
5252
|
+
}
|
|
5253
|
+
const block = sizeBlock(sizes, size);
|
|
5254
|
+
if (/^\s*same-as\s*:/im.test(block)) {
|
|
5255
|
+
if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
|
|
5256
|
+
continue;
|
|
5257
|
+
}
|
|
5258
|
+
for (const field of ["regions", "nav"]) {
|
|
5259
|
+
if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
|
|
5260
|
+
problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
|
|
5261
|
+
}
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
5264
|
+
return problems;
|
|
5265
|
+
}
|
|
5266
|
+
function sizeBlock(sizes, size) {
|
|
5267
|
+
const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
|
|
5268
|
+
if (!start) return "";
|
|
5269
|
+
const rest = sizes.slice(start.index + start[0].length);
|
|
5270
|
+
const indent = /^\s*/.exec(start[0])[0].length;
|
|
5271
|
+
const lines = [];
|
|
5272
|
+
for (const line of rest.split("\n")) {
|
|
5273
|
+
if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
|
|
5274
|
+
lines.push(line);
|
|
5275
|
+
}
|
|
5276
|
+
return lines.join("\n");
|
|
5277
|
+
}
|
|
5278
|
+
var MENU_RE = /\b(menu|hamburger|drawer|burger)\b/i;
|
|
5279
|
+
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
5280
|
+
function navProblems(spec) {
|
|
5281
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
5282
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5283
|
+
const problems = [];
|
|
5284
|
+
for (const size of ["tablet", "desktop"]) {
|
|
5285
|
+
const block = sizeBlock(sizes, size);
|
|
5286
|
+
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
5287
|
+
if (!nav || NONE_RE.test(nav)) continue;
|
|
5288
|
+
if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
|
|
5289
|
+
problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
|
|
5290
|
+
}
|
|
5291
|
+
}
|
|
5292
|
+
return problems;
|
|
5293
|
+
}
|
|
5294
|
+
|
|
5295
|
+
// src/commands/gate.ts
|
|
5296
|
+
var MAX_BLOCKS = 3;
|
|
5297
|
+
function lastJigCommand(transcriptPath) {
|
|
5298
|
+
if (!transcriptPath || !existsSync20(transcriptPath)) return void 0;
|
|
5299
|
+
let text;
|
|
5300
|
+
try {
|
|
5301
|
+
text = readFileSync27(transcriptPath, "utf8");
|
|
5302
|
+
} catch {
|
|
5303
|
+
return void 0;
|
|
5304
|
+
}
|
|
5305
|
+
let found;
|
|
5306
|
+
for (const line of text.split("\n")) {
|
|
5307
|
+
if (!line.includes("/jig")) continue;
|
|
5308
|
+
const name = /<command-name>\/?jig<\/command-name>[\s\S]{0,200}?<command-args>([^<]*)<\/command-args>/.exec(line);
|
|
5309
|
+
const plain = /(?:^|["\s>])\/jig\s+([a-z]+)/.exec(line);
|
|
5310
|
+
const arg = (name?.[1] ?? plain?.[1] ?? "").trim().split(/\s+/)[0];
|
|
5311
|
+
if (arg) found = arg.toLowerCase();
|
|
5312
|
+
}
|
|
5313
|
+
return found;
|
|
5314
|
+
}
|
|
5315
|
+
function commandProblems(root, command) {
|
|
5316
|
+
const problems = [];
|
|
5317
|
+
const spec = newestSpec(root);
|
|
5318
|
+
if (command === "decide") {
|
|
5319
|
+
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join33(root, p)).find((p) => existsSync20(p));
|
|
5320
|
+
if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
5321
|
+
else {
|
|
5322
|
+
const body = readFileSync27(file, "utf8");
|
|
5323
|
+
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
5324
|
+
problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
|
|
5325
|
+
}
|
|
5326
|
+
if (/\[TODO\]/.test(body)) problems.push("DECISIONS.md still contains [TODO] markers.");
|
|
5327
|
+
}
|
|
5328
|
+
}
|
|
5329
|
+
if (command === "spec" || command === "mockup" || command === "make" || command === "critique") {
|
|
5330
|
+
if (!spec) problems.push(`${command} needs a spec: there is no file in .jig/specs/.`);
|
|
5331
|
+
else {
|
|
5332
|
+
problems.push(...specProblems(spec));
|
|
5333
|
+
if (problems.length === 0) problems.push(...navProblems(spec));
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
if (command === "mockup" && spec) {
|
|
5337
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
5338
|
+
const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
|
|
5339
|
+
if (/^pending/i.test(mockup) || !mockup) problems.push(`${spec.path}: \`mockup:\` is still pending. It records the user's own word \u2014 approved, or skipped with their reason.`);
|
|
5340
|
+
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
5341
|
+
if (/^approved/i.test(mockup)) {
|
|
5342
|
+
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
5343
|
+
else if (!/^https?:/i.test(at) && !existsSync20(join33(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
5344
|
+
else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
|
|
5345
|
+
}
|
|
5346
|
+
}
|
|
5347
|
+
if (command === "critique") {
|
|
5348
|
+
const dir = join33(root, ".jig", "critique");
|
|
5349
|
+
const surfaces = existsSync20(dir) ? readdirSync13(dir).filter((s) => existsSync20(join33(dir, s, "screen.json")) || existsSync20(join33(dir, s, "code.json"))) : [];
|
|
5350
|
+
for (const surface of surfaces) {
|
|
5351
|
+
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5352
|
+
if (v.ok && v.screen.state === "skipped") {
|
|
5353
|
+
problems.push(`${surface}: the screen pass judged ${v.screen.judged} rules with rendered: false. Those rules are judged on a render \u2014 open the page at 360, 768 and 1280, run \`jig probe\` at each, and judge them there.`);
|
|
5354
|
+
}
|
|
5355
|
+
}
|
|
5356
|
+
if (surfaces.length === 0) {
|
|
5357
|
+
problems.push("critique wrote no verdict files. Each reader arm writes .jig/critique/<surface>/screen.json or code.json, and `jig verdicts <surface>` \u2014 not your own count \u2014 decides whether the review is complete. A report without them is not a review.");
|
|
5358
|
+
}
|
|
5359
|
+
}
|
|
5360
|
+
return problems;
|
|
5361
|
+
}
|
|
5362
|
+
function gate(opts) {
|
|
5363
|
+
const root = opts.projectRoot;
|
|
5364
|
+
if (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
|
|
5365
|
+
return { block: false, reason: "" };
|
|
5366
|
+
}
|
|
5367
|
+
const command = lastJigCommand(opts.input.transcript_path);
|
|
5368
|
+
const problems = command ? commandProblems(root, command).map((p) => `/jig ${command}: ${p}`) : [];
|
|
5369
|
+
const selection = selectFiles(root, false);
|
|
5370
|
+
const changedStyles = selection.mode === "changed" && selection.files.some((f) => isStyleBearing(f));
|
|
5371
|
+
if (changedStyles) {
|
|
5372
|
+
const result = check({ projectRoot: root, homeDir: "", version: opts.version, all: false, ci: false });
|
|
5373
|
+
const errors = result.findings.filter((f) => f.bucket === "mechanical" && f.severity === "error");
|
|
5374
|
+
if (errors.length > 0) {
|
|
5375
|
+
const shown = errors.slice(0, 8).map((f) => ` ${f.ruleId} ${f.file}:${f.line} ${f.message}`);
|
|
5376
|
+
problems.push(
|
|
5377
|
+
`jig check: ${errors.length} mechanical error(s) in the files you changed. Fix every one, then run \`jig check\` again.
|
|
5378
|
+
` + shown.join("\n") + (errors.length > shown.length ? `
|
|
5379
|
+
\u2026 and ${errors.length - shown.length} more` : "")
|
|
5380
|
+
);
|
|
5381
|
+
}
|
|
5382
|
+
}
|
|
5383
|
+
const critiqueDir = join33(root, ".jig", "critique");
|
|
5384
|
+
if (existsSync20(critiqueDir)) {
|
|
5385
|
+
for (const surface of readdirSync13(critiqueDir)) {
|
|
5386
|
+
const dir = join33(critiqueDir, surface);
|
|
5387
|
+
if (!existsSync20(join33(dir, "screen.json")) && !existsSync20(join33(dir, "code.json"))) continue;
|
|
5388
|
+
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5389
|
+
if (!v.ok) {
|
|
5390
|
+
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
5391
|
+
problems.push(
|
|
5392
|
+
`jig verdicts ${surface}: the critique is not complete. Re-run the arm it names \u2014 do not edit the verdict files to pass.
|
|
5393
|
+
` + shown.join("\n") + (v.errors.length > shown.length ? `
|
|
5394
|
+
\u2026 and ${v.errors.length - shown.length} more` : "")
|
|
5395
|
+
);
|
|
5396
|
+
}
|
|
5397
|
+
}
|
|
5398
|
+
}
|
|
5399
|
+
const session = opts.input.session_id ?? "unknown";
|
|
5400
|
+
const stateFile = join33(root, ".jig", "gate.json");
|
|
5401
|
+
let state = {};
|
|
5402
|
+
try {
|
|
5403
|
+
state = JSON.parse(readFileSync27(stateFile, "utf8"));
|
|
5404
|
+
} catch {
|
|
5405
|
+
}
|
|
5406
|
+
if (problems.length === 0) {
|
|
5407
|
+
if (state[session]) {
|
|
5408
|
+
delete state[session];
|
|
5409
|
+
save(stateFile, state);
|
|
5410
|
+
}
|
|
5411
|
+
return { block: false, reason: "" };
|
|
5412
|
+
}
|
|
5413
|
+
const count = (state[session] ?? 0) + 1;
|
|
5414
|
+
if (count > MAX_BLOCKS) {
|
|
5415
|
+
return {
|
|
5416
|
+
block: false,
|
|
5417
|
+
reason: `jig gate: still failing after ${MAX_BLOCKS} attempts; letting you stop. Tell the user plainly that the work is not finished:
|
|
5418
|
+
${problems.join("\n\n")}`
|
|
5419
|
+
};
|
|
5420
|
+
}
|
|
5421
|
+
state[session] = count;
|
|
5422
|
+
save(stateFile, state);
|
|
5423
|
+
return {
|
|
5424
|
+
block: true,
|
|
5425
|
+
reason: `Not finished \u2014 Jig's gate failed (attempt ${count} of ${MAX_BLOCKS}). Fix these before you stop, and do not report the work as done until they pass:
|
|
5426
|
+
|
|
5427
|
+
${problems.join("\n\n")}`
|
|
5428
|
+
};
|
|
5429
|
+
}
|
|
5430
|
+
function save(file, state) {
|
|
5431
|
+
try {
|
|
5432
|
+
mkdirSync8(join33(file, ".."), { recursive: true });
|
|
5433
|
+
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
5434
|
+
} catch {
|
|
5435
|
+
}
|
|
5436
|
+
}
|
|
5437
|
+
|
|
4208
5438
|
// src/index.ts
|
|
4209
5439
|
var packageRoot = getPackageRoot();
|
|
4210
|
-
var { version } = JSON.parse(
|
|
5440
|
+
var { version } = JSON.parse(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
|
|
4211
5441
|
function warnIfUnpublishedPin() {
|
|
4212
5442
|
if (isPublishedBuild(packageRoot)) return;
|
|
4213
5443
|
console.warn(
|
|
@@ -4216,12 +5446,28 @@ function warnIfUnpublishedPin() {
|
|
|
4216
5446
|
}
|
|
4217
5447
|
var program = new Command();
|
|
4218
5448
|
program.name("jig").description("A design system for coding agents.").version(version);
|
|
4219
|
-
program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").action((opts) => {
|
|
5449
|
+
program.command("install").description("Install Jig rules and the agent skill file into a repository.").requiredOption("--agent <name>", `target agent (${adapterNames().join(", ")})`).option("--scope <scope>", "project or global", "project").option("--hook", "add the Stop hook that blocks finishing while check or a step fails (Claude Code, project scope)").option("--no-hook", "do not add the Stop hook, and do not ask for it").option("--yes", "non-interactive: ask nothing, and add no Stop hook unless --hook is given", false).action(async (opts) => {
|
|
4220
5450
|
if (opts.scope !== "project" && opts.scope !== "global") {
|
|
4221
5451
|
console.error(`Invalid scope '${opts.scope}'. Use 'project' or 'global'.`);
|
|
4222
5452
|
process.exit(1);
|
|
4223
5453
|
}
|
|
4224
5454
|
const projectRoot = findProjectRoot(process.cwd());
|
|
5455
|
+
let hook = opts.hook === true;
|
|
5456
|
+
const unanswered = opts.hook === void 0;
|
|
5457
|
+
if (unanswered && opts.agent === "claude" && opts.scope === "project" && !opts.yes && process.stdin.isTTY) {
|
|
5458
|
+
const { createInterface } = await import("readline/promises");
|
|
5459
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
5460
|
+
try {
|
|
5461
|
+
console.log("\nClaude Code can run a Jig check when the agent tries to finish, and hold it there while");
|
|
5462
|
+
console.log("the files it changed fail `check`, or the /jig step it just ran is unfinished. It adds one");
|
|
5463
|
+
console.log("entry to .claude/settings.json, keeps everything else in that file, and lets go after three");
|
|
5464
|
+
console.log("attempts. Weak models skip steps they are only asked to run; this is what catches that.");
|
|
5465
|
+
const answer = (await rl.question(" Add it? [y/N]: ")).trim().toLowerCase();
|
|
5466
|
+
hook = answer === "y" || answer === "yes";
|
|
5467
|
+
} finally {
|
|
5468
|
+
rl.close();
|
|
5469
|
+
}
|
|
5470
|
+
}
|
|
4225
5471
|
try {
|
|
4226
5472
|
const result = install({
|
|
4227
5473
|
agent: opts.agent,
|
|
@@ -4229,7 +5475,8 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
4229
5475
|
projectRoot,
|
|
4230
5476
|
packageRoot: assetRoot(),
|
|
4231
5477
|
version,
|
|
4232
|
-
homeDir: homedir()
|
|
5478
|
+
homeDir: homedir(),
|
|
5479
|
+
hook
|
|
4233
5480
|
});
|
|
4234
5481
|
if (result.warning) {
|
|
4235
5482
|
console.warn(result.warning);
|
|
@@ -4239,14 +5486,19 @@ program.command("install").description("Install Jig rules and the agent skill fi
|
|
|
4239
5486
|
warnIfUnpublishedPin();
|
|
4240
5487
|
for (const f of result.written) console.log(` + ${f}`);
|
|
4241
5488
|
for (const f of result.skipped) console.log(` \xB7 ${f} (edited locally, left alone)`);
|
|
5489
|
+
if (result.stopHook === true) console.log(" + .claude/settings.json (Stop hook: jig gate blocks finishing while check or a critique fails)");
|
|
5490
|
+
if (result.stopHook === false) console.log(" ! .claude/settings.json is not valid JSON \u2014 the Stop hook was not added. Fix the file and run install again.");
|
|
5491
|
+
if (result.stopHook === void 0 && opts.agent === "claude" && opts.scope === "project") {
|
|
5492
|
+
console.log(" \xB7 No Stop hook. Add it any time with: npx jig-ui@" + version + " install --agent claude --hook");
|
|
5493
|
+
}
|
|
4242
5494
|
} catch (err) {
|
|
4243
5495
|
console.error(err.message);
|
|
4244
5496
|
process.exit(1);
|
|
4245
5497
|
}
|
|
4246
5498
|
});
|
|
4247
|
-
program.command("explain").description("Explain a rule, or find the rules you cannot name.").argument("[query]", "a rule id ('C-19'), a word to search for ('contrast'), or a section letter with --list").option("--list", "list every rule id and title, or one section with a section letter").action((query, options) => {
|
|
5499
|
+
program.command("explain").description("Explain a rule, or find the rules you cannot name.").argument("[query]", "a rule id ('C-19'), a word to search for ('contrast'), or a section letter with --list").option("--list", "list every rule id and title, or one section with a section letter").option("--layer", "name the six layers, or list one of them by name").action((query, options) => {
|
|
4248
5500
|
try {
|
|
4249
|
-
console.log(explain({ ruleId: query ?? "", version, list: options.list }));
|
|
5501
|
+
console.log(explain({ ruleId: query ?? "", version, list: options.list, layer: options.layer }));
|
|
4250
5502
|
} catch (err) {
|
|
4251
5503
|
console.error(err.message);
|
|
4252
5504
|
process.exit(1);
|
|
@@ -4275,6 +5527,19 @@ program.command("update").description("Update vendored Jig rules, skipping files
|
|
|
4275
5527
|
process.exit(1);
|
|
4276
5528
|
}
|
|
4277
5529
|
});
|
|
5530
|
+
program.command("verdicts").description("Verify a critique's verdict files and compute its counts.").argument("<surface>", "the surface slug the critique wrote under .jig/critique/").action((surface) => {
|
|
5531
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
5532
|
+
try {
|
|
5533
|
+
const result = verifyVerdicts({ projectRoot, surface });
|
|
5534
|
+
for (const error of result.errors) console.error(` \u2717 ${error}`);
|
|
5535
|
+
if (result.ok) console.log(` Every rule in both passes has a verdict.`);
|
|
5536
|
+
console.log(` ${result.line}`);
|
|
5537
|
+
process.exit(result.ok ? 0 : 1);
|
|
5538
|
+
} catch (err) {
|
|
5539
|
+
console.error(err.message);
|
|
5540
|
+
process.exit(1);
|
|
5541
|
+
}
|
|
5542
|
+
});
|
|
4278
5543
|
program.command("check").description("Check the repo against Jig's mechanical + hybrid rules.").option(
|
|
4279
5544
|
"--all",
|
|
4280
5545
|
// Names the files, because the default scope surprised a user who read
|
|
@@ -4298,6 +5563,45 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
4298
5563
|
process.exit(1);
|
|
4299
5564
|
}
|
|
4300
5565
|
});
|
|
5566
|
+
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").action(async (opts) => {
|
|
5567
|
+
if (!opts.save) {
|
|
5568
|
+
console.log(PROBE_SCRIPT);
|
|
5569
|
+
return;
|
|
5570
|
+
}
|
|
5571
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
5572
|
+
try {
|
|
5573
|
+
const json = process.stdin.isTTY ? "" : await new Promise((done, fail) => {
|
|
5574
|
+
let text = "";
|
|
5575
|
+
process.stdin.setEncoding("utf8");
|
|
5576
|
+
process.stdin.on("data", (chunk) => {
|
|
5577
|
+
text += chunk;
|
|
5578
|
+
});
|
|
5579
|
+
process.stdin.on("end", () => done(text));
|
|
5580
|
+
process.stdin.on("error", fail);
|
|
5581
|
+
});
|
|
5582
|
+
const result = saveProbe({ projectRoot, surface: opts.save, json });
|
|
5583
|
+
console.log(` Recorded ${result.path} \u2014 ${result.page} at ${result.width}px.`);
|
|
5584
|
+
} catch (err) {
|
|
5585
|
+
console.error(` \u2717 ${err.message}`);
|
|
5586
|
+
process.exit(1);
|
|
5587
|
+
}
|
|
5588
|
+
});
|
|
5589
|
+
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
|
|
5590
|
+
let input = {};
|
|
5591
|
+
try {
|
|
5592
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
|
|
5593
|
+
} catch {
|
|
5594
|
+
}
|
|
5595
|
+
const cwd = input.cwd ?? process.cwd();
|
|
5596
|
+
try {
|
|
5597
|
+
const result = gate({ projectRoot: findProjectRoot(cwd), version, input });
|
|
5598
|
+
if (result.block) console.log(JSON.stringify({ decision: "block", reason: result.reason }));
|
|
5599
|
+
else if (result.reason) console.error(result.reason);
|
|
5600
|
+
} catch (err) {
|
|
5601
|
+
console.error(`jig gate: ${err.message}`);
|
|
5602
|
+
}
|
|
5603
|
+
process.exit(0);
|
|
5604
|
+
});
|
|
4301
5605
|
program.command("init").description("Set the project up to use Jig: a brand file, jig.config.json, and a baseline check.").option("--yes", "non-interactive: derive everything, accept the proposal, ask nothing", false).action(async (opts) => {
|
|
4302
5606
|
const projectRoot = findProjectRoot(process.cwd());
|
|
4303
5607
|
try {
|