jig-ui 0.26.2 → 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 +30 -0
- package/dist/index.js +86 -32
- package/package.json +1 -1
- package/rules/00-anti-patterns.md +3 -3
- package/rules/03-patterns.md +5 -1
- package/tokens/mode.operator.css +8 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
3
33
|
## 0.26.2 (2026-10-13)
|
|
4
34
|
|
|
5
35
|
A button in a table row keeps the row's height, and the render probe reports
|
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
|
|
|
@@ -6692,7 +6737,7 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
6692
6737
|
|
|
6693
6738
|
// src/probe/browser.ts
|
|
6694
6739
|
import { spawn } from "child_process";
|
|
6695
|
-
import { existsSync as existsSync20, mkdtempSync, readdirSync as
|
|
6740
|
+
import { existsSync as existsSync20, mkdtempSync, readdirSync as readdirSync13, rmSync as rmSync3 } from "fs";
|
|
6696
6741
|
import { homedir as homedir2, tmpdir } from "os";
|
|
6697
6742
|
import { join as join33 } from "path";
|
|
6698
6743
|
var CHROME_ENV = ["JIG_CHROME", "CHROME_PATH", "PUPPETEER_EXECUTABLE_PATH", "CHROMIUM_PATH"];
|
|
@@ -6719,7 +6764,7 @@ function cachedBrowsers() {
|
|
|
6719
6764
|
for (const root of roots) {
|
|
6720
6765
|
let entries;
|
|
6721
6766
|
try {
|
|
6722
|
-
entries =
|
|
6767
|
+
entries = readdirSync13(root);
|
|
6723
6768
|
} catch {
|
|
6724
6769
|
continue;
|
|
6725
6770
|
}
|
|
@@ -7027,7 +7072,7 @@ async function withPageUrl(projectRoot, abs, serve, use) {
|
|
|
7027
7072
|
function recordedPage(projectRoot, surface) {
|
|
7028
7073
|
const dir = join36(projectRoot, ".jig", "critique", surface);
|
|
7029
7074
|
if (!existsSync22(dir)) return void 0;
|
|
7030
|
-
for (const f of
|
|
7075
|
+
for (const f of readdirSync14(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
|
|
7031
7076
|
try {
|
|
7032
7077
|
const probe = JSON.parse(readFileSync30(join36(dir, f), "utf8"));
|
|
7033
7078
|
if (probe.pageFile && existsSync22(resolve4(projectRoot, probe.pageFile))) return probe.pageFile;
|
|
@@ -7045,7 +7090,7 @@ async function ensureProbes(opts) {
|
|
|
7045
7090
|
} catch {
|
|
7046
7091
|
return { recorded: [], reason: `${opts.page} could not be read` };
|
|
7047
7092
|
}
|
|
7048
|
-
const recorded = existsSync22(dir) ?
|
|
7093
|
+
const recorded = existsSync22(dir) ? readdirSync14(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
|
|
7049
7094
|
const widths = [.../* @__PURE__ */ new Set([...probeWidths(PROBE_WIDTHS, declaredSwitches(opts.projectRoot)), ...recorded])].sort((a, b) => a - b);
|
|
7050
7095
|
const missing = widths.filter((width) => {
|
|
7051
7096
|
try {
|
|
@@ -7078,7 +7123,7 @@ async function ensureProbes(opts) {
|
|
|
7078
7123
|
function critiquedSurfaces(projectRoot) {
|
|
7079
7124
|
const root = join36(projectRoot, ".jig", "critique");
|
|
7080
7125
|
if (!existsSync22(root)) return [];
|
|
7081
|
-
return
|
|
7126
|
+
return readdirSync14(root).filter((surface) => !surface.startsWith("_") && (existsSync22(join36(root, surface, "screen.json")) || existsSync22(join36(root, surface, "code.json"))));
|
|
7082
7127
|
}
|
|
7083
7128
|
|
|
7084
7129
|
// src/probe/check.ts
|
|
@@ -7102,7 +7147,7 @@ function stampProblem(projectRoot, file, p) {
|
|
|
7102
7147
|
function readProbes(projectRoot, dir, errors) {
|
|
7103
7148
|
if (!existsSync23(dir)) return [];
|
|
7104
7149
|
const probes = [];
|
|
7105
|
-
for (const f of
|
|
7150
|
+
for (const f of readdirSync15(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
7106
7151
|
try {
|
|
7107
7152
|
const p = JSON.parse(readFileSync31(join37(dir, f), "utf8"));
|
|
7108
7153
|
if (typeof p.jigProbe === "number" && p.jigProbe < PROBE_VERSION && typeof p.width === "number") {
|
|
@@ -7228,12 +7273,12 @@ function probeCheck(probes, verdictOf, indexable = true, mode) {
|
|
|
7228
7273
|
}
|
|
7229
7274
|
|
|
7230
7275
|
// src/check/spec-shape.ts
|
|
7231
|
-
import { existsSync as existsSync24, readdirSync as
|
|
7276
|
+
import { existsSync as existsSync24, readdirSync as readdirSync16, statSync as statSync4 } from "fs";
|
|
7232
7277
|
import { join as join38 } from "path";
|
|
7233
7278
|
function newestSpec(projectRoot) {
|
|
7234
7279
|
const dir = join38(projectRoot, ".jig", "specs");
|
|
7235
7280
|
if (!existsSync24(dir)) return void 0;
|
|
7236
|
-
const files =
|
|
7281
|
+
const files = readdirSync16(dir).filter((f) => f.endsWith(".md"));
|
|
7237
7282
|
if (files.length === 0) return void 0;
|
|
7238
7283
|
const newest = files.map((f) => ({ f, at: statSync4(join38(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
7239
7284
|
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readText(join38(dir, newest)) };
|
|
@@ -7413,9 +7458,18 @@ function specIndexable(projectRoot, surface) {
|
|
|
7413
7458
|
}
|
|
7414
7459
|
const declared2 = specIndexableField(front);
|
|
7415
7460
|
if (declared2 === true || declared2 === false) return declared2;
|
|
7416
|
-
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);
|
|
7417
7462
|
return mode !== "product" && mode !== "operator";
|
|
7418
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
|
+
}
|
|
7419
7473
|
function specNeedsNav(projectRoot, surface) {
|
|
7420
7474
|
const path = join39(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
7421
7475
|
if (!existsSync25(path)) return false;
|
|
@@ -7622,7 +7676,7 @@ function verifyVerdicts(opts) {
|
|
|
7622
7676
|
if (specIndexableField(specFront) === "unreadable") {
|
|
7623
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.`);
|
|
7624
7678
|
} else {
|
|
7625
|
-
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);
|
|
7626
7680
|
const probed = probeCheck(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface), mode);
|
|
7627
7681
|
errors.push(...probed.contradictions);
|
|
7628
7682
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -7743,7 +7797,7 @@ function sourceChanges(git, from, to) {
|
|
|
7743
7797
|
}
|
|
7744
7798
|
|
|
7745
7799
|
// src/commands/gate.ts
|
|
7746
|
-
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";
|
|
7747
7801
|
import { createHash as createHash3 } from "crypto";
|
|
7748
7802
|
import { join as join44 } from "path";
|
|
7749
7803
|
|
|
@@ -7920,7 +7974,7 @@ function sizeBlock2(sizes, size) {
|
|
|
7920
7974
|
import { execFileSync as execFileSync4 } from "child_process";
|
|
7921
7975
|
|
|
7922
7976
|
// src/commands/ship.ts
|
|
7923
|
-
import { existsSync as existsSync26, readdirSync as
|
|
7977
|
+
import { existsSync as existsSync26, readdirSync as readdirSync17, readFileSync as readFileSync35 } from "fs";
|
|
7924
7978
|
import { join as join42 } from "path";
|
|
7925
7979
|
|
|
7926
7980
|
// src/commands/seo.ts
|
|
@@ -8118,7 +8172,7 @@ function ship(opts) {
|
|
|
8118
8172
|
const seoResult = seo({ projectRoot: root });
|
|
8119
8173
|
const seoErrors = seoResult.findings.filter((f) => f.severity === "error").length;
|
|
8120
8174
|
const specsDir = join42(root, ".jig", "specs");
|
|
8121
|
-
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() : [];
|
|
8122
8176
|
const pages = surfaces.map((s) => pageStatus(root, s));
|
|
8123
8177
|
const owed = pages.filter((p) => p.state !== "judged" && p.state !== "in-progress" && p.state !== "superseded");
|
|
8124
8178
|
const agentFiles = agentFileProblems(root);
|
|
@@ -8412,7 +8466,7 @@ function verdictsMtime(dir) {
|
|
|
8412
8466
|
function surfacesInPlay(root, command, transcriptPath, surface) {
|
|
8413
8467
|
const critiqueDir = join44(root, ".jig", "critique");
|
|
8414
8468
|
if (!existsSync28(critiqueDir)) return [];
|
|
8415
|
-
const all =
|
|
8469
|
+
const all = readdirSync18(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
|
|
8416
8470
|
const start = sessionStart(transcriptPath);
|
|
8417
8471
|
if (start === void 0) return all;
|
|
8418
8472
|
const current = command === "critique" || command === "tweak" ? specFor(root, surface)?.slug : void 0;
|
|
@@ -8588,7 +8642,7 @@ function commandProblems(root, command, surface, start, owner = "") {
|
|
|
8588
8642
|
}
|
|
8589
8643
|
if (command === "critique") {
|
|
8590
8644
|
const dir = join44(root, ".jig", "critique");
|
|
8591
|
-
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"))) : [];
|
|
8592
8646
|
for (const surface2 of surfaces) {
|
|
8593
8647
|
const v = verifyVerdicts({ projectRoot: root, surface: surface2 });
|
|
8594
8648
|
if (v.ok && v.decisions.state !== "ran" && v.decisions.total > 0) {
|
|
@@ -8934,7 +8988,7 @@ function verdictGuard(root, command, inPlay) {
|
|
|
8934
8988
|
const critiqueDir = join44(root, ".jig", "critique");
|
|
8935
8989
|
if (!existsSync28(critiqueDir)) return [];
|
|
8936
8990
|
const problems = [];
|
|
8937
|
-
for (const surface of
|
|
8991
|
+
for (const surface of readdirSync18(critiqueDir)) {
|
|
8938
8992
|
if (surface.startsWith("_")) continue;
|
|
8939
8993
|
const dir = join44(critiqueDir, surface);
|
|
8940
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
|
@@ -578,7 +578,11 @@ everything else follows from.
|
|
|
578
578
|
5. **Content that is genuinely wider scrolls inside itself, except in
|
|
579
579
|
`editorial` on a phone.** A code block, a wide table, a long identifier: its
|
|
580
580
|
own container scrolls with a visible edge (`E-62`), keeps its type size, and
|
|
581
|
-
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
|
|
582
586
|
region on mobile (`M-01`), so there each one reflows instead:
|
|
583
587
|
- **A code block** keeps its line breaks and indents and wraps a long line
|
|
584
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
|
}
|