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 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-size\s*:\s*([^;}]+)/gi;
3673
- var TOUCH_QUERY_RE = /@media[^{]*\b(?:pointer\s*:\s*coarse|hover\s*:\s*none)\b/i;
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
- if (TOUCH_QUERY_RE.test(source)) return [];
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
- const px2 = pixels(m[1], ctx.mode);
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 readdirSync11, readFileSync as readFileSync27, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "fs";
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 = readdirSync11(dir).find((f) => /^brand\.[\w.-]+\.css$/.test(f));
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 readdirSync11(oldAbsDir).filter((f) => f.endsWith(".css"))) {
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 readdirSync14, readFileSync as readFileSync31 } from "fs";
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 readdirSync13, readFileSync as readFileSync30, writeFileSync as writeFileSync5 } from "fs";
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 readdirSync12, rmSync as rmSync3 } from "fs";
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 = readdirSync12(root);
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 readdirSync13(dir).filter((name) => /^probe-\d+\.json$/.test(name)).sort()) {
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) ? readdirSync13(dir).map((f) => /^probe-(\d+)\.json$/.exec(f)?.[1]).filter((w) => !!w).map(Number) : [];
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 readdirSync13(root).filter((surface) => !surface.startsWith("_") && (existsSync22(join36(root, surface, "screen.json")) || existsSync22(join36(root, surface, "code.json"))));
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 readdirSync14(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
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 readdirSync15, statSync as statSync4 } from "fs";
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 = readdirSync15(dir).filter((f) => f.endsWith(".md"));
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 readdirSync17, readFileSync as readFileSync37, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
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 readdirSync16, readFileSync as readFileSync35 } from "fs";
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) ? readdirSync16(specsDir).filter((f) => f.endsWith(".spec.md") && !f.startsWith("_")).map((f) => f.replace(/\.spec\.md$/, "")).sort() : [];
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 = readdirSync17(critiqueDir, { withFileTypes: true }).filter((d) => d.isDirectory() && !d.name.startsWith("_") && !d.name.startsWith(".")).map((d) => d.name);
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) ? readdirSync17(dir).filter((s) => existsSync28(join44(dir, s, "screen.json")) || existsSync28(join44(dir, s, "code.json"))) : [];
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 readdirSync17(critiqueDir)) {
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.2",
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`, and `--text-caption` in every mode
560
- ✅ At least 16px on touch screens. To keep a denser size on desktop, raise it only where it matters: `@media (pointer: coarse) { input, select, textarea { font-size: max(16px, var(--text-body)); } }`
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
 
@@ -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`). `editorial` allows no scrolling
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
@@ -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 { --size-row: var(--size-touch-target); --size-row-compact: var(--size-touch-target); }
115
+ :root {
116
+ --size-row: var(--size-touch-target); --size-row-compact: var(--size-touch-target);
117
+ --text-body: 16px;
118
+ }
113
119
  }