Skip to content

Navigation Menu

Sign in
Sign up

Validate theme visual props from generated Core contract #6061

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
imdreamrunner wants to merge 1 commit into feat/theme-tiers
base: feat/theme-tiers
Choose a base branch
Loading
from agentcloud/theme-alias-validator
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions .changeset/theme-adaptations.md
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,13 @@ point renders the wider layout instead of the mobile layout.
`defineTheme` now rejects malformed token values instead of coercing non-string
scalars or accepting arrays with a length other than two. It also validates the
combined portable and theme-local token graph for every reachable set of matching
adaptation rules, rejecting cycles before CSS is emitted. Rule-only visual-prop
values are rejected when tooling can enumerate the finite built-in domain; opaque
alias-backed domains retain the known validation boundary shared with root themes.
adaptation rules, rejecting cycles before CSS is emitted. A generated Core
visual-prop contract now gives theme build the checked domain and augmentation
wiring for every current component axis. Root and adaptation layers reject
unsupported values on finite closed axes, including imported aliases, while
open string/number domains and existing public augmentation points retain their
intended behavior. Generated component selectors also escape punctuation in class
tokens, so finite fractional values such as `gap:0.5` match the class emitted by
`themeProps()`.

@imdreamrunner
133 changes: 26 additions & 107 deletions .github/scripts/visual-gate/generate-probe-theme.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file Generate (or verify) the probe theme from the component docs.
* @file Generate (or verify) the probe theme from Core's generated contract.
*
* @input the repo's component docs
* @input packages/core/theme-visual-props.json
* @output packages/themes/probe/src/probeTheme.ts, and a coverage summary
*
* `--check` makes it a CI guard: adding a theming target without regenerating
Expand All @@ -17,15 +17,21 @@

import * as fs from 'node:fs';
import * as path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {fileURLToPath} from 'node:url';

import {buildProbeComponents, renderProbeTheme} from './lib/probe-theme.mjs';
import {PROBE_FONT, PROBE_SYNTAX, PROBE_TOKENS} from './lib/probe-axes.mjs';
import {loadThemingTargets} from './lib/sources.mjs';
import {loadThemeVisualPropsContract} from '../../../packages/cli/foundation/discovery/theme-visual-props.mjs';

const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..');
const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../../..',
);
const OUT = path.join(REPO_ROOT, 'packages/themes/probe/src/probeTheme.ts');
const CONFIG_OUT = path.join(REPO_ROOT, 'packages/themes/probe/src/probeConfig.ts');
const CONFIG_OUT = path.join(
REPO_ROOT,
'packages/themes/probe/src/probeConfig.ts',
);

/**
* The non-component axes, emitted as TypeScript.
Expand Down Expand Up @@ -59,106 +65,15 @@ export const PROBE_SYNTAX: SyntaxThemeTokenInput = ${json(PROBE_SYNTAX)};
}
const check = process.argv.includes('--check');

/**
* Documented props per component, for resolving a visual prop's value set.
* @returns {Promise<Record<string, Array<{name: string, type?: string}>>>}
*/
async function loadProps() {
const {loadComponentDoc} = await import(
pathToFileURL(path.join(REPO_ROOT, 'packages/cli/foundation/discovery/component-loader.mjs'))
.href
const coreRoot = path.join(REPO_ROOT, 'packages/core');
const contract = loadThemeVisualPropsContract(coreRoot);
if (!contract) {
throw new Error(
`Missing or unsupported ${path.relative(REPO_ROOT, path.join(coreRoot, 'theme-visual-props.json'))}. ` +
'Run: pnpm generate:theme-visual-props',
);
/** @type {Record<string, Array<{name: string, type?: string}>>} */
const byComponent = {};
const root = path.join(REPO_ROOT, 'packages/core/src');

/** @param {string} dir */
const scan = async dir => {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
await scan(full);
continue;
}
if (!entry.name.endsWith('.doc.mjs')) continue;
try {
const doc = await loadComponentDoc(full);
const name = doc?.name || path.basename(path.dirname(full));
byComponent[name] = [...(byComponent[name] ?? []), ...(doc?.props ?? [])];
} catch {
// An unreadable doc contributes no props; the target enumeration skips
// it too, so the two stay consistent.
}
}
};
await scan(root);
return byComponent;
}

/**
* Exported string-union type aliases across core, so a doc that says
* `size: AvatarSize` still yields its values.
*
* A regex over source, not the TypeScript compiler: this runs in a `--check`
* on every PR, and the cost of a full type-check is not worth it for what is
* a one-line declaration in practice. A union it cannot parse falls through to
* the skipped list, where it is reported rather than silently dropped.
*
* @returns {Record<string, string[]>}
*/
function loadTypeAliases() {
/** @type {Record<string, string>} */
const raw = {};
const root = path.join(REPO_ROOT, 'packages/core/src');
const pattern = /(?:export\s+)?type\s+(\w+)\s*=\s*([^;{]+);/g;

/** @param {string} dir */
const scan = dir => {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '__tests__') continue;
scan(full);
continue;
}
if (!/\.tsx?$/.test(entry.name) || entry.name.endsWith('.test.ts')) continue;
for (const match of fs.readFileSync(full, 'utf8').matchAll(pattern)) {
raw[match[1]] ??= match[2];
}
}
};
scan(root);

