jig-ui 0.24.0 → 0.25.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 +102 -0
- package/README.md +14 -10
- package/dist/index.js +249 -165
- package/examples/J-164.html +7 -0
- package/package.json +2 -2
- package/rules/00-anti-patterns.md +8 -2
- package/rules/01-modes.md +2 -2
- package/rules/03-patterns.md +14 -4
- package/rules/05-copy.md +1 -1
- package/rules.index.json +7 -0
- package/templates/COMMAND.md.tmpl +72 -7
- package/templates/SKILL.md.tmpl +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,107 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.25.0 (2026-10-09)
|
|
4
|
+
|
|
5
|
+
Link previews, code and tables that reflow on an `editorial` phone, and the
|
|
6
|
+
gate's rules stated before a session stops.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **The render probe measures a box that scrolls sideways.** `editorial`
|
|
11
|
+
allows no scrolling region on a phone (`M-01`), and the probe only measured
|
|
12
|
+
a page wider than the screen. In a control run on this release's reflow
|
|
13
|
+
text, a spec said the code would wrap and the built page scrolled it in a
|
|
14
|
+
box, and nothing caught it. The probe now records each box that scrolls
|
|
15
|
+
sideways, and on an `editorial` surface at phone width each is a measured
|
|
16
|
+
failure: a critique reports it, `ship` counts it, and the gate holds make and
|
|
17
|
+
tweak until the page reflows.
|
|
18
|
+
- **J-164: an indexable page a link preview cannot read.** A page pasted into a
|
|
19
|
+
message or a post is met as a card before anyone opens it, and the card is
|
|
20
|
+
built from `og:title` and `og:description`. Section J said it covered the link
|
|
21
|
+
preview, and no rule asked for one, so `seo` and `check` passed a site with no
|
|
22
|
+
preview on any page. `check` reports it in a file that writes its own
|
|
23
|
+
`<head>`, and in a head fragment (`<svelte:head>`, Astro, `next/head`) that
|
|
24
|
+
writes the page's own description and no `og:description`, which no layout
|
|
25
|
+
can supply; elsewhere a layout may write it, and the render decides: the probe
|
|
26
|
+
now reads `og:description`, and a served page missing either tag is a
|
|
27
|
+
finding on the page. An image and `og:url` need the page's full address, so
|
|
28
|
+
the rule asks for them once a site has one. 160 rules, 40 of them decided by
|
|
29
|
+
a machine.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- **`editorial` on a phone says what replaces a scrolling box.** `L-01` sent a
|
|
34
|
+
wide code block or table to scroll inside itself, and the same sentence said
|
|
35
|
+
`editorial` allows no scrolling region on mobile, naming nothing in its
|
|
36
|
+
place. `L-01`, `D-115` and `M-01` now say it: a code block keeps its line
|
|
37
|
+
breaks and indents and wraps its long lines, a table too wide for the screen
|
|
38
|
+
stacks its rows with each cell's column name in front and stays a `<table>`,
|
|
39
|
+
and a long identifier breaks where it must.
|
|
40
|
+
- **`E-51`'s near-universal glyphs are a test, not a list.** Close, search and
|
|
41
|
+
menu were read as the only icon-only controls allowed. The rule now asks
|
|
42
|
+
whether most readers would name the glyph the same way without its label,
|
|
43
|
+
with examples that pass (a theme's sun and moon, a well-known service's mark)
|
|
44
|
+
and that do not.
|
|
45
|
+
- **`E-28` says what a browser's own tab stop on an `<iframe>` gets.** One that
|
|
46
|
+
only shows something is taken out of the tab order rather than given states;
|
|
47
|
+
one the reader uses keeps its stop and shows focus.
|
|
48
|
+
- **spec, mockup and tweak each end with what the gate holds them to.** A short
|
|
49
|
+
"Before you stop" list names the checks the Stop hook makes on what that
|
|
50
|
+
command records, beyond the shape its steps already teach: where it may write, whose words a quotation is, the check record's
|
|
51
|
+
format, what counts as the owner's confirmation or approval, and what
|
|
52
|
+
`tweak.json` holds. Sessions learned these from refusals, and each refusal
|
|
53
|
+
cost a round of fixing, re-checking and committing. A test pairs each check
|
|
54
|
+
with its line in the list, so a check added without one fails.
|
|
55
|
+
- **`jig check` exits non-zero on a mechanical error, with or without `--ci`.**
|
|
56
|
+
It used to exit 0 whatever it found, so a pre-commit hook, a CI step or a
|
|
57
|
+
script running it as a linter read a run full of errors as a pass. Judgment
|
|
58
|
+
findings are still reported without failing the run. `--ci` now only limits
|
|
59
|
+
the run to the mechanical rules.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **The skill and `spec` say `L-01` has six steps, and `spec` names the sixth.**
|
|
64
|
+
`L-01` gained step 6, how a screen collapses, and both kept telling the agent
|
|
65
|
+
to run "its five steps", so it stopped before the one that gives the phone
|
|
66
|
+
composition. In a control run on this release's reflow text, a spec chose a
|
|
67
|
+
sideways-scrolling code block for an `editorial` phone. A test now ties the
|
|
68
|
+
count to `L-01`'s own step headings.
|
|
69
|
+
- **`I-118` exempts quoted output in the rule and in `check`, as the probe
|
|
70
|
+
already did.** The rule exempted a quotation in `<blockquote>` or `<q>`; the
|
|
71
|
+
render probe also leaves `<pre>`, `<code>`, `<samp>` and `<kbd>` alone, and
|
|
72
|
+
`check` exempted none of them. A page quoting a tool's real output was told
|
|
73
|
+
by `check` to rewrite it, and by the rule to keep the quotation's own
|
|
74
|
+
punctuation. All three now agree.
|
|
75
|
+
- **A critique is no longer held on what the page fails.** An em dash on the
|
|
76
|
+
rendered page, a missing or overlong title or description, unresolved tokens
|
|
77
|
+
or the browser's default font are failures of the page, not of the review.
|
|
78
|
+
`verdicts` reported them as a critique left incomplete, so the gate held the
|
|
79
|
+
critique session until they were gone, and the session could clear them only
|
|
80
|
+
by editing the page, which a critique never does. They are now listed apart,
|
|
81
|
+
under `!`, as `measured=` on the `JIG_VERDICTS` line, and once each rather than
|
|
82
|
+
at every width. A critique reports them and finishes; `ship` counts them as
|
|
83
|
+
findings, and the gate holds make and tweak until the page is fixed. A verdict
|
|
84
|
+
the probe contradicts still means the review re-runs.
|
|
85
|
+
- **A quoted `mockup:` value reads as the value it holds.** A spec that wrote
|
|
86
|
+
`mockup: "skipped — the owner: \"…\""`, valid YAML, read to the gate as the
|
|
87
|
+
word `"skipped`, so every tweak of the page was refused for having no approved
|
|
88
|
+
drawing, and the check that the owner's words are quoted was silently skipped.
|
|
89
|
+
- **An em dash inside tool output no longer counts as the page's own.** The
|
|
90
|
+
probe leaves quotations and `<pre>`, `<code>`, `<samp>` and `<kbd>` out of the
|
|
91
|
+
dash scan, but it cut them out of the page's text one string at a time. An
|
|
92
|
+
inline `<code>` earlier in the prose, such as a rule id, removed those
|
|
93
|
+
characters from the output block below it, the block no longer matched, and
|
|
94
|
+
its dash was reported. Quotations are now left out of the text as it is read.
|
|
95
|
+
- **The README no longer says every command is also a slash command.** `gate`
|
|
96
|
+
has no slash form (only the Stop hook runs it), and six slash commands are
|
|
97
|
+
agent procedures with no command of their own in the CLI. The table under it
|
|
98
|
+
was right; the sentence above it now says what the table does.
|
|
99
|
+
- **`check` no longer says it examined nothing while reporting findings.** A
|
|
100
|
+
file styled only with utility classes, such as `class="p-[13px]"`, is read by
|
|
101
|
+
H-47 but was left out of the styled-file count. A change to such a file
|
|
102
|
+
reported H-47 errors and, below them, that no file carried a style region. The
|
|
103
|
+
count now includes markup whose class attributes hold classes.
|
|
104
|
+
|
|
3
105
|
## 0.24.0 (2026-09-30)
|
|
4
106
|
|
|
5
107
|
The owner's word held to one rule, Jig's instructions in your project's AGENTS.md,
|
package/README.md
CHANGED
|
@@ -27,13 +27,13 @@ later.
|
|
|
27
27
|
Jig is **a skill your coding agent reads**, and **a CLI you can run yourself**.
|
|
28
28
|
They are two halves of the same thing, and the split is not arbitrary:
|
|
29
29
|
|
|
30
|
-
- Of the
|
|
30
|
+
- Of the 160 rules, **40 can be decided by a machine** — a hard-coded colour, a
|
|
31
31
|
contrast ratio below the floor, a removed focus ring. The CLI decides those.
|
|
32
32
|
- The other **120 are judgment** — whether an empty state says anything useful,
|
|
33
33
|
whether a label reads as an instruction, whether motion earns its place. No
|
|
34
34
|
regex settles those. An agent reads the rules and applies them.
|
|
35
35
|
|
|
36
|
-
Running only the CLI gets you the
|
|
36
|
+
Running only the CLI gets you the 40. Running only the agent gets you the 120 with
|
|
37
37
|
no verification. **A clean `jig check` is not a clean review**, and the skill
|
|
38
38
|
says so to every agent that reads it.
|
|
39
39
|
|
|
@@ -406,7 +406,7 @@ overwrites a config or brand file you have edited.
|
|
|
406
406
|
| --- | --- |
|
|
407
407
|
| `install --agent <name> [--scope project\|global] [--hook]` | Puts the skill and its rules where your agent will find them. At project scope, also keeps Jig's block in `AGENTS.md` (and `CLAUDE.md` for Claude Code); with `--hook`, adds the Stop hook. |
|
|
408
408
|
| `init [--yes]` | Sets the project up: CSS system, brand colour, token files, `jig.config.json`, wired imports, baseline check. |
|
|
409
|
-
| `check [--all] [--ci] [--json]` | Runs the rules a machine can decide. Reports findings by rule id. |
|
|
409
|
+
| `check [--all] [--ci] [--json]` | Runs the rules a machine can decide. Reports findings by rule id, and exits non-zero on any mechanical error. |
|
|
410
410
|
| `update` | Refreshes an install to a newer version, leaving alone any file you have edited. |
|
|
411
411
|
| `seo [--json]` | Audits what a search engine and a link preview read, across the whole project: a route whose metadata says `noindex` sitting in the sitemap, two pages claiming one title, a sitemap that lists nothing or lists paths a crawler drops. Whether a sitemap and a robots file exist is counted, not reported: no rule asks for either, and a site with no domain yet cannot write an honest sitemap. Needs no config, no decisions and no spec. |
|
|
412
412
|
| `checksum <spec>` | Prints a spec's checksum, the value its `.checked.json` records as `spec`, so the spec's reader never computes it by hand. |
|
|
@@ -422,7 +422,7 @@ Flags worth knowing:
|
|
|
422
422
|
| --- | --- |
|
|
423
423
|
| `check --all` | Scan the whole repo instead of just changed files. Use on a first run. |
|
|
424
424
|
| `explain --list` | Every rule id and title. Add a section letter (`explain G --list`) for one section. |
|
|
425
|
-
| `check --ci` | Mechanical bucket only
|
|
425
|
+
| `check --ci` | Mechanical bucket only, so the run is deterministic. |
|
|
426
426
|
| `check --json` | Machine-readable findings, for tooling or for reading every finding when the terminal output elides repeats. |
|
|
427
427
|
| `init --yes` | Non-interactive; accept every derived default. |
|
|
428
428
|
| `install --scope global` | Install once for every project. |
|
|
@@ -435,9 +435,12 @@ mean it could never move.
|
|
|
435
435
|
|
|
436
436
|
### As slash commands
|
|
437
437
|
|
|
438
|
-
Every command
|
|
439
|
-
|
|
440
|
-
on the result — the CLI reports, the
|
|
438
|
+
Every command but `gate`, which only the Stop hook runs, is also a slash command
|
|
439
|
+
in your agent, installed alongside the skill. `/jig check --all` does what
|
|
440
|
+
`npx jig-ui check --all` does, and then acts on the result — the CLI reports, the
|
|
441
|
+
agent applies the judgment half. Six slash commands are agent procedures with no
|
|
442
|
+
command of their own in the CLI: `decide`, `spec`, `mockup`, `make`, `critique` and
|
|
443
|
+
`tweak` (`critique` runs `verdicts` and `probe` along the way).
|
|
441
444
|
|
|
442
445
|
| Slash command | Equivalent |
|
|
443
446
|
| --- | --- |
|
|
@@ -642,7 +645,8 @@ metadata in the same change as the headline, and `check` holds the budgets.
|
|
|
642
645
|
|
|
643
646
|
**Which pages are meant to be found is decided by mode, not by taste.**
|
|
644
647
|
`editorial` is first-visit content, so it is indexable and needs its own title
|
|
645
|
-
and description
|
|
648
|
+
and description, and its own link preview (`og:title` and `og:description`) for
|
|
649
|
+
the card a pasted link shows. `product` and `operator` are what somebody reaches after signing
|
|
646
650
|
in, so they must carry `noindex` — an admin screen in a search result is an
|
|
647
651
|
invitation, and a sign-in page in one invites credential stuffing. A spec
|
|
648
652
|
overrides the default per page, with a reason: a CV shared by link, a
|
|
@@ -697,7 +701,7 @@ No agent required. `check` is a linter with a design system behind it.
|
|
|
697
701
|
```bash
|
|
698
702
|
npx jig-ui@latest check --all # everything
|
|
699
703
|
npx jig-ui@latest check # just what changed
|
|
700
|
-
npx jig-ui@latest check --ci # for CI:
|
|
704
|
+
npx jig-ui@latest check --ci # for CI: mechanical rules only, deterministic
|
|
701
705
|
npx jig-ui@latest check --json # for tooling
|
|
702
706
|
```
|
|
703
707
|
|
|
@@ -793,7 +797,7 @@ treatment.
|
|
|
793
797
|
|
|
794
798
|
| File | Contents |
|
|
795
799
|
| --- | --- |
|
|
796
|
-
| `rules/00-anti-patterns.md` |
|
|
800
|
+
| `rules/00-anti-patterns.md` | 129 universal rules with corrections |
|
|
797
801
|
| `rules/01-modes.md` | `editorial` / `product` / `operator` profiles |
|
|
798
802
|
| `rules/02-tokens.md` | Token contract, naming, consumption |
|
|
799
803
|
| `rules/03-patterns.md` | Component anatomy and behaviour |
|
package/dist/index.js
CHANGED
|
@@ -2098,6 +2098,139 @@ function isReaderText(file) {
|
|
|
2098
2098
|
return hasExtension(file, STYLE_HOST_EXTENSIONS);
|
|
2099
2099
|
}
|
|
2100
2100
|
|
|
2101
|
+
// src/check/length.ts
|
|
2102
|
+
var LENGTH_UNITS = [
|
|
2103
|
+
"px",
|
|
2104
|
+
"rem",
|
|
2105
|
+
"em",
|
|
2106
|
+
"pt",
|
|
2107
|
+
"vh",
|
|
2108
|
+
"vw",
|
|
2109
|
+
"vmin",
|
|
2110
|
+
"vmax",
|
|
2111
|
+
"ch",
|
|
2112
|
+
"ex"
|
|
2113
|
+
];
|
|
2114
|
+
var UNITS = LENGTH_UNITS.join("|");
|
|
2115
|
+
var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
|
|
2116
|
+
var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
|
|
2117
|
+
function isExcludedLength(value, unit) {
|
|
2118
|
+
if (value === 0) return true;
|
|
2119
|
+
return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
|
|
2120
|
+
}
|
|
2121
|
+
function hasHardCodedLength(value) {
|
|
2122
|
+
LENGTH_SCAN_RE.lastIndex = 0;
|
|
2123
|
+
let m;
|
|
2124
|
+
while (m = LENGTH_SCAN_RE.exec(value)) {
|
|
2125
|
+
if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
|
|
2126
|
+
}
|
|
2127
|
+
return false;
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
// src/check/tailwind.ts
|
|
2131
|
+
var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
|
|
2132
|
+
function readBraced(source, open) {
|
|
2133
|
+
let depth = 0;
|
|
2134
|
+
for (let i = open; i < source.length; i++) {
|
|
2135
|
+
const ch = source[i];
|
|
2136
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2137
|
+
const quote = ch;
|
|
2138
|
+
i++;
|
|
2139
|
+
while (i < source.length && source[i] !== quote) {
|
|
2140
|
+
if (source[i] === "\\") i++;
|
|
2141
|
+
i++;
|
|
2142
|
+
}
|
|
2143
|
+
continue;
|
|
2144
|
+
}
|
|
2145
|
+
if (ch === "{") depth++;
|
|
2146
|
+
else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
|
|
2147
|
+
}
|
|
2148
|
+
return null;
|
|
2149
|
+
}
|
|
2150
|
+
function classAttributeValues(source) {
|
|
2151
|
+
const out = [];
|
|
2152
|
+
const lineAt = (offset) => source.slice(0, offset).split("\n").length;
|
|
2153
|
+
for (const m of source.matchAll(CLASS_ATTR)) {
|
|
2154
|
+
const at = m.index + m[0].length;
|
|
2155
|
+
const opener = source[at];
|
|
2156
|
+
if (opener === '"' || opener === "'") {
|
|
2157
|
+
const end = source.indexOf(opener, at + 1);
|
|
2158
|
+
if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
|
|
2159
|
+
continue;
|
|
2160
|
+
}
|
|
2161
|
+
if (opener !== "{") continue;
|
|
2162
|
+
const raw = readBraced(source, at);
|
|
2163
|
+
if (raw === null) continue;
|
|
2164
|
+
const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
|
|
2165
|
+
(q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
|
|
2166
|
+
);
|
|
2167
|
+
if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
|
|
2168
|
+
}
|
|
2169
|
+
return out;
|
|
2170
|
+
}
|
|
2171
|
+
var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
|
|
2172
|
+
var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
|
|
2173
|
+
var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
|
|
2174
|
+
function arbitraryValues(classes) {
|
|
2175
|
+
const out = [];
|
|
2176
|
+
for (const m of classes.matchAll(ARBITRARY)) {
|
|
2177
|
+
const [, utility, rawValue] = m;
|
|
2178
|
+
const value = rawValue.replace(/_/g, " ");
|
|
2179
|
+
if (/var\(|--/.test(value)) continue;
|
|
2180
|
+
if (COLOUR.test(value)) {
|
|
2181
|
+
out.push({ utility, value, kind: "colour" });
|
|
2182
|
+
continue;
|
|
2183
|
+
}
|
|
2184
|
+
if (!LENGTH_RE.test(value)) continue;
|
|
2185
|
+
const parts = LENGTH_PARTS.exec(value);
|
|
2186
|
+
if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
|
|
2187
|
+
out.push({ utility, value, kind: "length" });
|
|
2188
|
+
}
|
|
2189
|
+
return out;
|
|
2190
|
+
}
|
|
2191
|
+
var PALETTE = {
|
|
2192
|
+
white: "#ffffff",
|
|
2193
|
+
black: "#000000",
|
|
2194
|
+
"gray-50": "#f9fafb",
|
|
2195
|
+
"gray-100": "#f3f4f6",
|
|
2196
|
+
"gray-200": "#e5e7eb",
|
|
2197
|
+
"gray-300": "#d1d5db",
|
|
2198
|
+
"gray-400": "#9ca3af",
|
|
2199
|
+
"gray-500": "#6b7280",
|
|
2200
|
+
"gray-600": "#4b5563",
|
|
2201
|
+
"gray-700": "#374151",
|
|
2202
|
+
"gray-800": "#1f2937",
|
|
2203
|
+
"gray-900": "#111827",
|
|
2204
|
+
"gray-950": "#030712",
|
|
2205
|
+
"slate-50": "#f8fafc",
|
|
2206
|
+
"slate-400": "#94a3b8",
|
|
2207
|
+
"slate-500": "#64748b",
|
|
2208
|
+
"slate-800": "#1e293b",
|
|
2209
|
+
"slate-900": "#0f172a",
|
|
2210
|
+
"slate-950": "#020617",
|
|
2211
|
+
"zinc-400": "#a1a1aa",
|
|
2212
|
+
"zinc-500": "#71717a",
|
|
2213
|
+
"zinc-900": "#18181b",
|
|
2214
|
+
"neutral-400": "#a3a3a3",
|
|
2215
|
+
"neutral-500": "#737373",
|
|
2216
|
+
"neutral-900": "#171717",
|
|
2217
|
+
"stone-400": "#a8a29e",
|
|
2218
|
+
"stone-500": "#78716c",
|
|
2219
|
+
"stone-900": "#1c1917"
|
|
2220
|
+
};
|
|
2221
|
+
function palettePairs(classes) {
|
|
2222
|
+
let background;
|
|
2223
|
+
let foreground;
|
|
2224
|
+
for (const token of classes.split(/\s+/)) {
|
|
2225
|
+
const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
|
|
2226
|
+
const bg = /^bg-(.+)$/.exec(bare2);
|
|
2227
|
+
const fg = /^text-(.+)$/.exec(bare2);
|
|
2228
|
+
if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
|
|
2229
|
+
if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
|
|
2230
|
+
}
|
|
2231
|
+
return background && foreground ? [{ background, foreground }] : [];
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2101
2234
|
// src/check/run.ts
|
|
2102
2235
|
import { readFileSync as readFileSync15 } from "fs";
|
|
2103
2236
|
import { join as join19 } from "path";
|
|
@@ -2904,139 +3037,6 @@ var pureBlackWhite = {
|
|
|
2904
3037
|
}
|
|
2905
3038
|
};
|
|
2906
3039
|
|
|
2907
|
-
// src/check/length.ts
|
|
2908
|
-
var LENGTH_UNITS = [
|
|
2909
|
-
"px",
|
|
2910
|
-
"rem",
|
|
2911
|
-
"em",
|
|
2912
|
-
"pt",
|
|
2913
|
-
"vh",
|
|
2914
|
-
"vw",
|
|
2915
|
-
"vmin",
|
|
2916
|
-
"vmax",
|
|
2917
|
-
"ch",
|
|
2918
|
-
"ex"
|
|
2919
|
-
];
|
|
2920
|
-
var UNITS = LENGTH_UNITS.join("|");
|
|
2921
|
-
var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
|
|
2922
|
-
var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
|
|
2923
|
-
function isExcludedLength(value, unit) {
|
|
2924
|
-
if (value === 0) return true;
|
|
2925
|
-
return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
|
|
2926
|
-
}
|
|
2927
|
-
function hasHardCodedLength(value) {
|
|
2928
|
-
LENGTH_SCAN_RE.lastIndex = 0;
|
|
2929
|
-
let m;
|
|
2930
|
-
while (m = LENGTH_SCAN_RE.exec(value)) {
|
|
2931
|
-
if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
|
|
2932
|
-
}
|
|
2933
|
-
return false;
|
|
2934
|
-
}
|
|
2935
|
-
|
|
2936
|
-
// src/check/tailwind.ts
|
|
2937
|
-
var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
|
|
2938
|
-
function readBraced(source, open) {
|
|
2939
|
-
let depth = 0;
|
|
2940
|
-
for (let i = open; i < source.length; i++) {
|
|
2941
|
-
const ch = source[i];
|
|
2942
|
-
if (ch === '"' || ch === "'" || ch === "`") {
|
|
2943
|
-
const quote = ch;
|
|
2944
|
-
i++;
|
|
2945
|
-
while (i < source.length && source[i] !== quote) {
|
|
2946
|
-
if (source[i] === "\\") i++;
|
|
2947
|
-
i++;
|
|
2948
|
-
}
|
|
2949
|
-
continue;
|
|
2950
|
-
}
|
|
2951
|
-
if (ch === "{") depth++;
|
|
2952
|
-
else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
|
|
2953
|
-
}
|
|
2954
|
-
return null;
|
|
2955
|
-
}
|
|
2956
|
-
function classAttributeValues(source) {
|
|
2957
|
-
const out = [];
|
|
2958
|
-
const lineAt = (offset) => source.slice(0, offset).split("\n").length;
|
|
2959
|
-
for (const m of source.matchAll(CLASS_ATTR)) {
|
|
2960
|
-
const at = m.index + m[0].length;
|
|
2961
|
-
const opener = source[at];
|
|
2962
|
-
if (opener === '"' || opener === "'") {
|
|
2963
|
-
const end = source.indexOf(opener, at + 1);
|
|
2964
|
-
if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
|
|
2965
|
-
continue;
|
|
2966
|
-
}
|
|
2967
|
-
if (opener !== "{") continue;
|
|
2968
|
-
const raw = readBraced(source, at);
|
|
2969
|
-
if (raw === null) continue;
|
|
2970
|
-
const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
|
|
2971
|
-
(q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
|
|
2972
|
-
);
|
|
2973
|
-
if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
|
|
2974
|
-
}
|
|
2975
|
-
return out;
|
|
2976
|
-
}
|
|
2977
|
-
var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
|
|
2978
|
-
var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
|
|
2979
|
-
var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
|
|
2980
|
-
function arbitraryValues(classes) {
|
|
2981
|
-
const out = [];
|
|
2982
|
-
for (const m of classes.matchAll(ARBITRARY)) {
|
|
2983
|
-
const [, utility, rawValue] = m;
|
|
2984
|
-
const value = rawValue.replace(/_/g, " ");
|
|
2985
|
-
if (/var\(|--/.test(value)) continue;
|
|
2986
|
-
if (COLOUR.test(value)) {
|
|
2987
|
-
out.push({ utility, value, kind: "colour" });
|
|
2988
|
-
continue;
|
|
2989
|
-
}
|
|
2990
|
-
if (!LENGTH_RE.test(value)) continue;
|
|
2991
|
-
const parts = LENGTH_PARTS.exec(value);
|
|
2992
|
-
if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
|
|
2993
|
-
out.push({ utility, value, kind: "length" });
|
|
2994
|
-
}
|
|
2995
|
-
return out;
|
|
2996
|
-
}
|
|
2997
|
-
var PALETTE = {
|
|
2998
|
-
white: "#ffffff",
|
|
2999
|
-
black: "#000000",
|
|
3000
|
-
"gray-50": "#f9fafb",
|
|
3001
|
-
"gray-100": "#f3f4f6",
|
|
3002
|
-
"gray-200": "#e5e7eb",
|
|
3003
|
-
"gray-300": "#d1d5db",
|
|
3004
|
-
"gray-400": "#9ca3af",
|
|
3005
|
-
"gray-500": "#6b7280",
|
|
3006
|
-
"gray-600": "#4b5563",
|
|
3007
|
-
"gray-700": "#374151",
|
|
3008
|
-
"gray-800": "#1f2937",
|
|
3009
|
-
"gray-900": "#111827",
|
|
3010
|
-
"gray-950": "#030712",
|
|
3011
|
-
"slate-50": "#f8fafc",
|
|
3012
|
-
"slate-400": "#94a3b8",
|
|
3013
|
-
"slate-500": "#64748b",
|
|
3014
|
-
"slate-800": "#1e293b",
|
|
3015
|
-
"slate-900": "#0f172a",
|
|
3016
|
-
"slate-950": "#020617",
|
|
3017
|
-
"zinc-400": "#a1a1aa",
|
|
3018
|
-
"zinc-500": "#71717a",
|
|
3019
|
-
"zinc-900": "#18181b",
|
|
3020
|
-
"neutral-400": "#a3a3a3",
|
|
3021
|
-
"neutral-500": "#737373",
|
|
3022
|
-
"neutral-900": "#171717",
|
|
3023
|
-
"stone-400": "#a8a29e",
|
|
3024
|
-
"stone-500": "#78716c",
|
|
3025
|
-
"stone-900": "#1c1917"
|
|
3026
|
-
};
|
|
3027
|
-
function palettePairs(classes) {
|
|
3028
|
-
let background;
|
|
3029
|
-
let foreground;
|
|
3030
|
-
for (const token of classes.split(/\s+/)) {
|
|
3031
|
-
const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
|
|
3032
|
-
const bg = /^bg-(.+)$/.exec(bare2);
|
|
3033
|
-
const fg = /^text-(.+)$/.exec(bare2);
|
|
3034
|
-
if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
|
|
3035
|
-
if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
|
|
3036
|
-
}
|
|
3037
|
-
return background && foreground ? [{ background, foreground }] : [];
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
3040
|
// src/check/detectors/contrast-floor.ts
|
|
3041
3041
|
var FLOOR = 4.5;
|
|
3042
3042
|
var LARGE_FLOOR = 3;
|
|
@@ -3631,7 +3631,7 @@ var EM_DASH = /—/;
|
|
|
3631
3631
|
var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
|
|
3632
3632
|
function maskNonProse(source) {
|
|
3633
3633
|
const blank3 = (m) => " ".repeat(m.length);
|
|
3634
|
-
return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3634
|
+
return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<(blockquote|q|pre|code|samp|kbd)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3635
3635
|
}
|
|
3636
3636
|
function markdownProse(source) {
|
|
3637
3637
|
const blank3 = (m) => " ".repeat(m.length);
|
|
@@ -3914,6 +3914,8 @@ function firstGroup(match) {
|
|
|
3914
3914
|
if (!value) return void 0;
|
|
3915
3915
|
return { value, index: match.index ?? 0 };
|
|
3916
3916
|
}
|
|
3917
|
+
var PREVIEW_TITLE = /property\s*=\s*["']og:title["']|(?:^|[\s{,])["']?(?:ogTitle|og:title)["']?\s*:|openGraph\s*:\s*\{[^}]*?\btitle\s*:/;
|
|
3918
|
+
var PREVIEW_DESCRIPTION = /property\s*=\s*["']og:description["']|(?:^|[\s{,])["']?(?:ogDescription|og:description)["']?\s*:|openGraph\s*:\s*\{[^}]*?\bdescription\s*:/;
|
|
3917
3919
|
function readMetadata(source) {
|
|
3918
3920
|
const htmlTitle = firstGroup(HTML_TITLE.exec(source));
|
|
3919
3921
|
const title = htmlTitle ?? firstGroup(KEYED_TITLE.exec(source));
|
|
@@ -3931,6 +3933,9 @@ function readMetadata(source) {
|
|
|
3931
3933
|
description: description && !/^\{|\$\{/.test(description.value) ? description : void 0,
|
|
3932
3934
|
hasTitle: ANY_TITLE.test(source),
|
|
3933
3935
|
hasDescription: ANY_DESCRIPTION.test(source),
|
|
3936
|
+
hasPreviewTitle: PREVIEW_TITLE.test(source),
|
|
3937
|
+
hasPreviewDescription: PREVIEW_DESCRIPTION.test(source),
|
|
3938
|
+
hasPreview: PREVIEW_TITLE.test(source) && PREVIEW_DESCRIPTION.test(source),
|
|
3934
3939
|
declares: DECLARES.test(source),
|
|
3935
3940
|
computedTitle: COMPUTED_TITLE.test(source),
|
|
3936
3941
|
noindex: NOINDEX.some((re) => re.test(source))
|
|
@@ -3943,6 +3948,7 @@ var TITLE_BUDGET = 60;
|
|
|
3943
3948
|
var DESCRIPTION_BUDGET = 155;
|
|
3944
3949
|
|
|
3945
3950
|
// src/check/detectors/metadata.ts
|
|
3951
|
+
var HEAD_FRAGMENT = /<svelte:head[\s>]|<head[\s>]|<Head[\s>]|<Helmet[\s>]|slot\s*=\s*["']head["']/;
|
|
3946
3952
|
var INVENTED_DATE = /(?:lastModified|datePublished|dateModified|publishedTime|modifiedTime)\s*:\s*(?:new Date\(\s*\)|Date\.now\(\s*\))/;
|
|
3947
3953
|
var metadata = {
|
|
3948
3954
|
name: "metadata",
|
|
@@ -3976,9 +3982,19 @@ var metadata = {
|
|
|
3976
3982
|
if (indexable && !found.hasDescription) {
|
|
3977
3983
|
at("J-121", 0, "this page has no meta description \u2014 the search engine writes one from whatever text it finds first, usually the navigation");
|
|
3978
3984
|
}
|
|
3985
|
+
if (indexable && !found.hasPreview) {
|
|
3986
|
+
const missing = [!found.hasPreviewTitle && "og:title", !found.hasPreviewDescription && "og:description"].filter(Boolean).join(" and ");
|
|
3987
|
+
at("J-164", 0, found.hasPreviewTitle || found.hasPreviewDescription ? `this page has no ${missing} \u2014 a link to it, pasted into a message or a post, shows a card with that part guessed` : "this page has no link preview (og:title and og:description) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry");
|
|
3988
|
+
}
|
|
3979
3989
|
if (!indexable && !found.noindex) {
|
|
3980
3990
|
at("J-123", 0, `this surface is ${ctx.mode}, which is what somebody reaches after signing in, and the page carries no noindex \u2014 robots.txt is public and advisory, and is not this`);
|
|
3981
3991
|
}
|
|
3992
|
+
} else {
|
|
3993
|
+
const own = HEAD_FRAGMENT.test(raw) ? HTML_DESCRIPTION.exec(raw) ?? HTML_DESCRIPTION_REVERSED.exec(raw) : null;
|
|
3994
|
+
const indexable = ctx.mode !== "product" && ctx.mode !== "operator";
|
|
3995
|
+
if (indexable && own && found.description && !found.hasPreviewDescription) {
|
|
3996
|
+
at("J-164", own.index, `this file writes the page's description but no og:description${found.hasPreviewTitle ? "" : " or og:title"} \u2014 a link to it, pasted into a message or a post, shows a card without the description written here`);
|
|
3997
|
+
}
|
|
3982
3998
|
}
|
|
3983
3999
|
return findings.filter((f) => f.ruleId === ctx.ruleId);
|
|
3984
4000
|
}
|
|
@@ -4837,6 +4853,9 @@ function auditTokenLayer(projectRoot) {
|
|
|
4837
4853
|
}
|
|
4838
4854
|
|
|
4839
4855
|
// src/commands/check.ts
|
|
4856
|
+
function checkExitCode(result) {
|
|
4857
|
+
return result.hasError ? 1 : 0;
|
|
4858
|
+
}
|
|
4840
4859
|
var LEGACY_INDEX_REL = join25(".jig", "rules.index.json");
|
|
4841
4860
|
function resolveIndexPath(projectRoot) {
|
|
4842
4861
|
const legacy = join25(projectRoot, LEGACY_INDEX_REL);
|
|
@@ -4913,7 +4932,9 @@ function check(opts) {
|
|
|
4913
4932
|
const withStyles = files.filter((f) => {
|
|
4914
4933
|
try {
|
|
4915
4934
|
const src = readFileSync21(join25(opts.projectRoot, f), "utf8");
|
|
4916
|
-
|
|
4935
|
+
if (hasExtension(f, CSS_EXTENSIONS)) return src.trim().length > 0;
|
|
4936
|
+
if (maskNonStyleRegions(src, f).trim().length > 0) return true;
|
|
4937
|
+
return isMarkupHost(f) && classAttributeValues(src).some((a) => a.classes.trim().length > 0);
|
|
4917
4938
|
} catch {
|
|
4918
4939
|
return false;
|
|
4919
4940
|
}
|
|
@@ -6119,12 +6140,18 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6119
6140
|
// the page's own copy around it still counts. So is a tool's output or a
|
|
6120
6141
|
// command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
|
|
6121
6142
|
// quotes \`jig check\`'s real output, whose own wording has an em dash, and
|
|
6122
|
-
// the probe blocked a page for words it had rightly not rewritten.
|
|
6123
|
-
|
|
6124
|
-
|
|
6125
|
-
|
|
6126
|
-
|
|
6127
|
-
|
|
6143
|
+
// the probe blocked a page for words it had rightly not rewritten. The
|
|
6144
|
+
// quotations are hidden while the text is read, not cut out of it by string:
|
|
6145
|
+
// an inline <code>A-01</code> earlier in the prose removed "A-01" from the
|
|
6146
|
+
// output block below it, so the block no longer matched and its dash counted.
|
|
6147
|
+
const quotedEls = [...document.querySelectorAll('blockquote, q, pre, code, samp, kbd')];
|
|
6148
|
+
const hide = document.createElement('style');
|
|
6149
|
+
hide.textContent = '[data-jig-quoted] { display: none !important; }';
|
|
6150
|
+
for (const q of quotedEls) q.setAttribute('data-jig-quoted', '');
|
|
6151
|
+
document.head.appendChild(hide);
|
|
6152
|
+
const ownText = document.body.innerText || '';
|
|
6153
|
+
hide.remove();
|
|
6154
|
+
for (const q of quotedEls) q.removeAttribute('data-jig-quoted');
|
|
6128
6155
|
const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
|
|
6129
6156
|
const unresolved = new Set();
|
|
6130
6157
|
for (const sheet of document.styleSheets) {
|
|
@@ -6294,6 +6321,17 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6294
6321
|
fauxSeen.add(key);
|
|
6295
6322
|
fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
|
|
6296
6323
|
}
|
|
6324
|
+
// M-01: \`editorial\` allows no box that scrolls sideways on a phone; a code
|
|
6325
|
+
// block wraps and a wide table stacks (L-01, step 6). In a control run a
|
|
6326
|
+
// spec said the code would wrap and the built page scrolled it, and nothing
|
|
6327
|
+
// measured the box: the page itself was not wider than the screen.
|
|
6328
|
+
const sidewaysBoxes = [];
|
|
6329
|
+
for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
|
|
6330
|
+
if (sidewaysBoxes.length >= 5) break;
|
|
6331
|
+
const ox = getComputedStyle(el).overflowX;
|
|
6332
|
+
if ((ox !== 'auto' && ox !== 'scroll') || el.scrollWidth <= el.clientWidth + 1 || !vis(el)) continue;
|
|
6333
|
+
sidewaysBoxes.push({ box: el.tagName.toLowerCase() + (el.classList[0] ? '.' + el.classList[0] : ''), text: (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth });
|
|
6334
|
+
}
|
|
6297
6335
|
return JSON.stringify({
|
|
6298
6336
|
jigProbe: ${PROBE_VERSION},
|
|
6299
6337
|
url: location.href,
|
|
@@ -6301,6 +6339,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6301
6339
|
scrollWidth: doc.scrollWidth,
|
|
6302
6340
|
clientWidth: doc.clientWidth,
|
|
6303
6341
|
sidewaysScroll: doc.scrollWidth > doc.clientWidth,
|
|
6342
|
+
sidewaysBoxes,
|
|
6304
6343
|
bodyFont: getComputedStyle(document.body).fontFamily,
|
|
6305
6344
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
6306
6345
|
unresolvedTokens: [...unresolved],
|
|
@@ -6317,6 +6356,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6317
6356
|
canonical: (document.querySelector('link[rel=canonical]') || {}).href || '',
|
|
6318
6357
|
robots: (document.querySelector('meta[name=robots]') || {}).content || '',
|
|
6319
6358
|
ogTitle: (document.querySelector('meta[property="og:title"]') || {}).content || '',
|
|
6359
|
+
ogDescription: (document.querySelector('meta[property="og:description"]') || {}).content || '',
|
|
6320
6360
|
ogImage: (document.querySelector('meta[property="og:image"]') || {}).content || '',
|
|
6321
6361
|
},
|
|
6322
6362
|
contentWidth: Math.round(region.getBoundingClientRect().width),
|
|
@@ -6765,29 +6805,34 @@ function readProbes(projectRoot, dir, errors) {
|
|
|
6765
6805
|
}
|
|
6766
6806
|
var TITLE_BUDGET2 = 60;
|
|
6767
6807
|
var DESCRIPTION_BUDGET2 = 155;
|
|
6768
|
-
function
|
|
6808
|
+
function probeCheck(probes, verdictOf, indexable = true, mode) {
|
|
6769
6809
|
const errors = [];
|
|
6810
|
+
const failures = [];
|
|
6770
6811
|
const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
|
|
6771
6812
|
const at = (p) => `probe-${p.width}.json`;
|
|
6772
6813
|
for (const p of probes) {
|
|
6773
6814
|
if (p.sidewaysScroll && clean("D-115")) {
|
|
6774
6815
|
errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
|
|
6775
6816
|
}
|
|
6817
|
+
if (mode === "editorial" && p.width <= 480 && p.sidewaysBoxes?.length) {
|
|
6818
|
+
const shown = p.sidewaysBoxes.map((b) => `${b.box}${b.text ? ` ("${b.text}")` : ""}, ${b.scrollWidth}px in ${b.clientWidth}px`).join("; ");
|
|
6819
|
+
failures.push(`${at(p)}: ${p.sidewaysBoxes.length === 1 ? "a box scrolls" : `${p.sidewaysBoxes.length} boxes scroll`} sideways on a phone: ${shown}. \`editorial\` allows no scrolling region on mobile (M-01): a code block wraps its long lines, keeping its line breaks and indents, and a wide table stacks its rows (L-01, step 6).`);
|
|
6820
|
+
}
|
|
6776
6821
|
if (p.defaultFont) {
|
|
6777
|
-
|
|
6822
|
+
failures.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
|
|
6778
6823
|
}
|
|
6779
6824
|
if (p.fauxFaces?.length) {
|
|
6780
6825
|
const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
|
|
6781
|
-
|
|
6826
|
+
failures.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
|
|
6782
6827
|
}
|
|
6783
6828
|
if (p.unresolvedTokens.length) {
|
|
6784
|
-
|
|
6829
|
+
failures.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
|
|
6785
6830
|
}
|
|
6786
6831
|
for (const inv of p.orderInversions ?? []) {
|
|
6787
|
-
|
|
6832
|
+
failures.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
|
|
6788
6833
|
}
|
|
6789
6834
|
if (p.emDashes?.length) {
|
|
6790
|
-
|
|
6835
|
+
failures.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
|
|
6791
6836
|
}
|
|
6792
6837
|
if (clean("B-106") && p.strandedCount) {
|
|
6793
6838
|
const shown = (p.strandedWords ?? []).map((w) => `"${w.word}" (in "${w.text}\u2026")`).join(", ");
|
|
@@ -6801,17 +6846,21 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6801
6846
|
if (head && p.width === probes[0]?.width) {
|
|
6802
6847
|
const noindex = /noindex/i.test(head.robots);
|
|
6803
6848
|
if (indexable && !noindex) {
|
|
6804
|
-
if (!head.title)
|
|
6805
|
-
else if (head.title.length > TITLE_BUDGET2)
|
|
6806
|
-
if (!head.description)
|
|
6807
|
-
else if (head.description.length > DESCRIPTION_BUDGET2)
|
|
6849
|
+
if (!head.title) failures.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
|
|
6850
|
+
else if (head.title.length > TITLE_BUDGET2) failures.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
|
|
6851
|
+
if (!head.description) failures.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
|
|
6852
|
+
else if (head.description.length > DESCRIPTION_BUDGET2) failures.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
|
|
6853
|
+
if (typeof head.ogDescription === "string" && (!head.ogTitle || !head.ogDescription)) {
|
|
6854
|
+
const missing = [!head.ogTitle && "og:title", !head.ogDescription && "og:description"].filter(Boolean).join(" and ");
|
|
6855
|
+
failures.push(`${at(p)}: the page served no ${missing} (J-164) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry.`);
|
|
6856
|
+
}
|
|
6808
6857
|
}
|
|
6809
6858
|
if (!indexable && !noindex) {
|
|
6810
|
-
|
|
6859
|
+
failures.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
|
|
6811
6860
|
}
|
|
6812
6861
|
}
|
|
6813
6862
|
if (p.junkText.length) {
|
|
6814
|
-
|
|
6863
|
+
failures.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
|
|
6815
6864
|
}
|
|
6816
6865
|
}
|
|
6817
6866
|
const navClean = clean("P-14") || clean("E-61");
|
|
@@ -6842,7 +6891,7 @@ function probeContradictions(probes, verdictOf, indexable = true) {
|
|
|
6842
6891
|
if (broken.length) errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} operated the menu: ${broken.join("; ")}.`);
|
|
6843
6892
|
}
|
|
6844
6893
|
}
|
|
6845
|
-
return errors;
|
|
6894
|
+
return { contradictions: errors, failures };
|
|
6846
6895
|
}
|
|
6847
6896
|
|
|
6848
6897
|
// src/check/decisions.ts
|
|
@@ -7336,6 +7385,7 @@ function verifyVerdicts(opts) {
|
|
|
7336
7385
|
const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
|
|
7337
7386
|
return typeof v?.verdict === "string" ? v.verdict : void 0;
|
|
7338
7387
|
};
|
|
7388
|
+
const measured = [];
|
|
7339
7389
|
let specFront = "";
|
|
7340
7390
|
try {
|
|
7341
7391
|
specFront = readText(join38(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`)).split(/^---\s*$/m)[1] ?? "";
|
|
@@ -7344,14 +7394,24 @@ function verifyVerdicts(opts) {
|
|
|
7344
7394
|
if (specIndexableField(specFront) === "unreadable") {
|
|
7345
7395
|
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.`);
|
|
7346
7396
|
} else {
|
|
7347
|
-
|
|
7397
|
+
const mode = /^\s*mode\s*:\s*["']?(\w+)/im.exec(specFront)?.[1]?.toLowerCase();
|
|
7398
|
+
const probed = probeCheck(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface), mode);
|
|
7399
|
+
errors.push(...probed.contradictions);
|
|
7400
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7401
|
+
for (const failure of probed.failures) {
|
|
7402
|
+
const what = failure.replace(/^probe-\d+\.json: /, "");
|
|
7403
|
+
if (!seen.has(what)) {
|
|
7404
|
+
seen.add(what);
|
|
7405
|
+
measured.push(failure);
|
|
7406
|
+
}
|
|
7407
|
+
}
|
|
7348
7408
|
}
|
|
7349
7409
|
if (screen.state === "ran" && !rendered) screen.state = "skipped";
|
|
7350
7410
|
const decisions = checkDecisions(opts.projectRoot, dir, errors);
|
|
7351
7411
|
const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
|
|
7352
|
-
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
|
|
7412
|
+
const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}${measured.length ? ` measured=${measured.length}` : ""}`;
|
|
7353
7413
|
const previous = previousFindings(opts.projectRoot, dir);
|
|
7354
|
-
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
|
|
7414
|
+
return { ok: errors.length === 0, errors, screen, code, decisions, rendered, measured, line, ...previous ? { previous } : {} };
|
|
7355
7415
|
}
|
|
7356
7416
|
function previousFindings(projectRoot, dir) {
|
|
7357
7417
|
const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -7816,6 +7876,10 @@ function pageStatus(projectRoot, surface) {
|
|
|
7816
7876
|
if (stale) return { surface, state: "reprobe", detail: `its page is as judged, but the styles or scripts it loads changed: re-probe it (\`jig verdicts ${surface} --reprobe\`)` };
|
|
7817
7877
|
if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
|
|
7818
7878
|
const findings = v.screen.findings + v.code.findings + v.decisions.findings;
|
|
7879
|
+
if (v.measured.length) {
|
|
7880
|
+
const also = findings > 0 ? `, and ${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` : "";
|
|
7881
|
+
return { surface, state: "findings", detail: `the page fails ${v.measured.length} check${v.measured.length === 1 ? "" : "s"} the render probe measured (\`jig verdicts ${surface}\` lists them)${also}` };
|
|
7882
|
+
}
|
|
7819
7883
|
if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
|
|
7820
7884
|
return { surface, state: "judged", detail: "judged as it stands, nothing open" };
|
|
7821
7885
|
}
|
|
@@ -7879,7 +7943,15 @@ function ownerWordProblem(word, quoted, owner, where) {
|
|
|
7879
7943
|
function specChecksum(body) {
|
|
7880
7944
|
return createHash2("sha256").update(body.replace(/\r\n/g, "\n").replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
|
|
7881
7945
|
}
|
|
7882
|
-
|
|
7946
|
+
function mockupField(body) {
|
|
7947
|
+
const raw = /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
|
|
7948
|
+
const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
|
|
7949
|
+
if (double) return double[1].replace(/\\(.)/g, "$1").trim();
|
|
7950
|
+
const single = /^'((?:[^']|'')*)'\s*(?:#.*)?$/.exec(raw);
|
|
7951
|
+
if (single) return single[1].replace(/''/g, "'").trim();
|
|
7952
|
+
return raw;
|
|
7953
|
+
}
|
|
7954
|
+
var mockupLine = mockupField;
|
|
7883
7955
|
function mockupWordProblems(spec, then, owner) {
|
|
7884
7956
|
const now = mockupLine(spec.body);
|
|
7885
7957
|
if (now === mockupLine(then)) return [];
|
|
@@ -8199,7 +8271,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
8199
8271
|
}
|
|
8200
8272
|
if (command === "mockup" && spec) {
|
|
8201
8273
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
8202
|
-
const mockup =
|
|
8274
|
+
const mockup = mockupField(spec.body);
|
|
8203
8275
|
if (/^pending/i.test(mockup) || !mockup) problems.push(`${spec.path}: \`mockup:\` is still pending. It records the user's own word \u2014 approved, or skipped with their reason.`);
|
|
8204
8276
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
8205
8277
|
if (/^approved/i.test(mockup)) {
|
|
@@ -8212,7 +8284,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
8212
8284
|
if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner, start));
|
|
8213
8285
|
if ((command === "spec" || command === "make") && spec) {
|
|
8214
8286
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
8215
|
-
const mockup =
|
|
8287
|
+
const mockup = mockupField(spec.body);
|
|
8216
8288
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
8217
8289
|
if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
|
|
8218
8290
|
const drift = approvedDrawingProblems(root, spec.body, at);
|
|
@@ -8233,7 +8305,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
8233
8305
|
}
|
|
8234
8306
|
if (command === "mockup" && spec) {
|
|
8235
8307
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
8236
|
-
const mockup =
|
|
8308
|
+
const mockup = mockupField(spec.body);
|
|
8237
8309
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
8238
8310
|
if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
|
|
8239
8311
|
const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
|
|
@@ -8361,8 +8433,8 @@ function tweakProblems(root, spec, owner = "", start) {
|
|
|
8361
8433
|
const problems = [];
|
|
8362
8434
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
8363
8435
|
if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
|
|
8364
|
-
const mockup =
|
|
8365
|
-
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
8436
|
+
const mockup = mockupField(spec.body);
|
|
8437
|
+
if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup.split(/\s/)[0] || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
|
|
8366
8438
|
else problems.push(...structureSinceApproval(root, spec, front));
|
|
8367
8439
|
const surface = spec.slug;
|
|
8368
8440
|
const record = readTweak(join43(root, ".jig", "critique", surface));
|
|
@@ -8536,6 +8608,13 @@ ${result.report}`);
|
|
|
8536
8608
|
` + shown.join("\n") + (v.errors.length > shown.length ? `
|
|
8537
8609
|
\u2026 and ${v.errors.length - shown.length} more` : "")
|
|
8538
8610
|
);
|
|
8611
|
+
} else if (v.measured.length && command !== "critique") {
|
|
8612
|
+
const shown = v.measured.slice(0, 6).map((e) => ` ${e}`);
|
|
8613
|
+
problems.push(
|
|
8614
|
+
`jig verdicts ${surface}: the page fails what the render probe measured. Fix the page, then re-take the probes (\`jig verdicts ${surface} --reprobe\`).
|
|
8615
|
+
` + shown.join("\n") + (v.measured.length > shown.length ? `
|
|
8616
|
+
\u2026 and ${v.measured.length - shown.length} more` : "")
|
|
8617
|
+
);
|
|
8539
8618
|
}
|
|
8540
8619
|
}
|
|
8541
8620
|
}
|
|
@@ -8874,6 +8953,10 @@ program.command("verdicts").description("Verify a critique's verdict files and c
|
|
|
8874
8953
|
console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
|
|
8875
8954
|
}
|
|
8876
8955
|
if (result.ok) console.log(` Every rule in both passes has a verdict.`);
|
|
8956
|
+
if (result.measured.length) {
|
|
8957
|
+
console.log(` The page fails ${result.measured.length} check(s) the render probe measured. They are findings on the page, for make or tweak to fix; a critique reports them and leaves the page as it is:`);
|
|
8958
|
+
for (const failure of result.measured) console.log(` ! ${failure}`);
|
|
8959
|
+
}
|
|
8877
8960
|
const since = result.decisions.since ?? [];
|
|
8878
8961
|
if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
|
|
8879
8962
|
const p = result.previous;
|
|
@@ -8894,7 +8977,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
8894
8977
|
// this as widening the RULES and attested a clean diff as a clean project.
|
|
8895
8978
|
"scan every file in the repo, not just those changed since HEAD (same rules either way)",
|
|
8896
8979
|
false
|
|
8897
|
-
).option("--ci", "mechanical bucket only
|
|
8980
|
+
).option("--ci", "mechanical bucket only, so the run is deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
|
|
8898
8981
|
const projectRoot = findProjectRoot(process.cwd());
|
|
8899
8982
|
try {
|
|
8900
8983
|
const result = check({
|
|
@@ -8905,7 +8988,8 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
8905
8988
|
ci: opts.ci
|
|
8906
8989
|
});
|
|
8907
8990
|
console.log(opts.json ? JSON.stringify(result.findings, null, 2) : result.report);
|
|
8908
|
-
|
|
8991
|
+
const code = checkExitCode(result);
|
|
8992
|
+
if (code !== 0) process.exit(code);
|
|
8909
8993
|
} catch (err) {
|
|
8910
8994
|
console.error(err.message);
|
|
8911
8995
|
process.exit(1);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!-- J-164 · An indexable page a link preview cannot read -->
|
|
2
|
+
<figure data-example="dont">
|
|
3
|
+
<div style="font-family:system-ui,-apple-system,'Segoe UI',sans-serif;font-size:13px;line-height:1.45;color:#1f1f1f;background:#f5f5f4;padding:16px;min-height:176px;box-sizing:border-box;"><div style="font-size:12px;color:#57534e;margin-bottom:6px">Sam, in a team chat:</div><div style="background:#fff;border:1px solid #d6d3d1;border-radius:8px;padding:10px;"><div style="color:#1a0dab;font-size:13px">tidewell.io/pricing</div><div style="border-left:3px solid #d6d3d1;margin-top:8px;padding-left:8px;font-size:12px;color:#4d5156">Tidewell<br>Home · Product · Docs · Pricing · Sign in</div></div><div style="font-size:11px;color:#5c5c5c;margin-top:8px">No preview tags: the card is the site's name and its navigation.</div></div>
|
|
4
|
+
</figure>
|
|
5
|
+
<figure data-example="do">
|
|
6
|
+
<div style="font-family:system-ui,-apple-system,'Segoe UI',sans-serif;font-size:13px;line-height:1.45;color:#1f1f1f;background:#f5f5f4;padding:16px;min-height:176px;box-sizing:border-box;"><div style="font-size:12px;color:#57534e;margin-bottom:6px">Sam, in a team chat:</div><div style="background:#fff;border:1px solid #d6d3d1;border-radius:8px;padding:10px;"><div style="color:#1a0dab;font-size:13px">tidewell.io/pricing</div><div style="border-left:3px solid #3f6212;margin-top:8px;padding-left:8px;font-size:12px;color:#1f1f1f"><b>Pricing · Tidewell</b><br><span style="color:#4d5156">Three plans, billed monthly or yearly. Cancel any time.</span></div></div><div style="font-size:11px;color:#5c5c5c;margin-top:8px">og:title and og:description: the card says what the page is.</div></div>
|
|
7
|
+
</figure>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A design system for coding agents.
|
|
3
|
+
"version": "0.25.0",
|
|
4
|
+
"description": "A design system for coding agents. 160 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",
|
|
7
7
|
"bin": {
|
|
@@ -365,7 +365,7 @@ Without `viewport-fit=cover`, the browser keeps the page inside the safe area on
|
|
|
365
365
|
|
|
366
366
|
### D-115 The page scrolls sideways on a phone
|
|
367
367
|
❌ At phone width the whole page is wider than the screen — a data table, a long URL, an image, a `width: 100vw` element or a fixed-width block pushes it out, and the reader can drag the page left and right
|
|
368
|
-
✅ At every width, nothing makes the page wider than the screen. Content that is genuinely wider — a data table, a code block — scrolls inside its own container with a visible edge (`E-62`), and the page itself never does. `editorial` goes further and allows no scrolling regions on mobile at all (`M-01`).
|
|
368
|
+
✅ At every width, nothing makes the page wider than the screen. Content that is genuinely wider — a data table, a code block — scrolls inside its own container with a visible edge (`E-62`), and the page itself never does. `editorial` goes further and allows no scrolling regions on mobile at all (`M-01`): there a code block wraps its long lines and a wide table stacks its rows (`L-01`, step 6).
|
|
369
369
|
Judge it on a render, not in the source: at 360px, `document.documentElement.scrollWidth` must not be greater than `document.documentElement.clientWidth`. The usual causes are each one line to fix — `overflow-wrap: anywhere` on text the author does not control, `max-width: 100%` on media, `width: 100%` instead of `100vw` (which includes the scrollbar), and a wrapper with `overflow-x: auto` around anything tabular.
|
|
370
370
|
A page that scrolls sideways is not merely untidy. The reader's vertical swipes drift, the page slides half off the screen, and every line of text needs re-centring before it can be read.
|
|
371
371
|
|
|
@@ -376,6 +376,7 @@ Agents render the happy path. This section exists because that is the single mos
|
|
|
376
376
|
### E-28 Missing states
|
|
377
377
|
❌ A component with only a default appearance
|
|
378
378
|
✅ Every interactive element defines: `hover`, `focus-visible`, `active`, `disabled`. Every data view defines: loading, empty, error, and partial/truncated.
|
|
379
|
+
A browser makes an `<iframe>` a tab stop whether or not anything in it can be used. One that only shows something, with nothing inside to operate or scroll, is taken out of the tab order (`tabindex="-1"`) rather than given states it has no use for; a keyboard reader otherwise meets a stop with nothing to see. One the reader uses (a player, a map, a form) keeps its stop and shows focus.
|
|
379
380
|
|
|
380
381
|
### E-29 Focus removed without replacement
|
|
381
382
|
❌ `outline: none` with nothing in its place
|
|
@@ -411,7 +412,7 @@ Where disabling is genuinely right, put a message beside it or a tooltip on it e
|
|
|
411
412
|
❌ A toolbar of icon-only buttons whose meaning depends on recognising the glyph
|
|
412
413
|
✅ A visible text label beside the icon, or the icon plus label on the primary path.
|
|
413
414
|
This is **not** the same problem as `E-34`. An `aria-label` serves a screen reader and does nothing for a sighted user with low computer literacy, or anyone meeting an unfamiliar glyph. Both are required.
|
|
414
|
-
Icon-only is acceptable for a small set of near-universal glyphs
|
|
415
|
+
Icon-only is acceptable for a small set of near-universal glyphs and in `operator`, where repetition builds recognition — and even there, on first-run surfaces the label stays. Close, search and menu are examples, not the whole list: the test is whether most readers would name the glyph the same way without the label. A sun and moon for the theme, or a service's own widely known mark linking to it, usually pass; a funnel, a sparkle or a lightning bolt does not. An icon-only control still carries its name in text a screen reader reads (`E-34`).
|
|
415
416
|
|
|
416
417
|
### E-52 Unconventional controls
|
|
417
418
|
❌ A bespoke form field, checkbox or select that looks and behaves unlike every other one the user has met
|
|
@@ -713,6 +714,11 @@ A second copy of the positioning is a second thing to keep in step by hand, whic
|
|
|
713
714
|
✅ Concatenate the static routes unconditionally, and let a failed read yield the stale list rather than an empty one. When a list is genuinely empty, prove it before shipping.
|
|
714
715
|
An empty sitemap is not a missing sitemap. It is a positive claim, and the crawler believes it.
|
|
715
716
|
|
|
717
|
+
### J-164 An indexable page a link preview cannot read
|
|
718
|
+
❌ A link pasted into a message or a post that shows a bare address, or the site's name over a line of its navigation
|
|
719
|
+
✅ Every indexable page states its own preview: `og:title` and `og:description`, beside its `<title>` and meta description, and `og:type` for what it is (`article` for a post). An image and `og:url` take the page's full address: add them once the site has one, never as a relative path.
|
|
720
|
+
The card is met before the page, often by someone who never opens it. Without these tags each service builds the card from whatever it finds, and they find different things. A preview is copy, like the title and description: the change that rewrites the page rewrites its card (`J-120`), and a value the page already holds is read, not typed again (`J-126`).
|
|
721
|
+
|
|
716
722
|
---
|
|
717
723
|
|
|
718
724
|
## K. Safety at the interface
|
package/rules/01-modes.md
CHANGED
|
@@ -85,7 +85,7 @@ Resolved values: `02-tokens.md` — the option sets for type, spacing, radius an
|
|
|
85
85
|
- One hero maximum, at the top. A second full-viewport section is a second hero.
|
|
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
|
-
- No horizontal scrolling regions on mobile. Reflow instead.
|
|
88
|
+
- No horizontal scrolling regions on mobile. Reflow instead: a code block wraps its long lines, keeping its line breaks and indents, and a table too wide for the screen stacks its rows, each cell with its column's name (`L-01`, step 6).
|
|
89
89
|
- JavaScript: **none by default.** A content page ships no script unless a feature on it needs some: a theme toggle, a search box, a filter. That is a default, not an absolute zero. Each script names, in a comment, the feature and the decision or rule that asks for it, and a review judges whether the feature needs it and whether it is as small as the feature allows, not whether it exists. What the budget forbids is script no feature asks for: a framework runtime to render static text, a library for one effect, tracking bundled into the page. **Behaviour a rule requires is not a feature and is not counted**: a few lines whose only job is something a rule asks for and the browser does not give, such as `Escape` closing a `<details>` menu, or a tap outside closing a menu in a fixed header (`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
|
---
|
|
@@ -190,7 +190,7 @@ Attempting to vary these by mode is a category error:
|
|
|
190
190
|
- **Accessibility floors.** Contrast, focus indication, target size, semantic markup. Identical in all three. `operator` being dense does not license a 24px tap target or a 3:1 body contrast.
|
|
191
191
|
- **Brand identity.** Palette, typeface, logo, voice.
|
|
192
192
|
- **State completeness.** Every mode renders loading, empty, error and disabled.
|
|
193
|
-
- **The anti-pattern file.** All
|
|
193
|
+
- **The anti-pattern file.** All 129 rules in it apply everywhere.
|
|
194
194
|
|
|
195
195
|
---
|
|
196
196
|
|
package/rules/03-patterns.md
CHANGED
|
@@ -564,10 +564,20 @@ everything else follows from.
|
|
|
564
564
|
fluid: they track the viewport between a floor and a ceiling with no media
|
|
565
565
|
query at all. A hand-written ladder of sizes per breakpoint reintroduces the
|
|
566
566
|
jumps the scale exists to remove, and goes stale the moment the scale changes.
|
|
567
|
-
5. **Content that is genuinely wider scrolls inside itself
|
|
568
|
-
|
|
569
|
-
(`E-62`), keeps its type size, and
|
|
570
|
-
|
|
567
|
+
5. **Content that is genuinely wider scrolls inside itself, except in
|
|
568
|
+
`editorial` on a phone.** A code block, a wide table, a long identifier: its
|
|
569
|
+
own container scrolls with a visible edge (`E-62`), keeps its type size, and
|
|
570
|
+
the page never scrolls sideways (`D-115`). `editorial` allows no scrolling
|
|
571
|
+
region on mobile (`M-01`), so there each one reflows instead:
|
|
572
|
+
- **A code block** keeps its line breaks and indents and wraps a long line
|
|
573
|
+
(`white-space: pre-wrap`, `overflow-wrap: anywhere`). What is copied is
|
|
574
|
+
the source as written, not the wrapped lines.
|
|
575
|
+
- **A table too wide to fit** stacks its rows: each row a block, each cell a
|
|
576
|
+
line with its column's name in front. It stays a `<table>` with its header
|
|
577
|
+
row, so a screen reader still reads it as one. Where it fits, it stays a
|
|
578
|
+
table: the switch is the width at which its columns stop fitting.
|
|
579
|
+
- **A long identifier** (a URL, a hash, a file path) breaks where it must
|
|
580
|
+
(`overflow-wrap: anywhere`).
|
|
571
581
|
6. **Each transition happens where the content needs it.** The width at which
|
|
572
582
|
three columns stop fitting is a property of the columns, not of a device. Set
|
|
573
583
|
it from the content, and expect the numbers to differ per component.
|
package/rules/05-copy.md
CHANGED
|
@@ -90,7 +90,7 @@ Whichever you choose, be consistent across sibling elements — a list where thr
|
|
|
90
90
|
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.
|
|
91
91
|
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.
|
|
92
92
|
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.
|
|
93
|
-
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
|
|
93
|
+
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 so is a program's own output or code shown as it is, in `<pre>`, `<code>`, `<samp>` or `<kbd>`. Changing either 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.
|
|
94
94
|
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.
|
|
95
95
|
|
|
96
96
|
### I-87 Inconsistent vocabulary
|
package/rules.index.json
CHANGED
|
@@ -322,10 +322,12 @@ Write the answer as `critique: each` or `critique: at-ship`.
|
|
|
322
322
|
|
|
323
323
|
### 2. Run `L-01`
|
|
324
324
|
|
|
325
|
-
Read `L-01 · Layout method` in `{{rules_path}}/03-patterns.md` and run its
|
|
325
|
+
Read `L-01 · Layout method` in `{{rules_path}}/03-patterns.md` and run its six
|
|
326
326
|
steps against the answers. The spec's structural half **is** that output — group,
|
|
327
|
-
order by importance, space from the inside out, align to a grid,
|
|
328
|
-
hierarchy plainly enough that the squint question can be answered against it
|
|
327
|
+
order by importance, space from the inside out, align to a grid, state the
|
|
328
|
+
hierarchy plainly enough that the squint question can be answered against it,
|
|
329
|
+
and decide how it collapses. Step 6 is where the phone composition comes from,
|
|
330
|
+
including what this mode does with a code block or table wider than the screen.
|
|
329
331
|
|
|
330
332
|
### 3. Write it down — one composition per screen size
|
|
331
333
|
|
|
@@ -483,7 +485,7 @@ to the spec, never the spec to the decisions.
|
|
|
483
485
|
- **"The design system decides" is not a value.** When the user defers — "you decide",
|
|
484
486
|
"whatever Jig says", "leave it to the system" — that is an instruction to look the
|
|
485
487
|
answer up, not a phrase to copy into the spec. Search in this order: the `P-`
|
|
486
|
-
pattern for the component (`{{command_prefix}}explain <component>`), `L-01`'s
|
|
488
|
+
pattern for the component (`{{command_prefix}}explain <component>`), `L-01`'s six
|
|
487
489
|
steps, then the rules. Write the composition that lookup gives — "stacked, Pro
|
|
488
490
|
first", "Menu button, links in a vertical list" — and cite the id it came from.
|
|
489
491
|
Three of six specs in a live run wrote "arrangement decided by design system" into
|
|
@@ -599,6 +601,34 @@ its size's frame of the drawing at `mockup_at:`. Otherwise a spec can go through
|
|
|
599
601
|
several confirmed rounds after its drawing was approved and still say `approved`
|
|
600
602
|
over a drawing of none of them.
|
|
601
603
|
|
|
604
|
+
### Before you stop: what the gate holds a spec to
|
|
605
|
+
|
|
606
|
+
With the Stop hook, each of these keeps the session open until it is fixed. Get
|
|
607
|
+
them right as you write, not after the gate names them:
|
|
608
|
+
|
|
609
|
+
- **A spec writes under `.jig/` only.** A change to `DECISIONS.md`, a stylesheet or
|
|
610
|
+
a page is for `{{command_prefix}}decide`, `make` or `tweak`: record in the spec
|
|
611
|
+
what the owner said, and hand the change on.
|
|
612
|
+
- **The front matter holds Jig's fields and nothing else.** Anything more goes in
|
|
613
|
+
the prose below it.
|
|
614
|
+
- **Quotation marks within a few words after "owner", or after "the user said",
|
|
615
|
+
mark the owner's words.** What is inside them must be what the owner said: in
|
|
616
|
+
this conversation, in `DECISIONS.md`, or already in the spec. To name a
|
|
617
|
+
decision, a heading or a file near the word "owner", leave the quotation marks
|
|
618
|
+
off.
|
|
619
|
+
- **`<name>.checked.json` is of the spec as it stands.** Its `spec` value matches
|
|
620
|
+
`{{scripts_path}} checksum <name>`, so an edit after the check means a second
|
|
621
|
+
check, of what changed.
|
|
622
|
+
- **Each fact has one `source`:** a path from the project root, `path:line`, or a
|
|
623
|
+
URL, and nothing else. The file exists and the line is in it. Two places are two
|
|
624
|
+
facts; what you found at the source goes in `note`.
|
|
625
|
+
- **`holds` is `true`, `false` or `"unchecked"`, with a `note` for anything but
|
|
626
|
+
`true`.** A fact found false is fixed in the spec before the owner is asked.
|
|
627
|
+
- **`confirmed: true` only on the owner's yes, in this session, to the sheet you
|
|
628
|
+
showed them.** Words in the request that started the session came before the
|
|
629
|
+
sheet existed, and are not a reply to it, even when they say the owner
|
|
630
|
+
confirms. Show the sheet and stop.
|
|
631
|
+
|
|
602
632
|
## mockup
|
|
603
633
|
|
|
604
634
|
**Low-fidelity design.** Draw the confirmed spec in grayscale, at every size it
|
|
@@ -849,6 +879,22 @@ confirmed again.
|
|
|
849
879
|
With the Stop hook, the gate reads the quotation after `approved` or `skipped` and
|
|
850
880
|
holds it to what the owner said in this session.
|
|
851
881
|
|
|
882
|
+
### Before you stop: what the gate holds a mockup to
|
|
883
|
+
|
|
884
|
+
- **A mockup writes under `.jig/` only:** the drawing in `.jig/mockups/`, and the
|
|
885
|
+
spec's `mockup:` and `mockup_at:` lines.
|
|
886
|
+
- **The drawing shows what the spec lists**, at every size, and either side of each
|
|
887
|
+
switch the page crosses. The gate checks this before you put it to the owner.
|
|
888
|
+
- **`mockup:` stays `pending` until the owner answers.** Then it is `approved` or
|
|
889
|
+
`skipped`, followed by their words in quotation marks: the reply that approves
|
|
890
|
+
this drawing, or says to skip it. Words that only say they looked at it, or
|
|
891
|
+
that are about something else, are not their word on the drawing. Ask them
|
|
892
|
+
whether they approve it for `make`, and quote the answer.
|
|
893
|
+
- **`mockup_at:` names the approved drawing**, a file under `.jig/mockups/` that
|
|
894
|
+
exists, or a link.
|
|
895
|
+
- **An approved drawing does not change.** Changing it after approval sets
|
|
896
|
+
`mockup: pending` and puts it to the owner again.
|
|
897
|
+
|
|
852
898
|
Then stop. The next step is `{{command_prefix}}make`.
|
|
853
899
|
|
|
854
900
|
## make
|
|
@@ -1242,9 +1288,13 @@ The probe opens the phone menu, presses Escape, measures sideways scroll, and re
|
|
|
1242
1288
|
whether the styles and tokens actually applied. `verdicts` refuses `rendered: true`
|
|
1243
1289
|
without a probe at each width, and refuses an `ok` the probe contradicts — a `P-14`
|
|
1244
1290
|
"ok" on a menu that did not open, a `D-115` "ok" on a page that scrolls sideways. It
|
|
1245
|
-
also
|
|
1246
|
-
|
|
1247
|
-
|
|
1291
|
+
also names what the page fails whatever the verdicts say: it renders in the
|
|
1292
|
+
browser's default font, shows `${` or an em dash to readers, or serves a missing
|
|
1293
|
+
or overlong title or description. Those are findings on the page, listed under
|
|
1294
|
+
`!` and counted as `measured=` on the `JIG_VERDICTS` line. Put each in the report;
|
|
1295
|
+
make or tweak fixes them, and the critique leaves the page as it is.
|
|
1296
|
+
Never write a probe file yourself: when it disagrees with you, it is the review
|
|
1297
|
+
that is wrong.
|
|
1248
1298
|
Then run:
|
|
1249
1299
|
|
|
1250
1300
|
```
|
|
@@ -1540,6 +1590,21 @@ judges it, and leave the verdicts alone. The page then owes a critique, and
|
|
|
1540
1590
|
`{{command_prefix}}ship` will not pass until one has judged it. Never defer it
|
|
1541
1591
|
on your own account: the gate refuses a deferral nobody gave.
|
|
1542
1592
|
|
|
1593
|
+
### Before you stop: what the gate holds a tweak to
|
|
1594
|
+
|
|
1595
|
+
- **The spec is confirmed, and its mockup approved or skipped.** A page that has
|
|
1596
|
+
neither goes through `mockup` and `make`.
|
|
1597
|
+
- **The page's regions and its approved drawing stay as they were.** A change to
|
|
1598
|
+
what the page holds goes through `spec` and `mockup`.
|
|
1599
|
+
- **`tweak.json` holds `at`, `change`, `ids` and, when the owner said so,
|
|
1600
|
+
`deferred`, and no other field.**
|
|
1601
|
+
- **`change` is the owner's words for the change, exactly as they gave them in
|
|
1602
|
+
this session.** Nothing goes before or after them, not even a label such as
|
|
1603
|
+
"the owner's words:". What you made of them goes in the spec's Tweak entry.
|
|
1604
|
+
- **`ids` names at least one rule or decision**, and each is re-judged by a reader
|
|
1605
|
+
that did not make the change, unless `deferred` quotes the owner telling you to
|
|
1606
|
+
leave it for later (or is `true` where the page's critique waits for ship).
|
|
1607
|
+
|
|
1543
1608
|
### Finish
|
|
1544
1609
|
|
|
1545
1610
|
Report what changed, in the owner's words; the decision and spec lines you
|
package/templates/SKILL.md.tmpl
CHANGED
|
@@ -49,7 +49,7 @@ cite the number when you follow or deliberately break one.
|
|
|
49
49
|
says is lost to every check. If it keeps recurring, tell the owner, since
|
|
50
50
|
Jig may be missing a field.
|
|
51
51
|
**Building a screen rather than a single component?** Read `L-01 · Layout
|
|
52
|
-
method` in `{{rules_path}}/03-patterns.md` and run its
|
|
52
|
+
method` in `{{rules_path}}/03-patterns.md` and run its six steps before
|
|
53
53
|
writing any markup. It is a procedure, not a component, so step 4 never
|
|
54
54
|
selects it and nothing else will. (`explain L-01` prints it too, but the
|
|
55
55
|
file is the source — do not skip the step if the command is unavailable.)
|