jig-ui 0.26.1 → 0.26.3
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 +54 -0
- package/dist/index.js +109 -32
- package/package.json +1 -1
- package/rules/00-anti-patterns.md +3 -3
- package/rules/03-patterns.md +16 -2
- package/tokens/mode.operator.css +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.26.3 (2026-10-14)
|
|
4
|
+
|
|
5
|
+
Form text in `operator` stops making phones zoom, and `check` catches three
|
|
6
|
+
things it missed or got wrong.
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- **`operator` body text is 16px on touch screens.** At 14px, any input set in
|
|
11
|
+
the body size, or inheriting it, made iOS zoom the page when it took focus.
|
|
12
|
+
Under a coarse pointer the body token now rises to 16px, beside the rows
|
|
13
|
+
that already rose to the touch target. A mouse keeps the dense 14px.
|
|
14
|
+
- **`check` reads form text that inherits its size.** An input that wrote
|
|
15
|
+
`font: inherit` under a 14px body went unreported, and so did any page with
|
|
16
|
+
a touch-screen query, whatever that query changed. A touch query now counts
|
|
17
|
+
only when it raises form text.
|
|
18
|
+
- **Low contrast in small inline text is reported.** Text sized no larger than
|
|
19
|
+
the text around it (`0.9em`, `85%`, `smaller`) was read as possibly large and
|
|
20
|
+
held to 3:1, so an inline code span at 4.03:1 passed. It is held to 4.5:1.
|
|
21
|
+
- **A pale red is not cream.** The cream-palette warning fired on a red status
|
|
22
|
+
tint behind an "Overdue" badge. It now needs a tint that leans toward yellow,
|
|
23
|
+
and leaves alone a token named for a state or a component.
|
|
24
|
+
- **A signed-in page without a spec is not asked for search metadata.** A
|
|
25
|
+
review took every page without a spec as public. It now takes the project's
|
|
26
|
+
mode when `jig.config.json` declares one, so an `operator` or `product`
|
|
27
|
+
page is asked for `noindex` instead of a description and a link preview.
|
|
28
|
+
- **A table's scroll box holds what is hidden inside it.** The layout method
|
|
29
|
+
now says the box a wide table scrolls in is `position: relative`. Without
|
|
30
|
+
it, a label hidden for screen readers in each row was placed against the
|
|
31
|
+
page, and an invoices page scrolled sideways by 200px on a phone.
|
|
32
|
+
|
|
33
|
+
## 0.26.2 (2026-10-13)
|
|
34
|
+
|
|
35
|
+
A button in a table row keeps the row's height, and the render probe reports
|
|
36
|
+
rows it stretches.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **A button in a table row takes the row's height.** 0.26.0 said so in the
|
|
41
|
+
tokens chapter, but the Button pattern, which an agent reads to build a
|
|
42
|
+
button, still gave its hit area a 48px floor with no exception, and an
|
|
43
|
+
invoices table on 0.26.1 had its "Mark as paid" rows at 65px among 41px ones.
|
|
44
|
+
The Button and Data table patterns now say it where buttons are built: a
|
|
45
|
+
button in a row is sized to the control height with no minimum of its own,
|
|
46
|
+
and on a touch screen the whole row rises to the touch target instead. On a
|
|
47
|
+
run of this release, every row stood 36px with a mouse and 48px on touch.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **The render probe reports a table whose rows are not one height because of
|
|
52
|
+
a control.** When the rows holding a button or link stand taller than the
|
|
53
|
+
rest, and the control is taller than the short rows, it is a measured
|
|
54
|
+
failure (`P-02`, `P-06`). Rows that differ only because text wraps are left
|
|
55
|
+
alone.
|
|
56
|
+
|
|
3
57
|
## 0.26.1 (2026-10-13)
|
|
4
58
|
|
|
5
59
|
The colour and typeface you decide in `/jig decide` now reach the pages, and
|
package/dist/index.js
CHANGED
|
@@ -1699,7 +1699,7 @@ function sectionsOf(entries) {
|
|
|
1699
1699
|
}
|
|
1700
1700
|
|
|
1701
1701
|
// src/commands/check.ts
|
|
1702
|
-
import { existsSync as existsSync18, readFileSync as readFileSync23 } from "fs";
|
|
1702
|
+
import { existsSync as existsSync18, readdirSync as readdirSync11, readFileSync as readFileSync23 } from "fs";
|
|
1703
1703
|
import { join as join27 } from "path";
|
|
1704
1704
|
|
|
1705
1705
|
// src/check/files.ts
|
|
@@ -3329,6 +3329,7 @@ var REM_TO_PX = 16;
|
|
|
3329
3329
|
var FONT_SIZE_DECL_RE = /(?<![-\w])font-size\s*:\s*([^;]+?)(?:;|$)/i;
|
|
3330
3330
|
var FONT_SIZE_PX_RE = /^(-?\d*\.?\d+)px$/i;
|
|
3331
3331
|
var FONT_SIZE_REM_RE = /^(-?\d*\.?\d+)rem$/i;
|
|
3332
|
+
var NOT_LARGER_RE = /^(?:0*\.\d+em|1(?:\.0+)?em|(?:\d{1,2}(?:\.\d+)?|100)%|smaller|small|x-small|xx-small)$/i;
|
|
3332
3333
|
var BOLD_RE = /(?<![-\w])font-weight\s*:\s*(bold|[6-9]\d\d)/i;
|
|
3333
3334
|
function resolveFloor(body) {
|
|
3334
3335
|
const m = FONT_SIZE_DECL_RE.exec(body);
|
|
@@ -3339,6 +3340,7 @@ function resolveFloor(body) {
|
|
|
3339
3340
|
let px2;
|
|
3340
3341
|
if (pxMatch) px2 = parseFloat(pxMatch[1]);
|
|
3341
3342
|
else if (remMatch) px2 = parseFloat(remMatch[1]) * REM_TO_PX;
|
|
3343
|
+
else if (NOT_LARGER_RE.test(raw)) return FLOOR;
|
|
3342
3344
|
else return LARGE_FLOOR;
|
|
3343
3345
|
const large = BOLD_RE.test(body) ? px2 >= 18.66 : px2 >= 24;
|
|
3344
3346
|
return large ? LARGE_FLOOR : FLOOR;
|
|
@@ -3669,9 +3671,22 @@ var viewportHeight = {
|
|
|
3669
3671
|
|
|
3670
3672
|
// src/check/detectors/input-zoom.ts
|
|
3671
3673
|
var SELECTOR_RE = /(?:^|[\s,>+~(])(?:input|select|textarea)\b(?![-\w])|\[contenteditable/i;
|
|
3672
|
-
var SIZE_RE = /(?<![-\w])font
|
|
3673
|
-
var TOUCH_QUERY_RE =
|
|
3674
|
+
var SIZE_RE = /(?<![-\w])font(?:-size)?\s*:\s*([^;}]+)/gi;
|
|
3675
|
+
var TOUCH_QUERY_RE = /^@media[^{]*\b(?:pointer\s*:\s*coarse|hover\s*:\s*none)\b/i;
|
|
3676
|
+
var BODY_SELECTOR_RE = /(?:^|,)\s*(?:html\s*,\s*)?body\s*(?:,|$)/i;
|
|
3674
3677
|
var ROOT_PX = 16;
|
|
3678
|
+
function touchFormText(source) {
|
|
3679
|
+
const out = { body: false, control: false };
|
|
3680
|
+
for (const media of splitRuleBlocks(source)) {
|
|
3681
|
+
if (!TOUCH_QUERY_RE.test(media.selector.trim())) continue;
|
|
3682
|
+
for (const block of leafBlocks(media.body)) {
|
|
3683
|
+
const body = /--text-body\s*:\s*([^;}]+)/i.exec(block.body);
|
|
3684
|
+
if (body && (pixels(body[1], void 0) ?? 0) >= ROOT_PX) out.body = true;
|
|
3685
|
+
if (SELECTOR_RE.test(block.selector) && /(?<![-\w])font(?:-size)?\s*:/i.test(block.body)) out.control = true;
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
return out;
|
|
3689
|
+
}
|
|
3675
3690
|
function pixels(value, mode) {
|
|
3676
3691
|
const v = value.trim().replace(/\s*!important$/i, "");
|
|
3677
3692
|
const unit = /^(\d*\.?\d+)(px|rem|em)$/i.exec(v);
|
|
@@ -3680,18 +3695,36 @@ function pixels(value, mode) {
|
|
|
3680
3695
|
if (/^var\(\s*--text-body\s*\)$/i.test(v) && mode === "operator") return 14;
|
|
3681
3696
|
return void 0;
|
|
3682
3697
|
}
|
|
3698
|
+
function sizeOf(property, value) {
|
|
3699
|
+
if (!/^font\s*:/i.test(property)) return value;
|
|
3700
|
+
const m = /(?:^|\s)((?:\d*\.?\d+(?:px|rem|em))|var\(\s*--text-[\w-]+\s*\))\s*(?:\/|\s|$)/i.exec(value.trim());
|
|
3701
|
+
return m ? m[1] : value.trim();
|
|
3702
|
+
}
|
|
3703
|
+
function bodySize(source) {
|
|
3704
|
+
for (const block of leafBlocks(source)) {
|
|
3705
|
+
if (block.atRuleDepth !== 0 || !BODY_SELECTOR_RE.test(block.selector.trim())) continue;
|
|
3706
|
+
const m = /(?<![-\w])font-size\s*:\s*([^;}]+)/i.exec(block.body);
|
|
3707
|
+
if (m) return m[1];
|
|
3708
|
+
}
|
|
3709
|
+
return void 0;
|
|
3710
|
+
}
|
|
3683
3711
|
var inputZoom = {
|
|
3684
3712
|
name: "input-zoom",
|
|
3685
3713
|
appliesTo: (file) => isStyleBearing(file),
|
|
3686
3714
|
run(source, file, ctx) {
|
|
3687
|
-
|
|
3715
|
+
const own = touchFormText(source);
|
|
3716
|
+
if (own.control || ctx.touchFormText?.control) return [];
|
|
3717
|
+
const mode = own.body || ctx.touchFormText?.body ? void 0 : ctx.mode;
|
|
3688
3718
|
const findings = [];
|
|
3689
3719
|
for (const block of leafBlocks(source)) {
|
|
3690
3720
|
if (!SELECTOR_RE.test(block.selector)) continue;
|
|
3691
3721
|
SIZE_RE.lastIndex = 0;
|
|
3692
3722
|
let m;
|
|
3693
3723
|
while (m = SIZE_RE.exec(block.body)) {
|
|
3694
|
-
|
|
3724
|
+
let value = sizeOf(m[0], m[1]);
|
|
3725
|
+
const inherited = /^inherit\b/i.test(value.trim());
|
|
3726
|
+
if (inherited) value = bodySize(source) ?? "";
|
|
3727
|
+
const px2 = pixels(value, mode);
|
|
3695
3728
|
if (px2 === void 0 || px2 >= ROOT_PX) continue;
|
|
3696
3729
|
const line = lineOfOffset(block, m.index);
|
|
3697
3730
|
findings.push(
|
|
@@ -3700,7 +3733,7 @@ var inputZoom = {
|
|
|
3700
3733
|
"input-zoom",
|
|
3701
3734
|
file,
|
|
3702
3735
|
line,
|
|
3703
|
-
`form control text resolves to ${px2}px; iOS zooms the page on focus below 16px`,
|
|
3736
|
+
inherited ? `form control inherits the body's ${px2}px; iOS zooms the page on focus below 16px` : `form control text resolves to ${px2}px; iOS zooms the page on focus below 16px`,
|
|
3704
3737
|
sourceLine(source, line)
|
|
3705
3738
|
)
|
|
3706
3739
|
);
|
|
@@ -4495,12 +4528,13 @@ var glowAccent = {
|
|
|
4495
4528
|
}
|
|
4496
4529
|
};
|
|
4497
4530
|
var PAGE_SELECTOR = /(?:^|[\s,>])(?:html|body|:root|main)\b|--[\w-]*(?:bg|background|surface|page|canvas)[\w-]*/i;
|
|
4531
|
+
var NOT_PAGE_TOKEN = /-(?:success|error|danger|warn|warning|info|notice|overdue|due|paid|draft|pending|status|badge|tag|chip|pill|alert|toast|hover|active|selected|highlight|mark|code|kbd|tooltip|input|button|btn)(?=-|$)/i;
|
|
4498
4532
|
var TW_CREAM = /^bg-(?:amber|orange|yellow)-(?:50|100)$|^bg-stone-(?:100|200)$/;
|
|
4499
4533
|
function cream(value, tokens) {
|
|
4500
4534
|
const c = extractColorComponents(value.trim(), tokens);
|
|
4501
4535
|
if (!c || c.alpha < 0.9) return false;
|
|
4502
4536
|
const { r, g, b } = c.rgb;
|
|
4503
|
-
return Math.min(r, g, b) >= 215 && r >= g && g >= b && r - b >= 6;
|
|
4537
|
+
return Math.min(r, g, b) >= 215 && r >= g && g >= b && r - b >= 6 && g - b >= (r - b) * 0.35;
|
|
4504
4538
|
}
|
|
4505
4539
|
var creamPalette = {
|
|
4506
4540
|
name: "cream-palette",
|
|
@@ -4510,7 +4544,7 @@ var creamPalette = {
|
|
|
4510
4544
|
for (const block of leafBlocks(source)) {
|
|
4511
4545
|
for (const m of block.body.matchAll(/(?:^|[;{\s])((?:--[\w-]+|background(?:-color)?))\s*:\s*([^;}]+)/g)) {
|
|
4512
4546
|
const prop = m[1];
|
|
4513
|
-
const onPage = prop.startsWith("--") ? PAGE_SELECTOR.test(prop) : PAGE_SELECTOR.test(block.selector);
|
|
4547
|
+
const onPage = prop.startsWith("--") ? PAGE_SELECTOR.test(prop) && !NOT_PAGE_TOKEN.test(prop) : PAGE_SELECTOR.test(block.selector);
|
|
4514
4548
|
if (!onPage || !cream(m[2], ctx.tokens)) continue;
|
|
4515
4549
|
hits.push({ line: lineOfOffset(block, m.index), message: `a cream page surface (${m[2].trim()}): the palette a model reaches for when asked to look tasteful` });
|
|
4516
4550
|
}
|
|
@@ -4692,7 +4726,7 @@ function getDetector(name) {
|
|
|
4692
4726
|
}
|
|
4693
4727
|
|
|
4694
4728
|
// src/check/run.ts
|
|
4695
|
-
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties) {
|
|
4729
|
+
function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParticipates = false, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties, touchFormText2) {
|
|
4696
4730
|
const findings = [];
|
|
4697
4731
|
const active = index.filter(
|
|
4698
4732
|
(e) => !!e.detector && !!getDetector(e.detector)
|
|
@@ -4710,7 +4744,7 @@ function runChecks(projectRoot, files, index, tokens, bucketFilter, projectParti
|
|
|
4710
4744
|
}
|
|
4711
4745
|
const source = maskComments(maskNonStyleRegions(raw, file));
|
|
4712
4746
|
for (const { entry, detector } of applicable) {
|
|
4713
|
-
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties };
|
|
4747
|
+
const ctx = { ruleId: entry.id, bucket: entry.bucket, severity: entry.severity, tokens, projectParticipates, raw, mode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties, touchFormText: touchFormText2 };
|
|
4714
4748
|
findings.push(...detector.run(source, file, ctx));
|
|
4715
4749
|
}
|
|
4716
4750
|
}
|
|
@@ -5214,6 +5248,17 @@ function check(opts) {
|
|
|
5214
5248
|
}
|
|
5215
5249
|
});
|
|
5216
5250
|
const declaredProperties = collectDeclaredProperties(opts.projectRoot, stylesheets, Object.keys(tokens));
|
|
5251
|
+
const touchFormText2 = { body: false, control: false };
|
|
5252
|
+
const vendoredTokens = existsSync18(join27(opts.projectRoot, ".jig", "tokens")) ? readdirSync11(join27(opts.projectRoot, ".jig", "tokens")).filter((f) => f.endsWith(".css")).map((f) => join27(".jig", "tokens", f)) : [];
|
|
5253
|
+
for (const f of [...stylesheets, ...vendoredTokens]) {
|
|
5254
|
+
if (!isStyleBearing(f)) continue;
|
|
5255
|
+
try {
|
|
5256
|
+
const found = touchFormText(maskComments(maskNonStyleRegions(readFileSync23(join27(opts.projectRoot, f), "utf8"), f)));
|
|
5257
|
+
touchFormText2.body ||= found.body;
|
|
5258
|
+
touchFormText2.control ||= found.control;
|
|
5259
|
+
} catch {
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5217
5262
|
const withStyles = files.filter((f) => {
|
|
5218
5263
|
try {
|
|
5219
5264
|
const src = readFileSync23(join27(opts.projectRoot, f), "utf8");
|
|
@@ -5225,7 +5270,7 @@ function check(opts) {
|
|
|
5225
5270
|
}
|
|
5226
5271
|
}).length;
|
|
5227
5272
|
const resolvedMode = resolveMode(opts.projectRoot);
|
|
5228
|
-
const detected = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties);
|
|
5273
|
+
const detected = runChecks(opts.projectRoot, files, index, tokens, bucketFilter, projectParticipates, resolvedMode, projectResponsive, viewportFitCover, projectMenuToggle, declaredProperties, touchFormText2);
|
|
5229
5274
|
const TOKEN_RULE = {
|
|
5230
5275
|
"--text-prose": "B-75",
|
|
5231
5276
|
"--size-touch-target": "E-51"
|
|
@@ -5284,7 +5329,7 @@ function check(opts) {
|
|
|
5284
5329
|
}
|
|
5285
5330
|
|
|
5286
5331
|
// src/commands/init.ts
|
|
5287
|
-
import { existsSync as existsSync19, mkdirSync as mkdirSync6, readdirSync as
|
|
5332
|
+
import { existsSync as existsSync19, mkdirSync as mkdirSync6, readdirSync as readdirSync12, readFileSync as readFileSync27, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
|
|
5288
5333
|
import { dirname as dirname7, isAbsolute, join as join31, resolve as resolve2, sep as sep3 } from "path";
|
|
5289
5334
|
|
|
5290
5335
|
// src/init/detect.ts
|
|
@@ -5878,7 +5923,7 @@ function isCssModule(path) {
|
|
|
5878
5923
|
function legacyBrandFile(projectRoot) {
|
|
5879
5924
|
const dir = join31(projectRoot, ".jig", "tokens");
|
|
5880
5925
|
if (!existsSync19(dir)) return void 0;
|
|
5881
|
-
const found =
|
|
5926
|
+
const found = readdirSync12(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
|
|
5882
5927
|
return found ? relKey(".jig", "tokens", found) : void 0;
|
|
5883
5928
|
}
|
|
5884
5929
|
function isTokenLayerFile(f) {
|
|
@@ -6253,7 +6298,7 @@ ${configRelPath} already exists and was not created by 'jig init' (or has been e
|
|
|
6253
6298
|
const oldDir = currentBrand.split("/").slice(0, -1);
|
|
6254
6299
|
const oldAbsDir = join31(opts.projectRoot, ...oldDir);
|
|
6255
6300
|
if (existsSync19(oldAbsDir)) {
|
|
6256
|
-
for (const name of
|
|
6301
|
+
for (const name of readdirSync12(oldAbsDir).filter((f) => f.endsWith(".css"))) {
|
|
6257
6302
|
const rel = relKey(...oldDir, name);
|
|
6258
6303
|
const abs = join31(oldAbsDir, name);
|
|
6259
6304
|
const state = fileState(opts.projectRoot, abs, rel, initManifest);
|
|
@@ -6401,11 +6446,11 @@ function citableIds(packageRoot2) {
|
|
|
6401
6446
|
}
|
|
6402
6447
|
|
|
6403
6448
|
// src/probe/check.ts
|
|
6404
|
-
import { existsSync as existsSync23, readdirSync as
|
|
6449
|
+
import { existsSync as existsSync23, readdirSync as readdirSync15, readFileSync as readFileSync31 } from "fs";
|
|
6405
6450
|
import { join as join37 } from "path";
|
|
6406
6451
|
|
|
6407
6452
|
// src/probe/save.ts
|
|
6408
|
-
import { existsSync as existsSync22, mkdirSync as mkdirSync7, readdirSync as
|
|
6453
|
+
import { existsSync as existsSync22, mkdirSync as mkdirSync7, readdirSync as readdirSync14, readFileSync as readFileSync30, writeFileSync as writeFileSync5 } from "fs";
|
|
6409
6454
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
6410
6455
|
import { join as join36, relative as relative3, resolve as resolve4, sep as sep5 } from "path";
|
|
6411
6456
|
|
|
@@ -6633,6 +6678,25 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6633
6678
|
if (!table || getComputedStyle(table).display !== 'table' || display === 'table-cell' || display === 'none' || !vis(cell)) continue;
|
|
6634
6679
|
brokenCells.push({ cell: cell.tagName.toLowerCase() + (cell.classList[0] ? '.' + cell.classList[0] : ''), text: (cell.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40), display });
|
|
6635
6680
|
}
|
|
6681
|
+
// Rows of one table at different heights, where the taller rows are the
|
|
6682
|
+
// ones holding a control taller than the short rows: a button given the
|
|
6683
|
+
// touch-target floor as its own minimum height. An invoices table had its
|
|
6684
|
+
// "Mark as paid" rows at 65px among 41px ones. Rows that differ because a
|
|
6685
|
+
// cell's text wraps are left alone: only a control can be the cause here.
|
|
6686
|
+
const unevenRows = [];
|
|
6687
|
+
for (const table of [...document.querySelectorAll('table')].slice(0, 20)) {
|
|
6688
|
+
if (unevenRows.length >= 3 || getComputedStyle(table).display !== 'table' || !vis(table)) continue;
|
|
6689
|
+
const rows = [...table.querySelectorAll('tbody tr')].filter((r) => vis(r));
|
|
6690
|
+
if (rows.length < 2) continue;
|
|
6691
|
+
const heights = rows.map((r) => Math.round(r.getBoundingClientRect().height));
|
|
6692
|
+
const short = Math.min(...heights);
|
|
6693
|
+
const control = rows
|
|
6694
|
+
.filter((r, i) => heights[i] > short + 2)
|
|
6695
|
+
.map((r) => [...r.querySelectorAll('button, a[href], input, select')].find((c) => vis(c) && c.getBoundingClientRect().height > short))
|
|
6696
|
+
.find(Boolean);
|
|
6697
|
+
if (!control) continue;
|
|
6698
|
+
unevenRows.push({ table: 'table' + (table.classList[0] ? '.' + table.classList[0] : ''), short, tall: Math.max(...heights), control: control.tagName.toLowerCase() + (control.classList[0] ? '.' + control.classList[0] : ''), controlHeight: Math.round(control.getBoundingClientRect().height) });
|
|
6699
|
+
}
|
|
6636
6700
|
return JSON.stringify({
|
|
6637
6701
|
jigProbe: ${PROBE_VERSION},
|
|
6638
6702
|
url: location.href,
|
|
@@ -6642,6 +6706,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6642
6706
|
sidewaysScroll: doc.scrollWidth > doc.clientWidth,
|
|
6643
6707
|
sidewaysBoxes,
|
|
6644
6708
|
brokenCells,
|
|
6709
|
+
unevenRows,
|
|
6645
6710
|
bodyFont: getComputedStyle(document.body).fontFamily,
|
|
6646
6711
|
defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
|
|
6647
6712
|
unresolvedTokens: [...unresolved],
|
|
@@ -6672,7 +6737,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6672
6737
|
|
|
6673
6738
|
// src/probe/browser.ts
|
|
6674
6739
|
import { spawn } from "child_process";
|
|
6675
|
-
import { existsSync as existsSync20, mkdtempSync, readdirSync as
|
|
6740
|
+
import { existsSync as existsSync20, mkdtempSync, readdirSync as readdirSync13, rmSync as rmSync3 } from "fs";
|
|
6676
6741
|
import { homedir as homedir2, tmpdir } from "os";
|
|
6677
6742
|
import { join as join33 } from "path";
|
|
6678
6743
|
var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
|
|
@@ -6699,7 +6764,7 @@ function cachedBrowsers() {
|
|
|
6699
6764
|
for (const root of roots) {
|
|
6700
6765
|
let entries;
|
|
6701
6766
|
try {
|
|
6702
|
-
entries =
|
|
6767
|
+
entries = readdirSync13(root);
|
|
6703
6768
|
} catch {
|
|
6704
6769
|
continue;
|
|
6705
6770
|
}
|
|
@@ -7007,7 +7072,7 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
7007
7072
|
function recordedPage(projectRoot, surface) {
|
|
7008
7073
|
const dir = join36(projectRoot, ".jig", "critique", surface);
|
|
7009
7074
|
if (!existsSync22(dir)) return void 0;
|
|
7010
|
-
for (const f of
|
|
7075
|
+
for (const f of readdirSync14(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
|
|
7011
7076
|
try {
|
|
7012
7077
|
const probe = JSON.parse(readFileSync30(join36(dir, f), "utf8"));
|
|
7013
7078
|
if (probe.pageFile && existsSync22(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
|
|
@@ -7025,7 +7090,7 @@ async function ensureProbes(opts) {
|
|
|
7025
7090
|
} catch {
|
|
7026
7091
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
7027
7092
|
}
|
|
7028
|
-
const recorded = existsSync22(dir) ?
|
|
7093
|
+
const recorded = existsSync22(dir) ? readdirSync14(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
|
|
7029
7094
|
const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
|
|
7030
7095
|
const missing = widths.filter((width) => {
|
|
7031
7096
|
try {
|
|
@@ -7058,7 +7123,7 @@ async function ensureProbes(opts) {
|
|
|
7058
7123
|
function critiquedSurfaces(projectRoot) {
|
|
7059
7124
|
const root = join36(projectRoot, ".jig", "critique");
|
|
7060
7125
|
if (!existsSync22(root)) return [];
|
|
7061
|
-
return
|
|
7126
|
+
return readdirSync14(root).filter((surface) => !surface.startsWith("_") && (existsSync22(join36(root, surface, "screen.json")) || existsSync22(join36(root, surface, "code.json"))));
|
|
7062
7127
|
}
|
|
7063
7128
|
|
|
7064
7129
|
// src/probe/check.ts
|
|
@@ -7082,7 +7147,7 @@ function stampProblem(projectRoot, file, p) {
|
|
|
7082
7147
|
function readProbes(projectRoot, dir, errors) {
|
|
7083
7148
|
if (!existsSync23(dir)) return [];
|
|
7084
7149
|
const probes = [];
|
|
7085
|
-
for (const f of
|
|
7150
|
+
for (const f of readdirSync15(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
7086
7151
|
try {
|
|
7087
7152
|
const p = JSON.parse(readFileSync31(join37(dir, f), "utf8"));
|
|
7088
7153
|
if (typeof p.jigProbe === "number" && p.jigProbe < PROBE_VERSION && typeof p.width === "number") {
|
|
@@ -7128,6 +7193,9 @@ function probeCheck(probes, verdictOf, indexable = true, mode) {
|
|
|
7128
7193
|
const shown = p.brokenCells.map((c) => `${c.cell}${c.text ? ` ("${c.text}")` : ""} is display: ${c.display}`).join("; ");
|
|
7129
7194
|
failures.push(`${at(p)}: ${p.brokenCells.length === 1 ? "a table cell has" : `${p.brokenCells.length} table cells have`} left the table: ${shown}. A cell given its own display stops taking its row's height, so its border and its text drift from the row's. Keep the cell a cell and put the flex on a wrapper inside it (P-06).`);
|
|
7130
7195
|
}
|
|
7196
|
+
for (const u of p.unevenRows ?? []) {
|
|
7197
|
+
failures.push(`${at(p)}: ${u.table}'s rows are not one height: those holding ${u.control} (${u.controlHeight}px tall) stand ${u.tall}px, the rest ${u.short}px. A button in a row takes the row's height and has no minimum height of its own; on a touch screen the row rises to the touch target instead (P-02, P-06).`);
|
|
7198
|
+
}
|
|
7131
7199
|
if (p.defaultFont) {
|
|
7132
7200
|
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.`);
|
|
7133
7201
|
}
|
|
@@ -7205,12 +7273,12 @@ function probeCheck(probes, verdictOf, indexable = true, mode) {
|
|
|
7205
7273
|
}
|
|
7206
7274
|
|
|
7207
7275
|
// src/check/spec-shape.ts
|
|
7208
|
-
import { existsSync as existsSync24, readdirSync as
|
|
7276
|
+
import { existsSync as existsSync24, readdirSync as readdirSync16, statSync as statSync4 } from "fs";
|
|
7209
7277
|
import { join as join38 } from "path";
|
|
7210
7278
|
function newestSpec(projectRoot) {
|
|
7211
7279
|
const dir = join38(projectRoot, ".jig", "specs");
|
|
7212
7280
|
if (!existsSync24(dir)) return void 0;
|
|
7213
|
-
const files =
|
|
7281
|
+
const files = readdirSync16(dir).filter((f) => f.endsWith(".md"));
|
|
7214
7282
|
if (files.length === 0) return void 0;
|
|
7215
7283
|
const newest = files.map((f) => ({ f, at: statSync4(join38(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
7216
7284
|
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readText(join38(dir, newest)) };
|
|
@@ -7390,9 +7458,18 @@ function specIndexable(projectRoot, surface) {
|
|
|
7390
7458
|
}
|
|
7391
7459
|
const declared2 = specIndexableField(front);
|
|
7392
7460
|
if (declared2 === true || declared2 === false) return declared2;
|
|
7393
|
-
const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase();
|
|
7461
|
+
const mode = /^\s*mode\s*:\s*(\w+)/im.exec(front)?.[1]?.toLowerCase() ?? projectMode(projectRoot);
|
|
7394
7462
|
return mode !== "product" && mode !== "operator";
|
|
7395
7463
|
}
|
|
7464
|
+
function projectMode(projectRoot) {
|
|
7465
|
+
try {
|
|
7466
|
+
const config = JSON.parse(readText(join39(projectRoot, "jig.config.json")));
|
|
7467
|
+
const modes = [...new Set((config.surfaces ?? []).map((s) => s?.mode).filter((m) => typeof m === "string"))];
|
|
7468
|
+
return modes.length === 1 ? modes[0].toLowerCase() : void 0;
|
|
7469
|
+
} catch {
|
|
7470
|
+
return void 0;
|
|
7471
|
+
}
|
|
7472
|
+
}
|
|
7396
7473
|
function specNeedsNav(projectRoot, surface) {
|
|
7397
7474
|
const path = join39(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
7398
7475
|
if (!existsSync25(path)) return false;
|
|
@@ -7599,7 +7676,7 @@ function verifyVerdicts(opts) {
|
|
|
7599
7676
|
if (specIndexableField(specFront) === "unreadable") {
|
|
7600
7677
|
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.`);
|
|
7601
7678
|
} else {
|
|
7602
|
-
const mode = /^\s*mode\s*:\s*["']?(\w+)/im.exec(specFront)?.[1]?.toLowerCase();
|
|
7679
|
+
const mode = /^\s*mode\s*:\s*["']?(\w+)/im.exec(specFront)?.[1]?.toLowerCase() ?? projectMode(opts.projectRoot);
|
|
7603
7680
|
const probed = probeCheck(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface), mode);
|
|
7604
7681
|
errors.push(...probed.contradictions);
|
|
7605
7682
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -7720,7 +7797,7 @@ function sourceChanges(git, from, to) {
|
|
|
7720
7797
|
}
|
|
7721
7798
|
|
|
7722
7799
|
// src/commands/gate.ts
|
|
7723
|
-
import { existsSync as existsSync28, mkdirSync as mkdirSync8, readdirSync as
|
|
7800
|
+
import { existsSync as existsSync28, mkdirSync as mkdirSync8, readdirSync as readdirSync18, readFileSync as readFileSync37, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
|
|
7724
7801
|
import { createHash as createHash3 } from "crypto";
|
|
7725
7802
|
import { join as join44 } from "path";
|
|
7726
7803
|
|
|
@@ -7897,7 +7974,7 @@ function sizeBlock2(sizes, size) {
|
|
|
7897
7974
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
7898
7975
|
|
|
7899
7976
|
// src/commands/ship.ts
|
|
7900
|
-
import { existsSync as existsSync26, readdirSync as
|
|
7977
|
+
import { existsSync as existsSync26, readdirSync as readdirSync17, readFileSync as readFileSync35 } from "fs";
|
|
7901
7978
|
import { join as join42 } from "path";
|
|
7902
7979
|
|
|
7903
7980
|
// src/commands/seo.ts
|
|
@@ -8095,7 +8172,7 @@ function ship(opts) {
|
|
|
8095
8172
|
const seoResult = seo({ projectRoot: root });
|
|
8096
8173
|
const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
|
|
8097
8174
|
const specsDir = join42(root, ".jig", "specs");
|
|
8098
|
-
const surfaces = existsSync26(specsDir) ?
|
|
8175
|
+
const surfaces = existsSync26(specsDir) ? readdirSync17(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
|
|
8099
8176
|
const pages = surfaces.map((s) => pageStatus(root, s));
|
|
8100
8177
|
const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress" && p.state !== "superseded");
|
|
8101
8178
|
const agentFiles = agentFileProblems(root);
|
|
@@ -8389,7 +8466,7 @@ function verdictsMtime(dir) {
|
|
|
8389
8466
|
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
8390
8467
|
const critiqueDir = join44(root, ".jig", "critique");
|
|
8391
8468
|
if (!existsSync28(critiqueDir)) return [];
|
|
8392
|
-
const all =
|
|
8469
|
+
const all = readdirSync18(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
8393
8470
|
const start = sessionStart(transcriptPath);
|
|
8394
8471
|
if (start === void 0) return all;
|
|
8395
8472
|
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
@@ -8565,7 +8642,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
8565
8642
|
}
|
|
8566
8643
|
if (command === "critique") {
|
|
8567
8644
|
const dir = join44(root, ".jig", "critique");
|
|
8568
|
-
const surfaces = existsSync28(dir) ?
|
|
8645
|
+
const surfaces = existsSync28(dir) ? readdirSync18(dir).filter((s) => existsSync28(join44(dir, s, "screen.json")) || existsSync28(join44(dir, s, "code.json"))) : [];
|
|
8569
8646
|
for (const surface2 of surfaces) {
|
|
8570
8647
|
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
8571
8648
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -8911,7 +8988,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
8911
8988
|
const critiqueDir = join44(root, ".jig", "critique");
|
|
8912
8989
|
if (!existsSync28(critiqueDir)) return [];
|
|
8913
8990
|
const problems = [];
|
|
8914
|
-
for (const surface of
|
|
8991
|
+
for (const surface of readdirSync18(critiqueDir)) {
|
|
8915
8992
|
if (surface.startsWith("_")) continue;
|
|
8916
8993
|
const dir = join44(critiqueDir, surface);
|
|
8917
8994
|
const now = verdictChecksum(dir);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.3",
|
|
4
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",
|
|
@@ -366,7 +366,7 @@ Without `viewport-fit=cover`, the browser keeps the page inside the safe area on
|
|
|
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
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
|
-
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.
|
|
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. That wrapper also takes `position: relative`, or a visually hidden label inside it, positioned absolutely, lays itself out against the page and widens it.
|
|
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
|
|
|
372
372
|
## E. States and interaction
|
|
@@ -556,8 +556,8 @@ Where people must *browse* to decide, split the list into two dependent fields
|
|
|
556
556
|
---
|
|
557
557
|
|
|
558
558
|
### F-113 Form text small enough to make the phone zoom
|
|
559
|
-
❌ An input, select or textarea whose text is below 16px — including `operator`'s 14px `--text-body
|
|
560
|
-
✅ At least 16px on touch screens. To keep
|
|
559
|
+
❌ An input, select or textarea whose text is below 16px — including `--text-caption` in every mode, a size written as `font: inherit` under a 14px body, and `operator`'s 14px `--text-body` where the tokens do not raise it on touch screens
|
|
560
|
+
✅ At least 16px on touch screens. `operator`'s tokens raise `--text-body` to 16px under a coarse pointer, so a control set in the body size, or inheriting it, is safe there. To keep any other denser size on desktop, raise it only where it matters: `@media (pointer: coarse) { input, select, textarea { font-size: max(16px, var(--text-body)); } }`
|
|
561
561
|
iOS Safari zooms the whole page when a field whose text is below 16px takes focus, and it does not zoom back out when the field loses it. The reader is left with a form wider than the screen, scrolling sideways to find the next field (`D-115`).
|
|
562
562
|
Setting `maximum-scale=1` on the viewport to stop it is not the fix. That disables pinch zoom for everyone, which is an accessibility failure in its own right.
|
|
563
563
|
|
package/rules/03-patterns.md
CHANGED
|
@@ -53,7 +53,16 @@ Four numbers, and most button designs in the wild fail at least one:
|
|
|
53
53
|
| Button **shape** — fill or border — against its background | **3:1** |
|
|
54
54
|
| Button **text** against the button | **4.5:1** |
|
|
55
55
|
| Two buttons sharing a style, distinguished only by contrast | **3:1 between them** |
|
|
56
|
-
| Hit area | `--size-touch-target` — **48×48px** floor |
|
|
56
|
+
| Hit area | `--size-touch-target` — **48×48px** floor, except inside a table row (below) |
|
|
57
|
+
|
|
58
|
+
**Inside a table row, a button takes the row's height.** A dense row is shorter
|
|
59
|
+
than the floor, and a button given `min-height: var(--size-touch-target)` there
|
|
60
|
+
stretches the rows that hold it and not the rest: an invoices table had its
|
|
61
|
+
"Mark as paid" rows at 65px among 41px ones, twice. Size it to `--size-control`
|
|
62
|
+
and never give it a minimum height of its own. On a touch screen the row itself
|
|
63
|
+
rises to `--size-touch-target` (the mode files do it under a coarse pointer), so
|
|
64
|
+
a finger still gets the floor. The render probe reports rows made uneven this
|
|
65
|
+
way.
|
|
57
66
|
|
|
58
67
|
A secondary button's fill or border is **not decorative**. It is the only thing identifying the element as a button, so it carries the 3:1 non-text requirement (`02-tokens.md`). Strip it and you have coloured text.
|
|
59
68
|
|
|
@@ -285,6 +294,7 @@ Primarily an `operator` pattern; the rules apply wherever a table appears.
|
|
|
285
294
|
**Rules**
|
|
286
295
|
- Row identity is stable across refresh, sort and filter. A row that moves under a click causes the wrong record to be acted on — the most damaging bug this pattern has.
|
|
287
296
|
- Numeric columns are right-aligned and use tabular figures (`--font-numeric`). Text columns are start-aligned. Never centre either.
|
|
297
|
+
- Every row is one height. A button or link in a row takes the row's height and has no minimum height of its own (see the Button pattern, Hit area).
|
|
288
298
|
- A cell stays a table cell. To line up what is inside it (a name and a tag, an icon and a label), put `display: flex` on a wrapper inside the cell, never on the `<td>` or `<th>` itself: a cell given its own display stops taking its row's height, so its text and its border drift from the rest of the row. A table stacked on a phone, the whole table set to `block`, is the exception (`L-01`, step 6). The render probe reports a cell that has left its table.
|
|
289
299
|
- Column headers state units. "Weight" is ambiguous; "Weight (kg)" is not.
|
|
290
300
|
- Truncation is resolvable by click or expand, never by hover alone (`E-31`, `01-modes.md`).
|
|
@@ -568,7 +578,11 @@ everything else follows from.
|
|
|
568
578
|
5. **Content that is genuinely wider scrolls inside itself, except in
|
|
569
579
|
`editorial` on a phone.** A code block, a wide table, a long identifier: its
|
|
570
580
|
own container scrolls with a visible edge (`E-62`), keeps its type size, and
|
|
571
|
-
the page never scrolls sideways (`D-115`).
|
|
581
|
+
the page never scrolls sideways (`D-115`). The container is
|
|
582
|
+
`position: relative` as well as `overflow-x: auto`: text hidden for screen
|
|
583
|
+
readers in a cell is positioned absolutely, against the nearest positioned
|
|
584
|
+
ancestor, and without one it escapes the container and widens the page,
|
|
585
|
+
though nothing shows. `editorial` allows no scrolling
|
|
572
586
|
region on mobile (`M-01`), so there each one reflows instead:
|
|
573
587
|
- **A code block** keeps its line breaks and indents and wraps a long line
|
|
574
588
|
(`white-space: pre-wrap`, `overflow-wrap: anywhere`). What is copied is
|
package/tokens/mode.operator.css
CHANGED
|
@@ -107,7 +107,13 @@
|
|
|
107
107
|
compact) is above WCAG 2.2's 24px minimum for a mouse, and below the 48px a
|
|
108
108
|
finger needs, so under a coarse pointer every row rises to the floor. A
|
|
109
109
|
control inside a row never sets its own height to reach it: every row in a
|
|
110
|
-
table is one height, and here the tokens make that height the right one.
|
|
110
|
+
table is one height, and here the tokens make that height the right one.
|
|
111
|
+
Body text rises to 16px with it: an input, select or textarea set in 14px
|
|
112
|
+
makes iOS zoom the page on focus (`F-113`), and a form control's text is
|
|
113
|
+
body text, set by this token or inherited from it. */
|
|
111
114
|
@media (pointer: coarse) {
|
|
112
|
-
:root {
|
|
115
|
+
:root {
|
|
116
|
+
--size-row: var(--size-touch-target); --size-row-compact: var(--size-touch-target);
|
|
117
|
+
--text-body: 16px;
|
|
118
|
+
}
|
|
113
119
|
}
|