jig-ui 0.15.1 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +6 -1
- package/dist/index.js +181 -104
- package/package.json +1 -1
- package/rules/02-tokens.md +16 -8
- package/rules/03-patterns.md +6 -6
- package/rules/04-principles.md +12 -12
- package/rules/05-copy.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **The corpus's own headings follow I-118.** Twelve `R-` titles ("Frame 1:
|
|
8
|
+
Minimise usability risk", the seven tiebreakers) and `L-01`'s six steps used
|
|
9
|
+
an em dash. Headings are interface text wherever they show, in `explain`, in
|
|
10
|
+
`check` output and on a rendered page, so they now use a colon.
|
|
11
|
+
- **A quotation keeps its own punctuation.** I-118 now says text marked as a
|
|
12
|
+
quotation with `<blockquote>` or `<q>` is the source's words, and changing
|
|
13
|
+
it would misquote it; the page's own copy around it is still held to the
|
|
14
|
+
rule. The render probe leaves quoted text out of its dash scan. Without
|
|
15
|
+
this, a page quoting a rule verbatim could never pass its review.
|
|
16
|
+
- **A spec's `indexable:` is `true` or `false`.** A sentence on that line was
|
|
17
|
+
read as nothing, the mode's default was used, and a correct page was
|
|
18
|
+
reported as contradicting J-123. Anything else is now a spec-shape problem,
|
|
19
|
+
and an error in `verdicts` that names the field.
|
|
20
|
+
|
|
21
|
+
## 0.16.0
|
|
22
|
+
|
|
23
|
+
With more than one mode, every barrel names its mode, and none sits in the
|
|
24
|
+
global stylesheet.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- **`theme.<mode>.css` for every mode, once there are two.** `init` kept
|
|
29
|
+
`theme.css` for the first mode and left it wired into the global stylesheet,
|
|
30
|
+
so a second mode's routes loaded the first mode's tokens globally and their
|
|
31
|
+
own from their layout, and got whichever came last. Now each barrel names its
|
|
32
|
+
mode, the global stylesheet keeps only what every route shares (Tailwind and
|
|
33
|
+
`utilities.css`), and each route's layout imports its own barrel. A project
|
|
34
|
+
with one mode is unchanged: `theme.css`, wired globally.
|
|
35
|
+
- **A project gaining a second mode is migrated.** An unedited `theme.css`
|
|
36
|
+
becomes `theme.<first mode>.css`, and every stylesheet import that resolves
|
|
37
|
+
to it is removed, found by where it points rather than by guessing the file.
|
|
38
|
+
An edited one is left, with a note on where its edits belong. `check` names a
|
|
39
|
+
`theme.css` left beside two modes.
|
|
40
|
+
|
|
3
41
|
## 0.15.1
|
|
4
42
|
|
|
5
43
|
### Fixed
|
package/README.md
CHANGED
|
@@ -123,6 +123,11 @@ jig.config.json route → mode map
|
|
|
123
123
|
.jig/state.json bookkeeping — version, modes, checksums
|
|
124
124
|
```
|
|
125
125
|
|
|
126
|
+
Declare a second mode and every barrel names its mode instead —
|
|
127
|
+
`theme.editorial.css`, `theme.operator.css` — and none sits in the global
|
|
128
|
+
stylesheet. Each route's layout imports the barrel for its mode; `init` removes
|
|
129
|
+
the import it added and prints which barrel each surface takes.
|
|
130
|
+
|
|
126
131
|
Nothing you wrote is touched beyond that one import line. Re-running `init`
|
|
127
132
|
never overwrites a config or brand file you have edited.
|
|
128
133
|
|
|
@@ -604,7 +609,7 @@ treatment.
|
|
|
604
609
|
| `rules/05-copy.md` | Interface text rules |
|
|
605
610
|
| `<css dir>/jig/brand.*.css` | Identity. One per project. |
|
|
606
611
|
| `<css dir>/jig/mode.*.css` | Density, scale, rhythm, motion |
|
|
607
|
-
| `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. |
|
|
612
|
+
| `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. With more than one mode, `theme.<mode>.css`, one per mode, each imported by its routes' layout. |
|
|
608
613
|
| `.jig/state.json` | What `init` wrote, with checksums. `update` reads it to leave your edits alone. |
|
|
609
614
|
| `.jig/specs/`, `.jig/mockups/`, `.jig/critique/` | The design loop's record: what was agreed, what was drawn, what the review found. |
|
|
610
615
|
|
package/dist/index.js
CHANGED
|
@@ -3911,10 +3911,20 @@ function modeWiringProblems(projectRoot) {
|
|
|
3911
3911
|
if (modes.length === 0) return [];
|
|
3912
3912
|
const dir = dirname6(config.brand);
|
|
3913
3913
|
const rel = (f) => dir === "." ? f : `${dir}/${f}`;
|
|
3914
|
-
|
|
3914
|
+
const multi = modes.length > 1;
|
|
3915
|
+
const barrelOf = (mode) => multi ? `theme.${mode}.css` : "theme.css";
|
|
3916
|
+
const anyBarrel = ["theme.css", ...modes.map((m) => `theme.${m}.css`)].some((f) => existsSync12(join22(projectRoot, rel(f))));
|
|
3917
|
+
if (!anyBarrel) return [];
|
|
3915
3918
|
const problems = [];
|
|
3916
|
-
|
|
3917
|
-
|
|
3919
|
+
if (multi && existsSync12(join22(projectRoot, rel("theme.css")))) {
|
|
3920
|
+
problems.push({
|
|
3921
|
+
mode: modes[0],
|
|
3922
|
+
barrel: rel("theme.css"),
|
|
3923
|
+
message: `jig.config.json declares ${modes.length} modes, so each barrel names its mode: ${rel("theme.css")} should be ${rel(barrelOf(modes[0]))}, imported by the layouts that serve it and not by the global stylesheet`
|
|
3924
|
+
});
|
|
3925
|
+
}
|
|
3926
|
+
modes.forEach((mode) => {
|
|
3927
|
+
const barrel = rel(barrelOf(mode));
|
|
3918
3928
|
const modeFile = `mode.${mode}.css`;
|
|
3919
3929
|
let body;
|
|
3920
3930
|
try {
|
|
@@ -5084,7 +5094,8 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5084
5094
|
}
|
|
5085
5095
|
const modeAbsPath = modeAbsPaths[primaryMode];
|
|
5086
5096
|
const brandFileOnly = brandRelPath.split("/").pop();
|
|
5087
|
-
const
|
|
5097
|
+
const multiMode = declaredModes.length > 1;
|
|
5098
|
+
const barrelFor = (mode) => multiMode ? `theme.${mode}.css` : "theme.css";
|
|
5088
5099
|
for (const mode of declaredModes) {
|
|
5089
5100
|
const rel = relKey(...tokensRelDir, barrelFor(mode));
|
|
5090
5101
|
const abs = join28(opts.projectRoot, ...tokensRelDir, barrelFor(mode));
|
|
@@ -5098,6 +5109,20 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5098
5109
|
writeFileSync5(abs, content, "utf8");
|
|
5099
5110
|
files[rel] = checksum(content);
|
|
5100
5111
|
}
|
|
5112
|
+
if (multiMode) {
|
|
5113
|
+
const rel = relKey(...tokensRelDir, "theme.css");
|
|
5114
|
+
const abs = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
|
|
5115
|
+
if (existsSync15(abs)) {
|
|
5116
|
+
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
5117
|
+
if (state.tracked && !state.modified) {
|
|
5118
|
+
rmSync2(abs);
|
|
5119
|
+
delete files[rel];
|
|
5120
|
+
log(` Removed ${rel}: with more than one mode, each barrel names its mode (${relKey(...tokensRelDir, barrelFor(primaryMode))}).`);
|
|
5121
|
+
} else {
|
|
5122
|
+
log(` ${rel} has been edited, so it is left alone. With more than one mode it is replaced by ${relKey(...tokensRelDir, barrelFor(primaryMode))}; move your edits there and delete it.`);
|
|
5123
|
+
}
|
|
5124
|
+
}
|
|
5125
|
+
}
|
|
5101
5126
|
if (detection.cssSystem === "tailwind-v4") {
|
|
5102
5127
|
const declared = declaredTokenNames(
|
|
5103
5128
|
[brandAbsPath, modeAbsPath].map((f) => {
|
|
@@ -5148,8 +5173,9 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5148
5173
|
}
|
|
5149
5174
|
}
|
|
5150
5175
|
}
|
|
5151
|
-
if (
|
|
5152
|
-
log("\nOne barrel per
|
|
5176
|
+
if (multiMode) {
|
|
5177
|
+
log("\nOne barrel per mode, and none in the global stylesheet. Import each in the layout");
|
|
5178
|
+
log("that serves its routes, and keep Tailwind and utilities.css in the global one:");
|
|
5153
5179
|
for (const surface of effectiveConfig.surfaces) {
|
|
5154
5180
|
log(` '${surface.match}' \u2192 ${relKey(...tokensRelDir, barrelFor(surface.mode))}`);
|
|
5155
5181
|
}
|
|
@@ -5174,7 +5200,33 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
5174
5200
|
}
|
|
5175
5201
|
const wireTarget = findWireTarget(detection);
|
|
5176
5202
|
let wiring;
|
|
5177
|
-
if (
|
|
5203
|
+
if (multiMode) {
|
|
5204
|
+
const bare = join28(opts.projectRoot, ...tokensRelDir, "theme.css");
|
|
5205
|
+
for (const file of detection.cssFiles.filter((f) => !isTokenLayerFile(f))) {
|
|
5206
|
+
const abs = join28(opts.projectRoot, file);
|
|
5207
|
+
try {
|
|
5208
|
+
const before = readFileSync23(abs, "utf8");
|
|
5209
|
+
const after = before.split("\n").filter((l) => {
|
|
5210
|
+
const m = /^\s*@import\s+["']([^"']+)["'];?\s*$/.exec(l);
|
|
5211
|
+
return !(m && !/^[a-z]+:|^\//i.test(m[1]) && resolve2(dirname7(abs), m[1]) === bare);
|
|
5212
|
+
}).join("\n");
|
|
5213
|
+
if (after !== before) {
|
|
5214
|
+
writeFileSync5(abs, after, "utf8");
|
|
5215
|
+
log(`
|
|
5216
|
+
Unwired ${file}: removed its import of ${relKey(...tokensRelDir, "theme.css")}.`);
|
|
5217
|
+
log(` With more than one mode, the global stylesheet imports no barrel: every route would carry that mode's tokens.`);
|
|
5218
|
+
}
|
|
5219
|
+
} catch (err) {
|
|
5220
|
+
log(`
|
|
5221
|
+
Could not edit ${file}: ${err.message}. Remove its import of ${relKey(...tokensRelDir, "theme.css")} by hand.`);
|
|
5222
|
+
}
|
|
5223
|
+
}
|
|
5224
|
+
wiring = {
|
|
5225
|
+
target: null,
|
|
5226
|
+
status: "per-route",
|
|
5227
|
+
snippet: effectiveConfig.surfaces.map((s) => `${s.match} \u2192 ${relKey(...tokensRelDir, barrelFor(s.mode))}`).join("\n")
|
|
5228
|
+
};
|
|
5229
|
+
} else if (wireTarget) {
|
|
5178
5230
|
const targetAbsDir = dirname7(join28(opts.projectRoot, wireTarget));
|
|
5179
5231
|
const barrelImport = relativeImportPath(
|
|
5180
5232
|
targetAbsDir,
|
|
@@ -5262,8 +5314,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
5262
5314
|
}
|
|
5263
5315
|
|
|
5264
5316
|
// src/commands/verdicts.ts
|
|
5265
|
-
import { existsSync as
|
|
5266
|
-
import { join as
|
|
5317
|
+
import { existsSync as existsSync21, readFileSync as readFileSync28 } from "fs";
|
|
5318
|
+
import { join as join35, resolve as resolve4 } from "path";
|
|
5267
5319
|
|
|
5268
5320
|
// src/rules/citations.ts
|
|
5269
5321
|
import { join as join29 } from "path";
|
|
@@ -5296,6 +5348,14 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5296
5348
|
const defaultFont = getComputedStyle(probe).fontFamily;
|
|
5297
5349
|
probe.remove();
|
|
5298
5350
|
const text = document.body.innerText || '';
|
|
5351
|
+
// I-118: a verbatim quotation keeps its own punctuation. Text marked as a
|
|
5352
|
+
// quotation is the source's words, so it is taken out before the dash scan;
|
|
5353
|
+
// the page's own copy around it still counts.
|
|
5354
|
+
let ownText = text;
|
|
5355
|
+
for (const q of document.querySelectorAll('blockquote, q')) {
|
|
5356
|
+
const quoted = (q.innerText || '').trim();
|
|
5357
|
+
if (quoted) ownText = ownText.split(quoted).join('\\n');
|
|
5358
|
+
}
|
|
5299
5359
|
const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
|
|
5300
5360
|
const unresolved = new Set();
|
|
5301
5361
|
for (const sheet of document.styleSheets) {
|
|
@@ -5406,7 +5466,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
5406
5466
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
5407
5467
|
unresolvedTokens: [...unresolved],
|
|
5408
5468
|
junkText: junk,
|
|
5409
|
-
emDashes: [...new Set((
|
|
5469
|
+
emDashes: [...new Set((ownText.match(/[^.!?\\n]{0,28}\u2014[^.!?\\n]{0,28}/g) || []).map((t) => t.trim()))].slice(0, 5),
|
|
5410
5470
|
brokenImages: [...document.images].filter((i) => i.complete && i.naturalWidth === 0).length,
|
|
5411
5471
|
navLinksVisible: navAtRest,
|
|
5412
5472
|
head: {
|
|
@@ -5825,35 +5885,120 @@ function decisionNames(projectRoot) {
|
|
|
5825
5885
|
return names;
|
|
5826
5886
|
}
|
|
5827
5887
|
|
|
5888
|
+
// src/check/spec-shape.ts
|
|
5889
|
+
import { existsSync as existsSync20, readFileSync as readFileSync27, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
|
|
5890
|
+
import { join as join34 } from "path";
|
|
5891
|
+
function newestSpec(projectRoot) {
|
|
5892
|
+
const dir = join34(projectRoot, ".jig", "specs");
|
|
5893
|
+
if (!existsSync20(dir)) return void 0;
|
|
5894
|
+
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
5895
|
+
if (files.length === 0) return void 0;
|
|
5896
|
+
const newest = files.map((f) => ({ f, at: statSync2(join34(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
5897
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync27(join34(dir, newest), "utf8") };
|
|
5898
|
+
}
|
|
5899
|
+
function specProblems(spec) {
|
|
5900
|
+
const parts = spec.body.split(/^---\s*$/m);
|
|
5901
|
+
const front = parts.length >= 3 ? parts[1] : "";
|
|
5902
|
+
if (!front.trim()) {
|
|
5903
|
+
return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
|
|
5904
|
+
}
|
|
5905
|
+
const problems = [];
|
|
5906
|
+
const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
|
|
5907
|
+
if (specIndexableField(front) === "unreadable") {
|
|
5908
|
+
problems.push(`${spec.path}: \`indexable:\` is neither true nor false. Write \`indexable: true\` or \`indexable: false\`; a per-page override and its reason go in the body, where they can be read without being parsed.`);
|
|
5909
|
+
}
|
|
5910
|
+
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
5911
|
+
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
5912
|
+
}
|
|
5913
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5914
|
+
for (const size of ["phone", "tablet", "desktop", "wide"]) {
|
|
5915
|
+
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
5916
|
+
if (!line) {
|
|
5917
|
+
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
5918
|
+
continue;
|
|
5919
|
+
}
|
|
5920
|
+
if (line[1].trim()) {
|
|
5921
|
+
problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
|
|
5922
|
+
continue;
|
|
5923
|
+
}
|
|
5924
|
+
const block = sizeBlock(sizes, size);
|
|
5925
|
+
if (/^\s*same-as\s*:/im.test(block)) {
|
|
5926
|
+
if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
|
|
5927
|
+
continue;
|
|
5928
|
+
}
|
|
5929
|
+
for (const field of ["regions", "nav"]) {
|
|
5930
|
+
if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
|
|
5931
|
+
problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
|
|
5932
|
+
}
|
|
5933
|
+
}
|
|
5934
|
+
}
|
|
5935
|
+
return problems;
|
|
5936
|
+
}
|
|
5937
|
+
function sizeBlock(sizes, size) {
|
|
5938
|
+
const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
|
|
5939
|
+
if (!start) return "";
|
|
5940
|
+
const rest = sizes.slice(start.index + start[0].length);
|
|
5941
|
+
const indent = /^\s*/.exec(start[0])[0].length;
|
|
5942
|
+
const lines = [];
|
|
5943
|
+
for (const line of rest.split("\n")) {
|
|
5944
|
+
if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
|
|
5945
|
+
lines.push(line);
|
|
5946
|
+
}
|
|
5947
|
+
return lines.join("\n");
|
|
5948
|
+
}
|
|
5949
|
+
var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
|
|
5950
|
+
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
5951
|
+
function navProblems(spec) {
|
|
5952
|
+
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
5953
|
+
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
5954
|
+
const problems = [];
|
|
5955
|
+
for (const size of ["tablet", "landscape", "desktop", "wide"]) {
|
|
5956
|
+
const block = sizeBlock(sizes, size);
|
|
5957
|
+
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
5958
|
+
if (!nav || NONE_RE.test(nav)) continue;
|
|
5959
|
+
if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
|
|
5960
|
+
problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
|
|
5961
|
+
}
|
|
5962
|
+
}
|
|
5963
|
+
return problems;
|
|
5964
|
+
}
|
|
5965
|
+
function specIndexableField(front) {
|
|
5966
|
+
const line = /^\s*indexable\s*:(.*)$/im.exec(front);
|
|
5967
|
+
if (!line) return "absent";
|
|
5968
|
+
const value = line[1].trim().replace(/^["']|["']$/g, "").toLowerCase();
|
|
5969
|
+
if (value === "true" || value === "yes") return true;
|
|
5970
|
+
if (value === "false" || value === "no") return false;
|
|
5971
|
+
return "unreadable";
|
|
5972
|
+
}
|
|
5973
|
+
|
|
5828
5974
|
// src/commands/verdicts.ts
|
|
5829
5975
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5830
5976
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
5831
5977
|
function readJson(path, errors) {
|
|
5832
|
-
if (!
|
|
5978
|
+
if (!existsSync21(path)) return null;
|
|
5833
5979
|
try {
|
|
5834
|
-
return JSON.parse(
|
|
5980
|
+
return JSON.parse(readFileSync28(path, "utf8"));
|
|
5835
5981
|
} catch (e) {
|
|
5836
5982
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
5837
5983
|
return {};
|
|
5838
5984
|
}
|
|
5839
5985
|
}
|
|
5840
5986
|
function specIndexable(projectRoot, surface) {
|
|
5841
|
-
const path =
|
|
5987
|
+
const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5842
5988
|
let front = "";
|
|
5843
5989
|
try {
|
|
5844
|
-
front =
|
|
5990
|
+
front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5845
5991
|
} catch {
|
|
5846
5992
|
}
|
|
5847
|
-
const declared =
|
|
5848
|
-
if (declared ===
|
|
5849
|
-
if (declared === "false" || declared === "no") return false;
|
|
5993
|
+
const declared = specIndexableField(front);
|
|
5994
|
+
if (declared === true || declared === false) return declared;
|
|
5850
5995
|
const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
|
|
5851
5996
|
return mode !== "product" && mode !== "operator";
|
|
5852
5997
|
}
|
|
5853
5998
|
function specNeedsNav(projectRoot, surface) {
|
|
5854
|
-
const path =
|
|
5855
|
-
if (!
|
|
5856
|
-
const front =
|
|
5999
|
+
const path = join35(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
6000
|
+
if (!existsSync21(path)) return false;
|
|
6001
|
+
const front = readFileSync28(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5857
6002
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5858
6003
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
5859
6004
|
return navField || navRegion;
|
|
@@ -5904,7 +6049,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5904
6049
|
function checkDecisions(projectRoot, dir, errors) {
|
|
5905
6050
|
const required = decisionNames(projectRoot);
|
|
5906
6051
|
if (required.length === 0) return { state: "ran", judged: 0, total: 0, findings: 0 };
|
|
5907
|
-
const file = readJson(
|
|
6052
|
+
const file = readJson(join35(dir, "decisions.json"), errors);
|
|
5908
6053
|
if (!file) {
|
|
5909
6054
|
errors.push(
|
|
5910
6055
|
`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.`
|
|
@@ -5949,7 +6094,7 @@ function checkDecisions(projectRoot, dir, errors) {
|
|
|
5949
6094
|
function verifyVerdicts(opts) {
|
|
5950
6095
|
const root = opts.packageRoot ?? assetRoot();
|
|
5951
6096
|
const errors = [];
|
|
5952
|
-
const index = JSON.parse(
|
|
6097
|
+
const index = JSON.parse(readFileSync28(join35(root, "rules.index.json"), "utf8"));
|
|
5953
6098
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5954
6099
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5955
6100
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -5958,16 +6103,16 @@ function verifyVerdicts(opts) {
|
|
|
5958
6103
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5959
6104
|
]);
|
|
5960
6105
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5961
|
-
const dir =
|
|
5962
|
-
const screenFile = readJson(
|
|
5963
|
-
const codeFile = readJson(
|
|
6106
|
+
const dir = join35(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
6107
|
+
const screenFile = readJson(join35(dir, "screen.json"), errors);
|
|
6108
|
+
const codeFile = readJson(join35(dir, "code.json"), errors);
|
|
5964
6109
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5965
6110
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5966
6111
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5967
6112
|
let rendered = false;
|
|
5968
6113
|
if (screenFile && screenFile.rendered === true) {
|
|
5969
6114
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5970
|
-
const absent = artefacts.filter((a) => !
|
|
6115
|
+
const absent = artefacts.filter((a) => !existsSync21(resolve4(opts.projectRoot, a)));
|
|
5971
6116
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5972
6117
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5973
6118
|
else rendered = true;
|
|
@@ -5986,7 +6131,16 @@ function verifyVerdicts(opts) {
|
|
|
5986
6131
|
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
5987
6132
|
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
5988
6133
|
};
|
|
5989
|
-
|
|
6134
|
+
let specFront = "";
|
|
6135
|
+
try {
|
|
6136
|
+
specFront = readFileSync28(join35(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`), "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
6137
|
+
} catch {
|
|
6138
|
+
}
|
|
6139
|
+
if (specIndexableField(specFront) === "unreadable") {
|
|
6140
|
+
errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
|
|
6141
|
+
} else {
|
|
6142
|
+
errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
|
|
6143
|
+
}
|
|
5990
6144
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
5991
6145
|
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
5992
6146
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
@@ -5998,83 +6152,6 @@ function verifyVerdicts(opts) {
|
|
|
5998
6152
|
import { existsSync as existsSync22, mkdirSync as mkdirSync8, readdirSync as readdirSync15, readFileSync as readFileSync29, writeFileSync as writeFileSync7 } from "fs";
|
|
5999
6153
|
import { createHash as createHash2 } from "crypto";
|
|
6000
6154
|
import { join as join36 } from "path";
|
|
6001
|
-
|
|
6002
|
-
// src/check/spec-shape.ts
|
|
6003
|
-
import { existsSync as existsSync21, readFileSync as readFileSync28, readdirSync as readdirSync14, statSync as statSync2 } from "fs";
|
|
6004
|
-
import { join as join35 } from "path";
|
|
6005
|
-
function newestSpec(projectRoot) {
|
|
6006
|
-
const dir = join35(projectRoot, ".jig", "specs");
|
|
6007
|
-
if (!existsSync21(dir)) return void 0;
|
|
6008
|
-
const files = readdirSync14(dir).filter((f) => f.endsWith(".md"));
|
|
6009
|
-
if (files.length === 0) return void 0;
|
|
6010
|
-
const newest = files.map((f) => ({ f, at: statSync2(join35(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
6011
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync28(join35(dir, newest), "utf8") };
|
|
6012
|
-
}
|
|
6013
|
-
function specProblems(spec) {
|
|
6014
|
-
const parts = spec.body.split(/^---\s*$/m);
|
|
6015
|
-
const front = parts.length >= 3 ? parts[1] : "";
|
|
6016
|
-
if (!front.trim()) {
|
|
6017
|
-
return [`${spec.path} has no frontmatter. A spec is the frontmatter \u2014 feature, surface, mode, sizes (phone, tablet, desktop), states, decisions, later, mockup, confirmed \u2014 with the reasoning below it. Rewrite it in that shape.`];
|
|
6018
|
-
}
|
|
6019
|
-
const problems = [];
|
|
6020
|
-
const has = (field) => new RegExp(`^\\s*${field}\\s*:`, "im").test(front);
|
|
6021
|
-
for (const field of ["feature", "surface", "mode", "sizes", "confirmed", "mockup"]) {
|
|
6022
|
-
if (!has(field)) problems.push(`${spec.path} frontmatter has no \`${field}:\`.`);
|
|
6023
|
-
}
|
|
6024
|
-
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6025
|
-
for (const size of ["phone", "tablet", "desktop", "wide"]) {
|
|
6026
|
-
const line = new RegExp(`^\\s+${size}\\s*:(.*)$`, "im").exec(sizes);
|
|
6027
|
-
if (!line) {
|
|
6028
|
-
problems.push(`${spec.path} has no \`${size}:\` composition under \`sizes:\`. Every size is written in full, phone first; \`same-as:\` needs a \`why:\`.`);
|
|
6029
|
-
continue;
|
|
6030
|
-
}
|
|
6031
|
-
if (line[1].trim()) {
|
|
6032
|
-
problems.push(`${spec.path}: \`${size}:\` is a one-line description. A size is a composition \u2014 \`regions:\` in order, \`hierarchy:\`, \`nav:\` \u2014 each on its own line, or \`same-as:\` with a \`why:\`.`);
|
|
6033
|
-
continue;
|
|
6034
|
-
}
|
|
6035
|
-
const block = sizeBlock(sizes, size);
|
|
6036
|
-
if (/^\s*same-as\s*:/im.test(block)) {
|
|
6037
|
-
if (!/^\s*why\s*:/im.test(block)) problems.push(`${spec.path}: \`${size}\` claims \`same-as:\` with no \`why:\`. The claim is checked on a render, so it says why it holds.`);
|
|
6038
|
-
continue;
|
|
6039
|
-
}
|
|
6040
|
-
for (const field of ["regions", "nav"]) {
|
|
6041
|
-
if (!new RegExp(`^\\s*${field}\\s*:`, "im").test(block)) {
|
|
6042
|
-
problems.push(`${spec.path}: \`${size}\` has no \`${field}:\`. Every size names its regions in order and what its navigation is at that width (\`nav: none\` when the screen has none).`);
|
|
6043
|
-
}
|
|
6044
|
-
}
|
|
6045
|
-
}
|
|
6046
|
-
return problems;
|
|
6047
|
-
}
|
|
6048
|
-
function sizeBlock(sizes, size) {
|
|
6049
|
-
const start = new RegExp(`^\\s+${size}\\s*:.*$`, "im").exec(sizes);
|
|
6050
|
-
if (!start) return "";
|
|
6051
|
-
const rest = sizes.slice(start.index + start[0].length);
|
|
6052
|
-
const indent = /^\s*/.exec(start[0])[0].length;
|
|
6053
|
-
const lines = [];
|
|
6054
|
-
for (const line of rest.split("\n")) {
|
|
6055
|
-
if (line.trim() && /^\s*/.exec(line)[0].length <= indent) break;
|
|
6056
|
-
lines.push(line);
|
|
6057
|
-
}
|
|
6058
|
-
return lines.join("\n");
|
|
6059
|
-
}
|
|
6060
|
-
var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
|
|
6061
|
-
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
6062
|
-
function navProblems(spec) {
|
|
6063
|
-
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
6064
|
-
const sizes = front.split(/^sizes\s*:/im)[1] ?? "";
|
|
6065
|
-
const problems = [];
|
|
6066
|
-
for (const size of ["tablet", "landscape", "desktop", "wide"]) {
|
|
6067
|
-
const block = sizeBlock(sizes, size);
|
|
6068
|
-
const nav = /^\s*nav\s*:\s*(.+)$/im.exec(block)?.[1]?.trim();
|
|
6069
|
-
if (!nav || NONE_RE.test(nav)) continue;
|
|
6070
|
-
if (MENU_RE.test(nav) && !/\bopen\b|\bexpanded\b/i.test(nav)) {
|
|
6071
|
-
problems.push(`${spec.path}: \`${size}\` has \`nav: ${nav}\`. Run P-14's table at that width \u2014 where every destination fits, the links show and there is no menu button. A decision about where the button sits is about position, not whether it exists.`);
|
|
6072
|
-
}
|
|
6073
|
-
}
|
|
6074
|
-
return problems;
|
|
6075
|
-
}
|
|
6076
|
-
|
|
6077
|
-
// src/commands/gate.ts
|
|
6078
6155
|
var MAX_BLOCKS = 3;
|
|
6079
6156
|
function lastJigCommand(transcriptPath) {
|
|
6080
6157
|
if (!transcriptPath || !existsSync22(transcriptPath)) return void 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "A design system for coding agents. 130 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/02-tokens.md
CHANGED
|
@@ -25,9 +25,11 @@ lives in `src/styles/`, `app/assets/stylesheets/jig/` in a Rails app. Set `brand
|
|
|
25
25
|
in `jig.config.json` to put it elsewhere. Projects set up before 0.7.0 keep their
|
|
26
26
|
`.jig/tokens/` layout; `update` does not move them.
|
|
27
27
|
|
|
28
|
-
So **never hardcode that path**. `init` writes
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
So **never hardcode that path**. `init` writes a barrel, `theme.css`, which imports
|
|
29
|
+
the brand and the mode in the right order — import the barrel, and relocating the
|
|
30
|
+
layer changes one line instead of every stylesheet. With more than one mode, every
|
|
31
|
+
barrel names its mode instead (`theme.editorial.css`, `theme.operator.css`); see
|
|
32
|
+
"Multiple modes in one app" below:
|
|
31
33
|
|
|
32
34
|
```css
|
|
33
35
|
/* src/styles/jig/theme.css — written by init */
|
|
@@ -512,8 +514,14 @@ the same token names with different values, so importing all three into one
|
|
|
512
514
|
document leaves only the last — the other two are inert. That is the mechanical
|
|
513
515
|
reason behind the seam rule below.
|
|
514
516
|
|
|
515
|
-
**Multiple modes in one app** — scope by route, not by class.
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
517
|
+
**Multiple modes in one app** — scope by route, not by class. Every barrel names its
|
|
518
|
+
mode, `jig/theme.<mode>.css`, and each route's layout or entry point imports the
|
|
519
|
+
one for its mode. The global stylesheet imports **no** barrel: one imported there
|
|
520
|
+
puts that mode's tokens under every route, so the operator pages carry editorial's
|
|
521
|
+
too and get whichever loaded last. It keeps what every route shares — Tailwind and
|
|
522
|
+
`utilities.css`, whose aliases name tokens rather than values and so hold for any
|
|
523
|
+
mode. When a second mode is declared, `init` replaces `theme.css` with
|
|
524
|
+
`theme.<first mode>.css` and removes the import it had wired, then prints which
|
|
525
|
+
barrel each surface imports; which layout serves `/admin/**` is your routing, which
|
|
526
|
+
it cannot see. Do not attempt to nest two modes in one document (`01-modes.md`,
|
|
527
|
+
seam rules).
|
package/rules/03-patterns.md
CHANGED
|
@@ -479,7 +479,7 @@ Compose it for the phone first. Mobile navigation is a different control — not
|
|
|
479
479
|
|
|
480
480
|
Not a component. The procedure for structuring any screen, before styling anything.
|
|
481
481
|
|
|
482
|
-
### Step 1
|
|
482
|
+
### Step 1: Group
|
|
483
483
|
|
|
484
484
|
Four tools, weakest to strongest. Use the weakest that works (`A-67`):
|
|
485
485
|
|
|
@@ -492,7 +492,7 @@ Four tools, weakest to strongest. Use the weakest that works (`A-67`):
|
|
|
492
492
|
|
|
493
493
|
Combine them and the container usually becomes unnecessary — a table's rows are already aligned, alike and close. Break continuity deliberately to mark the end of a group, or to interrupt a list with something that is not part of it.
|
|
494
494
|
|
|
495
|
-
### Step 2
|
|
495
|
+
### Step 2: Order by importance
|
|
496
496
|
|
|
497
497
|
Six variables carry hierarchy: **size**, **colour**, **contrast**, **spacing**, **position**, **depth**. The procedure:
|
|
498
498
|
|
|
@@ -504,11 +504,11 @@ Position does more than it looks: people best recall the **first and last** item
|
|
|
504
504
|
|
|
505
505
|
Give elements *similar* prominence where they should be read as a pair — matching a label's weight to its icon's balances them instead of letting one shout.
|
|
506
506
|
|
|
507
|
-
### Step 3
|
|
507
|
+
### Step 3: Space from the inside out
|
|
508
508
|
|
|
509
509
|
Start at XS on the innermost rectangle and step up moving outward (`D-69`). Between two options, take the larger.
|
|
510
510
|
|
|
511
|
-
### Step 4
|
|
511
|
+
### Step 4: Align to a grid
|
|
512
512
|
|
|
513
513
|
Main containers align to a 12-column grid; small elements *inside* them do not — those use the spacing options.
|
|
514
514
|
|
|
@@ -516,7 +516,7 @@ Main containers align to a 12-column grid; small elements *inside* them do not
|
|
|
516
516
|
- **Gutters** fixed, narrower than columns, and kept empty. `--grid-gutter`.
|
|
517
517
|
- **Margins** keep content off the screen edge, wider on large screens. `--grid-margin`.
|
|
518
518
|
|
|
519
|
-
### Step 5
|
|
519
|
+
### Step 5: The squint test
|
|
520
520
|
|
|
521
521
|
Blur the design, zoom out, or step back. You should still be able to tell what the screen is for and which element matters most. If everything reads at one weight the hierarchy has failed; if elements smear together the white space is too tight.
|
|
522
522
|
|
|
@@ -524,7 +524,7 @@ Blur the design, zoom out, or step back. You should still be able to tell what t
|
|
|
524
524
|
|
|
525
525
|
**When nothing can render it, use the analogue:** if all type were one size and one colour, would the layout still communicate its order? If the hierarchy depends entirely on type styling, it is too weak. The analogue is a fallback, not an equal — a reading of the source is not a look at the page.
|
|
526
526
|
|
|
527
|
-
### Step 6
|
|
527
|
+
### Step 6: Decide how it collapses
|
|
528
528
|
|
|
529
529
|
A spec writes a composition per size. This step is what happens **between** them:
|
|
530
530
|
the same content, arranged for less room. Six rules, and the first is the one
|
package/rules/04-principles.md
CHANGED
|
@@ -15,7 +15,7 @@ If you reach for Part 2 often, the rules in `00`–`03` are underspecified and t
|
|
|
15
15
|
|
|
16
16
|
# Part 1 · Frames
|
|
17
17
|
|
|
18
|
-
## R-01 · Frame 1
|
|
18
|
+
## R-01 · Frame 1: Minimise usability risk
|
|
19
19
|
|
|
20
20
|
**Ask: who could struggle with this, and why?**
|
|
21
21
|
|
|
@@ -35,7 +35,7 @@ The risk is rarely to the median user. It falls on people with reduced vision, l
|
|
|
35
35
|
|
|
36
36
|
**Floor:** WCAG 2.1 level AA. Meeting AA is the starting point, not the achievement.
|
|
37
37
|
|
|
38
|
-
## R-02 · Frame 2
|
|
38
|
+
## R-02 · Frame 2: Every detail has a reason you can state
|
|
39
39
|
|
|
40
40
|
**Ask: why this way rather than another way?**
|
|
41
41
|
|
|
@@ -45,7 +45,7 @@ This is the test every rule in this system had to pass, and it is why the token
|
|
|
45
45
|
|
|
46
46
|
**Use it like this:** when you make a call the rules do not cover, state the reason in one line. If you cannot, you are guessing — and a guess should be surfaced as a question, not shipped as a decision (Tiebreaker 5).
|
|
47
47
|
|
|
48
|
-
## R-03 · Frame 3
|
|
48
|
+
## R-03 · Frame 3: Minimise interaction cost
|
|
49
49
|
|
|
50
50
|
**Ask: what does this cost the user, counted?**
|
|
51
51
|
|
|
@@ -59,7 +59,7 @@ Three reliable reductions:
|
|
|
59
59
|
|
|
60
60
|
**Use it like this:** count before and after, and state it. "3 clicks + 1 scroll → 2 clicks" is reviewable. "Improved the UX" is not. See `P-10`.
|
|
61
61
|
|
|
62
|
-
## R-04 · Frame 4
|
|
62
|
+
## R-04 · Frame 4: Minimise cognitive load
|
|
63
63
|
|
|
64
64
|
**Ask: how much thinking does this require that is not the user's actual task?**
|
|
65
65
|
|
|
@@ -73,7 +73,7 @@ Attention spent decoding the interface is unavailable for the work. Reliable red
|
|
|
73
73
|
|
|
74
74
|
**Use it like this:** when something feels heavy but no rule is broken, the load is usually ungrouped information or an unnecessary decision. Split it or remove it. A long form becomes steps; a wide table becomes fewer default columns; six equal options become two recommended and four behind "more".
|
|
75
75
|
|
|
76
|
-
## R-05 · Frame 5
|
|
76
|
+
## R-05 · Frame 5: Optimise for the common path
|
|
77
77
|
|
|
78
78
|
**Ask: what are most people here to do?**
|
|
79
79
|
|
|
@@ -91,31 +91,31 @@ Effort should follow it. Make the common task excellent before making the rare o
|
|
|
91
91
|
|
|
92
92
|
Seven. Each resolves a specific conflict in a specific direction. A principle that does not tell you what to give up is decoration.
|
|
93
93
|
|
|
94
|
-
## R-06 · Tiebreaker 1
|
|
94
|
+
## R-06 · Tiebreaker 1: Prefer the loud failure
|
|
95
95
|
|
|
96
96
|
**Between silent failure and visible failure, choose visible.**
|
|
97
97
|
|
|
98
98
|
A form that discards a submission and shows success is worse than one that errors. A page serving stale data without saying so is worse than a slow one. Silent failure is the most expensive class of defect, because the cost is paid by someone who never finds out.
|
|
99
99
|
|
|
100
|
-
## R-07 · Tiebreaker 2
|
|
100
|
+
## R-07 · Tiebreaker 2: Never destroy on suspicion
|
|
101
101
|
|
|
102
102
|
**When the system suspects input is wrong, mark it and hold it. Do not discard it.**
|
|
103
103
|
|
|
104
104
|
Spam scores, validation failures, duplicate detection — all heuristics, all wrong sometimes. Hold the item, record why, let a person decide. Applies equally to the user's typing: never clear a form, drop a draft, or overwrite without a copy.
|
|
105
105
|
|
|
106
|
-
## R-08 · Tiebreaker 3
|
|
106
|
+
## R-08 · Tiebreaker 3: Recoverable beats correct
|
|
107
107
|
|
|
108
108
|
**Between preventing a mistake and allowing it to be undone, choose undo.**
|
|
109
109
|
|
|
110
110
|
Prevention charges every user friction on every interaction to guard against a rare error. Recovery costs nothing until the error happens. Exception: genuinely irreversible operations, which confirm — and in `operator`, confirm by typing.
|
|
111
111
|
|
|
112
|
-
## R-09 · Tiebreaker 4
|
|
112
|
+
## R-09 · Tiebreaker 4: Optimise for who is actually there
|
|
113
113
|
|
|
114
114
|
**When density and legibility conflict, decide by the user's real conditions, not by preference.**
|
|
115
115
|
|
|
116
116
|
A first-time visitor on mobile data in bright sun and an operator at a large display for eight hours need opposite things. Mode encodes this. When the mode is genuinely unclear, ask — do not average, because the average serves neither.
|
|
117
117
|
|
|
118
|
-
## R-10 · Tiebreaker 5
|
|
118
|
+
## R-10 · Tiebreaker 5: Restraint is the default
|
|
119
119
|
|
|
120
120
|
**When a decision has not been made, ship the plainer thing and surface the question.**
|
|
121
121
|
|
|
@@ -125,13 +125,13 @@ An invented accent, a decorative animation, a gradient filling an empty space
|
|
|
125
125
|
|
|
126
126
|
**Ceiling: restraint applies to decoration, never to information.** Minimal is not the same as simple. A sparse interface that has dropped labels, selected states or visible actions is harder to use than a busier one that keeps them — it just photographs better. Strip styling freely; never strip the answers to *what is this*, *which one is selected*, and *what can I do next* (`E-63`).
|
|
127
127
|
|
|
128
|
-
## R-11 · Tiebreaker 6
|
|
128
|
+
## R-11 · Tiebreaker 6: The platform before the framework
|
|
129
129
|
|
|
130
130
|
**When the browser can already do it, use the browser.**
|
|
131
131
|
|
|
132
132
|
`<dialog>`, `<details>`, `position: sticky`, `:has()`, container queries, native form validation, `popover`. Platform features carry accessibility, keyboard handling and state management that a reimplementation gets wrong and then needs maintaining.
|
|
133
133
|
|
|
134
|
-
## R-12 · Tiebreaker 7
|
|
134
|
+
## R-12 · Tiebreaker 7: Match the codebase before matching this document
|
|
135
135
|
|
|
136
136
|
**When local convention conflicts with these rules, local convention wins.**
|
|
137
137
|
|
package/rules/05-copy.md
CHANGED
|
@@ -89,6 +89,7 @@ Whichever you choose, be consistent across sibling elements — a list where thr
|
|
|
89
89
|
Interface text is read in fragments, at a glance, in a space someone else's content has to fit too. An em dash is a pause the reader has to interpret: it stands in for a comma, a colon, a bracket or a full stop, and which one it is only becomes clear after reading past it. The punctuation that says exactly one thing is faster.
|
|
90
90
|
It is also the clearest tell of machine-written copy. Generated text reaches for the em dash far more often than a person does, and readers have learned to notice. Copy that reads as generated is copy the reader trusts less, whatever it says.
|
|
91
91
|
This is about interface strings: labels, buttons, headings, errors, empty states, help text, and the prose a page ships. Markdown counts where a framework renders it as a page, which is most of them (`src/content`, `content/`, MDX routes). A repository document does not: `README.md`, `CHANGELOG.md`, `AGENTS.md` and their kin are written for whoever works on the code, and so are your commit messages and this file.
|
|
92
|
+
A verbatim quotation keeps its own punctuation. Text a page quotes from another source (a document, a spec, a person), marked as a quotation with `<blockquote>` or `<q>`, is that source's words, and changing them to satisfy this rule would misquote it. The page's own copy around the quotation, and any heading or label it gives it, is still held to the rule.
|
|
92
93
|
The en dash keeps its one job: ranges, where it is read as "to" (`2–10 seats`, `Mon–Fri`). That is not a pause, and it is not affected.
|
|
93
94
|
|
|
94
95
|
### I-87 Inconsistent vocabulary
|