jig-ui 0.24.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2098,6 +2098,139 @@ function isReaderText(file) {
2098
2098
  return hasExtension(file, STYLE_HOST_EXTENSIONS);
2099
2099
  }
2100
2100
 
2101
+ // src/check/length.ts
2102
+ var LENGTH_UNITS = [
2103
+ "px",
2104
+ "rem",
2105
+ "em",
2106
+ "pt",
2107
+ "vh",
2108
+ "vw",
2109
+ "vmin",
2110
+ "vmax",
2111
+ "ch",
2112
+ "ex"
2113
+ ];
2114
+ var UNITS = LENGTH_UNITS.join("|");
2115
+ var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
2116
+ var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
2117
+ function isExcludedLength(value, unit) {
2118
+ if (value === 0) return true;
2119
+ return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
2120
+ }
2121
+ function hasHardCodedLength(value) {
2122
+ LENGTH_SCAN_RE.lastIndex = 0;
2123
+ let m;
2124
+ while (m = LENGTH_SCAN_RE.exec(value)) {
2125
+ if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
2126
+ }
2127
+ return false;
2128
+ }
2129
+
2130
+ // src/check/tailwind.ts
2131
+ var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2132
+ function readBraced(source, open) {
2133
+ let depth = 0;
2134
+ for (let i = open; i < source.length; i++) {
2135
+ const ch = source[i];
2136
+ if (ch === '"' || ch === "'" || ch === "`") {
2137
+ const quote = ch;
2138
+ i++;
2139
+ while (i < source.length && source[i] !== quote) {
2140
+ if (source[i] === "\\") i++;
2141
+ i++;
2142
+ }
2143
+ continue;
2144
+ }
2145
+ if (ch === "{") depth++;
2146
+ else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2147
+ }
2148
+ return null;
2149
+ }
2150
+ function classAttributeValues(source) {
2151
+ const out = [];
2152
+ const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2153
+ for (const m of source.matchAll(CLASS_ATTR)) {
2154
+ const at = m.index + m[0].length;
2155
+ const opener = source[at];
2156
+ if (opener === '"' || opener === "'") {
2157
+ const end = source.indexOf(opener, at + 1);
2158
+ if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2159
+ continue;
2160
+ }
2161
+ if (opener !== "{") continue;
2162
+ const raw = readBraced(source, at);
2163
+ if (raw === null) continue;
2164
+ const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2165
+ (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2166
+ );
2167
+ if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2168
+ }
2169
+ return out;
2170
+ }
2171
+ var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2172
+ var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2173
+ var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
2174
+ function arbitraryValues(classes) {
2175
+ const out = [];
2176
+ for (const m of classes.matchAll(ARBITRARY)) {
2177
+ const [, utility, rawValue] = m;
2178
+ const value = rawValue.replace(/_/g, " ");
2179
+ if (/var\(|--/.test(value)) continue;
2180
+ if (COLOUR.test(value)) {
2181
+ out.push({ utility, value, kind: "colour" });
2182
+ continue;
2183
+ }
2184
+ if (!LENGTH_RE.test(value)) continue;
2185
+ const parts = LENGTH_PARTS.exec(value);
2186
+ if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
2187
+ out.push({ utility, value, kind: "length" });
2188
+ }
2189
+ return out;
2190
+ }
2191
+ var PALETTE = {
2192
+ white: "#ffffff",
2193
+ black: "#000000",
2194
+ "gray-50": "#f9fafb",
2195
+ "gray-100": "#f3f4f6",
2196
+ "gray-200": "#e5e7eb",
2197
+ "gray-300": "#d1d5db",
2198
+ "gray-400": "#9ca3af",
2199
+ "gray-500": "#6b7280",
2200
+ "gray-600": "#4b5563",
2201
+ "gray-700": "#374151",
2202
+ "gray-800": "#1f2937",
2203
+ "gray-900": "#111827",
2204
+ "gray-950": "#030712",
2205
+ "slate-50": "#f8fafc",
2206
+ "slate-400": "#94a3b8",
2207
+ "slate-500": "#64748b",
2208
+ "slate-800": "#1e293b",
2209
+ "slate-900": "#0f172a",
2210
+ "slate-950": "#020617",
2211
+ "zinc-400": "#a1a1aa",
2212
+ "zinc-500": "#71717a",
2213
+ "zinc-900": "#18181b",
2214
+ "neutral-400": "#a3a3a3",
2215
+ "neutral-500": "#737373",
2216
+ "neutral-900": "#171717",
2217
+ "stone-400": "#a8a29e",
2218
+ "stone-500": "#78716c",
2219
+ "stone-900": "#1c1917"
2220
+ };
2221
+ function palettePairs(classes) {
2222
+ let background;
2223
+ let foreground;
2224
+ for (const token of classes.split(/\s+/)) {
2225
+ const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
2226
+ const bg = /^bg-(.+)$/.exec(bare2);
2227
+ const fg = /^text-(.+)$/.exec(bare2);
2228
+ if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
2229
+ if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
2230
+ }
2231
+ return background && foreground ? [{ background, foreground }] : [];
2232
+ }
2233
+
2101
2234
  // src/check/run.ts
2102
2235
  import { readFileSync as readFileSync15 } from "fs";
2103
2236
  import { join as join19 } from "path";
@@ -2904,139 +3037,6 @@ var pureBlackWhite = {
2904
3037
  }
2905
3038
  };
2906
3039
 
