jig-ui 0.18.5 → 0.18.8
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 +57 -0
- package/dist/index.js +260 -79
- package/package.json +1 -1
- package/templates/COMMAND.md.tmpl +13 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.8 (2026-09-25)
|
|
4
|
+
|
|
5
|
+
Two runs writing one manifest no longer lose an entry.
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Two runs writing one manifest no longer lose an entry.** The merge added
|
|
10
|
+
for this in 0.4 re-read after writing and retried if its own keys were gone,
|
|
11
|
+
but a writer only checked its own keys: one that read before another's write
|
|
12
|
+
could rename over it afterwards, and both reported success. The
|
|
13
|
+
parallel-process test caught it under load in the 0.18.7 release run. The
|
|
14
|
+
read, merge and write now happen under a lock file; a lock whose process has
|
|
15
|
+
died, or older than any write takes, is taken over, so a killed run cannot
|
|
16
|
+
wedge an install.
|
|
17
|
+
|
|
18
|
+
## 0.18.7 (2026-09-25)
|
|
19
|
+
|
|
20
|
+
The mockup gate reads a spec's switches however they are spelled, and refuses
|
|
21
|
+
icons and images in a drawing's frames.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **`switches:` reads a switch however the spec spells it.** jig-site's
|
|
26
|
+
Versions spec wrote `switches: [breakpoint-nav]`; read as a switch named
|
|
27
|
+
that, it matched nothing, no switch was checked, and the gate passed. `nav`,
|
|
28
|
+
`breakpoint-nav` and `--breakpoint-nav` now name the same switch, and a name
|
|
29
|
+
the project does not record is reported with the ones it does.
|
|
30
|
+
- **The gate refuses icons and images in a mockup's frames.** The procedure
|
|
31
|
+
says an icon is its name in brackets and an image a labelled box; jig-site's
|
|
32
|
+
Versions drawing carried the site's real icons and GitHub's mark, and nothing
|
|
33
|
+
checked. `<svg>`, `<img>`, `<picture>`, `<canvas>` and icon fonts inside a
|
|
34
|
+
frame now stop the session.
|
|
35
|
+
|
|
36
|
+
## 0.18.6 (2026-09-25)
|
|
37
|
+
|
|
38
|
+
A mockup's frames must hold each size's regions, and the drawing shows either
|
|
39
|
+
side of each switch the project records.
|
|
40
|
+
|
|
41
|
+
### Fixed
|
|
42
|
+
|
|
43
|
+
- **The gate checks what a mockup's frames hold, not only that they exist.**
|
|
44
|
+
0.18.5 required a frame per size; a frame can still be empty, or hold the
|
|
45
|
+
desktop composition narrower. The procedure's self-check, "go down the spec's
|
|
46
|
+
`regions:` and find each one in that frame", was prose only. Now each size's
|
|
47
|
+
regions must be labelled in its frame: by name where the spec gives a short
|
|
48
|
+
one (`nav`, or `h1: "Versions"`), and by count where it writes a description,
|
|
49
|
+
so wording never trips it.
|
|
50
|
+
- **A mockup draws either side of each switch the project records.** The probe
|
|
51
|
+
has measured one pixel either side of every `--breakpoint-*` since 0.18.0, but
|
|
52
|
+
the drawing the owner approves showed only the four sizes; jig-site's header
|
|
53
|
+
changes at 540, between phone and tablet, and no frame showed 539 beside 540.
|
|
54
|
+
A new spec field, `switches:`, names the switches a page crosses (`none` if it
|
|
55
|
+
crosses none); absent, the mockup draws every one.
|
|
56
|
+
- **A spec's `indexable:` reads past a YAML comment.** The spec template itself
|
|
57
|
+
writes `indexable: true # from the mode ...`; a spec that copied it was
|
|
58
|
+
reported unreadable.
|
|
59
|
+
|
|
3
60
|
## 0.18.5 (2026-09-25)
|
|
4
61
|
|
|
5
62
|
The gate holds an HTML mockup that does not draw the page at every size.
|
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 readFileSync34 } from "fs";
|
|
6
6
|
import { homedir as homedir2 } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join41 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -42,7 +42,7 @@ function isPublishedBuild(packageRoot2) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
// src/commands/install.ts
|
|
45
|
-
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as
|
|
45
|
+
import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "fs";
|
|
46
46
|
import { join as join7 } from "path";
|
|
47
47
|
|
|
48
48
|
// src/adapters/types.ts
|
|
@@ -270,7 +270,7 @@ function referenceFiles(packageRoot2) {
|
|
|
270
270
|
|
|
271
271
|
// src/install/manifest.ts
|
|
272
272
|
import { createHash } from "crypto";
|
|
273
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync2 } from "fs";
|
|
273
|
+
import { closeSync, existsSync as existsSync3, mkdirSync as mkdirSync2, openSync, readFileSync as readFileSync2, statSync, unlinkSync as unlinkSync2, writeSync } from "fs";
|
|
274
274
|
import { dirname as dirname3, join as join4 } from "path";
|
|
275
275
|
|
|
276
276
|
// src/install/atomic.ts
|
|
@@ -342,19 +342,70 @@ function readManifest(root, manifestDir = ".jig") {
|
|
|
342
342
|
}
|
|
343
343
|
return validateManifest(raw, path);
|
|
344
344
|
}
|
|
345
|
-
var
|
|
345
|
+
var LOCK_STALE_MS = 1e4;
|
|
346
|
+
var LOCK_WAIT_MS = 15e3;
|
|
346
347
|
function writeManifest(root, m, manifestDir = ".jig") {
|
|
347
348
|
const path = manifestPath(root, manifestDir);
|
|
348
349
|
mkdirSync2(dirname3(path), { recursive: true });
|
|
349
|
-
|
|
350
|
+
withLock(`${path}.lock`, () => {
|
|
350
351
|
const onDisk = readManifestQuietly(path);
|
|
351
352
|
const merged = { ...m, files: { ...onDisk?.files ?? {}, ...m.files } };
|
|
352
353
|
writeFileAtomic(path, `${JSON.stringify(merged, null, 2)}
|
|
353
354
|
`);
|
|
354
|
-
|
|
355
|
-
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
function withLock(lock, fn) {
|
|
358
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
359
|
+
for (; ; ) {
|
|
360
|
+
try {
|
|
361
|
+
const fd = openSync(lock, "wx");
|
|
362
|
+
try {
|
|
363
|
+
writeSync(fd, `${process.pid} ${Date.now()}`);
|
|
364
|
+
} finally {
|
|
365
|
+
closeSync(fd);
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
if (err.code !== "EEXIST") throw err;
|
|
370
|
+
if (lockIsStale(lock) || Date.now() > deadline) {
|
|
371
|
+
try {
|
|
372
|
+
unlinkSync2(lock);
|
|
373
|
+
} catch {
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
sleep(10 + Math.random() * 20);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
fn();
|
|
382
|
+
} finally {
|
|
383
|
+
try {
|
|
384
|
+
unlinkSync2(lock);
|
|
385
|
+
} catch {
|
|
386
|
+
}
|
|
356
387
|
}
|
|
357
388
|
}
|
|
389
|
+
function lockIsStale(lock) {
|
|
390
|
+
try {
|
|
391
|
+
const [pid, at] = readFileSync2(lock, "utf8").trim().split(/\s+/).map(Number);
|
|
392
|
+
if (at && Date.now() - at > LOCK_STALE_MS) return true;
|
|
393
|
+
if (pid && pid !== process.pid) {
|
|
394
|
+
try {
|
|
395
|
+
process.kill(pid, 0);
|
|
396
|
+
} catch (e) {
|
|
397
|
+
if (e.code === "ESRCH") return true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
if (!pid || !at) return Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS;
|
|
401
|
+
return false;
|
|
402
|
+
} catch {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function sleep(ms) {
|
|
407
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
408
|
+
}
|
|
358
409
|
function readManifestQuietly(path) {
|
|
359
410
|
try {
|
|
360
411
|
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
@@ -695,7 +746,7 @@ function installStopHook(projectRoot, version2) {
|
|
|
695
746
|
stop.push({ hooks: [{ type: "command", command }] });
|
|
696
747
|
settings.hooks = { ...hooks, Stop: stop };
|
|
697
748
|
mkdirSync4(join7(projectRoot, ".claude"), { recursive: true });
|
|
698
|
-
|
|
749
|
+
writeFileSync2(path, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
699
750
|
return true;
|
|
700
751
|
}
|
|
701
752
|
|
|
@@ -764,11 +815,11 @@ function readInitManifest(projectRoot) {
|
|
|
764
815
|
return null;
|
|
765
816
|
}
|
|
766
817
|
}
|
|
767
|
-
var
|
|
818
|
+
var MERGE_ATTEMPTS = 5;
|
|
768
819
|
function writeInitManifest(projectRoot, m) {
|
|
769
820
|
const path = join8(projectRoot, INIT_MANIFEST_REL);
|
|
770
821
|
mkdirSync5(dirname5(path), { recursive: true });
|
|
771
|
-
for (let attempt = 0; attempt <
|
|
822
|
+
for (let attempt = 0; attempt < MERGE_ATTEMPTS; attempt++) {
|
|
772
823
|
const onDisk = readInitManifest(projectRoot);
|
|
773
824
|
const merged = {
|
|
774
825
|
...m,
|
|
@@ -1476,7 +1527,7 @@ import { join as join24 } from "path";
|
|
|
1476
1527
|
|
|
1477
1528
|
// src/check/files.ts
|
|
1478
1529
|
import { execFileSync } from "child_process";
|
|
1479
|
-
import { readdirSync as readdirSync7, realpathSync, statSync } from "fs";
|
|
1530
|
+
import { readdirSync as readdirSync7, realpathSync, statSync as statSync2 } from "fs";
|
|
1480
1531
|
import { join as join16, relative, sep } from "path";
|
|
1481
1532
|
var EXCLUDE_DIRS = /* @__PURE__ */ new Set([
|
|
1482
1533
|
"node_modules",
|
|
@@ -1557,7 +1608,7 @@ function walk(root, dir, out, seen) {
|
|
|
1557
1608
|
let isFile = entry.isFile();
|
|
1558
1609
|
if (entry.isSymbolicLink()) {
|
|
1559
1610
|
try {
|
|
1560
|
-
const target =
|
|
1611
|
+
const target = statSync2(abs);
|
|
1561
1612
|
isDir = target.isDirectory();
|
|
1562
1613
|
isFile = target.isFile();
|
|
1563
1614
|
} catch {
|
|
@@ -4584,7 +4635,7 @@ function check(opts) {
|
|
|
4584
4635
|
}
|
|
4585
4636
|
|
|
4586
4637
|
// src/commands/init.ts
|
|
4587
|
-
import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as
|
|
4638
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync6, readdirSync as readdirSync10, readFileSync as readFileSync23, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
4588
4639
|
import { dirname as dirname7, isAbsolute, join as join28, resolve as resolve2, sep as sep3 } from "path";
|
|
4589
4640
|
|
|
4590
4641
|
// src/init/detect.ts
|
|
@@ -5215,7 +5266,7 @@ function wireBarrel(absPath, barrelImport) {
|
|
|
5215
5266
|
if (legacy) {
|
|
5216
5267
|
const next2 = content.slice(0, legacy.index) + `${line}
|
|
5217
5268
|
` + content.slice(legacy.index + legacy[0].length);
|
|
5218
|
-
|
|
5269
|
+
writeFileSync4(absPath, next2, "utf8");
|
|
5219
5270
|
return { status: "rewired", detail: "replaced the separate brand and mode imports with the barrel" };
|
|
5220
5271
|
}
|
|
5221
5272
|
const tailwind = /@import\s+["']tailwindcss["'];?\r?\n?/.exec(content);
|
|
@@ -5225,7 +5276,7 @@ function wireBarrel(absPath, barrelImport) {
|
|
|
5225
5276
|
` : `${line}
|
|
5226
5277
|
`);
|
|
5227
5278
|
mkdirSync6(dirname7(absPath), { recursive: true });
|
|
5228
|
-
|
|
5279
|
+
writeFileSync4(next === content ? absPath : absPath, next, "utf8");
|
|
5229
5280
|
return { status: "wired" };
|
|
5230
5281
|
}
|
|
5231
5282
|
async function init(opts) {
|
|
@@ -5385,7 +5436,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5385
5436
|
const defaultCssRaw = readFileSync23(join28(opts.packageRoot, "tokens", "brand.default.css"), "utf8");
|
|
5386
5437
|
const content = renderBrandFile(defaultCssRaw, projectSlug, opts.version, proposal);
|
|
5387
5438
|
mkdirSync6(dirname7(brandAbsPath), { recursive: true });
|
|
5388
|
-
|
|
5439
|
+
writeFileSync4(brandAbsPath, content, "utf8");
|
|
5389
5440
|
files[brandRelPath] = checksum(content);
|
|
5390
5441
|
}
|
|
5391
5442
|
if (relocateTo && configAction !== "written") {
|
|
@@ -5393,7 +5444,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5393
5444
|
const existing = JSON.parse(readFileSync23(configAbsPath, "utf8"));
|
|
5394
5445
|
const content = `${JSON.stringify({ ...existing, brand: brandRelPath }, null, 2)}
|
|
5395
5446
|
`;
|
|
5396
|
-
|
|
5447
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
5397
5448
|
files[configRelPath] = checksum(content);
|
|
5398
5449
|
log(` Updated ${configRelPath} to point at the new location.`);
|
|
5399
5450
|
} catch {
|
|
@@ -5404,7 +5455,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5404
5455
|
const config = opts.yes ? { brand: brandRelPath } : { brand: brandRelPath, surfaces };
|
|
5405
5456
|
const content = `${JSON.stringify(config, null, 2)}
|
|
5406
5457
|
`;
|
|
5407
|
-
|
|
5458
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
5408
5459
|
files[configRelPath] = checksum(content);
|
|
5409
5460
|
} else if (configAction === "merged") {
|
|
5410
5461
|
let existing = {};
|
|
@@ -5424,7 +5475,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5424
5475
|
};
|
|
5425
5476
|
const content = `${JSON.stringify(merged, null, 2)}
|
|
5426
5477
|
`;
|
|
5427
|
-
|
|
5478
|
+
writeFileSync4(configAbsPath, content, "utf8");
|
|
5428
5479
|
files[configRelPath] = checksum(content);
|
|
5429
5480
|
}
|
|
5430
5481
|
const effectiveConfig = loadEffectiveConfig(opts.projectRoot, configAbsPath, brandRelPath, surfaces);
|
|
@@ -5445,7 +5496,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5445
5496
|
const modeSourceRaw = readFileSync23(join28(opts.packageRoot, "tokens", modeFileName), "utf8");
|
|
5446
5497
|
const content = vendorHeader(modeFileName, opts.version, "css", null) + modeSourceRaw;
|
|
5447
5498
|
mkdirSync6(dirname7(modeAbsPath2), { recursive: true });
|
|
5448
|
-
|
|
5499
|
+
writeFileSync4(modeAbsPath2, content, "utf8");
|
|
5449
5500
|
files[modeRelPath] = checksum(content);
|
|
5450
5501
|
} else {
|
|
5451
5502
|
log(` ${modeRelPath} exists and is not jig-tracked (or has been edited) \u2014 leaving it alone (re-run 'jig update' to refresh it).`);
|
|
@@ -5465,7 +5516,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5465
5516
|
}
|
|
5466
5517
|
const content = barrelBody(barrelFor(mode), brandFileOnly, mode, opts.version);
|
|
5467
5518
|
mkdirSync6(dirname7(abs), { recursive: true });
|
|
5468
|
-
|
|
5519
|
+
writeFileSync4(abs, content, "utf8");
|
|
5469
5520
|
files[rel] = checksum(content);
|
|
5470
5521
|
}
|
|
5471
5522
|
if (multiMode) {
|
|
@@ -5518,7 +5569,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5518
5569
|
} else {
|
|
5519
5570
|
const content = utilitiesBody(aliasable, opts.version);
|
|
5520
5571
|
mkdirSync6(dirname7(abs), { recursive: true });
|
|
5521
|
-
|
|
5572
|
+
writeFileSync4(abs, content, "utf8");
|
|
5522
5573
|
files[rel] = checksum(content);
|
|
5523
5574
|
const target = findWireTarget(detection);
|
|
5524
5575
|
const importPath = target ? relativeImportPath(
|
|
@@ -5570,7 +5621,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5570
5621
|
return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare2);
|
|
5571
5622
|
}).join("\n");
|
|
5572
5623
|
if (after !== before) {
|
|
5573
|
-
|
|
5624
|
+
writeFileSync4(abs, after, "utf8");
|
|
5574
5625
|
log(`
|
|
5575
5626
|
Unwired ${file}: removed its import of ${relKey(...tokensRelDir, "theme.css")}.`);
|
|
5576
5627
|
log(` With more than one mode, the global stylesheet imports no barrel: every route would carry that mode's tokens.`);
|
|
@@ -5598,7 +5649,7 @@ Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensR
|
|
|
5598
5649
|
const oldDirName = currentBrand.split("/").slice(0, -1).join("/");
|
|
5599
5650
|
const before = existsSync16(abs) ? readFileSync23(abs, "utf8") : "";
|
|
5600
5651
|
const after = before.split("\n").filter((l) => !(l.includes("@import") && l.includes(oldDirName))).join("\n");
|
|
5601
|
-
if (after !== before)
|
|
5652
|
+
if (after !== before) writeFileSync4(abs, after, "utf8");
|
|
5602
5653
|
}
|
|
5603
5654
|
const outcome = wireBarrel(join28(opts.projectRoot, wireTarget), barrelImport);
|
|
5604
5655
|
wiring = { target: wireTarget, status: outcome.status, snippet };
|
|
@@ -5693,7 +5744,7 @@ import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync
|
|
|
5693
5744
|
import { join as join34 } from "path";
|
|
5694
5745
|
|
|
5695
5746
|
// src/probe/save.ts
|
|
5696
|
-
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as
|
|
5747
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync5 } from "fs";
|
|
5697
5748
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5698
5749
|
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5699
5750
|
|
|
@@ -6042,7 +6093,7 @@ async function evaluate(endpoint, url, width, height, timeoutMs, expression) {
|
|
|
6042
6093
|
|
|
6043
6094
|
// src/probe/serve.ts
|
|
6044
6095
|
import { createServer } from "http";
|
|
6045
|
-
import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as
|
|
6096
|
+
import { existsSync as existsSync18, readFileSync as readFileSync24, statSync as statSync3 } from "fs";
|
|
6046
6097
|
import { extname, join as join31, resolve as resolve3, sep as sep4 } from "path";
|
|
6047
6098
|
var TYPES = {
|
|
6048
6099
|
".html": "text/html; charset=utf-8",
|
|
@@ -6075,7 +6126,7 @@ async function serveDirectory(dir) {
|
|
|
6075
6126
|
res.writeHead(403).end();
|
|
6076
6127
|
return;
|
|
6077
6128
|
}
|
|
6078
|
-
if (existsSync18(file) &&
|
|
6129
|
+
if (existsSync18(file) && statSync3(file).isDirectory()) file = join31(file, "index.html");
|
|
6079
6130
|
else if (!existsSync18(file) && existsSync18(`${file}.html`)) file = `${file}.html`;
|
|
6080
6131
|
if (!existsSync18(file)) {
|
|
6081
6132
|
res.writeHead(404).end();
|
|
@@ -6143,7 +6194,7 @@ function saveProbe(opts) {
|
|
|
6143
6194
|
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6144
6195
|
mkdirSync7(dir, { recursive: true });
|
|
6145
6196
|
const path = join33(dir, `probe-${width}.json`);
|
|
6146
|
-
|
|
6197
|
+
writeFileSync5(path, JSON.stringify(probe), "utf8");
|
|
6147
6198
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
6148
6199
|
}
|
|
6149
6200
|
function projectFile(projectRoot, path) {
|
|
@@ -6392,14 +6443,14 @@ function decisionHeadings(projectRoot) {
|
|
|
6392
6443
|
}
|
|
6393
6444
|
|
|
6394
6445
|
// src/check/spec-shape.ts
|
|
6395
|
-
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as
|
|
6446
|
+
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync4 } from "fs";
|
|
6396
6447
|
import { join as join36 } from "path";
|
|
6397
6448
|
function newestSpec(projectRoot) {
|
|
6398
6449
|
const dir = join36(projectRoot, ".jig", "specs");
|
|
6399
6450
|
if (!existsSync22(dir)) return void 0;
|
|
6400
6451
|
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6401
6452
|
if (files.length === 0) return void 0;
|
|
6402
|
-
const newest = files.map((f) => ({ f, at:
|
|
6453
|
+
const newest = files.map((f) => ({ f, at: statSync4(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6403
6454
|
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
|
|
6404
6455
|
}
|
|
6405
6456
|
function specProblems(spec) {
|
|
@@ -6471,7 +6522,7 @@ function navProblems(spec) {
|
|
|
6471
6522
|
function specIndexableField(front) {
|
|
6472
6523
|
const line = /^\s*indexable\s*:(.*)$/im.exec(front);
|
|
6473
6524
|
if (!line) return "absent";
|
|
6474
|
-
const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
|
|
6525
|
+
const value = line[1].replace(/\s+#.*$/, "").trim().replace(/^["']|["']$/g, "").toLowerCase();
|
|
6475
6526
|
if (value === "true" || value === "yes") return true;
|
|
6476
6527
|
if (value === "false" || value === "no") return false;
|
|
6477
6528
|
return "unreadable";
|
|
@@ -6685,15 +6736,159 @@ function verifyVerdicts(opts) {
|
|
|
6685
6736
|
}
|
|
6686
6737
|
|
|
6687
6738
|
// src/commands/gate.ts
|
|
6688
|
-
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as
|
|
6739
|
+
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync32, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
6689
6740
|
import { createHash as createHash2 } from "crypto";
|
|
6741
|
+
import { join as join39 } from "path";
|
|
6742
|
+
|
|
6743
|
+
// src/check/mockup-drawing.ts
|
|
6744
|
+
import { readFileSync as readFileSync31 } from "fs";
|
|
6690
6745
|
import { join as join38 } from "path";
|
|
6746
|
+
var FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
|
|
6747
|
+
function mockupDrawingProblems(root, specBody, at) {
|
|
6748
|
+
let html;
|
|
6749
|
+
try {
|
|
6750
|
+
html = readFileSync31(join38(root, at), "utf8");
|
|
6751
|
+
} catch {
|
|
6752
|
+
return [];
|
|
6753
|
+
}
|
|
6754
|
+
const frames = readFrames(html);
|
|
6755
|
+
const problems = [];
|
|
6756
|
+
const missing = FRAMES.filter(([size]) => !frames.some((f) => f.size === size));
|
|
6757
|
+
if (missing.length) {
|
|
6758
|
+
problems.push(
|
|
6759
|
+
`${at} has no frame for ${missing.map(([size, px2]) => `${size} (${px2}px)`).join(", ")}. A mockup draws the page once per size, side by side, in the wireframe template's \`<section class="frame" data-size="\u2026">\` boxes, so the owner sees every size at once. A single responsive page shows only the width of the window it is opened in.`
|
|
6760
|
+
);
|
|
6761
|
+
}
|
|
6762
|
+
const drawn = frames.flatMap((f) => f.art.map((a) => `${f.size ?? "a"} frame: ${a}`));
|
|
6763
|
+
if (drawn.length) {
|
|
6764
|
+
problems.push(
|
|
6765
|
+
`${at} draws icons or images in its frames (${[...new Set(drawn)].slice(0, 4).join("; ")}). A mockup is structure only: an icon is its name in brackets, \`[search]\`, and an image is a labelled box saying what goes there.`
|
|
6766
|
+
);
|
|
6767
|
+
}
|
|
6768
|
+
const regions = specRegions(specBody);
|
|
6769
|
+
for (const [size] of FRAMES) {
|
|
6770
|
+
const listed = regions[size];
|
|
6771
|
+
const inFrame = frames.filter((f) => f.size === size).flatMap((f) => f.labels);
|
|
6772
|
+
if (!listed || listed.length === 0 || !frames.some((f) => f.size === size)) continue;
|
|
6773
|
+
const named = listed.filter((r) => r.name).map((r) => r.name);
|
|
6774
|
+
const absent = named.filter((name) => !inFrame.some((label) => labelMatches(label, name)));
|
|
6775
|
+
if (absent.length) {
|
|
6776
|
+
problems.push(
|
|
6777
|
+
`${at}: the ${size} frame has no labelled region for ${absent.map((n) => `"${n}"`).join(", ")}. Every region the spec lists for a size is drawn in that size's frame, with its name in a \`.name\` label.`
|
|
6778
|
+
);
|
|
6779
|
+
} else if (inFrame.length < listed.length) {
|
|
6780
|
+
problems.push(
|
|
6781
|
+
`${at}: the ${size} frame labels ${inFrame.length} region(s) and the spec lists ${listed.length} for ${size}. Draw each one in the frame, with its name in a \`.name\` label.`
|
|
6782
|
+
);
|
|
6783
|
+
}
|
|
6784
|
+
}
|
|
6785
|
+
const widths = new Set(frames.map((f) => f.width).filter((w) => w !== void 0));
|
|
6786
|
+
const crossed = specSwitches(specBody);
|
|
6787
|
+
const recorded = declaredSwitches(root);
|
|
6788
|
+
const unknown = (crossed ?? []).filter((name) => !recorded.some((sw) => sw.name === name));
|
|
6789
|
+
if (unknown.length) {
|
|
6790
|
+
problems.push(
|
|
6791
|
+
`The spec's \`switches:\` names ${unknown.map((n) => `"${n}"`).join(", ")}, which the project does not record. Recorded: ${recorded.map((sw) => `${sw.name} (${sw.px}px)`).join(", ") || "none"}. Name them as \`--breakpoint-<name>\` gives them.`
|
|
6792
|
+
);
|
|
6793
|
+
}
|
|
6794
|
+
for (const s of recorded.filter((sw) => !crossed || crossed.includes(sw.name))) {
|
|
6795
|
+
const absent = [s.px - 1, s.px].filter((w) => !widths.has(w));
|
|
6796
|
+
if (absent.length) {
|
|
6797
|
+
problems.push(
|
|
6798
|
+
`${at} does not draw either side of the \`--breakpoint-${s.name}\` switch at ${s.px}px (no frame at ${absent.join(" or ")}px). The layout changes there, between the four sizes, so the owner sees the last width before it and the first at it: a frame with \`data-width="${s.px - 1}"\` and one with \`data-width="${s.px}"\`, each that wide. A page that never crosses it says so in its spec: \`switches:\` lists the ones it does, or \`none\`.`
|
|
6799
|
+
);
|
|
6800
|
+
}
|
|
6801
|
+
}
|
|
6802
|
+
return problems;
|
|
6803
|
+
}
|
|
6804
|
+
function specSwitches(specBody) {
|
|
6805
|
+
const front = specBody.split(/^---\s*$/m)[1] ?? "";
|
|
6806
|
+
const value = /^switches\s*:\s*(.+)$/im.exec(front)?.[1]?.replace(/#.*$/, "").trim();
|
|
6807
|
+
if (value === void 0) return void 0;
|
|
6808
|
+
if (/^(none|\[\s*\])$/i.test(value)) return [];
|
|
6809
|
+
return value.replace(/^\[|\]$/g, "").split(",").map((n) => n.trim().replace(/^["']|["']$/g, "").replace(/^(--)?breakpoint-/, "")).filter(Boolean);
|
|
6810
|
+
}
|
|
6811
|
+
function readFrames(html) {
|
|
6812
|
+
const opens = [...html.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bframe\b[^"']*["'][^>]*>/gi)];
|
|
6813
|
+
return opens.map((open, i) => {
|
|
6814
|
+
const tag = open[0];
|
|
6815
|
+
const body = html.slice(open.index + tag.length, opens[i + 1]?.index ?? html.length);
|
|
6816
|
+
const size = /\bdata-size\s*=\s*["']([\w-]+)["']/i.exec(tag)?.[1]?.toLowerCase();
|
|
6817
|
+
const width = Number(/\bdata-width\s*=\s*["'](\d+)["']/i.exec(tag)?.[1]) || void 0;
|
|
6818
|
+
const labels = [...body.matchAll(/<\w+\b[^>]*\bclass\s*=\s*["'][^"']*\bname\b[^"']*["'][^>]*>([^<]*)/gi)].map((m) => normalise(m[1])).filter(Boolean);
|
|
6819
|
+
const art = [...body.matchAll(/<(svg|img|picture|canvas)\b|<i\b[^>]*\bclass\s*=\s*["'][^"']*\b(?:fa|fa-[\w-]+|material-icons|material-symbols[\w-]*|bi|bi-[\w-]+)\b/gi)].map((m) => m[1] ? `<${m[1].toLowerCase()}>` : "an icon font");
|
|
6820
|
+
return { size, width, labels, art };
|
|
6821
|
+
});
|
|
6822
|
+
}
|
|
6823
|
+
function specRegions(specBody) {
|
|
6824
|
+
const front = specBody.split(/^---\s*$/m)[1] ?? "";
|
|
6825
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6826
|
+
const out = {};
|
|
6827
|
+
const own = {};
|
|
6828
|
+
const sameAs = {};
|
|
6829
|
+
for (const [size] of [...FRAMES, ["landscape", 900]]) {
|
|
6830
|
+
const block = sizeBlock2(sizes, size);
|
|
6831
|
+
if (!block) continue;
|
|
6832
|
+
const alias = /^\s*same-as\s*:\s*([\w-]+)/im.exec(block)?.[1];
|
|
6833
|
+
if (alias) {
|
|
6834
|
+
sameAs[size] = alias.toLowerCase();
|
|
6835
|
+
continue;
|
|
6836
|
+
}
|
|
6837
|
+
own[size] = regionEntries(block).map(regionName);
|
|
6838
|
+
}
|
|
6839
|
+
for (const size of [...Object.keys(own), ...Object.keys(sameAs)]) {
|
|
6840
|
+
let at = size;
|
|
6841
|
+
for (let hops = 0; sameAs[at] && hops < 5; hops++) at = sameAs[at];
|
|
6842
|
+
if (own[at]) out[size] = own[at];
|
|
6843
|
+
}
|
|
6844
|
+
return out;
|
|
6845
|
+
}
|
|
6846
|
+
function regionEntries(block) {
|
|
6847
|
+
const inline = /^\s*regions\s*:\s*\[(.*)\]\s*$/im.exec(block);
|
|
6848
|
+
if (inline) return inline[1].split(",").map((s) => s.trim()).filter(Boolean);
|
|
6849
|
+
const start = /^(\s*)regions\s*:\s*$/im.exec(block);
|
|
6850
|
+
if (!start) return [];
|
|
6851
|
+
const indent = start[1].length;
|
|
6852
|
+
const entries = [];
|
|
6853
|
+
for (const line of block.slice(start.index + start[0].length).split("\n")) {
|
|
6854
|
+
if (!line.trim()) continue;
|
|
6855
|
+
const lead = /^\s*/.exec(line)[0].length;
|
|
6856
|
+
if (lead <= indent) break;
|
|
6857
|
+
const item = /^\s*-\s*(.+)$/.exec(line);
|
|
6858
|
+
if (item) entries.push(item[1]);
|
|
6859
|
+
}
|
|
6860
|
+
return entries;
|
|
6861
|
+
}
|
|
6862
|
+
function regionName(entry) {
|
|
6863
|
+
const text = entry.trim().replace(/^["']|["']$/g, "").replace(/\\"/g, '"');
|
|
6864
|
+
const head = normalise(text.split(":")[0]);
|
|
6865
|
+
return head && head.split(" ").length <= 4 ? { name: head } : {};
|
|
6866
|
+
}
|
|
6867
|
+
function labelMatches(label, name) {
|
|
6868
|
+
return label === name || label.startsWith(`${name} `) || label.startsWith(`${name}:`) || new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(label);
|
|
6869
|
+
}
|
|
6870
|
+
function normalise(text) {
|
|
6871
|
+
return text.toLowerCase().replace(/[`"'“”]/g, "").replace(/\s+/g, " ").trim();
|
|
6872
|
+
}
|
|
6873
|
+
function sizeBlock2(sizes, size) {
|
|
6874
|
+
const start = new RegExp(`^(\\s+)${size}\\s*:.*$`, "im").exec(sizes);
|
|
6875
|
+
if (!start) return "";
|
|
6876
|
+
const indent = start[1].length;
|
|
6877
|
+
const lines = [];
|
|
6878
|
+
for (const line of sizes.slice(start.index + start[0].length).split("\n")) {
|
|
6879
|
+
if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
|
|
6880
|
+
lines.push(line);
|
|
6881
|
+
}
|
|
6882
|
+
return lines.join("\n");
|
|
6883
|
+
}
|
|
6884
|
+
|
|
6885
|
+
// src/commands/gate.ts
|
|
6691
6886
|
var MAX_BLOCKS = 3;
|
|
6692
6887
|
function lastJigCommand(transcriptPath) {
|
|
6693
6888
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6694
6889
|
let text;
|
|
6695
6890
|
try {
|
|
6696
|
-
text =
|
|
6891
|
+
text = readFileSync32(transcriptPath, "utf8");
|
|
6697
6892
|
} catch {
|
|
6698
6893
|
return void 0;
|
|
6699
6894
|
}
|
|
@@ -6711,7 +6906,7 @@ function lastAssistantText(transcriptPath) {
|
|
|
6711
6906
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6712
6907
|
let found;
|
|
6713
6908
|
try {
|
|
6714
|
-
for (const line of
|
|
6909
|
+
for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
|
|
6715
6910
|
if (!line.includes('"assistant"')) continue;
|
|
6716
6911
|
let entry;
|
|
6717
6912
|
try {
|
|
@@ -6737,7 +6932,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
|
|
|
6737
6932
|
function sessionStart(transcriptPath) {
|
|
6738
6933
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6739
6934
|
try {
|
|
6740
|
-
for (const line of
|
|
6935
|
+
for (const line of readFileSync32(transcriptPath, "utf8").split("\n")) {
|
|
6741
6936
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
6742
6937
|
if (m) {
|
|
6743
6938
|
const t = Date.parse(m[1]);
|
|
@@ -6752,20 +6947,20 @@ function verdictsMtime(dir) {
|
|
|
6752
6947
|
let newest = 0;
|
|
6753
6948
|
for (const name of VERDICT_FILES) {
|
|
6754
6949
|
try {
|
|
6755
|
-
newest = Math.max(newest,
|
|
6950
|
+
newest = Math.max(newest, statSync5(join39(dir, name)).mtimeMs);
|
|
6756
6951
|
} catch {
|
|
6757
6952
|
}
|
|
6758
6953
|
}
|
|
6759
6954
|
return newest;
|
|
6760
6955
|
}
|
|
6761
6956
|
function surfacesInPlay(root, command, transcriptPath) {
|
|
6762
|
-
const critiqueDir =
|
|
6957
|
+
const critiqueDir = join39(root, ".jig", "critique");
|
|
6763
6958
|
if (!existsSync24(critiqueDir)) return [];
|
|
6764
6959
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6765
6960
|
const start = sessionStart(transcriptPath);
|
|
6766
6961
|
if (start === void 0) return all;
|
|
6767
6962
|
const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
|
|
6768
|
-
return all.filter((s) => s === current || verdictsMtime(
|
|
6963
|
+
return all.filter((s) => s === current || verdictsMtime(join39(critiqueDir, s)) >= start - 1e3);
|
|
6769
6964
|
}
|
|
6770
6965
|
function commandProblems(root, command) {
|
|
6771
6966
|
const problems = [];
|
|
@@ -6774,7 +6969,7 @@ function commandProblems(root, command) {
|
|
|
6774
6969
|
const found = decisionsFile(root);
|
|
6775
6970
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
6776
6971
|
else {
|
|
6777
|
-
const body =
|
|
6972
|
+
const body = readFileSync32(join39(root, found), "utf8");
|
|
6778
6973
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
6779
6974
|
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.`");
|
|
6780
6975
|
}
|
|
@@ -6795,14 +6990,14 @@ function commandProblems(root, command) {
|
|
|
6795
6990
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
6796
6991
|
if (/^approved/i.test(mockup)) {
|
|
6797
6992
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
6798
|
-
else if (!/^https?:/i.test(at) && !existsSync24(
|
|
6993
|
+
else if (!/^https?:/i.test(at) && !existsSync24(join39(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
6799
6994
|
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.`);
|
|
6800
6995
|
}
|
|
6801
|
-
if (at && /\.html?$/i.test(at) && existsSync24(
|
|
6996
|
+
if (at && /\.html?$/i.test(at) && existsSync24(join39(root, at))) problems.push(...mockupDrawingProblems(root, spec.body, at));
|
|
6802
6997
|
}
|
|
6803
6998
|
if (command === "critique") {
|
|
6804
|
-
const dir =
|
|
6805
|
-
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(
|
|
6999
|
+
const dir = join39(root, ".jig", "critique");
|
|
7000
|
+
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join39(dir, s, "screen.json")) || existsSync24(join39(dir, s, "code.json"))) : [];
|
|
6806
7001
|
for (const surface of surfaces) {
|
|
6807
7002
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6808
7003
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -6824,13 +7019,13 @@ function surfacePage(projectRoot, surface) {
|
|
|
6824
7019
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
6825
7020
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
6826
7021
|
for (const candidate of candidates) {
|
|
6827
|
-
if (candidate && existsSync24(
|
|
7022
|
+
if (candidate && existsSync24(join39(projectRoot, candidate))) return candidate;
|
|
6828
7023
|
}
|
|
6829
7024
|
return void 0;
|
|
6830
7025
|
}
|
|
6831
7026
|
function gate(opts) {
|
|
6832
7027
|
const root = opts.projectRoot;
|
|
6833
|
-
if (!existsSync24(
|
|
7028
|
+
if (!existsSync24(join39(root, "jig.config.json")) && !existsSync24(join39(root, ".jig"))) {
|
|
6834
7029
|
return { block: false, reason: "" };
|
|
6835
7030
|
}
|
|
6836
7031
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -6857,11 +7052,11 @@ function gate(opts) {
|
|
|
6857
7052
|
}
|
|
6858
7053
|
}
|
|
6859
7054
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
|
|
6860
|
-
const critiqueDir =
|
|
7055
|
+
const critiqueDir = join39(root, ".jig", "critique");
|
|
6861
7056
|
if (existsSync24(critiqueDir)) {
|
|
6862
7057
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
|
|
6863
|
-
const dir =
|
|
6864
|
-
if (!existsSync24(
|
|
7058
|
+
const dir = join39(critiqueDir, surface);
|
|
7059
|
+
if (!existsSync24(join39(dir, "screen.json")) && !existsSync24(join39(dir, "code.json"))) continue;
|
|
6865
7060
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6866
7061
|
if (!v.ok) {
|
|
6867
7062
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -6875,10 +7070,10 @@ function gate(opts) {
|
|
|
6875
7070
|
}
|
|
6876
7071
|
const session = opts.input.session_id ?? "unknown";
|
|
6877
7072
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
6878
|
-
const stateFile =
|
|
7073
|
+
const stateFile = join39(root, ".jig", "gate.json");
|
|
6879
7074
|
let state = {};
|
|
6880
7075
|
try {
|
|
6881
|
-
state = JSON.parse(
|
|
7076
|
+
state = JSON.parse(readFileSync32(stateFile, "utf8"));
|
|
6882
7077
|
} catch {
|
|
6883
7078
|
}
|
|
6884
7079
|
if (problems.length === 0) {
|
|
@@ -6906,27 +7101,13 @@ ${problems.join("\n\n")}`
|
|
|
6906
7101
|
${problems.join("\n\n")}`
|
|
6907
7102
|
};
|
|
6908
7103
|
}
|
|
6909
|
-
var MOCKUP_FRAMES = [["phone", 360], ["tablet", 768], ["desktop", 1280], ["wide", 1600]];
|
|
6910
|
-
function mockupFrameProblems(root, at) {
|
|
6911
|
-
let html;
|
|
6912
|
-
try {
|
|
6913
|
-
html = readFileSync31(join38(root, at), "utf8");
|
|
6914
|
-
} catch {
|
|
6915
|
-
return [];
|
|
6916
|
-
}
|
|
6917
|
-
const missing = MOCKUP_FRAMES.filter(([size]) => !new RegExp(`data-size\\s*=\\s*["']${size}["']`).test(html));
|
|
6918
|
-
if (missing.length === 0) return [];
|
|
6919
|
-
return [
|
|
6920
|
-
`${at} has no frame for ${missing.map(([size, px2]) => `${size} (${px2}px)`).join(", ")}. A mockup draws the page once per size, side by side, in the wireframe template's \`<div class="frame" data-size="\u2026">\` boxes, so the owner sees every size at once. A single responsive page shows only the width of the window it is opened in.`
|
|
6921
|
-
];
|
|
6922
|
-
}
|
|
6923
7104
|
var VERDICT_FILES = ["screen.json", "code.json", "decisions.json"];
|
|
6924
7105
|
var LOCK = "verdicts.lock";
|
|
6925
7106
|
function verdictChecksum(dir) {
|
|
6926
7107
|
const parts = VERDICT_FILES.map((f) => {
|
|
6927
7108
|
try {
|
|
6928
7109
|
return `${f}
|
|
6929
|
-
${
|
|
7110
|
+
${readFileSync32(join39(dir, f), "utf8")}`;
|
|
6930
7111
|
} catch {
|
|
6931
7112
|
return "";
|
|
6932
7113
|
}
|
|
@@ -6934,25 +7115,25 @@ ${readFileSync31(join38(dir, f), "utf8")}`;
|
|
|
6934
7115
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
6935
7116
|
}
|
|
6936
7117
|
function verdictGuard(root, command, inPlay) {
|
|
6937
|
-
const critiqueDir =
|
|
7118
|
+
const critiqueDir = join39(root, ".jig", "critique");
|
|
6938
7119
|
if (!existsSync24(critiqueDir)) return [];
|
|
6939
7120
|
const problems = [];
|
|
6940
7121
|
for (const surface of readdirSync15(critiqueDir)) {
|
|
6941
7122
|
if (surface.startsWith("_")) continue;
|
|
6942
|
-
const dir =
|
|
7123
|
+
const dir = join39(critiqueDir, surface);
|
|
6943
7124
|
const now = verdictChecksum(dir);
|
|
6944
7125
|
if (!now) continue;
|
|
6945
|
-
const lockPath =
|
|
7126
|
+
const lockPath = join39(dir, LOCK);
|
|
6946
7127
|
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
6947
7128
|
try {
|
|
6948
|
-
|
|
7129
|
+
writeFileSync6(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
6949
7130
|
} catch {
|
|
6950
7131
|
}
|
|
6951
7132
|
continue;
|
|
6952
7133
|
}
|
|
6953
7134
|
let locked;
|
|
6954
7135
|
try {
|
|
6955
|
-
locked = JSON.parse(
|
|
7136
|
+
locked = JSON.parse(readFileSync32(lockPath, "utf8")).checksum;
|
|
6956
7137
|
} catch {
|
|
6957
7138
|
continue;
|
|
6958
7139
|
}
|
|
@@ -6966,15 +7147,15 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6966
7147
|
}
|
|
6967
7148
|
function save(file, state) {
|
|
6968
7149
|
try {
|
|
6969
|
-
mkdirSync8(
|
|
6970
|
-
|
|
7150
|
+
mkdirSync8(join39(file, ".."), { recursive: true });
|
|
7151
|
+
writeFileSync6(file, JSON.stringify(state), "utf8");
|
|
6971
7152
|
} catch {
|
|
6972
7153
|
}
|
|
6973
7154
|
}
|
|
6974
7155
|
|
|
6975
7156
|
// src/commands/seo.ts
|
|
6976
|
-
import { readFileSync as
|
|
6977
|
-
import { join as
|
|
7157
|
+
import { readFileSync as readFileSync33 } from "fs";
|
|
7158
|
+
import { join as join40 } from "path";
|
|
6978
7159
|
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
6979
7160
|
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
6980
7161
|
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
@@ -7015,7 +7196,7 @@ function seo(opts) {
|
|
|
7015
7196
|
for (const file of files) {
|
|
7016
7197
|
let raw;
|
|
7017
7198
|
try {
|
|
7018
|
-
raw =
|
|
7199
|
+
raw = readFileSync33(join40(root, file), "utf8");
|
|
7019
7200
|
} catch {
|
|
7020
7201
|
continue;
|
|
7021
7202
|
}
|
|
@@ -7071,7 +7252,7 @@ function seo(opts) {
|
|
|
7071
7252
|
|
|
7072
7253
|
// src/index.ts
|
|
7073
7254
|
var packageRoot = getPackageRoot();
|
|
7074
|
-
var { version } = JSON.parse(
|
|
7255
|
+
var { version } = JSON.parse(readFileSync34(join41(packageRoot, "package.json"), "utf8"));
|
|
7075
7256
|
function warnIfUnpublishedPin() {
|
|
7076
7257
|
if (isPublishedBuild(packageRoot)) return;
|
|
7077
7258
|
console.warn(
|
|
@@ -7265,7 +7446,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
7265
7446
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
7266
7447
|
let input = {};
|
|
7267
7448
|
try {
|
|
7268
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
7449
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync34(0, "utf8") || "{}");
|
|
7269
7450
|
} catch {
|
|
7270
7451
|
}
|
|
7271
7452
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -7304,7 +7485,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
7304
7485
|
});
|
|
7305
7486
|
function resolveSurfaceMode(projectRoot) {
|
|
7306
7487
|
try {
|
|
7307
|
-
const config = JSON.parse(
|
|
7488
|
+
const config = JSON.parse(readFileSync34(join41(projectRoot, "jig.config.json"), "utf8"));
|
|
7308
7489
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
7309
7490
|
return modes.length === 1 ? modes[0] : void 0;
|
|
7310
7491
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.8",
|
|
4
4
|
"description": "A design system for coding agents. 143 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -343,6 +343,7 @@ sizes: # phone first. Each size is a whole composition, not
|
|
|
343
343
|
wide: # judged at 1600px — where a layout with no upper bound shows itself
|
|
344
344
|
same-as: desktop
|
|
345
345
|
why: "content is capped at 1200px, so 1600 adds margin and nothing else"
|
|
346
|
+
switches: none # the recorded switches (--breakpoint-*, T-04) this page crosses, by name; none if it crosses none. Absent, the mockup draws either side of every one
|
|
346
347
|
states: [default, loading, error, success]
|
|
347
348
|
decisions: [The Engineer Reads First] # every DECISIONS.md entry this screen implements, by name
|
|
348
349
|
later: [social sign-in, remember this device] # cut from V1, by name — the next specs start here
|
|
@@ -561,6 +562,14 @@ look. So the drawing contains:
|
|
|
561
562
|
detail: a phone composition that is only the desktop one narrower is the failure
|
|
562
563
|
specs are written per size to prevent. A `same-as:` size is drawn anyway, so the
|
|
563
564
|
claim can be seen to hold.
|
|
565
|
+
- **Either side of each switch the project records.** Where the project has
|
|
566
|
+
measured and recorded a width its layout changes at (`--breakpoint-<what>`,
|
|
567
|
+
`T-04`), draw the last width before it and the first at it, after the four
|
|
568
|
+
sizes: `<section class="frame" data-size="switch" data-width="539"
|
|
569
|
+
style="width:539px">` and the same at 540. The spec's `switches:` names the
|
|
570
|
+
ones this page crosses; absent, draw every one. The four sizes are where a page is
|
|
571
|
+
judged, not where it changes, and a switch between them is otherwise never
|
|
572
|
+
seen before it is built.
|
|
564
573
|
- **Navigation at every size, exactly as the spec's `nav:` says for that size.** On
|
|
565
574
|
the phone that usually means the Menu button, drawn where `DECISIONS.md` puts it —
|
|
566
575
|
and, because an open menu changes the structure, a second phone frame labelled
|
|
@@ -634,7 +643,10 @@ for is review: is the header taking a sixth of the phone? Is a tap target under
|
|
|
634
643
|
|
|
635
644
|
**Check the drawing against the spec before anyone sees it.** For each size, go
|
|
636
645
|
down the spec's `regions:` and its `nav:` and find each one in that frame. A region
|
|
637
|
-
the spec names and the frame lacks is drawn now, not raised in review.
|
|
646
|
+
the spec names and the frame lacks is drawn now, not raised in review. The gate
|
|
647
|
+
checks an HTML drawing when you stop: a frame for each of the four sizes, each
|
|
648
|
+
size's regions labelled in its frame (by name where the spec names them), a
|
|
649
|
+
frame either side of each recorded switch, and no icon or image in any frame. This matters
|
|
638
650
|
most on the phone, where a region dropped for space is easy to miss and the reviewer
|
|
639
651
|
is looking at what is there, not at what is not.
|
|
640
652
|
|