jig-ui 0.17.3 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/dist/index.js +148 -83
- package/package.json +1 -1
- package/rules/01-modes.md +1 -1
- package/rules/02-tokens.md +53 -3
- package/rules/03-patterns.md +13 -1
- package/tokens/mode.editorial.css +7 -0
- package/tokens/mode.operator.css +7 -0
- package/tokens/mode.product.css +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,76 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.1
|
|
4
|
+
|
|
5
|
+
Two fixes found by jig-site's fourth round: the probe misread a `<details>`
|
|
6
|
+
menu, and `A-05` missed an arrow that renders as an emoji.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **The probe reads a `<details>` menu the way the browser shows it.** On
|
|
11
|
+
jig-site's Reference it reported a working phone menu as broken, and
|
|
12
|
+
`jig verdicts` refused a correct `P-14` verdict on the strength of it. Four
|
|
13
|
+
defects, each now covered by a browser test:
|
|
14
|
+
- Where the banner's menu is hidden at a width, the probe took the next
|
|
15
|
+
disclosure in some other `<nav>` (a docs rail's section tree) as the menu,
|
|
16
|
+
and reported "a menu button beside links that already show". A menu the
|
|
17
|
+
page names, or one in its banner, is now the only candidate once it exists.
|
|
18
|
+
- A closed `<details>` hides its content with `content-visibility` in
|
|
19
|
+
Chromium, which still gives its links boxes, so the closed menu's links
|
|
20
|
+
counted as showing and opening it "showed no more links". Visibility now
|
|
21
|
+
also asks `checkVisibility()`.
|
|
22
|
+
- A label swapped with CSS ("Menu" to "Close") keeps both words in
|
|
23
|
+
`textContent`; the label is now read from the rendered text.
|
|
24
|
+
- A native `<summary>` has no `aria-expanded` attribute; its expanded state
|
|
25
|
+
is its `<details>`'s `open`, which is what assistive technology is given.
|
|
26
|
+
|
|
27
|
+
Probe files are now version 6, so earlier ones are re-recorded.
|
|
28
|
+
|
|
29
|
+
- **`A-05` catches an arrow that turns into an emoji.** ↔ ↕ ↖ ↗ ↘ ↙ ↩ ↪ ▶ ◀
|
|
30
|
+
are text by default but have an emoji form, and the detector skipped the
|
|
31
|
+
whole arrow block so prose arrows would not fire. jig-site marked its
|
|
32
|
+
external links with `<span aria-hidden="true">↗</span>`, which some platforms
|
|
33
|
+
draw as a colour emoji, and `check --all` and three critiques passed it. One
|
|
34
|
+
of these characters now fires when it stands alone as an icon (the whole
|
|
35
|
+
content of an element, or a pseudo-element's `content`) without U+FE0E. The
|
|
36
|
+
same arrow in a sentence still does not.
|
|
37
|
+
|
|
38
|
+
## 0.18.0
|
|
39
|
+
|
|
40
|
+
A page's frame gets tokens, a project gets a place to record where its layout
|
|
41
|
+
switches, and keyboard access a rule requires fits every script budget. All of
|
|
42
|
+
it found building jig-site's header and Reference with the full loop.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **Keyboard access a rule requires does not count against a script budget.**
|
|
47
|
+
`P-14` requires `Escape` to close an open menu, and `editorial`'s budget was
|
|
48
|
+
0 KB, with `<details>` as the menu, which cannot do it. No editorial page
|
|
49
|
+
could satisfy both, and a real site's home and not-found pages kept a menu a
|
|
50
|
+
keyboard could not dismiss. `M-01` now allows, in every mode, a few lines
|
|
51
|
+
under 1 KB whose only job is keyboard behaviour a rule asks for and the
|
|
52
|
+
browser does not give, with the rule named in a comment. `P-14` carries the
|
|
53
|
+
six lines for `Escape`.
|
|
54
|
+
- **Layout sizes: `--size-container`, `--size-rail`, `--size-header`.** Every
|
|
55
|
+
mode now sizes a page's frame, not only what is inside it. Building one site
|
|
56
|
+
with the full loop, four gaps turned out to be one: no token for a page's
|
|
57
|
+
maximum width, a rail's width, or a fixed header's height. The site made up
|
|
58
|
+
its own, one of them a raw pixel value. The container is 1280px in every
|
|
59
|
+
mode, because it is not density. A rail is 288, 256 or 240px, sized to hold
|
|
60
|
+
labels at the mode's type size. The header is `--size-touch-target` plus
|
|
61
|
+
`--border-width-hairline`. See T-04.
|
|
62
|
+
- **Where a layout switches is measured and recorded.** Jig still defines no
|
|
63
|
+
breakpoint, and the four judged widths are checkpoints, not places the CSS
|
|
64
|
+
changes. T-04 now says how a project records a width it measured:
|
|
65
|
+
`--breakpoint-<what switches>` in its own layer, with the measurement
|
|
66
|
+
beside it.
|
|
67
|
+
- **`jig probe --run` measures either side of every declared switch.** A switch
|
|
68
|
+
falls between the judged widths by design, and a range nobody measured is
|
|
69
|
+
where a layout breaks: a real site's three-column frame appeared at 1216px,
|
|
70
|
+
and 1024 to 1215 still got the phone arrangement. The probe now also records
|
|
71
|
+
one pixel before and at each `--breakpoint-*` the project declares, and the
|
|
72
|
+
gate's refresh keeps those probes current too.
|
|
73
|
+
|
|
3
74
|
## 0.17.3
|
|
4
75
|
|
|
5
76
|
Three defects found building jig-site's header with the full loop.
|
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 readFileSync33 } from "fs";
|
|
6
6
|
import { homedir as homedir2 } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join40 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -2265,6 +2265,11 @@ function maskProseComments(raw) {
|
|
|
2265
2265
|
// src/check/detectors/emoji-icon.ts
|
|
2266
2266
|
var EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{1F000}-\u{1F2FF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}]/gu;
|
|
2267
2267
|
var TEXTUAL = /* @__PURE__ */ new Set(["\u2192", "\u2190", "\u2191", "\u2193", "\u2194", "\u21D2", "\u21D0", "\u2122", "\u2713", "\u2717", "\u2212", "\u2217", "\u2318", "\u2325", "\u23CE"]);
|
|
2268
|
+
var EMOJI_CAPABLE = "\\u2194-\\u2199\\u21A9\\u21AA\\u25B6\\u25C0";
|
|
2269
|
+
var LONE_GLYPH = new RegExp(
|
|
2270
|
+
`>\\s*([${EMOJI_CAPABLE}])(?!\\uFE0E)\\s*<|content\\s*:\\s*["']([${EMOJI_CAPABLE}])(?!\\uFE0E)["']`,
|
|
2271
|
+
"gu"
|
|
2272
|
+
);
|
|
2268
2273
|
var emojiIcon = {
|
|
2269
2274
|
name: "emoji-icon",
|
|
2270
2275
|
appliesTo: (file) => isStyleBearing(file),
|
|
@@ -2289,6 +2294,22 @@ var emojiIcon = {
|
|
|
2289
2294
|
)
|
|
2290
2295
|
);
|
|
2291
2296
|
}
|
|
2297
|
+
for (const m of masked.matchAll(LONE_GLYPH)) {
|
|
2298
|
+
const glyph = m[1] ?? m[2];
|
|
2299
|
+
const line = masked.slice(0, m.index + m[0].indexOf(glyph)).split("\n").length;
|
|
2300
|
+
if (seen.has(line)) continue;
|
|
2301
|
+
seen.add(line);
|
|
2302
|
+
findings.push(
|
|
2303
|
+
mkFinding(
|
|
2304
|
+
ctx,
|
|
2305
|
+
"emoji-icon",
|
|
2306
|
+
file,
|
|
2307
|
+
line,
|
|
2308
|
+
`A character with an emoji form used as an icon (${glyph}). Standing alone, it renders as a colour emoji on some platforms. Use an icon set, or words.`,
|
|
2309
|
+
lines[line - 1] ?? ""
|
|
2310
|
+
)
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2292
2313
|
return findings;
|
|
2293
2314
|
}
|
|
2294
2315
|
};
|
|
@@ -5652,8 +5673,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5652
5673
|
}
|
|
5653
5674
|
|
|
5654
5675
|
// src/commands/verdicts.ts
|
|
5655
|
-
import { existsSync as existsSync23, readFileSync as
|
|
5656
|
-
import { join as
|
|
5676
|
+
import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
|
|
5677
|
+
import { join as join37, resolve as resolve5 } from "path";
|
|
5657
5678
|
|
|
5658
5679
|
// src/rules/citations.ts
|
|
5659
5680
|
import { join as join29 } from "path";
|
|
@@ -5667,19 +5688,22 @@ function citableIds(packageRoot2) {
|
|
|
5667
5688
|
}
|
|
5668
5689
|
|
|
5669
5690
|
// src/probe/check.ts
|
|
5670
|
-
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as
|
|
5671
|
-
import { join as
|
|
5691
|
+
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
|
|
5692
|
+
import { join as join34 } from "path";
|
|
5672
5693
|
|
|
5673
5694
|
// src/probe/save.ts
|
|
5674
|
-
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as
|
|
5695
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
|
|
5675
5696
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5676
|
-
import { join as
|
|
5697
|
+
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5677
5698
|
|
|
5678
5699
|
// src/probe/script.ts
|
|
5679
|
-
var PROBE_VERSION =
|
|
5700
|
+
var PROBE_VERSION = 6;
|
|
5680
5701
|
var PROBE_SCRIPT = `(async () => {
|
|
5681
5702
|
const doc = document.documentElement;
|
|
5682
|
-
|
|
5703
|
+
// checkVisibility also sees content a closed <details> hides: Chromium hides
|
|
5704
|
+
// it with content-visibility, which still gives its links real boxes, so a
|
|
5705
|
+
// closed menu counted as open and an opened one showed "no more links".
|
|
5706
|
+
const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && (!e.checkVisibility || e.checkVisibility()); };
|
|
5683
5707
|
const probe = document.createElement('div');
|
|
5684
5708
|
probe.style.all = 'initial';
|
|
5685
5709
|
document.body.appendChild(probe);
|
|
@@ -5772,19 +5796,32 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5772
5796
|
const inChrome = (b) => !!b.closest('header, [role=banner]') ||
|
|
5773
5797
|
(!!b.closest('nav, [role=navigation]') && !b.closest('main, article, aside, [role=main]'));
|
|
5774
5798
|
const named = (b) => /\b(menu|navigation)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + b.textContent);
|
|
5775
|
-
const
|
|
5776
|
-
|
|
5799
|
+
const candidates = [...document.querySelectorAll('button, summary, [role=button]')].filter((b) =>
|
|
5800
|
+
named(b) || (inChrome(b) && (b.tagName === 'SUMMARY' || b.hasAttribute('aria-expanded') || b.hasAttribute('aria-controls'))));
|
|
5801
|
+
// A menu the page names, or one in its banner, is the menu. Where it exists
|
|
5802
|
+
// but is hidden at this width, the links show instead and there is no menu
|
|
5803
|
+
// here; the next disclosure in some other <nav> (a docs rail's tree) is not
|
|
5804
|
+
// a stand-in for it.
|
|
5805
|
+
const primary = (b) => named(b) || !!b.closest('header, [role=banner]');
|
|
5806
|
+
const pool = candidates.some(primary) ? candidates.filter(primary) : candidates;
|
|
5807
|
+
const toggle = pool.find(vis);
|
|
5777
5808
|
let menu = null;
|
|
5778
5809
|
if (toggle) {
|
|
5779
|
-
|
|
5810
|
+
// The label as rendered: a menu that swaps "Menu" for "Close" with CSS keeps
|
|
5811
|
+
// both words in its textContent at all times, so only innerText sees the swap.
|
|
5812
|
+
const name = () => ((toggle.getAttribute('aria-label') || '') + ' ' + (toggle.innerText || toggle.textContent || '')).replace(/\\s+/g, ' ').trim();
|
|
5813
|
+
// A native <summary> carries no aria-expanded attribute; the browser exposes
|
|
5814
|
+
// its <details>'s open state as the expanded state, which is what counts.
|
|
5815
|
+
const expanded = () => toggle.getAttribute('aria-expanded') ??
|
|
5816
|
+
(toggle.tagName === 'SUMMARY' && toggle.parentElement && toggle.parentElement.tagName === 'DETAILS' ? String(toggle.parentElement.open) : null);
|
|
5780
5817
|
// Opening is judged by what becomes visible anywhere: a menu panel is often
|
|
5781
5818
|
// a sibling of the header, not inside it (arm test 3, tw-1).
|
|
5782
5819
|
const allLinks = () => [...document.querySelectorAll('a')].filter(vis).length;
|
|
5783
5820
|
const controlled = () => { const id = toggle.getAttribute('aria-controls'); const el = id && document.getElementById(id); return el ? vis(el) : null; };
|
|
5784
|
-
const before = { links: allLinks(), controlled: controlled(), expanded:
|
|
5821
|
+
const before = { links: allLinks(), controlled: controlled(), expanded: expanded(), name: name(), html: toggle.innerHTML };
|
|
5785
5822
|
toggle.click();
|
|
5786
5823
|
await new Promise((r) => setTimeout(r, 350));
|
|
5787
|
-
const after = { links: allLinks(), controlled: controlled(), expanded:
|
|
5824
|
+
const after = { links: allLinks(), controlled: controlled(), expanded: expanded(), name: name(), htmlChanged: toggle.innerHTML !== before.html };
|
|
5788
5825
|
toggle.focus();
|
|
5789
5826
|
toggle.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
5790
5827
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
@@ -5794,7 +5831,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5794
5831
|
expandedBefore: before.expanded, expandedAfter: after.expanded,
|
|
5795
5832
|
opened: after.links > before.links || (before.controlled === false && after.controlled === true),
|
|
5796
5833
|
labelChanged: after.name !== before.name || after.htmlChanged,
|
|
5797
|
-
escapeCloses:
|
|
5834
|
+
escapeCloses: expanded() !== 'true' && allLinks() <= before.links,
|
|
5798
5835
|
focusReturned: document.activeElement === toggle,
|
|
5799
5836
|
};
|
|
5800
5837
|
}
|
|
@@ -6055,6 +6092,31 @@ async function serveDirectory(dir) {
|
|
|
6055
6092
|
};
|
|
6056
6093
|
}
|
|
6057
6094
|
|
|
6095
|
+
// src/probe/switches.ts
|
|
6096
|
+
import { readFileSync as readFileSync25 } from "fs";
|
|
6097
|
+
import { join as join32 } from "path";
|
|
6098
|
+
var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
|
|
6099
|
+
function declaredSwitches(projectRoot) {
|
|
6100
|
+
const found = /* @__PURE__ */ new Map();
|
|
6101
|
+
for (const file of wholeRepoFiles(projectRoot)) {
|
|
6102
|
+
if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
|
|
6103
|
+
let text;
|
|
6104
|
+
try {
|
|
6105
|
+
text = readFileSync25(join32(projectRoot, file), "utf8");
|
|
6106
|
+
} catch {
|
|
6107
|
+
continue;
|
|
6108
|
+
}
|
|
6109
|
+
for (const m of text.matchAll(DECLARATION)) {
|
|
6110
|
+
const px2 = Math.round(m[3] === "rem" ? Number(m[2]) * 16 : Number(m[2]));
|
|
6111
|
+
if (px2 > 0) found.set(`${m[1]}:${px2}`, { name: m[1], px: px2 });
|
|
6112
|
+
}
|
|
6113
|
+
}
|
|
6114
|
+
return [...found.values()].sort((a, b) => a.px - b.px);
|
|
6115
|
+
}
|
|
6116
|
+
function probeWidths(base, switches) {
|
|
6117
|
+
return [.../* @__PURE__ */ new Set([...base, ...switches.flatMap((s) => [s.px - 1, s.px])])].sort((a, b) => a - b);
|
|
6118
|
+
}
|
|
6119
|
+
|
|
6058
6120
|
// src/probe/save.ts
|
|
6059
6121
|
function saveProbe(opts) {
|
|
6060
6122
|
let probe;
|
|
@@ -6075,11 +6137,11 @@ function saveProbe(opts) {
|
|
|
6075
6137
|
}
|
|
6076
6138
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
6077
6139
|
if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
|
|
6078
|
-
probe.pageChecksum = checksum(
|
|
6140
|
+
probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
|
|
6079
6141
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6080
|
-
const dir =
|
|
6142
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6081
6143
|
mkdirSync7(dir, { recursive: true });
|
|
6082
|
-
const path =
|
|
6144
|
+
const path = join33(dir, `probe-${width}.json`);
|
|
6083
6145
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
6084
6146
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
6085
6147
|
}
|
|
@@ -6105,8 +6167,9 @@ async function runAndSaveProbes(opts) {
|
|
|
6105
6167
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6106
6168
|
if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
6107
6169
|
const saved = [];
|
|
6170
|
+
const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
|
|
6108
6171
|
await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
|
|
6109
|
-
for (const width of
|
|
6172
|
+
for (const width of widths) {
|
|
6110
6173
|
const json = await runProbe({ url, width });
|
|
6111
6174
|
saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
|
|
6112
6175
|
}
|
|
@@ -6127,17 +6190,19 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
6127
6190
|
}
|
|
6128
6191
|
}
|
|
6129
6192
|
async function ensureProbes(opts) {
|
|
6130
|
-
const dir =
|
|
6193
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6131
6194
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6132
6195
|
let current;
|
|
6133
6196
|
try {
|
|
6134
|
-
current = checksum(
|
|
6197
|
+
current = checksum(readFileSync26(abs, "utf8"));
|
|
6135
6198
|
} catch {
|
|
6136
6199
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
6137
6200
|
}
|
|
6138
|
-
const
|
|
6201
|
+
const recorded = existsSync19(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
|
|
6202
|
+
const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
|
|
6203
|
+
const missing = widths.filter((width) => {
|
|
6139
6204
|
try {
|
|
6140
|
-
const probe = JSON.parse(
|
|
6205
|
+
const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
|
|
6141
6206
|
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
6142
6207
|
} catch {
|
|
6143
6208
|
return true;
|
|
@@ -6146,9 +6211,9 @@ async function ensureProbes(opts) {
|
|
|
6146
6211
|
if (missing.length === 0) return { recorded: [] };
|
|
6147
6212
|
if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
|
|
6148
6213
|
let serve;
|
|
6149
|
-
for (const width of
|
|
6214
|
+
for (const width of widths) {
|
|
6150
6215
|
try {
|
|
6151
|
-
const probe = JSON.parse(
|
|
6216
|
+
const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
|
|
6152
6217
|
if (probe.serveRoot) {
|
|
6153
6218
|
serve = probe.serveRoot;
|
|
6154
6219
|
break;
|
|
@@ -6164,9 +6229,9 @@ async function ensureProbes(opts) {
|
|
|
6164
6229
|
return { recorded: missing };
|
|
6165
6230
|
}
|
|
6166
6231
|
function critiquedSurfaces(projectRoot) {
|
|
6167
|
-
const root =
|
|
6232
|
+
const root = join33(projectRoot, ".jig", "critique");
|
|
6168
6233
|
if (!existsSync19(root)) return [];
|
|
6169
|
-
return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(
|
|
6234
|
+
return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join33(root, surface, "screen.json")) || existsSync19(join33(root, surface, "code.json"))));
|
|
6170
6235
|
}
|
|
6171
6236
|
|
|
6172
6237
|
// src/probe/check.ts
|
|
@@ -6178,7 +6243,7 @@ function stampProblem(projectRoot, file, p) {
|
|
|
6178
6243
|
if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
|
|
6179
6244
|
let current;
|
|
6180
6245
|
try {
|
|
6181
|
-
current =
|
|
6246
|
+
current = readFileSync27(page, "utf8");
|
|
6182
6247
|
} catch {
|
|
6183
6248
|
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
6184
6249
|
}
|
|
@@ -6192,7 +6257,7 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
6192
6257
|
const probes = [];
|
|
6193
6258
|
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
6194
6259
|
try {
|
|
6195
|
-
const p = JSON.parse(
|
|
6260
|
+
const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
|
|
6196
6261
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
6197
6262
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
6198
6263
|
continue;
|
|
@@ -6284,19 +6349,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6284
6349
|
}
|
|
6285
6350
|
|
|
6286
6351
|
// src/check/decisions.ts
|
|
6287
|
-
import { existsSync as existsSync21, readFileSync as
|
|
6288
|
-
import { join as
|
|
6352
|
+
import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
|
|
6353
|
+
import { join as join35, posix as posix3 } from "path";
|
|
6289
6354
|
var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
6290
6355
|
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
6291
6356
|
function decisionsFile(projectRoot) {
|
|
6292
6357
|
const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
|
|
6293
6358
|
try {
|
|
6294
|
-
const brand = JSON.parse(
|
|
6359
|
+
const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
|
|
6295
6360
|
if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
|
|
6296
6361
|
} catch {
|
|
6297
6362
|
}
|
|
6298
6363
|
for (const candidate of candidates) {
|
|
6299
|
-
if (existsSync21(
|
|
6364
|
+
if (existsSync21(join35(projectRoot, candidate))) return candidate;
|
|
6300
6365
|
}
|
|
6301
6366
|
return void 0;
|
|
6302
6367
|
}
|
|
@@ -6305,7 +6370,7 @@ function decisionNames(projectRoot) {
|
|
|
6305
6370
|
if (!path) return [];
|
|
6306
6371
|
let body;
|
|
6307
6372
|
try {
|
|
6308
|
-
body =
|
|
6373
|
+
body = readFileSync28(join35(projectRoot, path), "utf8");
|
|
6309
6374
|
} catch {
|
|
6310
6375
|
return [];
|
|
6311
6376
|
}
|
|
@@ -6319,15 +6384,15 @@ function decisionNames(projectRoot) {
|
|
|
6319
6384
|
}
|
|
6320
6385
|
|
|
6321
6386
|
// src/check/spec-shape.ts
|
|
6322
|
-
import { existsSync as existsSync22, readFileSync as
|
|
6323
|
-
import { join as
|
|
6387
|
+
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
|
|
6388
|
+
import { join as join36 } from "path";
|
|
6324
6389
|
function newestSpec(projectRoot) {
|
|
6325
|
-
const dir =
|
|
6390
|
+
const dir = join36(projectRoot, ".jig", "specs");
|
|
6326
6391
|
if (!existsSync22(dir)) return void 0;
|
|
6327
6392
|
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6328
6393
|
if (files.length === 0) return void 0;
|
|
6329
|
-
const newest = files.map((f) => ({ f, at: statSync3(
|
|
6330
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body:
|
|
6394
|
+
const newest = files.map((f) => ({ f, at: statSync3(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6395
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
|
|
6331
6396
|
}
|
|
6332
6397
|
function specProblems(spec) {
|
|
6333
6398
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -6410,17 +6475,17 @@ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (fi
|
|
|
6410
6475
|
function readJson(path, errors) {
|
|
6411
6476
|
if (!existsSync23(path)) return null;
|
|
6412
6477
|
try {
|
|
6413
|
-
return JSON.parse(
|
|
6478
|
+
return JSON.parse(readFileSync30(path, "utf8"));
|
|
6414
6479
|
} catch (e) {
|
|
6415
6480
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
6416
6481
|
return {};
|
|
6417
6482
|
}
|
|
6418
6483
|
}
|
|
6419
6484
|
function specIndexable(projectRoot, surface) {
|
|
6420
|
-
const path =
|
|
6485
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6421
6486
|
let front = "";
|
|
6422
6487
|
try {
|
|
6423
|
-
front =
|
|
6488
|
+
front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6424
6489
|
} catch {
|
|
6425
6490
|
}
|
|
6426
6491
|
const declared = specIndexableField(front);
|
|
@@ -6429,9 +6494,9 @@ function specIndexable(projectRoot, surface) {
|
|
|
6429
6494
|
return mode !== "product" && mode !== "operator";
|
|
6430
6495
|
}
|
|
6431
6496
|
function specNeedsNav(projectRoot, surface) {
|
|
6432
|
-
const path =
|
|
6497
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6433
6498
|
if (!existsSync23(path)) return false;
|
|
6434
|
-
const front =
|
|
6499
|
+
const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6435
6500
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
6436
6501
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
6437
6502
|
return navField || navRegion;
|
|
@@ -6482,7 +6547,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6482
6547
|
function checkDecisions(projectRoot, dir, errors) {
|
|
6483
6548
|
const required = decisionNames(projectRoot);
|
|
6484
6549
|
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
6485
|
-
const file = readJson(
|
|
6550
|
+
const file = readJson(join37(dir, "decisions.json"), errors);
|
|
6486
6551
|
if (!file) {
|
|
6487
6552
|
errors.push(
|
|
6488
6553
|
`decisions.json is missing. Every decision in DECISIONS.md is judged against the built page, one verdict each: ${required.slice(0, 4).join(", ")}${required.length > 4 ? `, and ${required.length - 4} more` : ""}. A page can satisfy every rule and still break what this project decided.`
|
|
@@ -6527,7 +6592,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6527
6592
|
function verifyVerdicts(opts) {
|
|
6528
6593
|
const root = opts.packageRoot ?? assetRoot();
|
|
6529
6594
|
const errors = [];
|
|
6530
|
-
const index = JSON.parse(
|
|
6595
|
+
const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
|
|
6531
6596
|
const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
|
|
6532
6597
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
6533
6598
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -6536,9 +6601,9 @@ function verifyVerdicts(opts) {
|
|
|
6536
6601
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
6537
6602
|
]);
|
|
6538
6603
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
6539
|
-
const dir =
|
|
6540
|
-
const screenFile = readJson(
|
|
6541
|
-
const codeFile = readJson(
|
|
6604
|
+
const dir = join37(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6605
|
+
const screenFile = readJson(join37(dir, "screen.json"), errors);
|
|
6606
|
+
const codeFile = readJson(join37(dir, "code.json"), errors);
|
|
6542
6607
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
6543
6608
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
6544
6609
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
@@ -6566,7 +6631,7 @@ function verifyVerdicts(opts) {
|
|
|
6566
6631
|
};
|
|
6567
6632
|
let specFront = "";
|
|
6568
6633
|
try {
|
|
6569
|
-
specFront =
|
|
6634
|
+
specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6570
6635
|
} catch {
|
|
6571
6636
|
}
|
|
6572
6637
|
if (specIndexableField(specFront) === "unreadable") {
|
|
@@ -6582,15 +6647,15 @@ function verifyVerdicts(opts) {
|
|
|
6582
6647
|
}
|
|
6583
6648
|
|
|
6584
6649
|
// src/commands/gate.ts
|
|
6585
|
-
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as
|
|
6650
|
+
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync31, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
|
|
6586
6651
|
import { createHash as createHash2 } from "crypto";
|
|
6587
|
-
import { join as
|
|
6652
|
+
import { join as join38 } from "path";
|
|
6588
6653
|
var MAX_BLOCKS = 3;
|
|
6589
6654
|
function lastJigCommand(transcriptPath) {
|
|
6590
6655
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6591
6656
|
let text;
|
|
6592
6657
|
try {
|
|
6593
|
-
text =
|
|
6658
|
+
text = readFileSync31(transcriptPath, "utf8");
|
|
6594
6659
|
} catch {
|
|
6595
6660
|
return void 0;
|
|
6596
6661
|
}
|
|
@@ -6608,7 +6673,7 @@ function lastAssistantText(transcriptPath) {
|
|
|
6608
6673
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6609
6674
|
let found;
|
|
6610
6675
|
try {
|
|
6611
|
-
for (const line of
|
|
6676
|
+
for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
|
|
6612
6677
|
if (!line.includes('"assistant"')) continue;
|
|
6613
6678
|
let entry;
|
|
6614
6679
|
try {
|
|
@@ -6634,7 +6699,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
|
|
|
6634
6699
|
function sessionStart(transcriptPath) {
|
|
6635
6700
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6636
6701
|
try {
|
|
6637
|
-
for (const line of
|
|
6702
|
+
for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
|
|
6638
6703
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
6639
6704
|
if (m) {
|
|
6640
6705
|
const t = Date.parse(m[1]);
|
|
@@ -6649,20 +6714,20 @@ function verdictsMtime(dir) {
|
|
|
6649
6714
|
let newest = 0;
|
|
6650
6715
|
for (const name of VERDICT_FILES) {
|
|
6651
6716
|
try {
|
|
6652
|
-
newest = Math.max(newest, statSync4(
|
|
6717
|
+
newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
|
|
6653
6718
|
} catch {
|
|
6654
6719
|
}
|
|
6655
6720
|
}
|
|
6656
6721
|
return newest;
|
|
6657
6722
|
}
|
|
6658
6723
|
function surfacesInPlay(root, command, transcriptPath) {
|
|
6659
|
-
const critiqueDir =
|
|
6724
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6660
6725
|
if (!existsSync24(critiqueDir)) return [];
|
|
6661
6726
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6662
6727
|
const start = sessionStart(transcriptPath);
|
|
6663
6728
|
if (start === void 0) return all;
|
|
6664
6729
|
const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
|
|
6665
|
-
return all.filter((s) => s === current || verdictsMtime(
|
|
6730
|
+
return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
|
|
6666
6731
|
}
|
|
6667
6732
|
function commandProblems(root, command) {
|
|
6668
6733
|
const problems = [];
|
|
@@ -6671,7 +6736,7 @@ function commandProblems(root, command) {
|
|
|
6671
6736
|
const found = decisionsFile(root);
|
|
6672
6737
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
6673
6738
|
else {
|
|
6674
|
-
const body =
|
|
6739
|
+
const body = readFileSync31(join38(root, found), "utf8");
|
|
6675
6740
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
6676
6741
|
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.`");
|
|
6677
6742
|
}
|
|
@@ -6692,13 +6757,13 @@ function commandProblems(root, command) {
|
|
|
6692
6757
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
6693
6758
|
if (/^approved/i.test(mockup)) {
|
|
6694
6759
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
6695
|
-
else if (!/^https?:/i.test(at) && !existsSync24(
|
|
6760
|
+
else if (!/^https?:/i.test(at) && !existsSync24(join38(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
6696
6761
|
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.`);
|
|
6697
6762
|
}
|
|
6698
6763
|
}
|
|
6699
6764
|
if (command === "critique") {
|
|
6700
|
-
const dir =
|
|
6701
|
-
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(
|
|
6765
|
+
const dir = join38(root, ".jig", "critique");
|
|
6766
|
+
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join38(dir, s, "screen.json")) || existsSync24(join38(dir, s, "code.json"))) : [];
|
|
6702
6767
|
for (const surface of surfaces) {
|
|
6703
6768
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6704
6769
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -6720,13 +6785,13 @@ function surfacePage(projectRoot, surface) {
|
|
|
6720
6785
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
6721
6786
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
6722
6787
|
for (const candidate of candidates) {
|
|
6723
|
-
if (candidate && existsSync24(
|
|
6788
|
+
if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
|
|
6724
6789
|
}
|
|
6725
6790
|
return void 0;
|
|
6726
6791
|
}
|
|
6727
6792
|
function gate(opts) {
|
|
6728
6793
|
const root = opts.projectRoot;
|
|
6729
|
-
if (!existsSync24(
|
|
6794
|
+
if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
|
|
6730
6795
|
return { block: false, reason: "" };
|
|
6731
6796
|
}
|
|
6732
6797
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -6753,11 +6818,11 @@ function gate(opts) {
|
|
|
6753
6818
|
}
|
|
6754
6819
|
}
|
|
6755
6820
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
|
|
6756
|
-
const critiqueDir =
|
|
6821
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6757
6822
|
if (existsSync24(critiqueDir)) {
|
|
6758
6823
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
|
|
6759
|
-
const dir =
|
|
6760
|
-
if (!existsSync24(
|
|
6824
|
+
const dir = join38(critiqueDir, surface);
|
|
6825
|
+
if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
|
|
6761
6826
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6762
6827
|
if (!v.ok) {
|
|
6763
6828
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -6771,10 +6836,10 @@ function gate(opts) {
|
|
|
6771
6836
|
}
|
|
6772
6837
|
const session = opts.input.session_id ?? "unknown";
|
|
6773
6838
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
6774
|
-
const stateFile =
|
|
6839
|
+
const stateFile = join38(root, ".jig", "gate.json");
|
|
6775
6840
|
let state = {};
|
|
6776
6841
|
try {
|
|
6777
|
-
state = JSON.parse(
|
|
6842
|
+
state = JSON.parse(readFileSync31(stateFile, "utf8"));
|
|
6778
6843
|
} catch {
|
|
6779
6844
|
}
|
|
6780
6845
|
if (problems.length === 0) {
|
|
@@ -6808,7 +6873,7 @@ function verdictChecksum(dir) {
|
|
|
6808
6873
|
const parts = VERDICT_FILES.map((f) => {
|
|
6809
6874
|
try {
|
|
6810
6875
|
return `${f}
|
|
6811
|
-
${
|
|
6876
|
+
${readFileSync31(join38(dir, f), "utf8")}`;
|
|
6812
6877
|
} catch {
|
|
6813
6878
|
return "";
|
|
6814
6879
|
}
|
|
@@ -6816,15 +6881,15 @@ ${readFileSync30(join37(dir, f), "utf8")}`;
|
|
|
6816
6881
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
6817
6882
|
}
|
|
6818
6883
|
function verdictGuard(root, command, inPlay) {
|
|
6819
|
-
const critiqueDir =
|
|
6884
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6820
6885
|
if (!existsSync24(critiqueDir)) return [];
|
|
6821
6886
|
const problems = [];
|
|
6822
6887
|
for (const surface of readdirSync15(critiqueDir)) {
|
|
6823
6888
|
if (surface.startsWith("_")) continue;
|
|
6824
|
-
const dir =
|
|
6889
|
+
const dir = join38(critiqueDir, surface);
|
|
6825
6890
|
const now = verdictChecksum(dir);
|
|
6826
6891
|
if (!now) continue;
|
|
6827
|
-
const lockPath =
|
|
6892
|
+
const lockPath = join38(dir, LOCK);
|
|
6828
6893
|
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
6829
6894
|
try {
|
|
6830
6895
|
writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
@@ -6834,7 +6899,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6834
6899
|
}
|
|
6835
6900
|
let locked;
|
|
6836
6901
|
try {
|
|
6837
|
-
locked = JSON.parse(
|
|
6902
|
+
locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
|
|
6838
6903
|
} catch {
|
|
6839
6904
|
continue;
|
|
6840
6905
|
}
|
|
@@ -6848,15 +6913,15 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6848
6913
|
}
|
|
6849
6914
|
function save(file, state) {
|
|
6850
6915
|
try {
|
|
6851
|
-
mkdirSync8(
|
|
6916
|
+
mkdirSync8(join38(file, ".."), { recursive: true });
|
|
6852
6917
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
6853
6918
|
} catch {
|
|
6854
6919
|
}
|
|
6855
6920
|
}
|
|
6856
6921
|
|
|
6857
6922
|
// src/commands/seo.ts
|
|
6858
|
-
import { readFileSync as
|
|
6859
|
-
import { join as
|
|
6923
|
+
import { readFileSync as readFileSync32 } from "fs";
|
|
6924
|
+
import { join as join39 } from "path";
|
|
6860
6925
|
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
6861
6926
|
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
6862
6927
|
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
@@ -6897,7 +6962,7 @@ function seo(opts) {
|
|
|
6897
6962
|
for (const file of files) {
|
|
6898
6963
|
let raw;
|
|
6899
6964
|
try {
|
|
6900
|
-
raw =
|
|
6965
|
+
raw = readFileSync32(join39(root, file), "utf8");
|
|
6901
6966
|
} catch {
|
|
6902
6967
|
continue;
|
|
6903
6968
|
}
|
|
@@ -6953,7 +7018,7 @@ function seo(opts) {
|
|
|
6953
7018
|
|
|
6954
7019
|
// src/index.ts
|
|
6955
7020
|
var packageRoot = getPackageRoot();
|
|
6956
|
-
var { version } = JSON.parse(
|
|
7021
|
+
var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
|
|
6957
7022
|
function warnIfUnpublishedPin() {
|
|
6958
7023
|
if (isPublishedBuild(packageRoot)) return;
|
|
6959
7024
|
console.warn(
|
|
@@ -7103,7 +7168,7 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
7103
7168
|
process.exit(1);
|
|
7104
7169
|
}
|
|
7105
7170
|
});
|
|
7106
|
-
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>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
|
|
7171
|
+
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>/").option("--run <page>", "render this page here, at 360, 768, 1280 and 1600 and either side of each --breakpoint-* the project declares, and record each (needs --save)").option("--serve <dir>", "serve this directory over local http and load --run from it, so root-relative links resolve (a built static site)").action(async (opts) => {
|
|
7107
7172
|
if (opts.run) {
|
|
7108
7173
|
if (!opts.save) {
|
|
7109
7174
|
console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
|
|
@@ -7145,7 +7210,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
7145
7210
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
7146
7211
|
let input = {};
|
|
7147
7212
|
try {
|
|
7148
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
7213
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
|
|
7149
7214
|
} catch {
|
|
7150
7215
|
}
|
|
7151
7216
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -7184,7 +7249,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
7184
7249
|
});
|
|
7185
7250
|
function resolveSurfaceMode(projectRoot) {
|
|
7186
7251
|
try {
|
|
7187
|
-
const config = JSON.parse(
|
|
7252
|
+
const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
|
|
7188
7253
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
7189
7254
|
return modes.length === 1 ? modes[0] : void 0;
|
|
7190
7255
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
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",
|
package/rules/01-modes.md
CHANGED
|
@@ -86,7 +86,7 @@ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius an
|
|
|
86
86
|
- Every page states its subject above the fold in text, not only in an image.
|
|
87
87
|
- Prose blocks are measure-capped even when the container is wide.
|
|
88
88
|
- No horizontal scrolling regions on mobile. Reflow instead.
|
|
89
|
-
- Total JS budget for a content page: **0 KB** unless a specific feature requires it. Interactivity is opt-in per component and must be justified in a comment.
|
|
89
|
+
- Total JS budget for a content page: **0 KB** unless a specific feature requires it. Interactivity is opt-in per component and must be justified in a comment. **Keyboard access a rule requires is not a feature and is not counted**: a few lines whose only job is behaviour a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu (`P-14`), are allowed in every mode, under 1 KB, with the rule named in a comment. Nothing else rides along in them.
|
|
90
90
|
|
|
91
91
|
---
|
|
92
92
|
|
package/rules/02-tokens.md
CHANGED
|
@@ -80,9 +80,10 @@ Modes **select** from these; they never define their own values. `--spacing-card
|
|
|
80
80
|
**A range means the heading is fluid.** `32–48` is not two values to choose
|
|
81
81
|
between: `--text-h1` interpolates continuously with viewport width, reaching its
|
|
82
82
|
minimum at a 360px viewport and its maximum at 1024px, saturating outside that
|
|
83
|
-
range. There is no breakpoint here and
|
|
84
|
-
`clamp()` has no threshold to place, which is exactly why it was chosen
|
|
85
|
-
second `-sm` scale.
|
|
83
|
+
range. There is no breakpoint here, and the system defines none anywhere
|
|
84
|
+
else — a `clamp()` has no threshold to place, which is exactly why it was chosen
|
|
85
|
+
over a second `-sm` scale. Where a project's layout does switch, the project
|
|
86
|
+
measures and records the width (T-04).
|
|
86
87
|
|
|
87
88
|
Editorial `--text-h1` at a fixed 48px gives **13 characters per line** on a
|
|
88
89
|
360px screen, so a 45-character headline sets as four lines and 211px of
|
|
@@ -258,6 +259,55 @@ It is an accessibility floor, so it is excluded from the table above — there i
|
|
|
258
259
|
nothing per-mode about it to resolve. The same is true of `--focus-ring-width`
|
|
259
260
|
and `--focus-ring-offset`, which live in the brand file for that reason.
|
|
260
261
|
|
|
262
|
+
**Layout sizes.** Three tokens size a page's frame, where the others size what
|
|
263
|
+
is inside it. A real site found none of them and made up its own three:
|
|
264
|
+
|
|
265
|
+
| Token | `editorial` | `product` | `operator` |
|
|
266
|
+
| --- | --- | --- | --- |
|
|
267
|
+
| `--size-container` | 1280px | 1280px | 1280px |
|
|
268
|
+
| `--size-rail` | 288px | 256px | 240px |
|
|
269
|
+
|
|
270
|
+
- `--size-container` is the page's frame: a rail, a readable column and a rail
|
|
271
|
+
fit inside it, centred, and the viewport less `--grid-margin-sm` below that.
|
|
272
|
+
- `--size-rail` is a side column of navigation or filters, sized to hold labels
|
|
273
|
+
at the mode's type size.
|
|
274
|
+
- `--size-header` is one row of touch targets and its hairline, in every mode:
|
|
275
|
+
`calc(var(--size-touch-target) + var(--border-width-hairline))`, 49px with
|
|
276
|
+
the default brand. A fixed header's rails
|
|
277
|
+
stick at it, their height is the viewport less it, and `scroll-padding-top`
|
|
278
|
+
clears it.
|
|
279
|
+
|
|
280
|
+
`--size-container` is one value for the same reason the touch target is: it is
|
|
281
|
+
not density. Past it, a header's last item drifts hundreds of pixels from where
|
|
282
|
+
the text stops, and the page runs empty down one side. A product whose screens
|
|
283
|
+
are wall-to-wall data raises it in its own layer, after the barrel, with the
|
|
284
|
+
reason written beside it.
|
|
285
|
+
|
|
286
|
+
**Where a layout switches is measured, not chosen.** This system defines no
|
|
287
|
+
breakpoint, and the four widths a page is judged at (360, 768, 1280, 1600) are
|
|
288
|
+
checkpoints, not places the CSS changes. A layout switches where its content
|
|
289
|
+
needs it: a header row where its labels fit (`P-14`), a third column where two
|
|
290
|
+
rails and a readable column fit. That width is the project's, found by
|
|
291
|
+
measuring, and it is recorded once, in the project's own layer:
|
|
292
|
+
|
|
293
|
+
```css
|
|
294
|
+
@theme {
|
|
295
|
+
/* The four header labels and the wordmark need 504px, measured in the
|
|
296
|
+
site's own fonts; the row replaces Menu here. */
|
|
297
|
+
--breakpoint-nav: 540px;
|
|
298
|
+
}
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Name it `--breakpoint-<what switches>`, never after a device (a name like
|
|
302
|
+
"tablet" says nothing about what changes), and write the measurement beside it. In
|
|
303
|
+
Tailwind 4, `@theme` makes it the variant `nav:`. In plain CSS a custom property
|
|
304
|
+
cannot be read inside `@media`, so the literal is repeated there, with a
|
|
305
|
+
comment naming the token. `jig probe --run` reads these declarations and also
|
|
306
|
+
measures one pixel either side of each, because a switch falls between the
|
|
307
|
+
judged widths by design, and a range nobody measured is where a layout breaks:
|
|
308
|
+
a real site's three-column frame appeared at 1216px, and 1024 to 1215 still got
|
|
309
|
+
the phone arrangement.
|
|
310
|
+
|
|
261
311
|
**Spacing selections.** `--spacing-card` and `--spacing-section` pick from the
|
|
262
312
|
shared ladder rather than stating their own values:
|
|
263
313
|
|
package/rules/03-patterns.md
CHANGED
|
@@ -458,13 +458,25 @@ Compose it for the phone first. Mobile navigation is a different control — not
|
|
|
458
458
|
- While open, its visible label or icon reads as close — the word **Close**, or a cross — and its accessible name says so. Tapping it again closes the menu.
|
|
459
459
|
- `Escape` closes an open menu and returns focus to the button.
|
|
460
460
|
- `<details>`/`<summary>` gives the first three for free; a hand-rolled button has to do each one.
|
|
461
|
+
- `Escape` is the one `<details>` does not give. A few lines add it, and they fit every mode's budget, `editorial`'s included (`M-01`), because keyboard access a rule requires is not counted:
|
|
462
|
+
|
|
463
|
+
```js
|
|
464
|
+
// P-14: Escape closes the open menu and returns focus to its button.
|
|
465
|
+
addEventListener('keydown', (e) => {
|
|
466
|
+
if (e.key !== 'Escape') return;
|
|
467
|
+
const menu = document.activeElement?.closest('details[open]');
|
|
468
|
+
if (!menu) return;
|
|
469
|
+
menu.open = false;
|
|
470
|
+
menu.querySelector('summary')?.focus();
|
|
471
|
+
});
|
|
472
|
+
```
|
|
461
473
|
- **Where the menu button sits is the project's decision.** Top right, top left, centred — that is taste, and it belongs in `DECISIONS.md`, not here. What the system asks is only that it stays in the same place on every screen and at every width it appears. **The decision is where it sits, never whether it exists:** at a width where every destination fits, the table above shows the links and there is no menu button, whatever `DECISIONS.md` says about its position.
|
|
462
474
|
- **Mark where the reader is, the same way at every width.** Every screen has to answer *where am I?* without the reader remembering how they arrived.
|
|
463
475
|
- The link to the current page carries `aria-current="page"`. A section link whose child page is open may carry `aria-current="true"`.
|
|
464
476
|
- Style the mark from that attribute — `[aria-current="page"]` in CSS — not from a separate `.active` or `.current` class. One source for both what is seen and what is announced means the two cannot drift apart; a class alone looks marked and tells a screen reader nothing.
|
|
465
477
|
- The visible cue is not colour alone (`C-20`): weight, an underline or bar, or a filled state. Which one is the project's decision.
|
|
466
478
|
- Inside an open menu, the current item is marked the same way. When the menu is closed nothing in the navigation is visible, so the page's `<h1>` is what tells the reader where they are — every page has one, and it names the page.
|
|
467
|
-
- **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where the script budget is zero (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all
|
|
479
|
+
- **It works with no JavaScript** (`F-41`). The links are ordinary links in the page and render visibly by default; script, if there is any, only adds the collapse. In `editorial`, where the script budget is zero (`M-01`), use `<details>` with `<summary>Menu</summary>` — a disclosure the browser provides with no script at all — and the few lines above for `Escape`.
|
|
468
480
|
- **Never let a row that does not fit scroll sideways.** Its last items go past the edge where nobody sees them (`E-62`), and `editorial` forbids horizontal scrolling on mobile outright. An open menu is a vertical list.
|
|
469
481
|
- **Same destinations, same order, at every width.** The phone may show fewer at once. It never shows different ones, and never reorders them — `product` fixes navigation position across the app (`M-02`), and a reader who learned the order on one screen should not have to relearn it on another.
|
|
470
482
|
- **Every item is at least `--size-touch-target` tall**, made with padding rather than a larger font. The target grows; the text does not.
|
|
@@ -78,6 +78,13 @@
|
|
|
78
78
|
--size-row: 56px; --size-row-compact: 48px;
|
|
79
79
|
--size-touch-target: 48px; --size-icon: 20px;
|
|
80
80
|
|
|
81
|
+
/* Layout sizes (T-04). The container is the page's frame: a rail, a readable
|
|
82
|
+
column and a rail fit inside it, and above it navigation stays near what it
|
|
83
|
+
navigates. A rail is sized to hold labels at this mode's type size. The
|
|
84
|
+
header is one touch-target row and its hairline. */
|
|
85
|
+
--size-container: 1280px; --size-rail: 288px;
|
|
86
|
+
--size-header: calc(var(--size-touch-target) + var(--border-width-hairline));
|
|
87
|
+
|
|
81
88
|
--border-width-control: var(--border-width-hairline);
|
|
82
89
|
--radius-control: var(--radius-sm); --radius-surface: var(--radius-md);
|
|
83
90
|
--shadow-surface: var(--shadow-none);
|
package/tokens/mode.operator.css
CHANGED
|
@@ -70,6 +70,13 @@
|
|
|
70
70
|
--size-row: 36px; --size-row-compact: 32px;
|
|
71
71
|
--size-touch-target: 48px; --size-icon: 16px;
|
|
72
72
|
|
|
73
|
+
/* Layout sizes (T-04). The container is the page's frame: a rail, a readable
|
|
74
|
+
column and a rail fit inside it, and above it navigation stays near what it
|
|
75
|
+
navigates. A rail is sized to hold labels at this mode's type size. The
|
|
76
|
+
header is one touch-target row and its hairline. */
|
|
77
|
+
--size-container: 1280px; --size-rail: 240px;
|
|
78
|
+
--size-header: calc(var(--size-touch-target) + var(--border-width-hairline));
|
|
79
|
+
|
|
73
80
|
--border-width-control: var(--border-width-hairline);
|
|
74
81
|
--radius-control: var(--radius-sm); --radius-surface: var(--radius-sm);
|
|
75
82
|
--shadow-surface: var(--shadow-none);
|
package/tokens/mode.product.css
CHANGED
|
@@ -74,6 +74,13 @@
|
|
|
74
74
|
--size-control: 40px; --size-control-sm: 32px; --size-row: 48px; --size-row-compact: 40px;
|
|
75
75
|
--size-touch-target: 48px; --size-icon: 18px;
|
|
76
76
|
|
|
77
|
+
/* Layout sizes (T-04). The container is the page's frame: a rail, a readable
|
|
78
|
+
column and a rail fit inside it, and above it navigation stays near what it
|
|
79
|
+
navigates. A rail is sized to hold labels at this mode's type size. The
|
|
80
|
+
header is one touch-target row and its hairline. */
|
|
81
|
+
--size-container: 1280px; --size-rail: 256px;
|
|
82
|
+
--size-header: calc(var(--size-touch-target) + var(--border-width-hairline));
|
|
83
|
+
|
|
77
84
|
--border-width-control: var(--border-width-hairline);
|
|
78
85
|
--radius-control: var(--radius-sm); --radius-surface: var(--radius-md);
|
|
79
86
|
--shadow-surface: var(--shadow-none);
|