2907
- // src/check/length.ts
2908
- var LENGTH_UNITS = [
2909
- "px",
2910
- "rem",
2911
- "em",
2912
- "pt",
2913
- "vh",
2914
- "vw",
2915
- "vmin",
2916
- "vmax",
2917
- "ch",
2918
- "ex"
2919
- ];
2920
- var UNITS = LENGTH_UNITS.join("|");
2921
- var LENGTH_RE = new RegExp(`^-?\\d*\\.?\\d+(?:${UNITS})$`, "i");
2922
- var LENGTH_SCAN_RE = new RegExp(`(-?\\d*\\.?\\d+)(${UNITS})`, "gi");
2923
- function isExcludedLength(value, unit) {
2924
- if (value === 0) return true;
2925
- return unit.toLowerCase() === "px" && (Math.abs(value) === 1 || Math.abs(value) === 2);
2926
- }
2927
- function hasHardCodedLength(value) {
2928
- LENGTH_SCAN_RE.lastIndex = 0;
2929
- let m;
2930
- while (m = LENGTH_SCAN_RE.exec(value)) {
2931
- if (!isExcludedLength(parseFloat(m[1]), m[2])) return true;
2932
- }
2933
- return false;
2934
- }
2935
-
2936
- // src/check/tailwind.ts
2937
- var CLASS_ATTR = /\b(?:class|className)\s*=\s*/g;
2938
- function readBraced(source, open) {
2939
- let depth = 0;
2940
- for (let i = open; i < source.length; i++) {
2941
- const ch = source[i];
2942
- if (ch === '"' || ch === "'" || ch === "`") {
2943
- const quote = ch;
2944
- i++;
2945
- while (i < source.length && source[i] !== quote) {
2946
- if (source[i] === "\\") i++;
2947
- i++;
2948
- }
2949
- continue;
2950
- }
2951
- if (ch === "{") depth++;
2952
- else if (ch === "}" && --depth === 0) return source.slice(open + 1, i);
2953
- }
2954
- return null;
2955
- }
2956
- function classAttributeValues(source) {
2957
- const out = [];
2958
- const lineAt = (offset) => source.slice(0, offset).split("\n").length;
2959
- for (const m of source.matchAll(CLASS_ATTR)) {
2960
- const at = m.index + m[0].length;
2961
- const opener = source[at];
2962
- if (opener === '"' || opener === "'") {
2963
- const end = source.indexOf(opener, at + 1);
2964
- if (end !== -1) out.push({ classes: source.slice(at + 1, end), line: lineAt(at) });
2965
- continue;
2966
- }
2967
- if (opener !== "{") continue;
2968
- const raw = readBraced(source, at);
2969
- if (raw === null) continue;
2970
- const literals = [...raw.matchAll(/(["'`])([\s\S]*?)\1/g)].map(
2971
- (q) => q[2].replace(/\$\{[\s\S]*?\}/g, " ")
2972
- );
2973
- if (literals.length > 0) out.push({ classes: literals.join(" ").trim(), line: lineAt(at) });
2974
- }
2975
- return out;
2976
- }
2977
- var ARBITRARY = /(?:^|\s)(?:[\w-]+:)*([a-z][\w-]*)-\[([^\]\s]+)\]/gi;
2978
- var COLOUR = /^(?:#[0-9a-f]{3,8}|(?:rgba?|hsla?|oklch|oklab|lab|lch|color)\()/i;
2979
- var LENGTH_PARTS = /^(-?\d*\.?\d+)([a-z]+)$/i;
2980
- function arbitraryValues(classes) {
2981
- const out = [];
2982
- for (const m of classes.matchAll(ARBITRARY)) {
2983
- const [, utility, rawValue] = m;
2984
- const value = rawValue.replace(/_/g, " ");
2985
- if (/var\(|--/.test(value)) continue;
2986
- if (COLOUR.test(value)) {
2987
- out.push({ utility, value, kind: "colour" });
2988
- continue;
2989
- }
2990
- if (!LENGTH_RE.test(value)) continue;
2991
- const parts = LENGTH_PARTS.exec(value);
2992
- if (isExcludedLength(parseFloat(parts[1]), parts[2])) continue;
2993
- out.push({ utility, value, kind: "length" });
2994
- }
2995
- return out;
2996
- }
2997
- var PALETTE = {
2998
- white: "#ffffff",
2999
- black: "#000000",
3000
- "gray-50": "#f9fafb",
3001
- "gray-100": "#f3f4f6",
3002
- "gray-200": "#e5e7eb",
3003
- "gray-300": "#d1d5db",
3004
- "gray-400": "#9ca3af",
3005
- "gray-500": "#6b7280",
3006
- "gray-600": "#4b5563",
3007
- "gray-700": "#374151",
3008
- "gray-800": "#1f2937",
3009
- "gray-900": "#111827",
3010
- "gray-950": "#030712",
3011
- "slate-50": "#f8fafc",
3012
- "slate-400": "#94a3b8",
3013
- "slate-500": "#64748b",
3014
- "slate-800": "#1e293b",
3015
- "slate-900": "#0f172a",
3016
- "slate-950": "#020617",
3017
- "zinc-400": "#a1a1aa",
3018
- "zinc-500": "#71717a",
3019
- "zinc-900": "#18181b",
3020
- "neutral-400": "#a3a3a3",
3021
- "neutral-500": "#737373",
3022
- "neutral-900": "#171717",
3023
- "stone-400": "#a8a29e",
3024
- "stone-500": "#78716c",
3025
- "stone-900": "#1c1917"
3026
- };
3027
- function palettePairs(classes) {
3028
- let background;
3029
- let foreground;
3030
- for (const token of classes.split(/\s+/)) {
3031
- const bare2 = token.replace(/^(?:[\w-]+:)*/, "");
3032
- const bg = /^bg-(.+)$/.exec(bare2);
3033
- const fg = /^text-(.+)$/.exec(bare2);
3034
- if (bg && PALETTE[bg[1]]) background = PALETTE[bg[1]];
3035
- if (fg && PALETTE[fg[1]]) foreground = PALETTE[fg[1]];
3036
- }
3037
- return background && foreground ? [{ background, foreground }] : [];
3038
- }
3039
-
3040
3040
  // src/check/detectors/contrast-floor.ts
3041
3041
  var FLOOR = 4.5;
3042
3042
  var LARGE_FLOOR = 3;
@@ -3631,7 +3631,7 @@ var EM_DASH = /—/;
3631
3631
  var TAG = /<\/?[A-Za-z][\w.:-]*(?:\s[^<>]*)?\/?>/g;
3632
3632
  function maskNonProse(source) {
3633
3633
  const blank3 = (m) => " ".repeat(m.length);
3634
- return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
3634
+ return source.replace(/^---\n[\s\S]*?\n---/, blank3).replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<(blockquote|q|pre|code|samp|kbd)\b[^>]*>[\s\S]*?<\/\1\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
3635
3635
  }
3636
3636
  function markdownProse(source) {
3637
3637
  const blank3 = (m) => " ".repeat(m.length);
@@ -3914,6 +3914,8 @@ function firstGroup(match) {
3914
3914
  if (!value) return void 0;
3915
3915
  return { value, index: match.index ?? 0 };
3916
3916
  }
3917
+ var PREVIEW_TITLE = /property\s*=\s*["']og:title["']|(?:^|[\s{,])["']?(?:ogTitle|og:title)["']?\s*:|openGraph\s*:\s*\{[^}]*?\btitle\s*:/;
3918
+ var PREVIEW_DESCRIPTION = /property\s*=\s*["']og:description["']|(?:^|[\s{,])["']?(?:ogDescription|og:description)["']?\s*:|openGraph\s*:\s*\{[^}]*?\bdescription\s*:/;
3917
3919
  function readMetadata(source) {
3918
3920
  const htmlTitle = firstGroup(HTML_TITLE.exec(source));
3919
3921
  const title = htmlTitle ?? firstGroup(KEYED_TITLE.exec(source));
@@ -3931,6 +3933,9 @@ function readMetadata(source) {
3931
3933
  description: description && !/^\{|\$\{/.test(description.value) ? description : void 0,
3932
3934
  hasTitle: ANY_TITLE.test(source),
3933
3935
  hasDescription: ANY_DESCRIPTION.test(source),
3936
+ hasPreviewTitle: PREVIEW_TITLE.test(source),
3937
+ hasPreviewDescription: PREVIEW_DESCRIPTION.test(source),
3938
+ hasPreview: PREVIEW_TITLE.test(source) && PREVIEW_DESCRIPTION.test(source),
3934
3939
  declares: DECLARES.test(source),
3935
3940
  computedTitle: COMPUTED_TITLE.test(source),
3936
3941
  noindex: NOINDEX.some((re) => re.test(source))
@@ -3943,6 +3948,7 @@ var TITLE_BUDGET = 60;
3943
3948
  var DESCRIPTION_BUDGET = 155;
3944
3949
 
3945
3950
  // src/check/detectors/metadata.ts
3951
+ var HEAD_FRAGMENT = /<svelte:head[\s>]|<head[\s>]|<Head[\s>]|<Helmet[\s>]|slot\s*=\s*["']head["']/;
3946
3952
  var INVENTED_DATE = /(?:lastModified|datePublished|dateModified|publishedTime|modifiedTime)\s*:\s*(?:new Date\(\s*\)|Date\.now\(\s*\))/;
3947
3953
  var metadata = {
3948
3954
  name: "metadata",
@@ -3976,9 +3982,19 @@ var metadata = {
3976
3982
  if (indexable && !found.hasDescription) {
3977
3983
  at("J-121", 0, "this page has no meta description \u2014 the search engine writes one from whatever text it finds first, usually the navigation");
3978
3984
  }
3985
+ if (indexable && !found.hasPreview) {
3986
+ const missing = [!found.hasPreviewTitle && "og:title", !found.hasPreviewDescription && "og:description"].filter(Boolean).join(" and ");
3987
+ at("J-164", 0, found.hasPreviewTitle || found.hasPreviewDescription ? `this page has no ${missing} \u2014 a link to it, pasted into a message or a post, shows a card with that part guessed` : "this page has no link preview (og:title and og:description) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry");
3988
+ }
3979
3989
  if (!indexable && !found.noindex) {
3980
3990
  at("J-123", 0, `this surface is ${ctx.mode}, which is what somebody reaches after signing in, and the page carries no noindex \u2014 robots.txt is public and advisory, and is not this`);
3981
3991
  }
3992
+ } else {
3993
+ const own = HEAD_FRAGMENT.test(raw) ? HTML_DESCRIPTION.exec(raw) ?? HTML_DESCRIPTION_REVERSED.exec(raw) : null;
3994
+ const indexable = ctx.mode !== "product" && ctx.mode !== "operator";
3995
+ if (indexable && own && found.description && !found.hasPreviewDescription) {
3996
+ at("J-164", own.index, `this file writes the page's description but no og:description${found.hasPreviewTitle ? "" : " or og:title"} \u2014 a link to it, pasted into a message or a post, shows a card without the description written here`);
3997
+ }
3982
3998
  }
3983
3999
  return findings.filter((f) => f.ruleId === ctx.ruleId);
3984
4000
  }
@@ -4252,7 +4268,7 @@ var HEADLINE_SELECTOR = /\bh1\b|\bh2\b|hero|display|headline|title/i;
4252
4268
  var SERIF = /font-family\s*:\s*([^;}]+)/i;
4253
4269
  function serifFamily(value) {
4254
4270
  const first = value.split(",")[0].trim().toLowerCase();
4255
- if (/sans|mono|system-ui|ui-sans|inter\b|helvetica|arial/.test(first)) return false;
4271
+ if (/sans|mono|system-ui|ui-sans|-apple-system|blinkmacsystemfont|segoe|roboto|inter\b|helvetica|arial/.test(first)) return false;
4256
4272
  return /serif|georgia|garamond|playfair|fraunces|lora|merriweather|instrument serif|dm serif|cormorant|newsreader|times|baskerville|bodoni|caslon|libre caslon|source serif|pt serif|crimson/.test(value.toLowerCase());
4257
4273
  }
4258
4274
  var italicSerifDisplay = {
@@ -4837,6 +4853,9 @@ function auditTokenLayer(projectRoot) {
4837
4853
  }
4838
4854
 
4839
4855
  // src/commands/check.ts
4856
+ function checkExitCode(result) {
4857
+ return result.hasError ? 1 : 0;
4858
+ }
4840
4859
  var LEGACY_INDEX_REL = join25(".jig", "rules.index.json");
4841
4860
  function resolveIndexPath(projectRoot) {
4842
4861
  const legacy = join25(projectRoot, LEGACY_INDEX_REL);
@@ -4913,7 +4932,9 @@ function check(opts) {
4913
4932
  const withStyles = files.filter((f) => {
4914
4933
  try {
4915
4934
  const src = readFileSync21(join25(opts.projectRoot, f), "utf8");
4916
- return hasExtension(f, CSS_EXTENSIONS) ? src.trim().length > 0 : maskNonStyleRegions(src, f).trim().length > 0;
4935
+ if (hasExtension(f, CSS_EXTENSIONS)) return src.trim().length > 0;
4936
+ if (maskNonStyleRegions(src, f).trim().length > 0) return true;
4937
+ return isMarkupHost(f) && classAttributeValues(src).some((a) => a.classes.trim().length > 0);
4917
4938
  } catch {
4918
4939
  return false;
4919
4940
  }
@@ -6119,12 +6140,18 @@ var PROBE_SCRIPT = `(async () => {
6119
6140
  // the page's own copy around it still counts. So is a tool's output or a
6120
6141
  // command shown in <pre>, <code>, <samp> or <kbd>: jig-site's home page
6121
6142
  // quotes \`jig check\`'s real output, whose own wording has an em dash, and
6122
- // the probe blocked a page for words it had rightly not rewritten.
6123
- let ownText = text;
6124
- for (const q of document.querySelectorAll('blockquote, q, pre, code, samp, kbd')) {
6125
- const quoted = (q.innerText || '').trim();
6126
- if (quoted) ownText = ownText.split(quoted).join('\\n');
6127
- }
6143
+ // the probe blocked a page for words it had rightly not rewritten. The
6144
+ // quotations are hidden while the text is read, not cut out of it by string:
6145
+ // an inline <code>A-01</code> earlier in the prose removed "A-01" from the
6146
+ // output block below it, so the block no longer matched and its dash counted.
6147
+ const quotedEls = [...document.querySelectorAll('blockquote, q, pre, code, samp, kbd')];
6148
+ const hide = document.createElement('style');
6149
+ hide.textContent = '[data-jig-quoted] { display: none !important; }';
6150
+ for (const q of quotedEls) q.setAttribute('data-jig-quoted', '');
6151
+ document.head.appendChild(hide);
6152
+ const ownText = document.body.innerText || '';
6153
+ hide.remove();
6154
+ for (const q of quotedEls) q.removeAttribute('data-jig-quoted');
6128
6155
  const junk = [...new Set(text.match(/\\$\\{|\\{\\{|\\bundefined\\b|\\bNaN\\b|\\[object Object\\]/g) || [])];
6129
6156
  const unresolved = new Set();
6130
6157
  for (const sheet of document.styleSheets) {
@@ -6294,6 +6321,30 @@ var PROBE_SCRIPT = `(async () => {
6294
6321
  fauxSeen.add(key);
6295
6322
  fauxFaces.push({ family: first.trim().replace(/^["']|["']$/g, ''), weight, style: italic ? 'italic' : 'normal', text: (el.innerText || '').trim().slice(0, 40) });
6296
6323
  }
6324
+ // M-01: \`editorial\` allows no box that scrolls sideways on a phone; a code
6325
+ // block wraps and a wide table stacks (L-01, step 6). In a control run a
6326
+ // spec said the code would wrap and the built page scrolled it, and nothing
6327
+ // measured the box: the page itself was not wider than the screen.
6328
+ const sidewaysBoxes = [];
6329
+ for (const el of [...document.body.querySelectorAll('*')].slice(0, 4000)) {
6330
+ if (sidewaysBoxes.length >= 5) break;
6331
+ const ox = getComputedStyle(el).overflowX;
6332
+ if ((ox !== 'auto' && ox !== 'scroll') || el.scrollWidth <= el.clientWidth + 1 || !vis(el)) continue;
6333
+ sidewaysBoxes.push({ box: el.tagName.toLowerCase() + (el.classList[0] ? '.' + el.classList[0] : ''), text: (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40), scrollWidth: el.scrollWidth, clientWidth: el.clientWidth });
6334
+ }
6335
+ // A table cell given its own display (flex, grid, block) while its table is
6336
+ // still drawn as a table stops being a cell: it no longer takes its row's
6337
+ // height, and its border no longer meets the row's. A team table set its
6338
+ // name cells to flex, to line up a name and a tag, and every name drifted
6339
+ // above its row. A stacked table (the table itself set to block) is left out.
6340
+ const brokenCells = [];
6341
+ for (const cell of [...document.querySelectorAll('td, th')].slice(0, 2000)) {
6342
+ if (brokenCells.length >= 5) break;
6343
+ const table = cell.closest('table');
6344
+ const display = getComputedStyle(cell).display;
6345
+ if (!table || getComputedStyle(table).display !== 'table' || display === 'table-cell' || display === 'none' || !vis(cell)) continue;
6346
+ brokenCells.push({ cell: cell.tagName.toLowerCase() + (cell.classList[0] ? '.' + cell.classList[0] : ''), text: (cell.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40), display });
6347
+ }
6297
6348
  return JSON.stringify({
6298
6349
  jigProbe: ${PROBE_VERSION},
6299
6350
  url: location.href,
@@ -6301,6 +6352,8 @@ var PROBE_SCRIPT = `(async () => {
6301
6352
  scrollWidth: doc.scrollWidth,
6302
6353
  clientWidth: doc.clientWidth,
6303
6354
  sidewaysScroll: doc.scrollWidth > doc.clientWidth,
6355
+ sidewaysBoxes,
6356
+ brokenCells,
6304
6357
  bodyFont: getComputedStyle(document.body).fontFamily,
6305
6358
  defaultFont: getComputedStyle(document.body).fontFamily === defaultFont,
6306
6359
  unresolvedTokens: [...unresolved],
@@ -6317,6 +6370,7 @@ var PROBE_SCRIPT = `(async () => {
6317
6370
  canonical: (document.querySelector('link[rel=canonical]') || {}).href || '',
6318
6371
  robots: (document.querySelector('meta[name=robots]') || {}).content || '',
6319
6372
  ogTitle: (document.querySelector('meta[property="og:title"]') || {}).content || '',
6373
+ ogDescription: (document.querySelector('meta[property="og:description"]') || {}).content || '',
6320
6374
  ogImage: (document.querySelector('meta[property="og:image"]') || {}).content || '',
6321
6375
  },
6322
6376
  contentWidth: Math.round(region.getBoundingClientRect().width),
@@ -6765,29 +6819,42 @@ function readProbes(projectRoot, dir, errors) {
6765
6819
  }
6766
6820
  var TITLE_BUDGET2 = 60;
6767
6821
  var DESCRIPTION_BUDGET2 = 155;
6768
- function probeContradictions(probes, verdictOf, indexable = true) {
6822
+ function probeCheck(probes, verdictOf, indexable = true, mode) {
6769
6823
  const errors = [];
6824
+ const failures = [];
6770
6825
  const clean = (id) => ["ok", "n/a"].includes(verdictOf(id) ?? "");
6771
6826
  const at = (p) => `probe-${p.width}.json`;
6772
6827
  for (const p of probes) {
6773
- if (p.sidewaysScroll && clean("D-115")) {
6774
- errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
6828
+ if (p.sidewaysScroll) {
6829
+ if (clean("D-115")) {
6830
+ errors.push(`D-115 is "${verdictOf("D-115")}", but ${at(p)} measured the page ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport \u2014 it scrolls sideways.`);
6831
+ } else {
6832
+ failures.push(`${at(p)}: the page is ${p.scrollWidth}px wide in a ${p.clientWidth}px viewport, so the whole page scrolls sideways (D-115). Find what is wider than the screen and let it wrap, stack, or scroll inside its own box (L-01, step 6).`);
6833
+ }
6834
+ }
6835
+ if (mode === "editorial" && p.width <= 480 && p.sidewaysBoxes?.length) {
6836
+ const shown = p.sidewaysBoxes.map((b) => `${b.box}${b.text ? ` ("${b.text}")` : ""}, ${b.scrollWidth}px in ${b.clientWidth}px`).join("; ");
6837
+ failures.push(`${at(p)}: ${p.sidewaysBoxes.length === 1 ? "a box scrolls" : `${p.sidewaysBoxes.length} boxes scroll`} sideways on a phone: ${shown}. \`editorial\` allows no scrolling region on mobile (M-01): a code block wraps its long lines, keeping its line breaks and indents, and a wide table stacks its rows (L-01, step 6).`);
6838
+ }
6839
+ if (p.brokenCells?.length) {
6840
+ const shown = p.brokenCells.map((c) => `${c.cell}${c.text ? ` ("${c.text}")` : ""} is display: ${c.display}`).join("; ");
6841
+ 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).`);
6775
6842
  }
6776
6843
  if (p.defaultFont) {
6777
- errors.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.`);
6844
+ failures.push(`${at(p)}: the page renders in the browser's default font (${p.bodyFont ?? "unknown"}) \u2014 its styles are not applying. No review of this page can pass until they do.`);
6778
6845
  }
6779
6846
  if (p.fauxFaces?.length) {
6780
6847
  const shown = p.fauxFaces.map((f) => `${f.family} ${f.weight}${f.style === "italic" ? " italic" : ""} ("${f.text}")`).join(", ");
6781
- errors.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
6848
+ failures.push(`${at(p)}: the page asks for ${shown}, and no loaded face covers ${p.fauxFaces.length === 1 ? "it" : "them"}, so the browser fakes the weight or slant from the nearest face it has. Load that weight, or use one the page already loads.`);
6782
6849
  }
6783
6850
  if (p.unresolvedTokens.length) {
6784
- errors.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
6851
+ failures.push(`${at(p)}: ${p.unresolvedTokens.length} token(s) have no value in the browser (${p.unresolvedTokens.slice(0, 6).join(", ")}) \u2014 every property using them is dropped (H-117).`);
6785
6852
  }
6786
6853
  for (const inv of p.orderInversions ?? []) {
6787
- errors.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
6854
+ failures.push(`${at(p)}: "${inv.seenFirst}" is read first on screen but comes after "${inv.markupFirst}" in the markup. At this width the markup order is not the reading order (H-119) \u2014 reorder the document, or move it with CSS that leaves the order intact.`);
6788
6855
  }
6789
6856
  if (p.emDashes?.length) {
6790
- errors.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
6857
+ failures.push(`${at(p)}: the rendered page shows an em dash in ${p.emDashes.map((t) => `"${t}"`).join(", ")} (I-118) \u2014 use a full stop, a comma, a colon, or a second element.`);
6791
6858
  }
6792
6859
  if (clean("B-106") && p.strandedCount) {
6793
6860
  const shown = (p.strandedWords ?? []).map((w) => `"${w.word}" (in "${w.text}\u2026")`).join(", ");
@@ -6801,17 +6868,21 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6801
6868
  if (head && p.width === probes[0]?.width) {
6802
6869
  const noindex = /noindex/i.test(head.robots);
6803
6870
  if (indexable && !noindex) {
6804
- if (!head.title) errors.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
6805
- else if (head.title.length > TITLE_BUDGET2) errors.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
6806
- if (!head.description) errors.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
6807
- else if (head.description.length > DESCRIPTION_BUDGET2) errors.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
6871
+ if (!head.title) failures.push(`${at(p)}: the page served no <title> (J-121) \u2014 a search result then shows a truncated URL.`);
6872
+ else if (head.title.length > TITLE_BUDGET2) failures.push(`${at(p)}: the title served is ${head.title.length} characters, past the ${TITLE_BUDGET2} a search result shows (J-122): "${head.title}".`);
6873
+ if (!head.description) failures.push(`${at(p)}: the page served no meta description (J-121) \u2014 the search engine writes one from whatever text it finds first, usually the navigation.`);
6874
+ else if (head.description.length > DESCRIPTION_BUDGET2) failures.push(`${at(p)}: the description served is ${head.description.length} characters, past the ${DESCRIPTION_BUDGET2} (J-122).`);
6875
+ if (typeof head.ogDescription === "string" && (!head.ogTitle || !head.ogDescription)) {
6876
+ const missing = [!head.ogTitle && "og:title", !head.ogDescription && "og:description"].filter(Boolean).join(" and ");
6877
+ failures.push(`${at(p)}: the page served no ${missing} (J-164) \u2014 a link to it, pasted into a message or a post, shows a card built from whatever the page happens to carry.`);
6878
+ }
6808
6879
  }
6809
6880
  if (!indexable && !noindex) {
6810
- errors.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
6881
+ failures.push(`${at(p)}: this page is not indexable, and the page served no noindex (J-123). robots.txt is public and advisory, and is not this.`);
6811
6882
  }
6812
6883
  }
6813
6884
  if (p.junkText.length) {
6814
- errors.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
6885
+ failures.push(`${at(p)}: the rendered text contains ${p.junkText.map((j) => `"${j}"`).join(", ")} \u2014 template code or a failed value is showing to readers.`);
6815
6886
  }
6816
6887
  }
6817
6888
  const navClean = clean("P-14") || clean("E-61");
@@ -6842,7 +6913,7 @@ function probeContradictions(probes, verdictOf, indexable = true) {
6842
6913
  if (broken.length) errors.push(`P-14 is "${verdictOf("P-14")}", but ${at(phone)} operated the menu: ${broken.join("; ")}.`);
6843
6914
  }
6844
6915
  }
6845
- return errors;
6916
+ return { contradictions: errors, failures };
6846
6917
  }
6847
6918
 
6848
6919
  // src/check/decisions.ts
@@ -7336,6 +7407,7 @@ function verifyVerdicts(opts) {
7336
7407
  const v = screenVerdicts.find((x) => typeof x.id === "string" && x.id.trim().toUpperCase() === id);
7337
7408
  return typeof v?.verdict === "string" ? v.verdict : void 0;
7338
7409
  };
7410
+ const measured = [];
7339
7411
  let specFront = "";
7340
7412
  try {
7341
7413
  specFront = readText(join38(opts.projectRoot, ".jig", "specs", `${opts.surface}.spec.md`)).split(/^---\s*$/m)[1] ?? "";
@@ -7344,14 +7416,24 @@ function verifyVerdicts(opts) {
7344
7416
  if (specIndexableField(specFront) === "unreadable") {
7345
7417
  errors.push(`.jig/specs/${opts.surface}.spec.md: \`indexable:\` is neither true nor false, so this review cannot tell whether the page is meant to be found and does not guess. Write \`indexable: true\` or \`indexable: false\`, and put the reason in the spec's body.`);
7346
7418
  } else {
7347
- errors.push(...probeContradictions(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface)));
7419
+ const mode = /^\s*mode\s*:\s*["']?(\w+)/im.exec(specFront)?.[1]?.toLowerCase();
7420
+ const probed = probeCheck(probes, verdictOf, specIndexable(opts.projectRoot, opts.surface), mode);
7421
+ errors.push(...probed.contradictions);
7422
+ const seen = /* @__PURE__ */ new Set();
7423
+ for (const failure of probed.failures) {
7424
+ const what = failure.replace(/^probe-\d+\.json: /, "");
7425
+ if (!seen.has(what)) {
7426
+ seen.add(what);
7427
+ measured.push(failure);
7428
+ }
7429
+ }
7348
7430
  }
7349
7431
  if (screen.state === "ran" && !rendered) screen.state = "skipped";
7350
7432
  const decisions = checkDecisions(opts.projectRoot, dir, errors);
7351
7433
  const field = (a) => a.state === "ran" ? `ran:${a.judged}` : `${a.state}:${a.judged}${a.state === "incomplete" ? `/${a.total}` : ""}`;
7352
- const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}`;
7434
+ const line = `JIG_VERDICTS: surface=${opts.surface} screen=${field(screen)} code=${field(code)} decisions=${field(decisions)} rendered=${rendered ? "yes" : "no"} findings=${screen.findings + code.findings + decisions.findings}${(screen.ruled ?? 0) + (code.ruled ?? 0) ? ` ruled=${(screen.ruled ?? 0) + (code.ruled ?? 0)}` : ""}${measured.length ? ` measured=${measured.length}` : ""}`;
7353
7435
  const previous = previousFindings(opts.projectRoot, dir);
7354
- return { ok: errors.length === 0, errors, screen, code, decisions, rendered, line, ...previous ? { previous } : {} };
7436
+ return { ok: errors.length === 0, errors, screen, code, decisions, rendered, measured, line, ...previous ? { previous } : {} };
7355
7437
  }
7356
7438
  function previousFindings(projectRoot, dir) {
7357
7439
  const git = (args) => execFileSync3("git", args, { cwd: projectRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
@@ -7816,6 +7898,10 @@ function pageStatus(projectRoot, surface) {
7816
7898
  if (stale) return { surface, state: "reprobe", detail: `its page is as judged, but the styles or scripts it loads changed: re-probe it (\`jig verdicts ${surface} --reprobe\`)` };
7817
7899
  if (!v.ok) return { surface, state: "incomplete", detail: `its critique is not complete: ${v.errors[0]}` };
7818
7900
  const findings = v.screen.findings + v.code.findings + v.decisions.findings;
7901
+ if (v.measured.length) {
7902
+ const also = findings > 0 ? `, and ${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` : "";
7903
+ return { surface, state: "findings", detail: `the page fails ${v.measured.length} check${v.measured.length === 1 ? "" : "s"} the render probe measured (\`jig verdicts ${surface}\` lists them)${also}` };
7904
+ }
7819
7905
  if (findings > 0) return { surface, state: "findings", detail: `${findings} finding${findings === 1 ? "" : "s"} the owner has not ruled on` };
7820
7906
  return { surface, state: "judged", detail: "judged as it stands, nothing open" };
7821
7907
  }
@@ -7879,7 +7965,15 @@ function ownerWordProblem(word, quoted, owner, where) {
7879
7965
  function specChecksum(body) {
7880
7966
  return createHash2("sha256").update(body.replace(/\r\n/g, "\n").replace(/^\s*(confirmed|mockup)\s*:.*$/gim, "")).digest("hex");
7881
7967
  }
7882
- var mockupLine = (body) => /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7968
+ function mockupField(body) {
7969
+ const raw = /^\s*mockup\s*:\s*(.*)$/im.exec(body.split(/^---\s*$/m)[1] ?? "")?.[1]?.trim() ?? "";
7970
+ const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
7971
+ if (double) return double[1].replace(/\\(.)/g, "$1").trim();
7972
+ const single = /^'((?:[^']|'')*)'\s*(?:#.*)?$/.exec(raw);
7973
+ if (single) return single[1].replace(/''/g, "'").trim();
7974
+ return raw;
7975
+ }
7976
+ var mockupLine = mockupField;
7883
7977
  function mockupWordProblems(spec, then, owner) {
7884
7978
  const now = mockupLine(spec.body);
7885
7979
  if (now === mockupLine(then)) return [];
@@ -8199,7 +8293,7 @@ function commandProblems(root, command, surface, start, owner = "") {
8199
8293
  }
8200
8294
  if (command === "mockup" && spec) {
8201
8295
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
8202
- const mockup = /^\s*mockup\s*:\s*(.+)$/im.exec(front)?.[1]?.trim() ?? "";
8296
+ const mockup = mockupField(spec.body);
8203
8297
  if (/^pending/i.test(mockup) || !mockup) problems.push(`${spec.path}: \`mockup:\` is still pending. It records the user's own word \u2014 approved, or skipped with their reason.`);
8204
8298
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
8205
8299
  if (/^approved/i.test(mockup)) {
@@ -8212,7 +8306,7 @@ function commandProblems(root, command, surface, start, owner = "") {
8212
8306
  if (command === "tweak" && spec) problems.push(...tweakProblems(root, spec, owner, start));
8213
8307
  if ((command === "spec" || command === "make") && spec) {
8214
8308
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
8215
- const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
8309
+ const mockup = mockupField(spec.body);
8216
8310
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
8217
8311
  if (/^approved/i.test(mockup) && /\.html?$/i.test(at) && !/^https?:/i.test(at)) {
8218
8312
  const drift = approvedDrawingProblems(root, spec.body, at);
@@ -8233,7 +8327,7 @@ function commandProblems(root, command, surface, start, owner = "") {
8233
8327
  }
8234
8328
  if (command === "mockup" && spec) {
8235
8329
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
8236
- const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
8330
+ const mockup = mockupField(spec.body);
8237
8331
  const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
8238
8332
  if (/^approved/i.test(mockup) && at && !/^https?:/i.test(at) && existsSync27(join43(root, at))) {
8239
8333
  const changedAfter = drawingChangedAfterApproval(root, spec.path, at);
@@ -8361,8 +8455,8 @@ function tweakProblems(root, spec, owner = "", start) {
8361
8455
  const problems = [];
8362
8456
  const front = spec.body.split(/^---\s*$/m)[1] ?? "";
8363
8457
  if (!/^\s*confirmed\s*:\s*true\b/im.test(front)) problems.push(`${spec.path} is not confirmed. A tweak changes a page the owner has confirmed; an unconfirmed spec goes through \`spec\`.`);
8364
- const mockup = /^\s*mockup\s*:\s*(\S+)/im.exec(front)?.[1] ?? "";
8365
- if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
8458
+ const mockup = mockupField(spec.body);
8459
+ if (!/^(approved|skipped)/i.test(mockup)) problems.push(`${spec.path}: \`mockup:\` is ${mockup.split(/\s/)[0] || "empty"}. A tweak changes a page whose drawing the owner has approved (or skipped); take a new page through \`mockup\` and \`make\`.`);
8366
8460
  else problems.push(...structureSinceApproval(root, spec, front));
8367
8461
  const surface = spec.slug;
8368
8462
  const record = readTweak(join43(root, ".jig", "critique", surface));
@@ -8536,6 +8630,13 @@ ${result.report}`);
8536
8630
  ` + shown.join("\n") + (v.errors.length > shown.length ? `
8537
8631
  \u2026 and ${v.errors.length - shown.length} more` : "")
8538
8632
  );
8633
+ } else if (v.measured.length && command !== "critique") {
8634
+ const shown = v.measured.slice(0, 6).map((e) => ` ${e}`);
8635
+ problems.push(
8636
+ `jig verdicts ${surface}: the page fails what the render probe measured. Fix the page, then re-take the probes (\`jig verdicts ${surface} --reprobe\`).
8637
+ ` + shown.join("\n") + (v.measured.length > shown.length ? `
8638
+ \u2026 and ${v.measured.length - shown.length} more` : "")
8639
+ );
8539
8640
  }
8540
8641
  }
8541
8642
  }
@@ -8874,6 +8975,10 @@ program.command("verdicts").description("Verify a critique's verdict files and c
8874
8975
  console.error(` The page changed after these probes. \`jig verdicts ${surface} --reprobe\` re-takes them on the page as it is now.`);
8875
8976
  }
8876
8977
  if (result.ok) console.log(` Every rule in both passes has a verdict.`);
8978
+ if (result.measured.length) {
8979
+ console.log(` The page fails ${result.measured.length} check(s) the render probe measured. They are findings on the page, for make or tweak to fix; a critique reports them and leaves the page as it is:`);
8980
+ for (const failure of result.measured) console.log(` ! ${failure}`);
8981
+ }
8877
8982
  const since = result.decisions.since ?? [];
8878
8983
  if (since.length) console.log(` ${since.length} decision(s) recorded after this critique, for the next one to judge: ${since.join(", ")}.`);
8879
8984
  const p = result.previous;
@@ -8894,7 +8999,7 @@ program.command("check").description("Check the repo against Jig's mechanical +
8894
8999
  // this as widening the RULES and attested a clean diff as a clean project.
8895
9000
  "scan every file in the repo, not just those changed since HEAD (same rules either way)",
8896
9001
  false
8897
- ).option("--ci", "mechanical bucket only; exits non-zero on any error, deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
9002
+ ).option("--ci", "mechanical bucket only, so the run is deterministic", false).option("--json", "emit findings as JSON", false).action((opts) => {
8898
9003
  const projectRoot = findProjectRoot(process.cwd());
8899
9004
  try {
8900
9005
  const result = check({
@@ -8905,7 +9010,8 @@ program.command("check").description("Check the repo against Jig's mechanical +
8905
9010
  ci: opts.ci
8906
9011
  });
8907
9012
  console.log(opts.json ? JSON.stringify(result.findings, null, 2) : result.report);
8908
- if (opts.ci && result.hasError) process.exit(1);
9013
+ const code = checkExitCode(result);
9014
+ if (code !== 0) process.exit(code);
8909
9015
  } catch (err) {
8910
9016
  console.error(err.message);
8911
9017
  process.exit(1);