jig-ui 0.17.3 → 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 +36 -0
- package/dist/index.js +103 -75
- 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,41 @@
|
|
|
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
|
+
|
|
3
39
|
## 0.17.3
|
|
4
40
|
|
|
5
41
|
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";
|
|
@@ -5652,8 +5652,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5652
5652
|
}
|
|
5653
5653
|
|
|
5654
5654
|
// src/commands/verdicts.ts
|
|
5655
|
-
import { existsSync as existsSync23, readFileSync as
|
|
5656
|
-
import { join as
|
|
5655
|
+
import { existsSync as existsSync23, readFileSync as readFileSync30 } from "fs";
|
|
5656
|
+
import { join as join37, resolve as resolve5 } from "path";
|
|
5657
5657
|
|
|
5658
5658
|
// src/rules/citations.ts
|
|
5659
5659
|
import { join as join29 } from "path";
|
|
@@ -5667,13 +5667,13 @@ function citableIds(packageRoot2) {
|
|
|
5667
5667
|
}
|
|
5668
5668
|
|
|
5669
5669
|
// src/probe/check.ts
|
|
5670
|
-
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as
|
|
5671
|
-
import { join as
|
|
5670
|
+
import { existsSync as existsSync20, readdirSync as readdirSync13, readFileSync as readFileSync27 } from "fs";
|
|
5671
|
+
import { join as join34 } from "path";
|
|
5672
5672
|
|
|
5673
5673
|
// src/probe/save.ts
|
|
5674
|
-
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";
|
|
5675
5675
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5676
|
-
import { join as
|
|
5676
|
+
import { join as join33, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
5677
5677
|
|
|
5678
5678
|
// src/probe/script.ts
|
|
5679
5679
|
var PROBE_VERSION = 5;
|
|
@@ -6055,6 +6055,31 @@ async function serveDirectory(dir) {
|
|
|
6055
6055
|
};
|
|
6056
6056
|
}
|
|
6057
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
|
+
|
|
6058
6083
|
// src/probe/save.ts
|
|
6059
6084
|
function saveProbe(opts) {
|
|
6060
6085
|
let probe;
|
|
@@ -6075,11 +6100,11 @@ function saveProbe(opts) {
|
|
|
6075
6100
|
}
|
|
6076
6101
|
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
6077
6102
|
if (opts.serveRoot) probe.serveRoot = relative3(opts.projectRoot, resolve4(opts.projectRoot, opts.serveRoot)).split("\\").join("/") || ".";
|
|
6078
|
-
probe.pageChecksum = checksum(
|
|
6103
|
+
probe.pageChecksum = checksum(readFileSync26(page, "utf8"));
|
|
6079
6104
|
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
6080
|
-
const dir =
|
|
6105
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6081
6106
|
mkdirSync7(dir, { recursive: true });
|
|
6082
|
-
const path =
|
|
6107
|
+
const path = join33(dir, `probe-${width}.json`);
|
|
6083
6108
|
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
6084
6109
|
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
6085
6110
|
}
|
|
@@ -6105,8 +6130,9 @@ async function runAndSaveProbes(opts) {
|
|
|
6105
6130
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6106
6131
|
if (!existsSync19(abs)) throw new Error(`${opts.page} does not exist, so there is nothing to render.`);
|
|
6107
6132
|
const saved = [];
|
|
6133
|
+
const widths = opts.widths ?? probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot));
|
|
6108
6134
|
await withPageUrl(opts.projectRoot, abs, opts.serve, async (url) => {
|
|
6109
|
-
for (const width of
|
|
6135
|
+
for (const width of widths) {
|
|
6110
6136
|
const json = await runProbe({ url, width });
|
|
6111
6137
|
saved.push(saveProbe({ projectRoot: opts.projectRoot, surface: opts.surface, json, page: abs, serveRoot: opts.serve }));
|
|
6112
6138
|
}
|
|
@@ -6127,17 +6153,19 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
6127
6153
|
}
|
|
6128
6154
|
}
|
|
6129
6155
|
async function ensureProbes(opts) {
|
|
6130
|
-
const dir =
|
|
6156
|
+
const dir = join33(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6131
6157
|
const abs = resolve4(opts.projectRoot, opts.page);
|
|
6132
6158
|
let current;
|
|
6133
6159
|
try {
|
|
6134
|
-
current = checksum(
|
|
6160
|
+
current = checksum(readFileSync26(abs, "utf8"));
|
|
6135
6161
|
} catch {
|
|
6136
6162
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
6137
6163
|
}
|
|
6138
|
-
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) => {
|
|
6139
6167
|
try {
|
|
6140
|
-
const probe = JSON.parse(
|
|
6168
|
+
const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
|
|
6141
6169
|
return probe.jigProbe !== PROBE_VERSION || probe.pageChecksum !== current;
|
|
6142
6170
|
} catch {
|
|
6143
6171
|
return true;
|
|
@@ -6146,9 +6174,9 @@ async function ensureProbes(opts) {
|
|
|
6146
6174
|
if (missing.length === 0) return { recorded: [] };
|
|
6147
6175
|
if (!findChrome()) return { recorded: [], reason: "no browser on this machine" };
|
|
6148
6176
|
let serve;
|
|
6149
|
-
for (const width of
|
|
6177
|
+
for (const width of widths) {
|
|
6150
6178
|
try {
|
|
6151
|
-
const probe = JSON.parse(
|
|
6179
|
+
const probe = JSON.parse(readFileSync26(join33(dir, `probe-${width}.json`), "utf8"));
|
|
6152
6180
|
if (probe.serveRoot) {
|
|
6153
6181
|
serve = probe.serveRoot;
|
|
6154
6182
|
break;
|
|
@@ -6164,9 +6192,9 @@ async function ensureProbes(opts) {
|
|
|
6164
6192
|
return { recorded: missing };
|
|
6165
6193
|
}
|
|
6166
6194
|
function critiquedSurfaces(projectRoot) {
|
|
6167
|
-
const root =
|
|
6195
|
+
const root = join33(projectRoot, ".jig", "critique");
|
|
6168
6196
|
if (!existsSync19(root)) return [];
|
|
6169
|
-
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"))));
|
|
6170
6198
|
}
|
|
6171
6199
|
|
|
6172
6200
|
// src/probe/check.ts
|
|
@@ -6178,7 +6206,7 @@ function stampProblem(projectRoot, file, p) {
|
|
|
6178
6206
|
if (!page) return `${file} names a page outside this project (${p.serveRoot ? p.pageFile : p.url ?? "no url"}).`;
|
|
6179
6207
|
let current;
|
|
6180
6208
|
try {
|
|
6181
|
-
current =
|
|
6209
|
+
current = readFileSync27(page, "utf8");
|
|
6182
6210
|
} catch {
|
|
6183
6211
|
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
6184
6212
|
}
|
|
@@ -6192,7 +6220,7 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
6192
6220
|
const probes = [];
|
|
6193
6221
|
for (const f of readdirSync13(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
6194
6222
|
try {
|
|
6195
|
-
const p = JSON.parse(
|
|
6223
|
+
const p = JSON.parse(readFileSync27(join34(dir, f), "utf8"));
|
|
6196
6224
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
6197
6225
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
6198
6226
|
continue;
|
|
@@ -6284,19 +6312,19 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6284
6312
|
}
|
|
6285
6313
|
|
|
6286
6314
|
// src/check/decisions.ts
|
|
6287
|
-
import { existsSync as existsSync21, readFileSync as
|
|
6288
|
-
import { join as
|
|
6315
|
+
import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
|
|
6316
|
+
import { join as join35, posix as posix3 } from "path";
|
|
6289
6317
|
var HEADING3 = /^(#{2,3})\s+(.+?)\s*$/gm;
|
|
6290
6318
|
var NOT_A_DECISION = /^(unresolved|open questions?|undecided|contents?|index)$/i;
|
|
6291
6319
|
function decisionsFile(projectRoot) {
|
|
6292
6320
|
const candidates = ["jig/DECISIONS.md", "DECISIONS.md", "src/jig/DECISIONS.md", "src/styles/jig/DECISIONS.md", ".jig/DECISIONS.md"];
|
|
6293
6321
|
try {
|
|
6294
|
-
const brand = JSON.parse(
|
|
6322
|
+
const brand = JSON.parse(readFileSync28(join35(projectRoot, "jig.config.json"), "utf8")).brand;
|
|
6295
6323
|
if (typeof brand === "string" && brand.includes("/")) candidates.unshift(`${posix3.dirname(brand.replace(/^\.\//, ""))}/DECISIONS.md`);
|
|
6296
6324
|
} catch {
|
|
6297
6325
|
}
|
|
6298
6326
|
for (const candidate of candidates) {
|
|
6299
|
-
if (existsSync21(
|
|
6327
|
+
if (existsSync21(join35(projectRoot, candidate))) return candidate;
|
|
6300
6328
|
}
|
|
6301
6329
|
return void 0;
|
|
6302
6330
|
}
|
|
@@ -6305,7 +6333,7 @@ function decisionNames(projectRoot) {
|
|
|
6305
6333
|
if (!path) return [];
|
|
6306
6334
|
let body;
|
|
6307
6335
|
try {
|
|
6308
|
-
body =
|
|
6336
|
+
body = readFileSync28(join35(projectRoot, path), "utf8");
|
|
6309
6337
|
} catch {
|
|
6310
6338
|
return [];
|
|
6311
6339
|
}
|
|
@@ -6319,15 +6347,15 @@ function decisionNames(projectRoot) {
|
|
|
6319
6347
|
}
|
|
6320
6348
|
|
|
6321
6349
|
// src/check/spec-shape.ts
|
|
6322
|
-
import { existsSync as existsSync22, readFileSync as
|
|
6323
|
-
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";
|
|
6324
6352
|
function newestSpec(projectRoot) {
|
|
6325
|
-
const dir =
|
|
6353
|
+
const dir = join36(projectRoot, ".jig", "specs");
|
|
6326
6354
|
if (!existsSync22(dir)) return void 0;
|
|
6327
6355
|
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6328
6356
|
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:
|
|
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") };
|
|
6331
6359
|
}
|
|
6332
6360
|
function specProblems(spec) {
|
|
6333
6361
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -6410,17 +6438,17 @@ var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (fi
|
|
|
6410
6438
|
function readJson(path, errors) {
|
|
6411
6439
|
if (!existsSync23(path)) return null;
|
|
6412
6440
|
try {
|
|
6413
|
-
return JSON.parse(
|
|
6441
|
+
return JSON.parse(readFileSync30(path, "utf8"));
|
|
6414
6442
|
} catch (e) {
|
|
6415
6443
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
6416
6444
|
return {};
|
|
6417
6445
|
}
|
|
6418
6446
|
}
|
|
6419
6447
|
function specIndexable(projectRoot, surface) {
|
|
6420
|
-
const path =
|
|
6448
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6421
6449
|
let front = "";
|
|
6422
6450
|
try {
|
|
6423
|
-
front =
|
|
6451
|
+
front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6424
6452
|
} catch {
|
|
6425
6453
|
}
|
|
6426
6454
|
const declared = specIndexableField(front);
|
|
@@ -6429,9 +6457,9 @@ function specIndexable(projectRoot, surface) {
|
|
|
6429
6457
|
return mode !== "product" && mode !== "operator";
|
|
6430
6458
|
}
|
|
6431
6459
|
function specNeedsNav(projectRoot, surface) {
|
|
6432
|
-
const path =
|
|
6460
|
+
const path = join37(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6433
6461
|
if (!existsSync23(path)) return false;
|
|
6434
|
-
const front =
|
|
6462
|
+
const front = readFileSync30(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6435
6463
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
6436
6464
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
6437
6465
|
return navField || navRegion;
|
|
@@ -6482,7 +6510,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
6482
6510
|
function checkDecisions(projectRoot, dir, errors) {
|
|
6483
6511
|
const required = decisionNames(projectRoot);
|
|
6484
6512
|
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
6485
|
-
const file = readJson(
|
|
6513
|
+
const file = readJson(join37(dir, "decisions.json"), errors);
|
|
6486
6514
|
if (!file) {
|
|
6487
6515
|
errors.push(
|
|
6488
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.`
|
|
@@ -6527,7 +6555,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
6527
6555
|
function verifyVerdicts(opts) {
|
|
6528
6556
|
const root = opts.packageRoot ?? assetRoot();
|
|
6529
6557
|
const errors = [];
|
|
6530
|
-
const index = JSON.parse(
|
|
6558
|
+
const index = JSON.parse(readFileSync30(join37(root, "rules.index.json"), "utf8"));
|
|
6531
6559
|
const judgment = index.filter((r) => r.bucket === "judgment" || r.bucket === "hybrid" && r.pass !== void 0);
|
|
6532
6560
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
6533
6561
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -6536,9 +6564,9 @@ function verifyVerdicts(opts) {
|
|
|
6536
6564
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
6537
6565
|
]);
|
|
6538
6566
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
6539
|
-
const dir =
|
|
6540
|
-
const screenFile = readJson(
|
|
6541
|
-
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);
|
|
6542
6570
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
6543
6571
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
6544
6572
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
@@ -6566,7 +6594,7 @@ function verifyVerdicts(opts) {
|
|
|
6566
6594
|
};
|
|
6567
6595
|
let specFront = "";
|
|
6568
6596
|
try {
|
|
6569
|
-
specFront =
|
|
6597
|
+
specFront = readFileSync30(join37(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6570
6598
|
} catch {
|
|
6571
6599
|
}
|
|
6572
6600
|
if (specIndexableField(specFront) === "unreadable") {
|
|
@@ -6582,15 +6610,15 @@ function verifyVerdicts(opts) {
|
|
|
6582
6610
|
}
|
|
6583
6611
|
|
|
6584
6612
|
// src/commands/gate.ts
|
|
6585
|
-
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";
|
|
6586
6614
|
import { createHash as createHash2 } from "crypto";
|
|
6587
|
-
import { join as
|
|
6615
|
+
import { join as join38 } from "path";
|
|
6588
6616
|
var MAX_BLOCKS = 3;
|
|
6589
6617
|
function lastJigCommand(transcriptPath) {
|
|
6590
6618
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6591
6619
|
let text;
|
|
6592
6620
|
try {
|
|
6593
|
-
text =
|
|
6621
|
+
text = readFileSync31(transcriptPath, "utf8");
|
|
6594
6622
|
} catch {
|
|
6595
6623
|
return void 0;
|
|
6596
6624
|
}
|
|
@@ -6608,7 +6636,7 @@ function lastAssistantText(transcriptPath) {
|
|
|
6608
6636
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6609
6637
|
let found;
|
|
6610
6638
|
try {
|
|
6611
|
-
for (const line of
|
|
6639
|
+
for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
|
|
6612
6640
|
if (!line.includes('"assistant"')) continue;
|
|
6613
6641
|
let entry;
|
|
6614
6642
|
try {
|
|
@@ -6634,7 +6662,7 @@ var ASKS_THE_OWNER = /* @__PURE__ */ new Set(["decide", "spec", "mockup"]);
|
|
|
6634
6662
|
function sessionStart(transcriptPath) {
|
|
6635
6663
|
if (!transcriptPath || !existsSync24(transcriptPath)) return void 0;
|
|
6636
6664
|
try {
|
|
6637
|
-
for (const line of
|
|
6665
|
+
for (const line of readFileSync31(transcriptPath, "utf8").split("\n")) {
|
|
6638
6666
|
const m = /"timestamp"\s*:\s*"([^"]+)"/.exec(line);
|
|
6639
6667
|
if (m) {
|
|
6640
6668
|
const t = Date.parse(m[1]);
|
|
@@ -6649,20 +6677,20 @@ function verdictsMtime(dir) {
|
|
|
6649
6677
|
let newest = 0;
|
|
6650
6678
|
for (const name of VERDICT_FILES) {
|
|
6651
6679
|
try {
|
|
6652
|
-
newest = Math.max(newest, statSync4(
|
|
6680
|
+
newest = Math.max(newest, statSync4(join38(dir, name)).mtimeMs);
|
|
6653
6681
|
} catch {
|
|
6654
6682
|
}
|
|
6655
6683
|
}
|
|
6656
6684
|
return newest;
|
|
6657
6685
|
}
|
|
6658
6686
|
function surfacesInPlay(root, command, transcriptPath) {
|
|
6659
|
-
const critiqueDir =
|
|
6687
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6660
6688
|
if (!existsSync24(critiqueDir)) return [];
|
|
6661
6689
|
const all = readdirSync15(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
6662
6690
|
const start = sessionStart(transcriptPath);
|
|
6663
6691
|
if (start === void 0) return all;
|
|
6664
6692
|
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(
|
|
6693
|
+
return all.filter((s) => s === current || verdictsMtime(join38(critiqueDir, s)) >= start - 1e3);
|
|
6666
6694
|
}
|
|
6667
6695
|
function commandProblems(root, command) {
|
|
6668
6696
|
const problems = [];
|
|
@@ -6671,7 +6699,7 @@ function commandProblems(root, command) {
|
|
|
6671
6699
|
const found = decisionsFile(root);
|
|
6672
6700
|
if (!found) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
6673
6701
|
else {
|
|
6674
|
-
const body =
|
|
6702
|
+
const body = readFileSync31(join38(root, found), "utf8");
|
|
6675
6703
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
6676
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.`");
|
|
6677
6705
|
}
|
|
@@ -6692,13 +6720,13 @@ function commandProblems(root, command) {
|
|
|
6692
6720
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
6693
6721
|
if (/^approved/i.test(mockup)) {
|
|
6694
6722
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
6695
|
-
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.`);
|
|
6696
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.`);
|
|
6697
6725
|
}
|
|
6698
6726
|
}
|
|
6699
6727
|
if (command === "critique") {
|
|
6700
|
-
const dir =
|
|
6701
|
-
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"))) : [];
|
|
6702
6730
|
for (const surface of surfaces) {
|
|
6703
6731
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6704
6732
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -6720,13 +6748,13 @@ function surfacePage(projectRoot, surface) {
|
|
|
6720
6748
|
const declared = /^\s*surface\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "");
|
|
6721
6749
|
const candidates = [declared, `${surface}.html`, declared ? `${declared.replace(/^\//, "")}.html` : void 0].filter((c) => !!c && /\.\w+$/.test(c) === (c === declared ? /\.\w+$/.test(c) : true));
|
|
6722
6750
|
for (const candidate of candidates) {
|
|
6723
|
-
if (candidate && existsSync24(
|
|
6751
|
+
if (candidate && existsSync24(join38(projectRoot, candidate))) return candidate;
|
|
6724
6752
|
}
|
|
6725
6753
|
return void 0;
|
|
6726
6754
|
}
|
|
6727
6755
|
function gate(opts) {
|
|
6728
6756
|
const root = opts.projectRoot;
|
|
6729
|
-
if (!existsSync24(
|
|
6757
|
+
if (!existsSync24(join38(root, "jig.config.json")) && !existsSync24(join38(root, ".jig"))) {
|
|
6730
6758
|
return { block: false, reason: "" };
|
|
6731
6759
|
}
|
|
6732
6760
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -6753,11 +6781,11 @@ function gate(opts) {
|
|
|
6753
6781
|
}
|
|
6754
6782
|
}
|
|
6755
6783
|
problems.push(...verdictGuard(root, command, surfacesInPlay(root, command, opts.input.transcript_path)));
|
|
6756
|
-
const critiqueDir =
|
|
6784
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6757
6785
|
if (existsSync24(critiqueDir)) {
|
|
6758
6786
|
for (const surface of surfacesInPlay(root, command, opts.input.transcript_path)) {
|
|
6759
|
-
const dir =
|
|
6760
|
-
if (!existsSync24(
|
|
6787
|
+
const dir = join38(critiqueDir, surface);
|
|
6788
|
+
if (!existsSync24(join38(dir, "screen.json")) && !existsSync24(join38(dir, "code.json"))) continue;
|
|
6761
6789
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
6762
6790
|
if (!v.ok) {
|
|
6763
6791
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -6771,10 +6799,10 @@ function gate(opts) {
|
|
|
6771
6799
|
}
|
|
6772
6800
|
const session = opts.input.session_id ?? "unknown";
|
|
6773
6801
|
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
6774
|
-
const stateFile =
|
|
6802
|
+
const stateFile = join38(root, ".jig", "gate.json");
|
|
6775
6803
|
let state = {};
|
|
6776
6804
|
try {
|
|
6777
|
-
state = JSON.parse(
|
|
6805
|
+
state = JSON.parse(readFileSync31(stateFile, "utf8"));
|
|
6778
6806
|
} catch {
|
|
6779
6807
|
}
|
|
6780
6808
|
if (problems.length === 0) {
|
|
@@ -6808,7 +6836,7 @@ function verdictChecksum(dir) {
|
|
|
6808
6836
|
const parts = VERDICT_FILES.map((f) => {
|
|
6809
6837
|
try {
|
|
6810
6838
|
return `${f}
|
|
6811
|
-
${
|
|
6839
|
+
${readFileSync31(join38(dir, f), "utf8")}`;
|
|
6812
6840
|
} catch {
|
|
6813
6841
|
return "";
|
|
6814
6842
|
}
|
|
@@ -6816,15 +6844,15 @@ ${readFileSync30(join37(dir, f), "utf8")}`;
|
|
|
6816
6844
|
return parts.some(Boolean) ? checksum(parts.join("\n")) : void 0;
|
|
6817
6845
|
}
|
|
6818
6846
|
function verdictGuard(root, command, inPlay) {
|
|
6819
|
-
const critiqueDir =
|
|
6847
|
+
const critiqueDir = join38(root, ".jig", "critique");
|
|
6820
6848
|
if (!existsSync24(critiqueDir)) return [];
|
|
6821
6849
|
const problems = [];
|
|
6822
6850
|
for (const surface of readdirSync15(critiqueDir)) {
|
|
6823
6851
|
if (surface.startsWith("_")) continue;
|
|
6824
|
-
const dir =
|
|
6852
|
+
const dir = join38(critiqueDir, surface);
|
|
6825
6853
|
const now = verdictChecksum(dir);
|
|
6826
6854
|
if (!now) continue;
|
|
6827
|
-
const lockPath =
|
|
6855
|
+
const lockPath = join38(dir, LOCK);
|
|
6828
6856
|
if (command === "critique" && (!inPlay || inPlay.includes(surface))) {
|
|
6829
6857
|
try {
|
|
6830
6858
|
writeFileSync7(lockPath, JSON.stringify({ checksum: now }) + "\n", "utf8");
|
|
@@ -6834,7 +6862,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6834
6862
|
}
|
|
6835
6863
|
let locked;
|
|
6836
6864
|
try {
|
|
6837
|
-
locked = JSON.parse(
|
|
6865
|
+
locked = JSON.parse(readFileSync31(lockPath, "utf8")).checksum;
|
|
6838
6866
|
} catch {
|
|
6839
6867
|
continue;
|
|
6840
6868
|
}
|
|
@@ -6848,15 +6876,15 @@ function verdictGuard(root, command, inPlay) {
|
|
|
6848
6876
|
}
|
|
6849
6877
|
function save(file, state) {
|
|
6850
6878
|
try {
|
|
6851
|
-
mkdirSync8(
|
|
6879
|
+
mkdirSync8(join38(file, ".."), { recursive: true });
|
|
6852
6880
|
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
6853
6881
|
} catch {
|
|
6854
6882
|
}
|
|
6855
6883
|
}
|
|
6856
6884
|
|
|
6857
6885
|
// src/commands/seo.ts
|
|
6858
|
-
import { readFileSync as
|
|
6859
|
-
import { join as
|
|
6886
|
+
import { readFileSync as readFileSync32 } from "fs";
|
|
6887
|
+
import { join as join39 } from "path";
|
|
6860
6888
|
var SITEMAP_FILE = /(^|\/)sitemap(-index)?\.(xml|ts|js|mjs|tsx|jsx|rb|php|py)$/i;
|
|
6861
6889
|
var ROBOTS_FILE = /(^|\/)robots\.(txt|ts|js|mjs|tsx|jsx)$/i;
|
|
6862
6890
|
var ROUTE_IN_SITEMAP = /(?:<loc>\s*([^<\s]+)\s*<\/loc>)|(?:url\s*:\s*["'`]([^"'`]+)["'`])/gi;
|
|
@@ -6897,7 +6925,7 @@ function seo(opts) {
|
|
|
6897
6925
|
for (const file of files) {
|
|
6898
6926
|
let raw;
|
|
6899
6927
|
try {
|
|
6900
|
-
raw =
|
|
6928
|
+
raw = readFileSync32(join39(root, file), "utf8");
|
|
6901
6929
|
} catch {
|
|
6902
6930
|
continue;
|
|
6903
6931
|
}
|
|
@@ -6953,7 +6981,7 @@ function seo(opts) {
|
|
|
6953
6981
|
|
|
6954
6982
|
// src/index.ts
|
|
6955
6983
|
var packageRoot = getPackageRoot();
|
|
6956
|
-
var { version } = JSON.parse(
|
|
6984
|
+
var { version } = JSON.parse(readFileSync33(join40(packageRoot, "package.json"), "utf8"));
|
|
6957
6985
|
function warnIfUnpublishedPin() {
|
|
6958
6986
|
if (isPublishedBuild(packageRoot)) return;
|
|
6959
6987
|
console.warn(
|
|
@@ -7103,7 +7131,7 @@ program.command("seo").description("Audit what a search engine and a link previe
|
|
|
7103
7131
|
process.exit(1);
|
|
7104
7132
|
}
|
|
7105
7133
|
});
|
|
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) => {
|
|
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) => {
|
|
7107
7135
|
if (opts.run) {
|
|
7108
7136
|
if (!opts.save) {
|
|
7109
7137
|
console.error(" \u2717 --run records what it measures, so it needs --save <surface>.");
|
|
@@ -7145,7 +7173,7 @@ program.command("probe").description("Print the render probe. With --save, read
|
|
|
7145
7173
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(async () => {
|
|
7146
7174
|
let input = {};
|
|
7147
7175
|
try {
|
|
7148
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
7176
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync33(0, "utf8") || "{}");
|
|
7149
7177
|
} catch {
|
|
7150
7178
|
}
|
|
7151
7179
|
const cwd = input.cwd ?? process.cwd();
|
|
@@ -7184,7 +7212,7 @@ program.command("init").description("Set the project up to use Jig: a brand file
|
|
|
7184
7212
|
});
|
|
7185
7213
|
function resolveSurfaceMode(projectRoot) {
|
|
7186
7214
|
try {
|
|
7187
|
-
const config = JSON.parse(
|
|
7215
|
+
const config = JSON.parse(readFileSync33(join40(projectRoot, "jig.config.json"), "utf8"));
|
|
7188
7216
|
const modes = [...new Set((config.surfaces ?? []).map((s) => s.mode).filter(Boolean))];
|
|
7189
7217
|
return modes.length === 1 ? modes[0] : void 0;
|
|
7190
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);
|