@multiplatform.one/config 7.34.4 → 7.36.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/README.md +48 -0
- package/design-lint.mjs +524 -0
- package/lib/index.js +3 -3
- package/lib/{storybook-Dxwh_fl2.js → storybook-Ba0KCHBo.js} +9 -2
- package/lib/storybook.js +1 -1
- package/lib/{tamaguiWorkspacePaths-B6aRC2BU.js → tamaguiWorkspacePaths-DR3dud79.js} +3 -2
- package/lib/{vite-n3XSaftn.js → vite-DkidIRYX.js} +1 -1
- package/lib/vite.js +2 -2
- package/lib/vitest.js +1 -1
- package/lint.mjs +642 -69
- package/package.json +13 -7
- package/src/lint.spec.ts +511 -5
- package/src/phosphorTokenColor.spec.ts +7 -1
- package/src/phosphorTokenColor.ts +3 -2
- package/src/storybook.spec.ts +7 -0
- package/src/storybook.ts +11 -1
- package/types/phosphorTokenColor.d.ts.map +1 -1
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 (
|
|
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/
|
|
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
|
|
414
|
+
const v5 = tryRequire("@tamagui/themes/v5");
|
|
417
415
|
const src = readPublicSource("theme/src/theme/createThemes.ts");
|
|
418
|
-
if (!builder?.createThemes || !
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
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
|
|
478
|
-
for (const
|
|
479
|
-
|
|
480
|
-
|
|
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(
|