jig-ui 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/dist/index.js +119 -88
- 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,70 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.18.0
|
|
4
|
+
|
|
5
|
+
A page's frame gets tokens, a project gets a place to record where its layout
|
|
6
|
+
switches, and keyboard access a rule requires fits every script budget. All of
|
|
7
|
+
it found building jig-site's header and Reference with the full loop.
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Keyboard access a rule requires does not count against a script budget.**
|
|
12
|
+
`P-14` requires `Escape` to close an open menu, and `editorial`'s budget was
|
|
13
|
+
0 KB, with `<details>` as the menu, which cannot do it. No editorial page
|
|
14
|
+
could satisfy both, and a real site's home and not-found pages kept a menu a
|
|
15
|
+
keyboard could not dismiss. `M-01` now allows, in every mode, a few lines
|
|
16
|
+
under 1 KB whose only job is keyboard behaviour a rule asks for and the
|
|
17
|
+
browser does not give, with the rule named in a comment. `P-14` carries the
|
|
18
|
+
six lines for `Escape`.
|
|
19
|
+
- **Layout sizes: `--size-container`, `--size-rail`, `--size-header`.** Every
|
|
20
|
+
mode now sizes a page's frame, not only what is inside it. Building one site
|
|
21
|
+
with the full loop, four gaps turned out to be one: no token for a page's
|
|
22
|
+
maximum width, a rail's width, or a fixed header's height. The site made up
|
|
23
|
+
its own, one of them a raw pixel value. The container is 1280px in every
|
|
24
|
+
mode, because it is not density. A rail is 288, 256 or 240px, sized to hold
|
|
25
|
+
labels at the mode's type size. The header is `--size-touch-target` plus
|
|
26
|
+
`--border-width-hairline`. See T-04.
|
|
27
|
+
- **Where a layout switches is measured and recorded.** Jig still defines no
|
|
28
|
+
breakpoint, and the four judged widths are checkpoints, not places the CSS
|
|
29
|
+
changes. T-04 now says how a project records a width it measured:
|
|
30
|
+
`--breakpoint-<what switches>` in its own layer, with the measurement
|
|
31
|
+
beside it.
|
|
32
|
+
- **`jig probe --run` measures either side of every declared switch.** A switch
|
|
33
|
+
falls between the judged widths by design, and a range nobody measured is
|
|
34
|
+
where a layout breaks: a real site's three-column frame appeared at 1216px,
|
|
35
|
+
and 1024 to 1215 still got the phone arrangement. The probe now also records
|
|
36
|
+
one pixel before and at each `--breakpoint-*` the project declares, and the
|
|
37
|
+
gate's refresh keeps those probes current too.
|
|
38
|
+
|
|
39
|
+
## 0.17.3
|
|
40
|
+
|
|
41
|
+
Three defects found building jig-site's header with the full loop.
|
|
42
|
+
|
|
43
|
+
### Fixed
|
|
44
|
+
|
|
45
|
+
- **The gate no longer asks `make` for a critique's verdicts.** A critique
|
|
46
|
+
counted as touched when any file in its folder changed, so a make round that
|
|
47
|
+
saved its probe there (`jig probe --save`) was held to that critique's
|
|
48
|
+
completeness. A critique written before a release that added rules has no
|
|
49
|
+
verdicts for them, and only `critique` may write verdicts, so the round was
|
|
50
|
+
blocked three times on something it was forbidden to do. A critique is now
|
|
51
|
+
touched only when its verdict files change.
|
|
52
|
+
- **`jig verdicts` accepts, and asks for, verdicts on hybrid rules with a
|
|
53
|
+
pass.** 0.17 gave ten code-pass rules (A-135 to A-140, A-143, A-144, A-146,
|
|
54
|
+
G-145) detectors and made them hybrid. Each kept its `pass: code`, because a
|
|
55
|
+
detector catches only some shapes of the failure, and the code arm judged
|
|
56
|
+
them as that field says. `verdicts` knew only judgment and mechanical ids,
|
|
57
|
+
so every verdict on them came back "not a rule", and a critique that did
|
|
58
|
+
what the rules said could not pass. A hybrid rule with a pass is now judged
|
|
59
|
+
in that pass, like a judgment rule: the code arm has 84 rules, not 74.
|
|
60
|
+
- **`jig update` refreshes a project's mode files where `init` wrote them.**
|
|
61
|
+
`init` has written `mode.<mode>.css` beside the brand file (for example
|
|
62
|
+
`src/styles/jig/`) since the token layer moved into the project's styles,
|
|
63
|
+
but `update` still looked for them under the pre-0.6 `.jig/tokens/`. It
|
|
64
|
+
found none, so every release left the old tokens in place, and nothing
|
|
65
|
+
said so; only `jig init --yes` refreshed them. `update` now refreshes each
|
|
66
|
+
untouched copy wherever `init` recorded writing it.
|
|
67
|
+
|
|
3
68
|
## 0.17.2
|
|
4
69
|
|
|
5
70
|
Four defects found by building one page 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";
|
|
@@ -1014,15 +1014,17 @@ function updateInitFiles(opts) {
|
|
|
1014
1014
|
let initChanged = false;
|
|
1015
1015
|
const writer = createWriter(opts.projectRoot, initFiles);
|
|
1016
1016
|
for (const file of bundleFiles(opts.packageRoot, "tokens")) {
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1017
|
+
if (!file.startsWith("mode.")) continue;
|
|
1018
|
+
const keys = Object.keys(initManifest.files).filter((k) => k === file || k.endsWith(`/${file}`));
|
|
1019
|
+
for (const key of keys) {
|
|
1020
|
+
if (isInitFileModified(opts.projectRoot, key, initManifest)) {
|
|
1021
|
+
skipped.push(key);
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
|
|
1025
|
+
updated.push(writer.write(key, content));
|
|
1026
|
+
initChanged = true;
|
|
1022
1027
|
}
|
|
1023
|
-
const content = vendorHeader(file, opts.version, "css", null) + readFileSync7(join10(tokensDir, file), "utf8");
|
|
1024
|
-
updated.push(writer.write(key, content));
|
|
1025
|
-
initChanged = true;
|
|
1026
1028
|
}
|
|
1027
1029
|
Object.assign(initFiles, writer.files);
|
|
1028
1030
|
if (initChanged) writeInitManifest(opts.projectRoot, { ...initManifest, version: opts.version, files: initFiles });
|
|
@@ -5650,8 +5652,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5650
5652
|
}
|
|
5651
5653
|
|
|
5652
5654
|
// src/commands/verdicts.ts
|
|
5653
|
-
import { existsSync as existsSync23, readFileSync as
|
|
5654
|
-
import { join as
|
|
5655
|
+
import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
|
|
5656
|
+
import { join as join37, resolve as resolve5 } from "path";
|
|
5655
5657
|
|
|
5656
5658
|
// src/rules/citations.ts
|
|
5657
5659
|
import { join as join29 } from "path";
|
|
@@ -5665,13 +5667,13 @@ function citableIds(packageRoot2) {
|
|
|
5665
5667
|
}
|
|
5666
5668
|
|
|
5667
5669
|
// src/probe/check.ts
|
|
5668
|
-
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as
|
|
5669
|
-
import { join as
|
|
5670
|
+
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
|
|
5671
|
+
import { join as join34 } from "path";
|
|
5670
5672
|
|
|
5671
5673
|
// src/probe/save.ts
|
|
5672
|
-
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as
|
|
5674
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync7, readdirSync as readdirSync12, readFileSync as readFileSync26, writeFileSync as writeFileSync6 } from "fs";
|
|
5673
5675
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5674
|
-
import { join as
|
|
5676
|
+
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5675
5677
|
|
|
5676
5678
|
// src/probe/script.ts
|
|
5677
5679
|
var PROBE_VERSION = 5;
|
|
@@ -6053,6 +6055,31 @@ async function serveDirectory(dir) {
|
|
|
6053
6055
|
};
|
|
6054
6056
|
}
|
|
6055
6057
|
|
|
6058
|
+
// src/probe/switches.ts
|
|
6059
|
+
import { readFileSync as readFileSync25 } from "fs";
|
|
6060
|
+
import { join as join32 } from "path";
|
|
6061
|
+
var DECLARATION = /--breakpoint-([a-z0-9-]+)\s*:\s*(\d+(?:\.\d+)?)(px|rem)\b/g;
|
|
6062
|
+
function declaredSwitches(projectRoot) {
|
|
6063
|
+
const found = /* @__PURE__ */ new Map();
|
|
6064
|
+
for (const file of wholeRepoFiles(projectRoot)) {
|
|
6065
|
+
if (!/\.(css|scss|astro|vue|svelte|html)$/i.test(file)) continue;
|
|
6066
|
+
let text;
|
|
6067
|
+
try {
|
|
6068
|
+
text = readFileSync25(join32(projectRoot, file), "utf8");
|
|
6069
|
+
} catch {
|
|
6070
|
+
continue;
|
|
6071
|
+
}
|
|
6072
|
+
for (const m of text.matchAll(DECLARATION)) {
|
|
6073
|
+
const px2 = Math.round(m[3] === "rem" ? Number(m[2]) * 16 : Number(m[2]));
|
|
6074
|
+
if (px2 > 0) found.set(`${m[1]}:${px2}`, { name: m[1], px: px2 });
|
|
6075
|
+
}
|
|
6076
|
+
}
|
|
6077
|
+
return [...found.values()].sort((a, b) => a.px - b.px);
|
|
6078
|
+
}
|
|
6079
|
+
function probeWidths(base, switches) {
|
|
6080
|
+
return [.../* @__PURE__ */ new Set([...base, ...switches.flatMap((s) => [s.px - 1, s.px])])].sort((a, b) => a - b);
|
|
6081
|
+
}
|
|
6082
|
+
|
|
6056
6083
|
// src/probe/save.ts
|
|
6057
6084
|
function saveProbe(opts) {
|
|
6058
6085
|
let probe;
|
|
@@ -6073,11 +6100,11 @@ function saveProbe(opts) {
|
|
|
6073
6100
|
}
|
|
6074
6101
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
6075
6102
|
if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
|
|
6076
|
-
probe.pageChecksum = checksum(
|
|
6103
|
+
probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
|
|
6077
6104
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6078
|
-
const dir =
|
|
6105
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6079
6106
|
mkdirSync7(dir, { recursive: true });
|
|
6080
|
-
const path =
|
|
6107
|
+
const path = join33(dir, `probe-${width}.json`);
|
|
6081
6108
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
6082
6109
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
6083
6110
|
}
|
|
@@ -6103,8 +6130,9 @@ async function runAndSaveProbes(opts) {
|
|
|
6103
6130
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6104
6131
|
if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
6105
6132
|
const saved = [];
|
|
6133
|
+
const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
|
|
6106
6134
|
await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
|
|
6107
|
-
for (const width of
|
|
6135
|
+
for (const width of widths) {
|
|
6108
6136
|
const json = await runProbe({ url, width });
|
|
6109
6137
|
saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
|
|
6110
6138
|
}
|
|
@@ -6125,17 +6153,19 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
6125
6153
|
}
|
|
6126
6154
|
}
|
|
6127
6155
|
async function ensureProbes(opts) {
|
|
6128
|
-
const dir =
|
|
6156
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6129
6157
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6130
6158
|
let current;
|
|
6131
6159
|
try {
|
|
6132
|
-
current = checksum(
|
|
6160
|
+
current = checksum(readFileSync26(abs, "utf8"));
|
|
6133
6161
|
} catch {
|
|
6134
6162
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
6135
6163
|
}
|
|
6136
|
-
const
|
|
6164
|
+
const recorded = existsSync19(dir) ? readdirSync12(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
|
|
6165
|
+
const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
|
|
6166
|
+
const missing = widths.filter((width) => {
|
|
6137
6167
|
try {
|
|
6138
|
-
const probe = JSON.parse(
|
|
6168
|
+
const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
|
|
6139
6169
|
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
6140
6170
|
} catch {
|
|
6141
6171
|
return true;
|
|
@@ -6144,9 +6174,9 @@ async function ensureProbes(opts) {
|
|
|
6144
6174
|
if (missing.length === 0) return { recorded: [] };
|
|
6145
6175
|
if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
|
|
6146
6176
|
let serve;
|
|
6147
|
-
for (const width of
|
|
6177
|
+
for (const width of widths) {
|
|
6148
6178
|
try {
|
|
6149
|
-
const probe = JSON.parse(
|
|
6179
|
+
const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
|
|
6150
6180
|
if (probe.serveRoot) {
|
|
6151
6181
|
serve = probe.serveRoot;
|
|
6152
6182
|
break;
|
|
@@ -6162,9 +6192,9 @@ async function ensureProbes(opts) {
|
|
|
6162
6192
|
return { recorded: missing };
|
|
6163
6193
|
}
|
|
6164
6194
|
function critiquedSurfaces(projectRoot) {
|
|
6165
|
-
const root =
|
|
6195
|
+
const root = join33(projectRoot, ".jig", "critique");
|
|
6166
6196
|
if (!existsSync19(root)) return [];
|
|
6167
|
-
return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(
|
|
6197
|
+
return readdirSync12(root).filter((surface) => !surface.startsWith("_") && (existsSync19(join33(root, surface, "screen.json")) || existsSync19(join33(root, surface, "code.json"))));
|
|
6168
6198
|
}
|
|
6169
6199
|
|
|
6170
6200
|
// src/probe/check.ts
|
|
@@ -6176,7 +6206,7 @@ function stampProblem(projectRoot, file, p) {
|
|
|
6176
6206
|
if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
|
|
6177
6207
|
let current;
|
|
6178
6208
|
try {
|
|
6179
|
-
current =
|
|
6209
|
+
current = readFileSync27(page, "utf8");
|
|
6180
6210
|
} catch {
|
|
6181
6211
|
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
6182
6212
|
}
|
|
@@ -6190,7 +6220,7 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
6190
6220
|
const probes = [];
|
|
6191
6221
|
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
6192
6222
|
try {
|
|
6193
|
-
const p = JSON.parse(
|
|
6223
|
+
const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
|
|
6194
6224
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
6195
6225
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
6196
6226
|
continue;
|
|
@@ -6282,19 +6312,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6282
6312
|
}
|
|
6283
6313
|
|
|
6284
6314
|
// src/check/decisions.ts
|
|
6285
|
-
import { existsSync as existsSync21, readFileSync as
|
|
6286
|
-
import { join as
|
|
6315
|
+
import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
|
|
6316
|
+
import { join as join35, posix as posix3 } from "path";
|
|
6287
6317
|
var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
6288
6318
|
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
6289
6319
|
function decisionsFile(projectRoot) {
|
|
6290
6320
|
const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
|
|
6291
6321
|
try {
|
|
6292
|
-
const brand = JSON.parse(
|
|
6322
|
+
const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
|
|
6293
6323
|
if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
|
|
6294
6324
|
} catch {
|
|
6295
6325
|
}
|
|
6296
6326
|
for (const candidate of candidates) {
|
|
6297
|
-
if (existsSync21(
|
|
6327
|
+
if (existsSync21(join35(projectRoot, candidate))) return candidate;
|
|
6298
6328
|
}
|
|
6299
6329
|
return void 0;
|
|
6300
6330
|
}
|
|
@@ -6303,7 +6333,7 @@ function decisionNames(projectRoot) {
|
|
|
6303
6333
|
if (!path) return [];
|
|
6304
6334
|
let body;
|
|
6305
6335
|
try {
|
|
6306
|
-
body =
|
|
6336
|
+
body = readFileSync28(join35(projectRoot, path), "utf8");
|
|
6307
6337
|
} catch {
|
|
6308
6338
|
return [];
|
|
6309
6339
|
}
|
|
@@ -6317,15 +6347,15 @@ function decisionNames(projectRoot) {
|
|
|
6317
6347
|
}
|
|
6318
6348
|
|
|
6319
6349
|
// src/check/spec-shape.ts
|
|
6320
|
-
import { existsSync as existsSync22, readFileSync as
|
|
6321
|
-
import { join as
|
|
6350
|
+
import { existsSync as existsSync22, readFileSync as readFileSync29, readdirSync as readdirSync14, statSync as statSync3 } from "fs";
|
|
6351
|
+
import { join as join36 } from "path";
|
|
6322
6352
|
function newestSpec(projectRoot) {
|
|
6323
|
-
const dir =
|
|
6353
|
+
const dir = join36(projectRoot, ".jig", "specs");
|
|
6324
6354
|
if (!existsSync22(dir)) return void 0;
|
|
6325
6355
|
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6326
6356
|
if (files.length === 0) return void 0;
|
|
6327
|
-
const newest = files.map((f) => ({ f, at: statSync3(
|
|
6328
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body:
|
|
6357
|
+
const newest = files.map((f) => ({ f, at: statSync3(join36(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6358
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync29(join36(dir, newest), "utf8") };
|
|
6329
6359
|
}
|
|
6330
6360
|
function specProblems(spec) {
|
|
6331
6361
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -6408,17 +6438,17 @@ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (fi
|
|
|
6408
6438
|
function readJson(path, errors) {
|
|
6409
6439
|
if (!existsSync23(path)) return null;
|
|
6410
6440
|
try {
|
|
6411
|
-
return JSON.parse(
|
|
6441
|
+
return JSON.parse(readFileSync30(path, "utf8"));
|
|
6412
6442
|
} catch (e) {
|
|
6413
6443
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
6414
6444
|
return {};
|
|
6415
6445
|
}
|
|
6416
6446
|
}
|
|
6417
6447
|
function specIndexable(projectRoot, surface) {
|
|
6418
|
-
const path =
|
|
6448
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6419
6449
|
let front = "";
|
|
6420
6450
|
try {
|
|
6421
|
-
front =
|
|
6451
|
+
front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6422
6452
|
} catch {
|
|
6423
6453
|
}
|
|
6424
6454
|
const declared = specIndexableField(front);
|
|
@@ -6427,9 +6457,9 @@ function specIndexable(projectRoot, surface) {
|
|
|
6427
6457
|
return mode !== "product" && mode !== "operator";
|
|
6428
6458
|
}
|
|
6429
6459
|
function specNeedsNav(projectRoot, surface) {
|
|
6430
|
-
const path =
|
|
6460
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6431
6461
|
if (!existsSync23(path)) return false;
|
|
6432
|
-
const front =
|
|
6462
|
+
const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6433
6463
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
6434
6464
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
6435
6465
|
return navField || navRegion;
|
|
@@ -6480,7 +6510,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6480
6510
|
function checkDecisions(projectRoot, dir, errors) {
|
|
6481
6511
|
const required = decisionNames(projectRoot);
|
|
6482
6512
|
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
6483
|
-
const file = readJson(
|
|
6513
|
+
const file = readJson(join37(dir, "decisions.json"), errors);
|
|
6484
6514
|
if (!file) {
|
|
6485
6515
|
errors.push(
|
|
6486
6516
|
`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.`
|
|
@@ -6525,8 +6555,8 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6525
6555
|
function verifyVerdicts(opts) {
|
|
6526
6556
|
const root = opts.packageRoot ?? assetRoot();
|
|
6527
6557
|
const errors = [];
|
|
6528
|
-
const index = JSON.parse(
|
|
6529
|
-
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
6558
|
+
const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
|
|
6559
|
+
const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
|
|
6530
6560
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
6531
6561
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
6532
6562
|
const passOf = new Map([
|
|
@@ -6534,9 +6564,9 @@ function verifyVerdicts(opts) {
|
|
|
6534
6564
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
6535
6565
|
]);
|
|
6536
6566
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
6537
|
-
const dir =
|
|
6538
|
-
const screenFile = readJson(
|
|
6539
|
-
const codeFile = readJson(
|
|
6567
|
+
const dir = join37(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6568
|
+
const screenFile = readJson(join37(dir, "screen.json"), errors);
|
|
6569
|
+
const codeFile = readJson(join37(dir, "code.json"), errors);
|
|
6540
6570
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
6541
6571
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
6542
6572
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
@@ -6564,7 +6594,7 @@ function verifyVerdicts(opts) {
|
|
|
6564
6594
|
};
|
|
6565
6595
|
let specFront = "";
|
|
6566
6596
|
try {
|
|
6567
|
-
specFront =
|
|
6597
|
+
specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6568
6598
|
} catch {
|
|
6569
6599
|
}
|
|
6570
6600
|
if (specIndexableField(specFront) === "unreadable") {
|
|
@@ -6580,15 +6610,15 @@ function verifyVerdicts(opts) {
|
|
|
6580
6610
|
}
|
|
6581
6611
|
|
|
6582
6612
|
// src/commands/gate.ts
|
|
6583
|
-
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as
|
|
6613
|
+
import { existsSync as existsSync24, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync31, statSync as statSync4, writeFileSync as writeFileSync7 } from "fs";
|
|
6584
6614
|
import { createHash as createHash2 } from "crypto";
|
|
6585
|
-
import { join as
|
|
6615
|
+
import { join as join38 } from "path";
|
|
6586
6616
|
var MAX_BLOCKS = 3;
|
|
6587
6617
|
function lastJigCommand(transcriptPath) {
|
|
6588
6618
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6589
6619
|
let text;
|
|
6590
6620
|
try {
|
|
6591
|
-
text =
|
|
6621
|
+
text = readFileSync31(transcriptPath, "utf8");
|
|
6592
6622
|
} catch {
|
|
6593
6623
|
return void 0;
|
|
6594
6624
|
}
|
|
@@ -6606,7 +6636,7 @@ function lastAssistantText(transcriptPath) {
|
|
|
6606
6636
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6607
6637
|
let found;
|
|
6608
6638
|
try {
|
|
6609
|
-
for (const line of
|
|
6639
|
+
for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
|
|
6610
6640
|
if (!line.includes('"assistant"')) continue;
|
|
6611
6641
|
let entry;
|
|
6612
6642
|
try {
|
|
@@ -6632,7 +6662,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
|
|
|
6632
6662
|
function sessionStart(transcriptPath) {
|
|
6633
6663
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6634
6664
|
try {
|
|
6635
|
-
for (const line of
|
|
6665
|
+
for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
|
|
6636
6666
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
6637
6667
|
if (m) {
|
|
6638
6668
|
const t = Date.parse(m[1]);
|
|
@@ -6643,23 +6673,24 @@ function sessionStart(transcriptPath) {
|
|
|
6643
6673
|
}
|
|
6644
6674
|
return void 0;
|
|
6645
6675
|
}
|
|
6646
|
-
function
|
|
6676
|
+
function verdictsMtime(dir) {
|
|
6647
6677
|
let newest = 0;
|
|
6648
|
-
for (const name of
|
|
6649
|
-
|
|
6650
|
-
|
|
6651
|
-
|
|
6678
|
+
for (const name of VERDICT_FILES) {
|
|
6679
|
+
try {
|
|
6680
|
+
newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
|
|
6681
|
+
} catch {
|
|
6682
|
+
}
|
|
6652
6683
|
}
|
|
6653
6684
|
return newest;
|
|
6654
6685
|
}
|
|
6655
6686
|
function surfacesInPlay(root, command, transcriptPath) {
|
|
6656
|
-
const critiqueDir =
|
|
6687
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6657
6688
|
if (!existsSync24(critiqueDir)) return [];
|
|
6658
6689
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6659
6690
|
const start = sessionStart(transcriptPath);
|
|
6660
6691
|
if (start === void 0) return all;
|
|
6661
6692
|
const current = command === "critique" ? /^\s*surface\s*:\s*(.+)$/im.exec(newestSpec(root)?.body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim().replace(/^["']|["']$/g, "") : void 0;
|
|
6662
|
-
return all.filter((s) => s === current ||
|
|
6693
|
+
return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
|
|
6663
6694
|
}
|
|
6664
6695
|
function commandProblems(root, command) {
|
|
6665
6696
|
const problems = [];
|
|
@@ -6668,7 +6699,7 @@ function commandProblems(root, command) {
|
|
|
6668
6699
|
const found = decisionsFile(root);
|
|
6669
6700
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
6670
6701
|
else {
|
|
6671
|
-
const body =
|
|
6702
|
+
const body = readFileSync31(join38(root, found), "utf8");
|
|
6672
6703
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
6673
6704
|
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.`");
|
|
6674
6705
|
}
|
|
@@ -6689,13 +6720,13 @@ function commandProblems(root, command) {
|
|
|
6689
6720
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
6690
6721
|
if (/^approved/i.test(mockup)) {
|
|
6691
6722
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
6692
|
-
else if (!/^https?:/i.test(at) && !existsSync24(
|
|
6723
|
+
else if (!/^https?:/i.test(at) && !existsSync24(join38(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
6693
6724
|
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.`);
|
|
6694
6725
|
}
|
|
6695
6726
|
}
|
|
6696
6727
|
if (command === "critique") {
|
|
6697
|
-
const dir =
|
|
6698
|
-
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(
|
|
6728
|
+
const dir = join38(root, ".jig", "critique");
|
|
6729
|
+
const surfaces = existsSync24(dir) ? readdirSync15(dir).filter((s) => existsSync24(join38(dir, s, "screen.json")) || existsSync24(join38(dir, s, "code.json"))) : [];
|
|
6699
6730
|
for (const surface of surfaces) {
|
|
6700
6731
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6701
6732
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -6717,13 +6748,13 @@ function surfacePage(projectRoot, surface) {
|
|
|
6717
6748
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
6718
6749
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
6719
6750
|
for (const candidate of candidates) {
|
|
6720
|
-
if (candidate && existsSync24(
|
|
6751
|
+
if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
|
|
6721
6752
|
}
|
|
6722
6753
|
return void 0;
|
|
6723
6754
|
}
|
|
6724
6755
|
function gate(opts) {
|
|
6725
6756
|
const root = opts.projectRoot;
|
|
6726
|
-
if (!existsSync24(
|
|
6757
|
+
if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
|
|
6727
6758
|
return { block: false, reason: "" };
|
|
6728
6759
|
}
|
|
6729
6760
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -6750,11 +6781,11 @@ function gate(opts) {
|
|
|
6750
6781
|
}
|
|
6751
6782
|
}
|
|
6752
6783
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
|
|
6753
|
-
const critiqueDir =
|
|
6784
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6754
6785
|
if (existsSync24(critiqueDir)) {
|
|
6755
6786
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
|
|
6756
|
-
const dir =
|
|
6757
|
-
if (!existsSync24(
|
|
6787
|
+
const dir = join38(critiqueDir, surface);
|
|
6788
|
+
if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
|
|
6758
6789
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6759
6790
|
if (!v.ok) {
|
|
6760
6791
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -6768,10 +6799,10 @@ function gate(opts) {
|
|
|
6768
6799
|
}
|
|
6769
6800
|
const session = opts.input.session_id ?? "unknown";
|
|
6770
6801
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
6771
|
-
const stateFile =
|
|
6802
|
+
const stateFile = join38(root, ".jig", "gate.json");
|
|
6772
6803
|
let state = {};
|
|
6773
6804
|
try {
|
|
6774
|
-
state = JSON.parse(
|
|
6805
|
+
state = JSON.parse(readFileSync31(stateFile, "utf8"));
|
|
6775
6806
|
} catch {
|
|
6776
6807
|
}
|
|
6777
6808
|
if (problems.length === 0) {
|
|
@@ -6805,7 +6836,7 @@ function verdictChecksum(dir) {
|
|
|
6805
6836
|
const parts = VERDICT_FILES.map((f) => {
|
|
6806
6837
|
try {
|
|
6807
6838
|
return `${f}
|
|
6808
|
-
${
|
|
6839
|
+
${readFileSync31(join38(dir, f), "utf8")}`;
|
|
6809
6840
|
} catch {
|
|
6810
6841
|
return "";
|
|
6811
6842
|
}
|
|
@@ -6813,15 +6844,15 @@ ${readFileSync30(join37(dir, f), "utf8")}`;
|
|
|
6813
6844
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
6814
6845
|
}
|
|
6815
6846
|
function verdictGuard(root, command, inPlay) {
|
|
6816
|
-
const critiqueDir =
|
|
6847
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6817
6848
|
if (!existsSync24(critiqueDir)) return [];
|
|
6818
6849
|
const problems = [];
|
|
6819
6850
|
for (const surface of readdirSync15(critiqueDir)) {
|
|
6820
6851
|
if (surface.startsWith("_")) continue;
|
|
6821
|
-
const dir =
|
|
6852
|
+
const dir = join38(critiqueDir, surface);
|
|
6822
6853
|
const now = verdictChecksum(dir);
|
|
6823
6854
|
if (!now) continue;
|
|
6824
|
-
const lockPath =
|
|
6855
|
+
const lockPath = join38(dir, LOCK);
|
|
6825
6856
|
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
6826
6857
|
try {
|
|
6827
6858
|
writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
@@ -6831,7 +6862,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6831
6862
|
}
|
|
6832
6863
|
let locked;
|
|
6833
6864
|
try {
|
|
6834
|
-
locked = JSON.parse(
|
|
6865
|
+
locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
|
|
6835
6866
|
} catch {
|
|
6836
6867
|
continue;
|
|
6837
6868
|
}
|
|
@@ -6845,15 +6876,15 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6845
6876
|
}
|
|
6846
6877
|
function save(file, state) {
|
|
6847
6878
|
try {
|
|
6848
|
-
mkdirSync8(
|
|
6879
|
+
mkdirSync8(join38(file, ".."), { recursive: true });
|
|
6849
6880
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
6850
6881
|
} catch {
|
|
6851
6882
|
}
|
|
6852
6883
|
}
|
|
6853
6884
|
|
|
6854
6885
|
// src/commands/seo.ts
|
|
6855
|
-
import { readFileSync as
|
|
6856
|
-
import { join as
|
|
6886
|
+
import { readFileSync as readFileSync32 } from "fs";
|
|
6887
|
+
import { join as join39 } from "path";
|
|
6857
6888
|
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
6858
6889
|
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
6859
6890
|
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
@@ -6894,7 +6925,7 @@ function seo(opts) {
|
|
|
6894
6925
|
for (const file of files) {
|
|
6895
6926
|
let raw;
|
|
6896
6927
|
try {
|
|
6897
|
-
raw =
|
|
6928
|
+
raw = readFileSync32(join39(root, file), "utf8");
|
|
6898
6929
|
} catch {
|
|
6899
6930
|
continue;
|
|
6900
6931
|
}
|
|
@@ -6950,7 +6981,7 @@ function seo(opts) {
|
|
|
6950
6981
|
|
|
6951
6982
|
// src/index.ts
|
|
6952
6983
|
var packageRoot = getPackageRoot();
|
|
6953
|
-
var { version } = JSON.parse(
|
|
6984
|
+
var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
|
|
6954
6985
|
function warnIfUnpublishedPin() {
|
|
6955
6986
|
if (isPublishedBuild(packageRoot)) return;
|
|
6956
6987
|
console.warn(
|
|
@@ -7100,7 +7131,7 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
7100
7131
|
process.exit(1);
|
|
7101
7132
|
}
|
|
7102
7133
|
});
|
|
7103
|
-
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) => {
|
|
7134
|
+
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) => {
|
|
7104
7135
|
if (opts.run) {
|
|
7105
7136
|
if (!opts.save) {
|
|
7106
7137
|
console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
|
|
@@ -7142,7 +7173,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
7142
7173
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
7143
7174
|
let input = {};
|
|
7144
7175
|
try {
|
|
7145
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
7176
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
|
|
7146
7177
|
} catch {
|
|
7147
7178
|
}
|
|
7148
7179
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -7181,7 +7212,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
7181
7212
|
});
|
|
7182
7213
|
function resolveSurfaceMode(projectRoot) {
|
|
7183
7214
|
try {
|
|
7184
|
-
const config = JSON.parse(
|
|
7215
|
+
const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
|
|
7185
7216
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
7186
7217
|
return modes.length === 1 ? modes[0] : void 0;
|
|
7187
7218
|
} catch {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
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);
|