// Aliases compose (`AvatarSize = AvatarNamedSize | AvatarNumericSize`), so
// resolve transitively. The depth bound is a cycle guard, not a real limit.
/** @param {string} type @param {number} depth @returns {string[]} */
const resolve = (type, depth = 0) => {
if (depth > 4) return [];
/** @type {string[]} */
const values = [];
for (const part of type.split('|').map(piece => piece.trim())) {
const literal = part.match(/^'([^']+)'$/);
if (literal) {
values.push(literal[1]);
continue;
}
if (raw[part]) values.push(...resolve(raw[part], depth + 1));
}
return values;
};

/** @type {Record<string, string[]>} */
const aliases = {};
for (const name of Object.keys(raw)) {
const values = resolve(raw[name]);
if (values.length > 1) aliases[name] = [...new Set(values)];
}
return aliases;
}

const targets = await loadThemingTargets(REPO_ROOT);
const built = buildProbeComponents(targets, await loadProps(), loadTypeAliases());
const built = buildProbeComponents(contract);

// Format with the repo's own prettier config. The pre-commit hook formats
// every staged .ts, so a generator emitting anything else would produce a file
Expand All @@ -176,7 +91,9 @@ const configSource = await prettier.format(renderProbeConfig(), {
});

if (check) {
const currentConfig = fs.existsSync(CONFIG_OUT) ? fs.readFileSync(CONFIG_OUT, 'utf8') : '';
const currentConfig = fs.existsSync(CONFIG_OUT)
? fs.readFileSync(CONFIG_OUT, 'utf8')
: '';
if (currentConfig !== configSource) {
process.stderr.write(
'::error::The probe theme config is out of date — a theme axis changed without regenerating it.\n' +
Expand Down Expand Up @@ -207,12 +124,14 @@ process.stdout.write(
);
if (built.coverage.skipped.length > 0) {
process.stdout.write(
`\n${built.coverage.skipped.length} visual prop(s) could not be enumerated (no string-union type), so their values are unprobed:\n`,
`\n${built.coverage.skipped.length} open or unresolved visual prop(s) have no finite selector set to probe:\n`,
);
for (const entry of built.coverage.skipped.slice(0, 15)) {
process.stdout.write(` ${entry.key}.${entry.prop} — ${entry.reason}\n`);
}
if (built.coverage.skipped.length > 15) {
process.stdout.write(` ... and ${built.coverage.skipped.length - 15} more\n`);
process.stdout.write(
` ... and ${built.coverage.skipped.length - 15} more\n`,
);
}
}
42 changes: 30 additions & 12 deletions .github/scripts/visual-gate/lib/probe-reach.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
* `variant:info` override beating `base` is the cascade working, not a miss.
*/

import {paint} from './probe-theme.mjs';
import {paint, paintCompatibilityAlias} from './probe-theme.mjs';

/**
* `hsl(H S% L%)` → the `rgb(r, g, b)` string getComputedStyle returns.
Expand All @@ -36,7 +36,8 @@ export function hslToRgb(hsl) {
const lightness = l / 100;
const k = n => (n + h / 30) % 12;
const a = saturation * Math.min(lightness, 1 - lightness);
const f = n => lightness - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const f = n =>
lightness - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
const to255 = n => Math.round(f(n) * 255);
return `rgb(${to255(0)}, ${to255(8)}, ${to255(4)})`;
}
Expand All @@ -53,7 +54,12 @@ export function expectedColors(key, data) {
// Every property the generator paints, for every selector that addresses
// this element — an element with no background can still prove the override
// arrived through its text or border colour.
return seeds.flatMap(seed => Object.values(paint(seed))).map(hslToRgb);
return seeds
.flatMap(seed => [
...Object.values(paint(seed)),
...Object.values(paintCompatibilityAlias(seed)),
])
.map(hslToRgb);
}

/**
Expand All @@ -76,7 +82,15 @@ export const READ_TARGETS = `(() => {
// reading carries every co-located target and the caller decides.
const keys = [...el.classList].filter(c => c.startsWith('astryx-')).map(c => c.slice(7));
if (keys.length === 0) continue;
out.push({keys, data, bg: cs.backgroundColor, color: cs.color, border: cs.borderTopColor});
out.push({
keys,
data,
bg: cs.backgroundColor,
color: cs.color,
border: cs.borderTopColor,
decoration: cs.textDecorationColor,
caret: cs.caretColor,
});
}
return out;
})()`;
Expand All @@ -90,15 +104,14 @@ export const READ_TARGETS = `(() => {
* un-verify it.
*
* @param {{verified: Set<string>, failures: Map<string, object>}} acc
* @param {Array<{key: string, data: string[], bg: string}>} readings
* @param {Array<{keys: string[], data: string[], bg: string, color?: string, border?: string, decoration?: string, caret?: string}>} readings
* @param {string} storyId
*/
export function fold(acc, readings, storyId) {
for (const {keys, data, bg, color, border} of readings) {
// The probe paints background, text and border from independent hashes, so
// an element that cannot show a background (an inline glyph, a
// display:contents wrapper) can still prove the override arrived.
const painted = [bg, color, border];
for (const {keys, data, bg, color, border, decoration, caret} of readings) {
// Canonical targets and compatibility aliases paint disjoint properties, so
// co-located classes can both prove reachability on one element.
const painted = [bg, color, border, decoration, caret].filter(Boolean);

for (const key of keys) {
if (acc.verified.has(key)) continue;
Expand All @@ -115,11 +128,16 @@ export function fold(acc, readings, storyId) {
// "these two targets are one element" is worth knowing and is NOT the
// same finding as "this override reaches nothing".
const sibling = keys.some(
other => other !== key && painted.some(v => expectedColors(other, data).includes(v)),
other =>
other !== key &&
painted.some(v => expectedColors(other, data).includes(v)),
);
if (sibling) {
if (!acc.failures.has(key) && !acc.shadowed.has(key)) {
acc.shadowed.set(key, {storyId, sharesElementWith: keys.filter(k => k !== key)});
acc.shadowed.set(key, {
storyId,
sharesElementWith: keys.filter(k => k !== key),
});
}
continue;
}
Expand Down
65 changes: 58 additions & 7 deletions .github/scripts/visual-gate/lib/probe-reach.test.mjs
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@

import {describe, expect, it} from 'vitest';

import {emptyAccumulator, expectedColors, fold, hslToRgb} from './probe-reach.mjs';
import {paint, probeColor} from './probe-theme.mjs';
import {
emptyAccumulator,
expectedColors,
fold,
hslToRgb,
} from './probe-reach.mjs';
import {paint, paintCompatibilityAlias, probeColor} from './probe-theme.mjs';

const rgbOf = seed => hslToRgb(probeColor(seed));
const textOf = seed => hslToRgb(paint(seed).color);
const aliasDecorationOf = seed =>
hslToRgb(paintCompatibilityAlias(seed).textDecorationColor);

describe('hslToRgb', () => {
it('matches the rgb() form getComputedStyle returns', () => {
Expand Down Expand Up @@ -39,12 +46,39 @@ describe('fold', () => {
it('accepts proof from text or border, so an element with no background still counts', () => {
const acc = fold(
emptyAccumulator(),
[{keys: ['icon'], data: [], bg: 'rgba(0, 0, 0, 0)', color: textOf('icon')}],
[
{
keys: ['icon'],
data: [],
bg: 'rgba(0, 0, 0, 0)',
color: textOf('icon'),
},
],
's',
);
expect([...acc.verified]).toEqual(['icon']);
});

it('verifies canonical and compatibility classes independently on one element', () => {
const acc = fold(
emptyAccumulator(),
[
{
keys: ['checkbox-indicator', 'checkbox'],
data: [],
bg: rgbOf('checkbox-indicator'),
decoration: aliasDecorationOf('checkbox'),
},
],
's',
);
expect([...acc.verified].sort()).toEqual([
'checkbox',
'checkbox-indicator',
]);
expect(acc.shadowed.size).toBe(0);
});

it('calls a target shadowed — not failed — when another target on the SAME element won', () => {
const acc = fold(
emptyAccumulator(),
Expand Down Expand Up @@ -78,21 +112,38 @@ describe('fold', () => {
});

it('verifies a target whose override arrived', () => {
const acc = fold(emptyAccumulator(), [{keys: ['badge'], data: [], bg: rgbOf('badge')}], 's');
const acc = fold(
emptyAccumulator(),
[{keys: ['badge'], data: [], bg: rgbOf('badge')}],
's',
);
expect([...acc.verified]).toEqual(['badge']);
expect(acc.failures.size).toBe(0);
});

it('fails a target showing the component colour instead of the override', () => {
const acc = fold(emptyAccumulator(), [{keys: ['badge'], data: [], bg: 'rgb(0, 100, 224)'}], 's');
const acc = fold(
emptyAccumulator(),
[{keys: ['badge'], data: [], bg: 'rgb(0, 100, 224)'}],
's',
);
expect(acc.verified.size).toBe(0);
expect(acc.failures.get('badge')).toMatchObject({got: 'rgb(0, 100, 224)', storyId: 's'});
expect(acc.failures.get('badge')).toMatchObject({
got: 'rgb(0, 100, 224)',
storyId: 's',
});
});

it('credits the variant colour on a variant element', () => {
const acc = fold(
emptyAccumulator(),
[{keys: ['badge'], data: ['variant:info'], bg: rgbOf('badge.variant:info')}],
[
{
keys: ['badge'],
data: ['variant:info'],
bg: rgbOf('badge.variant:info'),
},
],
's',
);
expect([...acc.verified]).toEqual(['badge']);
Expand Down
Loading

AltStyle によって変換されたページ (->オリジナル) /