@multiplatform.one/config 7.34.3 → 7.35.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/lint.mjs CHANGED
@@ -70,6 +70,15 @@
70
70
  * `mpo-conventions/no-hex-literals`, whose file and reason are not a row
71
71
  * in the spec's `## Colour-literal escapes` table, or a row whose comment
72
72
  * is gone.
73
+ * 14. Fail on a RETIRED SCALE KNOB (LC-100 TOKEN-SCALE) — a `size`, `space`
74
+ * or `density` key in a knob literal (`<Preset overrides>`, a `knobs:`
75
+ * block, a `Knobs` / `StateKnobs` / `Preset` / `IntentOverride` typed
76
+ * literal or a `registerPreset` call), a `density: "compact"` value, a
77
+ * `compact` prop on a house component, a `data-density` attribute, or a
78
+ * `<Surface>` given `density` or a word size (`sm`, `small`, `medium`, …).
79
+ * Size, spacing and radius are the preset's token scale; the only
80
+ * per-site control is Tamagui's own `size="$n"`. Specs are exempt: they
81
+ * are where the retirement is asserted.
73
82
  *
74
83
  * Usage:
75
84
  * import { runConventionChecks } from "@multiplatform.one/config/lint";
@@ -311,8 +320,8 @@ function sliceBlock(src, marker, end) {
311
320
  function keysOfBlock(body, indent) {
312
321
  /** @type {string[]} */
313
322
  const keys = [];
314
- const re = new RegExp(`^ {${indent}}([A-Za-z0-9][A-Za-z0-9]*)\\s*:`, "gm");
315
- for (const m of body.matchAll(re)) keys.push(m[1]);
323
+ const re = new RegExp(`^ {${indent}}(?:"([^"\\r\\n]+)"|([A-Za-z0-9][A-Za-z0-9]*))\\s*:`, "gm");
324
+ for (const m of body.matchAll(re)) keys.push(m[1] ?? m[2]);
316
325
  return keys;
317
326
  }
318
327
 
@@ -365,7 +374,7 @@ function scanLiterals(re, check) {
365
374
  }
366
375
 
367
376
  /**
368
- * The animation vocabulary is a CLOSED set (css.ts / reactNative.ts register
377
+ * The animation vocabulary is a CLOSED set (motionTiming.ts / reactNative.ts register
369
378
  * exactly these keys). A `transition="X"` with an X outside the set is not a
370
379
  * type error — Tamagui silently ignores the unknown animation name, so the
371
380
  * element renders with NO animation. That is invisible until someone notices
@@ -377,7 +386,7 @@ function scanLiterals(re, check) {
377
386
  function readAnimationTokens() {
378
387
  /** @type {Set<string>} */
379
388
  const tokens = new Set();
380
- const src = readPublicSource("theme/src/theme/animations/css.ts");
389
+ const src = readPublicSource("theme/src/theme/motionTiming.ts");
381
390
  if (!src) return tokens;
382
391
  tokens.add("none"); // valid non-animating value
383
392
  for (const key of keysOfBlock(sliceBlock(src, "animationConfig =", "} as const"), 2)) {
@@ -391,7 +400,7 @@ function checkTransitionTokens() {
391
400
  const valid = readAnimationTokens();
392
401
  if (!valid.size) return { violations: [], valid: [], ok: false };
393
402
  // Only string LITERALS directly on the prop — dynamic values (variables,
394
- // ternaries, knobProps.transition) resolve at runtime and are out of scope.
403
+ // ternaries, knobProps.motion.control.transition) resolve at runtime and are out of scope.
395
404
  const re = /\btransition=(?:"([a-zA-Z0-9]+)"|\{\s*"([a-zA-Z0-9]+)"\s*\})/g;
396
405
  const violations = scanLiterals(re, (m) => {
397
406
  const token = m[1] ?? m[2];
@@ -400,88 +409,70 @@ function checkTransitionTokens() {
400
409
  return { violations, valid: [...valid].filter((t) => t !== "none").sort(), ok: true };
401
410
  }
402
411
 
403
- /**
404
- * Every registered Tamagui theme name, plus each sub-theme SEGMENT a `theme`
405
- * prop may name on its own (`theme="accent"` selects `light_accent` under the
406
- * light scheme). Rebuilt from the same three sources `createThemesBuilder`
407
- * composes: the theme-builder's generated names for this base/children/accent
408
- * shape, the stock `@tamagui/themes` names, and the gray aliases declared in
409
- * createThemes.ts. Palettes are irrelevant here — only the NAMES are.
410
- * @returns {Set<string>}
411
- */
412
- function readThemeNames() {
413
- /** @type {Set<string>} */
414
- const names = new Set();
412
+ function readThemeMatrix() {
415
413
  const builder = tryRequire("@tamagui/theme-builder");
416
- const stock = tryRequire("@tamagui/themes");
414
+ const v5 = tryRequire("@tamagui/themes/v5");
417
415
  const src = readPublicSource("theme/src/theme/createThemes.ts");
418
- if (!builder?.createThemes || !stock?.themes || !src) return names;
419
- const ramp = Array.from({ length: 14 }, (_, i) => `hsl(0, 0%, ${i * 7}%)`);
420
- const palette = { palette: { dark: ramp, light: ramp } };
421
- /** @type {Record<string, unknown>} */
422
- const childrenThemes = {};
416
+ if (!builder?.createThemes || !v5?.defaultChildrenThemes || !src) return {};
417
+ const childrenHues = src.match(/const childrenHues = \[([^\]]+)\]/)?.[1];
418
+ if (!childrenHues) return {};
419
+ const palette = { palette: { dark: v5.defaultDarkPalette, light: v5.defaultLightPalette } };
420
+ const childrenThemes = Object.fromEntries(
421
+ [...childrenHues.matchAll(/"([a-z]+)"/g)].map(([, name]) => [name, palette]),
422
+ );
423
423
  for (const key of keysOfBlock(sliceBlock(src, "const childrenThemes =", "\n };"), 4)) {
424
424
  childrenThemes[key] = palette;
425
425
  }
426
+ const templatesOf = (name) =>
427
+ Object.fromEntries(
428
+ [
429
+ ...sliceBlock(src, `export const ${name} =`, "} as const").matchAll(
430
+ /^ {2}(\w+): \{ template: "(\w+)" \}/gm,
431
+ ),
432
+ ].map((match) => [match[1], { template: match[2] }]),
433
+ );
426
434
  try {
427
- for (const name of Object.keys(
428
- builder.createThemes({ base: palette, childrenThemes, accent: palette }),
429
- )) {
430
- names.add(name);
431
- }
435
+ return builder.createThemes({
436
+ templates: v5.v5Templates,
437
+ componentThemes: templatesOf("houseComponentThemes"),
438
+ grandChildrenThemes: templatesOf("houseGrandChildrenThemes"),
439
+ base: palette,
440
+ childrenThemes,
441
+ accent: palette,
442
+ });
432
443
  } catch {
433
- return new Set();
434
- }
435
- for (const name of Object.keys(stock.themes)) names.add(name);
436
- for (const m of sliceBlock(src, "const grayTheme =", "\n};").matchAll(
437
- /^ {2}([a-zA-Z][a-zA-Z0-9_]*)\s*:/gm,
438
- )) {
439
- names.add(m[1]);
444
+ return {};
440
445
  }
441
- /** @type {string[]} */
442
- const segments = [];
443
- for (const name of names) for (const part of name.split("_").slice(1)) segments.push(part);
444
- for (const segment of segments) names.add(segment);
445
- return names;
446
446
  }
447
447
 
448
- /**
449
- * Every key resolvable as `$value` on a color-bearing prop, composed exactly
450
- * the way `createThemesBuilder` composes the live themes: the structural keys
451
- * the theme-builder generates (colorN / background0N / accentN / …), the stock
452
- * base themes, the Radix ramps createThemes.ts splats into `extra`, the shadow
453
- * slots, and the project's semantic aliases from `getBaseTheme`.
454
- * @returns {Set<string>}
455
- */
448
+ /** Names and sub-theme segments must come from the same v5 matrix as the builder. */
449
+ function readThemeNames() {
450
+ const names = Object.keys(readThemeMatrix());
451
+ return new Set(names.flatMap((name) => [name, ...name.split("_").slice(1)]));
452
+ }
453
+
456
454
  function readThemeKeys() {
457
455
  /** @type {Set<string>} */
458
456
  const keys = new Set();
459
- const builder = tryRequire("@tamagui/theme-builder");
460
- const stock = tryRequire("@tamagui/themes");
461
- const colors = tryRequire("@tamagui/colors");
457
+ const v5 = tryRequire("@tamagui/themes/v5");
462
458
  const src = readPublicSource("theme/src/theme/createThemes.ts");
463
459
  const builderSrc = readPublicSource("theme/src/theme/defaults/builderOptions.ts");
464
- if (!builder?.createThemes || !stock?.themes || !colors || !src || !builderSrc) return keys;
465
- const ramp = Array.from({ length: 14 }, (_, i) => `hsl(0, 0%, ${i * 7}%)`);
466
- const palette = { palette: { dark: ramp, light: ramp } };
467
- try {
468
- const generated = builder.createThemes({
469
- base: palette,
470
- childrenThemes: { warning: palette },
471
- accent: palette,
472
- });
473
- for (const theme of Object.values(generated)) for (const k of Object.keys(theme)) keys.add(k);
474
- } catch {
475
- return new Set();
460
+ const matrix = readThemeMatrix();
461
+ if (!Object.keys(matrix).length || !v5?.defaultChildrenThemes || !builderSrc) return keys;
462
+ for (const theme of Object.values(matrix)) {
463
+ for (const key of Object.keys(theme)) keys.add(key);
476
464
  }
477
- for (const name of ["light", "dark"]) {
478
- for (const k of Object.keys(stock.themes[name] ?? {})) keys.add(k);
479
- }
480
- for (const m of src.matchAll(/\.\.\.Colors\.([A-Za-z0-9]+)\s*,/g)) {
481
- for (const k of Object.keys(colors[m[1]] ?? {})) keys.add(k);
465
+ for (const theme of Object.values(v5.defaultChildrenThemes)) {
466
+ for (const scheme of ["light", "dark"]) {
467
+ for (const key of Object.keys(theme[scheme])) keys.add(key);
468
+ }
482
469
  }
483
470
  for (const k of keysOfBlock(sliceBlock(src, "export interface Shadows", "\n}"), 2)) keys.add(k);
484
471
  keys.add("shadowColor");
472
+ const syntaxHues = src.match(/const syntaxInkHues = \[([^\]]+)\]/)?.[1] ?? "";
473
+ for (const [, hue] of syntaxHues.matchAll(/"([a-z]+)"/g)) {
474
+ keys.add(`syntax${hue[0].toUpperCase()}${hue.slice(1)}`);
475
+ }
485
476
  for (const k of keysOfBlock(sliceBlock(builderSrc, "function getBaseTheme", "\n}"), 4))
486
477
  keys.add(k);
487
478
  return keys;
@@ -665,7 +656,7 @@ function checkAnimationProps() {
665
656
  const src = readFileSync(filePath, "utf8");
666
657
  const rel = relative(ROOT, filePath);
667
658
  // Any `…transition`-valued animation key, so prefixed spellings like
668
- // `ctx.knobProps.transition` are caught too. Deliberately NOT "any value":
659
+ // `ctx.knobProps.motion.control.transition` are caught too. Deliberately NOT "any value":
669
660
  // that also matches TS annotations (`animation: any`), css-in-js, and
670
661
  // unrelated config objects that have nothing to do with the tamagui prop.
671
662
  // The lookbehind keeps `data-animation={…}` (probe attributes: Spinner,
@@ -1455,6 +1446,19 @@ function nonCommentOffsets(src) {
1455
1446
  i += 2;
1456
1447
  continue;
1457
1448
  }
1449
+ if (char === '"' || char === "'" || char === "`") {
1450
+ const quote = char;
1451
+ i++;
1452
+ while (i < src.length) {
1453
+ if (src[i] === "\\") {
1454
+ i += 2;
1455
+ continue;
1456
+ }
1457
+ if (quote !== "`" && src[i] === "\n") break;
1458
+ if (src[i++] === quote) break;
1459
+ }
1460
+ continue;
1461
+ }
1458
1462
  i++;
1459
1463
  }
1460
1464
  return mask;
@@ -1582,6 +1586,513 @@ function checkStoryForeignControls() {
1582
1586
  return violations;
1583
1587
  }
1584
1588
 
1589
+ // ── Retired scale knobs (LC-100 TOKEN-SCALE) ─────────────────────────────
1590
+
1591
+ const RETIRED_SCANNED_RE = /\.(tsx|ts|jsx|js|mjs)$/;
1592
+ const WORD_SIZES = new Set(["sm", "small", "md", "medium", "lg", "large"]);
1593
+ const KNOB_LITERAL_OPENERS = [
1594
+ /\bknobs\s*:\s*\{/g,
1595
+ /:\s*(?:Partial<\s*)?(?:Knobs|StateKnobs|Preset|IntentOverride)\s*>?\s*=\s*\{/g,
1596
+ /\bregisterPreset\(\s*[^,()]+,\s*\{/g,
1597
+ ];
1598
+ const KNOB_KEY_RE = /(?:^|[\s{,(])(["']?)(size|space|density)\1\s*:/g;
1599
+ const DENSITY_VALUE_RE = /\bdensity\s*:\s*["'](?:compact|comfortable)["']/g;
1600
+ const HOUSE_IMPORT_RE = /import\s+(?!type\b)([^;'"]*?)from\s*["']@multiplatform\.one\/[^"']+["']/g;
1601
+
1602
+ /**
1603
+ * Index just past the brace that closes the one at `open`, skipping strings
1604
+ * and comments. `src.length` when it never closes.
1605
+ * @param {string} src
1606
+ * @param {number} open
1607
+ */
1608
+ function closingBrace(src, open) {
1609
+ const mask = codeOffsets(src.slice(open));
1610
+ let depth = 0;
1611
+ for (let i = open; i < src.length; i++) {
1612
+ if (mask[i - open] !== 1) continue;
1613
+ if (src[i] === "{") depth++;
1614
+ else if (src[i] === "}" && --depth === 0) return i + 1;
1615
+ }
1616
+ return src.length;
1617
+ }
1618
+
1619
+ /**
1620
+ * The attribute text of the JSX opening tag whose `<` is at `start`, with
1621
+ * braces balanced so an arrow inside a prop does not end it.
1622
+ * @param {string} src
1623
+ * @param {Uint8Array} mask
1624
+ * @param {number} start
1625
+ */
1626
+ function openingTag(src, mask, start) {
1627
+ let depth = 0;
1628
+ for (let i = start + 1; i < src.length; i++) {
1629
+ const char = src[i];
1630
+ if (char === '"' || char === "'") {
1631
+ if (depth === 0) {
1632
+ const close = src.indexOf(char, i + 1);
1633
+ if (close === -1) return src.slice(start);
1634
+ i = close;
1635
+ }
1636
+ continue;
1637
+ }
1638
+ if (mask[i] !== 1) continue;
1639
+ if (char === "{") depth++;
1640
+ else if (char === "}") depth--;
1641
+ else if (char === ">" && depth === 0) return src.slice(start, i + 1);
1642
+ }
1643
+ return src.slice(start);
1644
+ }
1645
+
1646
+ /**
1647
+ * Top-level attribute names of an opening tag (outside `{…}`, quotes and
1648
+ * comments), with their literal string values.
1649
+ * @param {string} raw
1650
+ */
1651
+ function tagAttributes(raw) {
1652
+ /** @type {Map<string, string>} */
1653
+ const attrs = new Map();
1654
+ const live = nonCommentOffsets(raw);
1655
+ const tag = raw
1656
+ .split("")
1657
+ .map((char, i) => (live[i] === 1 || char === "\n" ? char : " "))
1658
+ .join("");
1659
+ let depth = 0;
1660
+ let flat = "";
1661
+ for (let i = 0; i < tag.length; i++) {
1662
+ const char = tag[i];
1663
+ if (char === "{") depth++;
1664
+ if (depth > 0) {
1665
+ flat += char === "\n" ? "\n" : " ";
1666
+ if (char === "}") depth--;
1667
+ continue;
1668
+ }
1669
+ flat += char;
1670
+ }
1671
+ for (const m of flat.matchAll(/\s([A-Za-z][\w-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|\s))?/g)) {
1672
+ attrs.set(m[1], m[2] ?? m[3] ?? "");
1673
+ }
1674
+ for (const m of tag.matchAll(/\s([A-Za-z][\w-]*)\s*=\s*\{\s*["']([^"']*)["']\s*\}/g)) {
1675
+ attrs.set(m[1], m[2]);
1676
+ }
1677
+ return attrs;
1678
+ }
1679
+
1680
+ /**
1681
+ * Local names bound by value imports from `@multiplatform.one/*`.
1682
+ * @param {string} src
1683
+ */
1684
+ function houseImportNames(src) {
1685
+ const names = new Set();
1686
+ for (const statement of src.matchAll(HOUSE_IMPORT_RE)) {
1687
+ const clause = statement[1];
1688
+ const named = clause.match(/\{([\s\S]*)\}/);
1689
+ const bare = clause.replace(/\{[\s\S]*\}/, "").replace(/\*\s+as\s+/, "");
1690
+ for (const part of bare.split(",")) {
1691
+ const name = part.trim();
1692
+ if (name) names.add(name);
1693
+ }
1694
+ if (!named) continue;
1695
+ for (const specifier of named[1].split(",")) {
1696
+ const entry = specifier.trim();
1697
+ if (!entry || /^type\b/.test(entry)) continue;
1698
+ const [imported, alias] = entry.split(/\s+as\s+/).map((part) => part.trim());
1699
+ names.add(alias || imported);
1700
+ }
1701
+ }
1702
+ return names;
1703
+ }
1704
+
1705
+ /**
1706
+ * LC-100: the size, space and density knobs are gone. A preset that needs
1707
+ * more or less room carries its own token scale; a use site picks a size
1708
+ * token with Tamagui's `size="$n"`. Every retired form fails here so a stale
1709
+ * copy cannot come back through a literal the type checker does not see
1710
+ * (a cookie payload, a story global, an untyped spread).
1711
+ */
1712
+ function checkRetiredScaleKnobs() {
1713
+ /** @type {string[]} */
1714
+ const violations = [];
1715
+ const scan = (filePath, catalog) => {
1716
+ if (!RETIRED_SCANNED_RE.test(filePath) || filePath.endsWith(".d.ts")) return;
1717
+ if (/\.(spec|test)\./.test(filePath)) return;
1718
+ const src = readFileSync(filePath, "utf8");
1719
+ if (!/\b(?:size|space|density|compact)\b/.test(src)) return;
1720
+ const rel = relative(ROOT, filePath).replaceAll("\\", "/");
1721
+ const code = codeOffsets(src);
1722
+ const live = nonCommentOffsets(src);
1723
+ const report = (index, what) => violations.push(`${rel}:${lineAt(src, index)} — ${what}`);
1724
+
1725
+ /** @type {[number, number][]} */
1726
+ const blocks = [];
1727
+ for (const opener of KNOB_LITERAL_OPENERS) {
1728
+ for (const m of src.matchAll(opener)) {
1729
+ const open = (m.index ?? 0) + m[0].length - 1;
1730
+ if (code[open] === 1) blocks.push([open, closingBrace(src, open)]);
1731
+ }
1732
+ }
1733
+ for (const m of src.matchAll(/\boverrides\s*=\s*\{\{/g)) {
1734
+ const open = (m.index ?? 0) + m[0].length - 1;
1735
+ if (code[open] !== 1) continue;
1736
+ const tagStart = src.lastIndexOf("<", m.index ?? 0);
1737
+ if (!/^<Preset\b/.test(src.slice(tagStart))) continue;
1738
+ blocks.push([open, closingBrace(src, open)]);
1739
+ }
1740
+ const seen = new Set();
1741
+ for (const [open, close] of blocks) {
1742
+ const body = src.slice(open, close);
1743
+ for (const m of body.matchAll(KNOB_KEY_RE)) {
1744
+ const colon = open + (m.index ?? 0) + m[0].length - 1;
1745
+ if (code[colon] !== 1 || seen.has(colon)) continue;
1746
+ seen.add(colon);
1747
+ report(colon, `\`${m[2]}\` knob in a knob literal`);
1748
+ }
1749
+ }
1750
+ for (const m of src.matchAll(DENSITY_VALUE_RE)) {
1751
+ const index = m.index ?? 0;
1752
+ if (code[index] !== 1) continue;
1753
+ const colon = src.indexOf(":", index);
1754
+ if (seen.has(colon)) continue;
1755
+ report(index, `\`${m[0]}\``);
1756
+ }
1757
+ for (const m of src.matchAll(/data-density/g)) {
1758
+ const index = m.index ?? 0;
1759
+ if (live[index] === 1) report(index, "`data-density` attribute");
1760
+ }
1761
+
1762
+ const house = houseImportNames(src);
1763
+ for (const m of src.matchAll(/<([A-Z][\w]*)((?:\.[A-Z]\w*)*)\b/g)) {
1764
+ const index = m.index ?? 0;
1765
+ if (code[index] !== 1) continue;
1766
+ const root = m[1];
1767
+ const name = root + (m[2] ?? "");
1768
+ const attrs = tagAttributes(openingTag(src, code, index));
1769
+ if (root === "Surface") {
1770
+ if (attrs.has("density")) report(index, "`<Surface density>`");
1771
+ const size = attrs.get("size");
1772
+ if (size !== undefined && WORD_SIZES.has(size)) {
1773
+ report(index, `\`<Surface size="${size}">\` (a size token, \`$3\`)`);
1774
+ }
1775
+ }
1776
+ if (attrs.has("compact") && (catalog || house.has(root))) {
1777
+ report(index, `\`compact\` on <${name}> (a size token, \`size="$3"\`)`);
1778
+ }
1779
+ }
1780
+ };
1781
+ if (existsSync(PUBLIC_DIR)) {
1782
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
1783
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
1784
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), (filePath) => scan(filePath, true));
1785
+ }
1786
+ }
1787
+ for (const dir of [APPS_DIR, FEATURES_DIR, PACKAGES_DIR]) {
1788
+ walkFiles(dir, (filePath) => scan(filePath, false));
1789
+ }
1790
+ return violations;
1791
+ }
1792
+
1793
+ function walkOwnedSource(scan) {
1794
+ if (existsSync(PUBLIC_DIR)) {
1795
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
1796
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
1797
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), scan);
1798
+ }
1799
+ }
1800
+ for (const dir of [FEATURES_DIR, PACKAGES_DIR, APPS_DIR]) walkFiles(dir, scan);
1801
+ }
1802
+
1803
+ function shadowLiteralAt(src, start) {
1804
+ let index = start;
1805
+ while (/\s/.test(src[index] ?? "") && index < src.length) index++;
1806
+ const quote = src[index];
1807
+ if (quote !== '"' && quote !== "'" && quote !== "`") return undefined;
1808
+ const begin = ++index;
1809
+ while (index < src.length) {
1810
+ if (src[index] === "\\") {
1811
+ index += 2;
1812
+ continue;
1813
+ }
1814
+ if (src[index] === quote) return src.slice(begin, index);
1815
+ index++;
1816
+ }
1817
+ return undefined;
1818
+ }
1819
+
1820
+ function checkOverlayOwnership() {
1821
+ const shadows = [];
1822
+ const frames = [];
1823
+ const overlayTags = new Set([
1824
+ "PopoverContent",
1825
+ "DialogContent",
1826
+ "AlertDialogContent",
1827
+ "SheetFrame",
1828
+ "Sheet.Frame",
1829
+ "Popover.Content",
1830
+ "Dialog.Content",
1831
+ "AlertDialog.Content",
1832
+ "Tooltip.Content",
1833
+ "TooltipContent",
1834
+ ]);
1835
+ walkOwnedSource((filePath) => {
1836
+ if (
1837
+ !/\.(?:tsx?|jsx?|mjs)$/.test(filePath) ||
1838
+ /\.(?:spec|test)\./.test(filePath) ||
1839
+ filePath.endsWith(".d.ts")
1840
+ )
1841
+ return;
1842
+ const src = readFileSync(filePath, "utf8");
1843
+ if (!/boxShadow|Content|Sheet/.test(src)) return;
1844
+ const rel = relative(ROOT, filePath).replaceAll("\\", "/");
1845
+ const code = codeOffsets(src);
1846
+ const exemptShadow =
1847
+ rel === "public/theme/src/theme/resolveKnobs.ts" ||
1848
+ rel.startsWith("public/connectkit/") ||
1849
+ rel.startsWith("public/web3/src/connectkit/") ||
1850
+ rel.startsWith("apps/one/webext/content_scripts/");
1851
+ if (!exemptShadow) {
1852
+ const bindings = new Map();
1853
+ for (const match of src.matchAll(
1854
+ /\b([A-Za-z_$][\w$]*)(?:\s*:\s*[^=;{}\n]+)?\s*=\s*(?=["'`])/g,
1855
+ )) {
1856
+ if (code[match.index] !== 1) continue;
1857
+ bindings.set(match[1], shadowLiteralAt(src, match.index + match[0].length));
1858
+ }
1859
+ for (const match of src.matchAll(/(?:\bboxShadow|["']boxShadow["'])\s*[:=]\s*/g)) {
1860
+ const separator = match.index + match[0].search(/[:=]/);
1861
+ if (code[separator] !== 1) continue;
1862
+ let start = match.index + match[0].length;
1863
+ if (src[separator] === "=" && src[start] === "{") start++;
1864
+ let value = shadowLiteralAt(src, start);
1865
+ if (value === undefined) {
1866
+ const identifier = /^\s*([A-Za-z_$][\w$]*)/.exec(src.slice(start));
1867
+ if (identifier && !/^[.([?]/.test(src.slice(start + identifier[0].length).trimStart())) {
1868
+ value = bindings.get(identifier[1]);
1869
+ }
1870
+ }
1871
+ if (value && /\d+(?:\.\d+)?px|\b0\s+0\b|rgba?|hsla?|#/i.test(value)) {
1872
+ shadows.push(
1873
+ `${rel}:${lineAt(src, match.index)} — owned boxShadow literal; spread knobProps.elevatedSurface, elevationChrome or thumbShadow`,
1874
+ );
1875
+ }
1876
+ }
1877
+ }
1878
+ if (!/\.(?:tsx|jsx)$/.test(filePath)) return;
1879
+ for (const match of src.matchAll(/<([A-Z][\w]*(?:\.[A-Z][\w]*)*)\b/g)) {
1880
+ if (code[match.index] !== 1) continue;
1881
+ const name = match[1];
1882
+ if (
1883
+ /^(?:Popover|Dialog|AlertDialog)\.Content$/.test(name) &&
1884
+ rel !== "public/theme/src/theme/overlaySurfaces.tsx"
1885
+ ) {
1886
+ frames.push(
1887
+ `${rel}:${lineAt(src, match.index)} — raw <${name}>; use the house frame from @multiplatform.one/theme or @multiplatform.one/components`,
1888
+ );
1889
+ }
1890
+ if (!overlayTags.has(name)) continue;
1891
+ const tag = openingTag(src, code, match.index);
1892
+ const elevation =
1893
+ /\belevation\s*=\s*\{[^}]*\bknobProps\.(?:elevation|overlayElevation)\b/.exec(tag);
1894
+ if (elevation && code[match.index + elevation.index] === 1) {
1895
+ frames.push(
1896
+ `${rel}:${lineAt(src, match.index + elevation.index)} — elevation overrides the house <${name}> frame; let its elevatedSurface recipe paint`,
1897
+ );
1898
+ }
1899
+ }
1900
+ });
1901
+ return { shadows, frames };
1902
+ }
1903
+
1904
+ /**
1905
+ * Run structural convention checks over a consumer tree.
1906
+ *
1907
+ * @param {RunConventionChecksOptions} [options]
1908
+ * @returns {number} 0 when clean, 1 when any check failed
1909
+ */
1910
+ // ── Layout rules (LC-101 … LC-103, FZ-7158 WP-h) ──────────────────────────
1911
+
1912
+ /** Ratchet for the layout rules that land on a tree already breaking them. */
1913
+ const LAYOUT_BASELINES = "docs/layout-rule-baselines.json";
1914
+
1915
+ /**
1916
+ * `null` when the baseline file is absent (a scaffolded project prunes
1917
+ * docs/), and the layout checks then skip, like the spec registries.
1918
+ * @returns {{ secondSidebar: string[], hugContent: Record<string, number> } | null}
1919
+ */
1920
+ function readLayoutBaselines() {
1921
+ const path = join(ROOT, LAYOUT_BASELINES);
1922
+ if (!existsSync(path)) return null;
1923
+ const raw = JSON.parse(readFileSync(path, "utf8"));
1924
+ return {
1925
+ secondSidebar: Array.isArray(raw.secondSidebar) ? raw.secondSidebar : [],
1926
+ hugContent: raw.hugContent && typeof raw.hugContent === "object" ? raw.hugContent : {},
1927
+ };
1928
+ }
1929
+
1930
+ /** Screen code: where a page composes chrome (not the catalog primitives). */
1931
+ function isScreenSource(rel) {
1932
+ const path = rel.replaceAll("\\", "/");
1933
+ return (
1934
+ path.startsWith("public/backoffice/src/") ||
1935
+ path.startsWith("public/frappe-ui/src/") ||
1936
+ path.startsWith("apps/") ||
1937
+ path.startsWith("features/")
1938
+ );
1939
+ }
1940
+
1941
+ const SIDEBAR_TAG_RE = /<([A-Z][A-Za-z0-9]*Sidebar(?:Panel)?)(?=[\s/>])/g;
1942
+
1943
+ /**
1944
+ * LC-101 ONE-SIDEBAR: a page renders no sidebar column of its own beside
1945
+ * the shell's nav. A `<…Sidebar>` / `<…SidebarPanel>` element in screen code
1946
+ * is legal in a shell (`*Shell.tsx`, which owns the one column), in the
1947
+ * sidebar's own file, or in a file that hands it to the shell through
1948
+ * `useShellSidebarPanel`. The sites that predate the rule are the
1949
+ * `secondSidebar` rows of docs/layout-rule-baselines.json; a row whose
1950
+ * site is gone fails too.
1951
+ */
1952
+ function checkOneSidebar() {
1953
+ const baselines = readLayoutBaselines();
1954
+ if (!baselines) return [];
1955
+ const allowed = new Map(baselines.secondSidebar.map((file) => [file, false]));
1956
+ /** @type {string[]} */
1957
+ const violations = [];
1958
+ for (const { rel, src } of sourceFiles()) {
1959
+ const path = rel.replaceAll("\\", "/");
1960
+ if (!isScreenSource(path) || !path.endsWith(".tsx") || /\.stories\./.test(path)) continue;
1961
+ const base = path.split("/").pop() ?? "";
1962
+ if (/Shell\.tsx$/.test(base) || /Sidebar(Panel)?\.tsx$/.test(base)) continue;
1963
+ const mask = codeOffsets(src);
1964
+ const hits = [...src.matchAll(SIDEBAR_TAG_RE)].filter((m) => mask[m.index ?? 0] === 1);
1965
+ if (hits.length === 0) continue;
1966
+ if (src.includes("useShellSidebarPanel(")) continue;
1967
+ if (allowed.has(path)) {
1968
+ allowed.set(path, true);
1969
+ continue;
1970
+ }
1971
+ for (const m of hits) {
1972
+ violations.push(
1973
+ `${path}:${lineAt(src, m.index ?? 0)} — <${m[1]}> opens a second sidebar column; stack it under the shell nav with useShellSidebarPanel`,
1974
+ );
1975
+ }
1976
+ }
1977
+ for (const [file, seen] of allowed) {
1978
+ if (!seen) violations.push(`${file} — secondSidebar baseline row has no site (stale row)`);
1979
+ }
1980
+ return violations;
1981
+ }
1982
+
1983
+ const SPREAD_JUSTIFY_RE =
1984
+ /justifyContent(?:\s*=\s*\{?\s*|\s*:\s*)["'](space-between|space-around|space-evenly)["']/g;
1985
+
1986
+ /**
1987
+ * LC-102 HUG-CONTENT: rows and columns hug their content; a spread
1988
+ * justification (`space-between` / `-around` / `-evenly`) pushes a value
1989
+ * away from its label. Ratcheted: each file may carry at most its
1990
+ * `hugContent` count from docs/layout-rule-baselines.json, and a file that
1991
+ * dropped below its row must lower it, so the count only goes down.
1992
+ */
1993
+ function checkHugContent() {
1994
+ const baselines = readLayoutBaselines();
1995
+ if (!baselines) return [];
1996
+ /** @type {string[]} */
1997
+ const violations = [];
1998
+ const seen = new Set();
1999
+ for (const { rel, src } of sourceFiles()) {
2000
+ const path = rel.replaceAll("\\", "/");
2001
+ if (!/\.(tsx|ts)$/.test(path) || /\.stories\./.test(path)) continue;
2002
+ const live = nonCommentOffsets(src);
2003
+ const hits = [...src.matchAll(SPREAD_JUSTIFY_RE)].filter((m) => live[m.index ?? 0] === 1);
2004
+ const allowed = baselines.hugContent[path] ?? 0;
2005
+ if (allowed > 0) seen.add(path);
2006
+ if (hits.length > allowed) {
2007
+ const first = hits[allowed] ?? hits[0];
2008
+ violations.push(
2009
+ `${path}:${lineAt(src, first.index ?? 0)} — justifyContent="${first[1]}" (${hits.length} in file, baseline ${allowed}); hug the content with a gap instead`,
2010
+ );
2011
+ } else if (hits.length < allowed) {
2012
+ violations.push(
2013
+ `${path} — ${hits.length} spread justifications, baseline says ${allowed}; lower its hugContent row`,
2014
+ );
2015
+ }
2016
+ }
2017
+ for (const [file, count] of Object.entries(baselines.hugContent)) {
2018
+ if (count > 0 && !seen.has(file)) {
2019
+ violations.push(`${file} — hugContent baseline row has no file (stale row)`);
2020
+ }
2021
+ }
2022
+ return violations;
2023
+ }
2024
+
2025
+ const PHONE_HIDE_RE = /\$max-(?:xs|sm|md)\s*=\s*\{\{\s*display\s*:\s*["']none["']/;
2026
+ const WIDE_REVEAL_RE = /\$(?:sm|md|lg)\s*=\s*\{\{\s*display\s*:\s*["'](?!none)/;
2027
+ const HIDDEN_ATTR_RE = /\sdisplay\s*=\s*(?:\{\s*)?["']none["']/;
2028
+
2029
+ /**
2030
+ * LC-103 RESPONSIVE-PARITY: a phone shows the same information. An element
2031
+ * hidden only on phones (`$max-sm={{ display: "none" }}`, or `display="none"`
2032
+ * revealed by `$sm`/`$md`/`$lg`) must say where its phone twin is with a
2033
+ * `responsive-parity: <reason>` comment inside the tag or on the line above
2034
+ * (the ⋯ menu entry, the icon twin, the sheet that carries it).
2035
+ */
2036
+ function checkResponsiveParity() {
2037
+ if (!readLayoutBaselines()) return [];
2038
+ /** @type {string[]} */
2039
+ const violations = [];
2040
+ for (const { rel, src } of sourceFiles()) {
2041
+ const path = rel.replaceAll("\\", "/");
2042
+ if (!isScreenSource(path) || !path.endsWith(".tsx") || /\.stories\./.test(path)) continue;
2043
+ if (!src.includes("display")) continue;
2044
+ const mask = codeOffsets(src);
2045
+ for (const m of src.matchAll(/<[A-Za-z][\w.]*/g)) {
2046
+ const start = m.index ?? 0;
2047
+ if (mask[start] !== 1) continue;
2048
+ const tag = openingTag(src, mask, start);
2049
+ const hidesOnPhone =
2050
+ PHONE_HIDE_RE.test(tag) || (HIDDEN_ATTR_RE.test(tag) && WIDE_REVEAL_RE.test(tag));
2051
+ if (!hidesOnPhone) continue;
2052
+ const lineStart = src.lastIndexOf("\n", start - 1);
2053
+ const prevLineStart = src.lastIndexOf("\n", lineStart - 1);
2054
+ const context = src.slice(Math.max(0, prevLineStart), start) + tag;
2055
+ if (/responsive-parity:\s*\S/.test(context)) continue;
2056
+ violations.push(
2057
+ `${path}:${lineAt(src, start)} — hidden on phones with no \`responsive-parity: <reason>\` naming its phone twin`,
2058
+ );
2059
+ }
2060
+ }
2061
+ return violations;
2062
+ }
2063
+
2064
+ function checkAccentSolidOwners() {
2065
+ const owners = new Set([
2066
+ "public/components/src/componentColors.ts",
2067
+ "public/forms/src/shared/colorRamps.ts",
2068
+ "public/theme/src/theme/createThemes.ts",
2069
+ "public/theme/src/theme/chartPalette.ts",
2070
+ "public/theme/src/theme/readableColor.ts",
2071
+ ]);
2072
+ const violations = [];
2073
+ const scan = (filePath) => {
2074
+ if (!/\.(ts|tsx|js|jsx|mjs)$/.test(filePath) || filePath.endsWith(".d.ts")) return;
2075
+ if (/\.(spec|test|stories)\./.test(filePath)) return;
2076
+ const rel = relative(ROOT, filePath).replaceAll("\\", "/");
2077
+ if (owners.has(rel)) return;
2078
+ const src = readFileSync(filePath, "utf8");
2079
+ const live = nonCommentOffsets(src);
2080
+ for (const match of src.matchAll(/accentBackground/g)) {
2081
+ if (live[match.index] === 1) {
2082
+ violations.push(`${rel}:${lineAt(src, match.index)} — accentBackground outside its owners`);
2083
+ }
2084
+ }
2085
+ };
2086
+ if (existsSync(PUBLIC_DIR)) {
2087
+ for (const pkg of readdirSync(PUBLIC_DIR, { withFileTypes: true })) {
2088
+ if (!pkg.isDirectory() || pkg.name.startsWith(".") || pkg.name === "node_modules") continue;
2089
+ walkFiles(join(PUBLIC_DIR, pkg.name, "src"), scan);
2090
+ }
2091
+ }
2092
+ for (const dir of [APPS_DIR, FEATURES_DIR, PACKAGES_DIR]) walkFiles(dir, scan);
2093
+ return violations;
2094
+ }
2095
+
1585
2096
  /**
1586
2097
  * Run structural convention checks over a consumer tree.
1587
2098
  *
@@ -1603,13 +2114,40 @@ export function runConventionChecks(options = {}) {
1603
2114
  const rnWebExports = checkReactNativeWebExports();
1604
2115
  const importMeta = checkImportMetaInPublicSource();
1605
2116
  const graphVars = checkGraphVarsCoverage();
2117
+ const retiredScaleKnobs = checkRetiredScaleKnobs();
2118
+ const overlayOwnership = checkOverlayOwnership();
2119
+ const accentSolidOwners = checkAccentSolidOwners();
1606
2120
  const tokens = checkTransitionTokens();
1607
2121
  const styleTokens = checkStyleTokens();
1608
2122
  const themeNames = checkThemeNames();
1609
2123
  const intents = checkIntentTokens();
1610
2124
  const animProps = checkAnimationProps();
2125
+ const oneSidebar = checkOneSidebar();
2126
+ const hugContent = checkHugContent();
2127
+ const responsiveParity = checkResponsiveParity();
1611
2128
  let failed = false;
1612
2129
 
2130
+ for (const [rule, list] of /** @type {const} */ ([
2131
+ [
2132
+ "Convention (LC-101 ONE-SIDEBAR): one sidebar column per page. A page's secondary panel (filters, group-by) stacks under the shell nav through useShellSidebarPanel, and phones open it as a sheet.",
2133
+ oneSidebar,
2134
+ ],
2135
+ [
2136
+ "Convention (LC-102 HUG-CONTENT): columns and fields hug their content; no space-between / space-around / space-evenly. Pre-rule sites are the hugContent ratchet in docs/layout-rule-baselines.json.",
2137
+ hugContent,
2138
+ ],
2139
+ [
2140
+ "Convention (LC-103 RESPONSIVE-PARITY): a phone shows the same information. Content hidden on phones names its phone twin (⋯ menu, icon twin, sheet) with a `responsive-parity: <reason>` comment.",
2141
+ responsiveParity,
2142
+ ],
2143
+ ])) {
2144
+ if (!list.length) continue;
2145
+ failed = true;
2146
+ console.error(`${rule}\n`);
2147
+ for (const msg of list) console.error(` ${msg}`);
2148
+ console.error("");
2149
+ }
2150
+
1613
2151
  if (animProps.legacyProp.length) {
1614
2152
  failed = true;
1615
2153
  console.error(
@@ -1732,6 +2270,41 @@ export function runConventionChecks(options = {}) {
1732
2270
  console.error("");
1733
2271
  }
1734
2272
 
2273
+ if (accentSolidOwners.length) {
2274
+ failed = true;
2275
+ console.error(
2276
+ "Convention (LC-05 ONE-EMPHASIS): the accent solid paints the primary CTA, the checked Switch track, progress fills and current/checked marks only, through `componentColors.indicator.mark` or `formSelectedColors.mark`. A selected, current or highlighted fill takes `componentColors.selection` / `formSelectionColors`; a data-identity default takes `useChartPalette().single`; accent text takes `$accentColor`.\n",
2277
+ );
2278
+ for (const msg of accentSolidOwners) console.error(` ${msg}`);
2279
+ console.error("");
2280
+ }
2281
+
2282
+ if (overlayOwnership.shadows.length) {
2283
+ failed = true;
2284
+ console.error(
2285
+ "Convention (LC-85 ONE SHADOW LADDER, check 15): spread knobProps.elevatedSurface, elevationChrome or thumbShadow instead of owning a shadow literal.\n",
2286
+ );
2287
+ for (const msg of overlayOwnership.shadows) console.error(` ${msg}`);
2288
+ console.error("");
2289
+ }
2290
+ if (overlayOwnership.frames.length) {
2291
+ failed = true;
2292
+ console.error(
2293
+ "Convention (E-OVERLAY ONE FRAME, check 16): use the house frame from @multiplatform.one/theme or @multiplatform.one/components and let it own elevation.\n",
2294
+ );
2295
+ for (const msg of overlayOwnership.frames) console.error(` ${msg}`);
2296
+ console.error("");
2297
+ }
2298
+
2299
+ if (retiredScaleKnobs.length) {
2300
+ failed = true;
2301
+ console.error(
2302
+ 'Convention (LC-100 TOKEN-SCALE, check 14): the size, space and density knobs are retired. Size, spacing and radius come from the preset\'s generated token scale (`Preset.scale`), and the only per-site control is Tamagui\'s own `size="$n"` (forwarded to a subtree by `<Surface size="$3">`). Drop the knob key, replace `compact` with a size token, drop `data-density` (`data-size-token` carries the token), and give `<Surface>` a `$size` key.\n',
2303
+ );
2304
+ for (const msg of retiredScaleKnobs) console.error(` ${msg}`);
2305
+ console.error("");
2306
+ }
2307
+
1735
2308
  if (importMeta.length) {
1736
2309
  failed = true;
1737
2310
  console.error(