diff --git a/.github/workflows/visual-regression.yml b/.github/workflows/visual-regression.yml index 918f9306..15ae6055 100644 --- a/.github/workflows/visual-regression.yml +++ b/.github/workflows/visual-regression.yml @@ -44,6 +44,7 @@ jobs: 'packages/@luke-ui/react/vitest.config.ts', 'packages/@luke-ui/react/src/core/stylesheet.css.ts', 'packages/@luke-ui/react/src/core/test-utils/render-setup.ts', + 'packages/@luke-ui/react/src/core/test-utils/render-mount-state.ts', 'packages/@luke-ui/react/src/core/test-utils/render.tsx', 'packages/@luke-ui/react/src/core/test-utils/visual-setup.ts') }} - name: Compare visual captures diff --git a/docs/STYLING.md b/docs/STYLING.md index 7ba3b13e..7ba58170 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -8,8 +8,9 @@ import `@luke-ui/react/stylesheet.css` and apply `rootClassName` from `@luke-ui/ `@luke-ui/react/themes/tactile/stylesheet.css`. That alone themes the whole document from `:root`, with no class and no JS required. Neither step injects styles at runtime. -The package build also extracts StyleX and appends those rules to `dist/stylesheet.css`. Production -component styles stay on Vanilla Extract. +The package build also extracts StyleX and appends those rules to `dist/stylesheet.css`. StyleX +rules live in generated `luke.sx.priorityN` cascade layers between the theme and recipes layers. +Production component styles stay on Vanilla Extract until their migration slices land. ## Structure @@ -179,12 +180,25 @@ explicit mode. All styles live in named CSS cascade layers. Layer order makes cross-layer priority explicit. Specificity and source order still decide conflicts within a layer. -| Layer | Purpose | -| ----------- | --------------------------------------------------- | -| `reset` | Browser defaults, box sizing, and margins. | -| `theme` | Design token custom properties and base typography. | -| `recipes` | Component styles, variants, and compound variants. | -| `utilities` | One-off layout and override escape hatches. | +| Layer | Purpose | +| ------------------- | ----------------------------------------------------------------------- | +| `reset` | Browser defaults, box sizing, and margins. | +| `theme` | Design token custom properties and base typography. | +| `luke.sx.priorityN` | StyleX atoms, ordered by internal priority. | +| `recipes` | Component styles, variants, and compound variants. | +| `structural` | Retained descendant rhythm, skeleton masking, and combinator selectors. | +| `utilities` | One-off layout and override escape hatches. | + +While Vanilla Extract recipes remain, the `recipes` layer is transitional. The stylesheet contract +requires it to contain at least one rule until the last recipe moves to StyleX. + +The public `dist/stylesheet.css` starts with one combined `@layer` order statement that lists every +Luke-owned layer before any rules create them. StyleX priority layers use dotted nested names such +as `luke.sx.priority1`, which sit between `theme` and `recipes` in the required precedence order. + +The compiler-facing StyleX token surface is `src/theme/tokens.stylex.ts`, generated from +`themeContractTree` with `defineConsts`. Each key resolves to a live `var(--luke-*)` reference. The +public `vars` object and theme stylesheets remain the sole authorities on token values. Use `styleInLayer` and `globalStyleInLayer` from `core/styles/layered-style.css.ts` to place a plain Vanilla Extract style for a recipe with no variants in a named layer (see @@ -201,9 +215,9 @@ authored as one of the Text recipe's `recipe()` compound-variant styles, so they Overrides that should beat component recipes belong in the `utilities` layer. Use `!important` only when a style must also beat consumer un-layered styles or inline styles. Layers cannot beat those. -`LoadingSkeleton` uses `!important` inside the `recipes` layer because it must force placeholder +`LoadingSkeleton` uses `!important` inside the `structural` layer because it must force placeholder styles onto arbitrary wrapped children. Moving `!important` to a lower layer does not weaken the -mask — in the `!important` cascade, lower layers win over higher layers. The `recipes` layer is +mask — in the `!important` cascade, lower layers win over higher layers. The `structural` layer is below `utilities`, so a `utilities`-layer `!important` override from a consumer cannot beat the skeleton. diff --git a/knip.config.ts b/knip.config.ts index b3397c78..f3a64c75 100644 --- a/knip.config.ts +++ b/knip.config.ts @@ -30,6 +30,7 @@ export default { 'src/core/stylesheet.css.ts', 'src/core/styles/index.css.ts', // Pack entry; nothing in `src/` imports it. + 'src/core/styles/stylex-bundle.ts', 'src/core/styles/stylex-fixture.ts', 'scripts/**/*.ts', ], diff --git a/packages/@luke-ui/react/AGENTS.md b/packages/@luke-ui/react/AGENTS.md index 8c671265..c9deedfc 100644 --- a/packages/@luke-ui/react/AGENTS.md +++ b/packages/@luke-ui/react/AGENTS.md @@ -1,7 +1,7 @@ # @luke-ui/react agent guide - Do not hand-edit `.generated/entries.ts` or `package.json#exports`. `vp pack` generates entries - and updates exports during build. The `stylesheet` and `stylex-fixture` build entries are excluded + and updates exports during build. The `stylesheet` and `stylex-bundle` build entries are excluded from the public export map via `exports.exclude` in `vite.config.ts`. Vanilla Extract serializes recipes to `#recipe-engine`; pack, Vitest, Storybook, and the docs app alias that specifier to `src/core/styles/recipe-engine.ts`. Pack then bundles a relative runtime chunk. The specifier is diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 52675639..8a9d9ff4 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -80,9 +80,10 @@ "fix:format": "vp fmt . --write", "fix:lint": "vp lint . --type-aware --fix", "fix:unsafe": "vp lint . --type-aware --fix-dangerously", - "generate": "pnpm run generate:assets", + "generate": "pnpm run generate:assets && pnpm run generate:stylex-tokens", "generate:assets": "pnpm run generate:icons && pnpm run generate:themes", "generate:icons": "tsx scripts/build-icons.ts", + "generate:stylex-tokens": "tsx scripts/generate-stylex-tokens.ts", "generate:themes": "tsx scripts/build-themes.ts", "test": "pnpm run test:ci && pnpm run test:visual", "test:browser": "vp test run --project=browser", diff --git a/packages/@luke-ui/react/scripts/generate-stylex-tokens.ts b/packages/@luke-ui/react/scripts/generate-stylex-tokens.ts new file mode 100644 index 00000000..a12ea7a6 --- /dev/null +++ b/packages/@luke-ui/react/scripts/generate-stylex-tokens.ts @@ -0,0 +1,41 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const contractPath = resolve(scriptDir, '../src/theme/contract.ts'); +const outputPath = resolve(scriptDir, '../src/theme/tokens.stylex.ts'); +const { flattenThemeContract } = (await import(contractPath)) as { + flattenThemeContract: () => Array<[path: string, variable: string]>; +}; + +function toCamelCaseKey(path: string): string { + const [first, ...rest] = path.split('.'); + if (first === undefined) throw new Error(`Theme contract path "${path}" is empty`); + return first + rest.map((segment) => segment[0]?.toUpperCase() + segment.slice(1)).join(''); +} + +export function generateStylexTokens(): string { + const entries = flattenThemeContract().map(([path, variable]) => { + const key = toCamelCaseKey(path); + return `\t${key}: 'var(${variable})',`; + }); + + return `// Generated by scripts/generate-stylex-tokens.ts — do not edit. + +import * as stylex from '@stylexjs/stylex'; + +/** Compiler-facing StyleX token surface. Each value is a live \`var(--luke-*)\` reference. */ +export const tokens = stylex.defineConsts({ +${entries.join('\n')} +}); +`; +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const output = generateStylexTokens(); + mkdirSync(dirname(outputPath), { recursive: true }); + writeFileSync(outputPath, output); + // oxlint-disable-next-line no-console + console.log(`generate-stylex-tokens: wrote ${flattenThemeContract().length} token entries`); +} diff --git a/packages/@luke-ui/react/scripts/visual-regression-contract.ts b/packages/@luke-ui/react/scripts/visual-regression-contract.ts index ac9c9053..366f5f0a 100644 --- a/packages/@luke-ui/react/scripts/visual-regression-contract.ts +++ b/packages/@luke-ui/react/scripts/visual-regression-contract.ts @@ -11,6 +11,7 @@ export const VISUAL_HARNESS_FILES = [ 'packages/@luke-ui/react/vitest.config.ts', VISUAL_HARNESS_LAYOUT_FILE, 'packages/@luke-ui/react/src/core/test-utils/render-setup.ts', + 'packages/@luke-ui/react/src/core/test-utils/render-mount-state.ts', 'packages/@luke-ui/react/src/core/test-utils/render.tsx', 'packages/@luke-ui/react/src/core/test-utils/visual-setup.ts', ] as const; diff --git a/packages/@luke-ui/react/src/core/combobox-field/combobox-field.visual.test.tsx b/packages/@luke-ui/react/src/core/combobox-field/combobox-field.visual.test.tsx index 781932c3..77b85ae1 100644 --- a/packages/@luke-ui/react/src/core/combobox-field/combobox-field.visual.test.tsx +++ b/packages/@luke-ui/react/src/core/combobox-field/combobox-field.visual.test.tsx @@ -380,10 +380,16 @@ test('rich section title', async () => { */ async function waitForMobileTrayToSettle() { const dialog = page.getByRole('dialog'); - await expect.element(dialog).toBeVisible(); - - const overlay = dialog.element().parentElement?.parentElement; - if (overlay == null) throw new Error('Expected the mobile modal structure.'); + await expect.element(dialog).toBeInTheDocument(); + const modal = dialog.element().parentElement; + const overlay = modal?.parentElement; + if (modal == null || overlay == null) throw new Error('Expected the mobile modal structure.'); await waitForOverlayEnter(overlay); + await expect.element(page.elementLocator(overlay)).toBeVisible(); + expect(window.innerWidth).toBe(390); + expect(window.innerHeight).toBe(700); + expect(window.matchMedia('(width > 450px)').matches).toBe(false); + expect(getComputedStyle(modal).borderEndStartRadius).toBe('0px'); + expect(getComputedStyle(modal).borderEndEndRadius).toBe('0px'); } diff --git a/packages/@luke-ui/react/src/core/loading-skeleton/styles.css.ts b/packages/@luke-ui/react/src/core/loading-skeleton/styles.css.ts index 01428ecb..9627d509 100644 --- a/packages/@luke-ui/react/src/core/loading-skeleton/styles.css.ts +++ b/packages/@luke-ui/react/src/core/loading-skeleton/styles.css.ts @@ -79,7 +79,7 @@ export const loadingSkeletonClassName = styleInLayer('recipes', { // The child's own background is forced flat and pulses in sync with the `::after` overlay below, // so at a rounded corner its square edge would otherwise show through the overlay's rounded // recess. Give it the same radius so both surfaces agree on the visible shape. -globalStyleInLayer('recipes', `${loadingSkeletonClassName}:not([data-skeleton-inline]) > *`, { +globalStyleInLayer('structural', `${loadingSkeletonClassName}:not([data-skeleton-inline]) > *`, { ...surface, ...pulse, borderRadius: `var(${skeletonRadiusVar}, 0px)`, @@ -87,7 +87,7 @@ globalStyleInLayer('recipes', `${loadingSkeletonClassName}:not([data-skeleton-in position: 'relative !important' as 'relative', }); -globalStyleInLayer('recipes', `${loadingSkeletonClassName}:not([data-skeleton-inline]) > * *`, { +globalStyleInLayer('structural', `${loadingSkeletonClassName}:not([data-skeleton-inline]) > * *`, { '@media': { '(forced-colors: active)': forcedColorsSurface, }, @@ -97,7 +97,7 @@ globalStyleInLayer('recipes', `${loadingSkeletonClassName}:not([data-skeleton-in // A pseudo-element painted over the child covers visuals the forced styles can't reach (nested backgrounds, // rounded corners); `inset: -1px` also covers the child's border box edges. globalStyleInLayer( - 'recipes', + 'structural', `${loadingSkeletonClassName}:not([data-skeleton-inline]) > *::after`, { ...surface, diff --git a/packages/@luke-ui/react/src/core/primitives/combobox/section-scope.css.ts b/packages/@luke-ui/react/src/core/primitives/combobox/section-scope.css.ts new file mode 100644 index 00000000..02cf8c60 --- /dev/null +++ b/packages/@luke-ui/react/src/core/primitives/combobox/section-scope.css.ts @@ -0,0 +1,7 @@ +import { styleInLayer } from '../../styles/layered-style.css.js'; + +/** + * Marker class for combobox section structural selectors. The adjacent-sibling border lives in + * `core/styles/structural.css.ts` so it can target `+` without living in the recipe layer. + */ +export const comboboxSectionScopeClassName = styleInLayer('recipes', {}, 'combobox-section'); diff --git a/packages/@luke-ui/react/src/core/primitives/combobox/section.tsx b/packages/@luke-ui/react/src/core/primitives/combobox/section.tsx index 428d7017..9fc31d2a 100644 --- a/packages/@luke-ui/react/src/core/primitives/combobox/section.tsx +++ b/packages/@luke-ui/react/src/core/primitives/combobox/section.tsx @@ -2,8 +2,10 @@ import type { JSX, ReactNode } from 'react'; import type { ListBoxSectionProps as RacListBoxSectionProps } from 'react-aria-components/ComboBox'; import { ListBoxSection as RacListBoxSection } from 'react-aria-components/ComboBox'; import { Header as RacHeader } from 'react-aria-components/Header'; +import { cx } from '../../../shared/utils/utils.js'; import type { DistributiveOmit } from '../../types/distributive-omit.js'; import type { Prettify } from '../../types/prettify.js'; +import { comboboxSectionScopeClassName } from './section-scope.css.js'; import { comboboxRecipe } from './styles.css.js'; type _ComboboxSectionOmit = DistributiveOmit< @@ -22,7 +24,7 @@ export type ComboboxSectionProps = Prettify<_ComboboxSectionPr export function ComboboxSection(props: ComboboxSectionProps): JSX.Element { const { children, className, title, ...sectionProps } = props; - const sectionClassName = comboboxRecipe().section(className); + const sectionClassName = cx(comboboxSectionScopeClassName, comboboxRecipe().section(className)); if (typeof children === 'function') { return ( diff --git a/packages/@luke-ui/react/src/core/primitives/combobox/styles.css.ts b/packages/@luke-ui/react/src/core/primitives/combobox/styles.css.ts index 7c4d7a96..6789b404 100644 --- a/packages/@luke-ui/react/src/core/primitives/combobox/styles.css.ts +++ b/packages/@luke-ui/react/src/core/primitives/combobox/styles.css.ts @@ -311,10 +311,6 @@ const comboboxConfig = { flexDirection: 'column', gap: vars.space.sp4, paddingBlock: vars.space.sp8, - - selectors: { - '& + &': { borderBlockStart: `1px solid ${vars.color.border.decorative}` }, - }, }, sectionHeading: { color: vars.color.text.secondary, diff --git a/packages/@luke-ui/react/src/core/prose/recipe.css.ts b/packages/@luke-ui/react/src/core/prose/recipe.css.ts index 79857230..dd7a5ddf 100644 --- a/packages/@luke-ui/react/src/core/prose/recipe.css.ts +++ b/packages/@luke-ui/react/src/core/prose/recipe.css.ts @@ -11,7 +11,7 @@ export type ProseRecipeVariants = RecipeSelection; // Wrapping the root and matched element keeps every rule at 0-0-0. function proseStyle(selector: string, rule: Parameters[2]) { - globalStyleInLayer('recipes', `:where(.${proseScopeClassName}) :where(${selector})`, rule); + globalStyleInLayer('structural', `:where(.${proseScopeClassName}) :where(${selector})`, rule); } // Each gap is the following block's start margin. No block-end margin can collapse or escape. diff --git a/packages/@luke-ui/react/src/core/styles/index.css.ts b/packages/@luke-ui/react/src/core/styles/index.css.ts index 5cc67d05..aa1b1ca8 100644 --- a/packages/@luke-ui/react/src/core/styles/index.css.ts +++ b/packages/@luke-ui/react/src/core/styles/index.css.ts @@ -2,4 +2,5 @@ import './layers.css.js'; import './reset.css.js'; import './theme-root.css.js'; import './modules.css.js'; +import './structural.css.js'; import './utilities.css.js'; diff --git a/packages/@luke-ui/react/src/core/styles/layer-order.browser.test.ts b/packages/@luke-ui/react/src/core/styles/layer-order.browser.test.ts new file mode 100644 index 00000000..161974e1 --- /dev/null +++ b/packages/@luke-ui/react/src/core/styles/layer-order.browser.test.ts @@ -0,0 +1,141 @@ +import '../../../dist/themes/tactile/stylesheet.css'; +import { afterAll, afterEach, beforeAll, expect, test } from 'vite-plus/test'; +import builtStylesheetCss from '../../../dist/stylesheet.css?inline'; + +const mounted: Array = []; +const STYLESHEET_ELEMENT_ID = 'luke-ui-layer-order-stylesheet'; +const stylesheetCss = builtStylesheetCss; + +beforeAll(() => { + const style = document.head.appendChild(document.createElement('style')); + style.id = STYLESHEET_ELEMENT_ID; + style.textContent = stylesheetCss; +}); + +afterAll(() => { + document.getElementById(STYLESHEET_ELEMENT_ID)?.remove(); +}); + +afterEach(() => { + for (const element of mounted) element.remove(); + mounted.length = 0; + for (const style of document.querySelectorAll('style[data-layer-order-probe]')) style.remove(); +}); + +function stylexPaddingClass(stylesheet: string): string { + const stylexSection = stylesheet.split('/* stylex */')[1] ?? ''; + const paddingClass = stylexSection.match( + /\.([a-z0-9]+)\{padding:var\(--luke-space-sp16\)\}/, + )?.[1]; + if (paddingClass == null) { + throw new Error('Expected StyleX padding fixture class in the built stylesheet.'); + } + return paddingClass; +} + +function mountProbe(className: string): HTMLDivElement { + const element = document.body.appendChild(document.createElement('div')); + mounted.push(element); + element.className = className; + return element; +} + +function loadingSkeletonClassName(stylesheet: string): string { + const match = stylesheet.match(/\.(_[a-z0-9]+)\[data-skeleton-inline\]/); + if (match == null || match[1] == null) { + throw new Error('Expected LoadingSkeleton class in the built stylesheet.'); + } + return match[1]; +} + +test('recipes beat StyleX priority layers in the built stylesheet cascade', () => { + const paddingClass = stylexPaddingClass(stylesheetCss); + const element = mountProbe(paddingClass); + + expect(getComputedStyle(element).paddingTop).toBe('16px'); + + const recipeStyle = document.head.appendChild(document.createElement('style')); + recipeStyle.dataset.layerOrderProbe = 'true'; + recipeStyle.textContent = `@layer recipes { .${paddingClass} { padding-top: 10px; } }`; + + expect(getComputedStyle(element).paddingTop).toBe('10px'); +}); + +test('utilities beat StyleX priority layers in the built stylesheet cascade', () => { + const paddingClass = stylexPaddingClass(stylesheetCss); + const element = mountProbe(paddingClass); + + expect(getComputedStyle(element).paddingTop).toBe('16px'); + + const utilityStyle = document.head.appendChild(document.createElement('style')); + utilityStyle.dataset.layerOrderProbe = 'true'; + utilityStyle.textContent = `@layer utilities { .${paddingClass} { padding-top: 20px; } }`; + + expect(getComputedStyle(element).paddingTop).toBe('20px'); +}); + +test('utilities beat recipes in the built stylesheet cascade', () => { + const paddingClass = stylexPaddingClass(stylesheetCss); + const element = mountProbe(paddingClass); + + expect(getComputedStyle(element).paddingTop).toBe('16px'); + + const recipeStyle = document.head.appendChild(document.createElement('style')); + recipeStyle.dataset.layerOrderProbe = 'true'; + recipeStyle.textContent = `@layer recipes { .${paddingClass} { padding-top: 10px; } }`; + expect(getComputedStyle(element).paddingTop).toBe('10px'); + + const utilityStyle = document.head.appendChild(document.createElement('style')); + utilityStyle.dataset.layerOrderProbe = 'true'; + utilityStyle.textContent = `@layer utilities { .${paddingClass} { padding-top: 20px; } }`; + + expect(getComputedStyle(element).paddingTop).toBe('20px'); +}); + +test('unlayered consumer CSS beats StyleX priority layers', () => { + const paddingClass = stylexPaddingClass(stylesheetCss); + const element = mountProbe(paddingClass); + + expect(getComputedStyle(element).paddingTop).toBe('16px'); + + const consumerStyle = document.head.appendChild(document.createElement('style')); + consumerStyle.dataset.layerOrderProbe = 'true'; + consumerStyle.textContent = `.${paddingClass} { padding-top: 30px; }`; + + expect(getComputedStyle(element).paddingTop).toBe('30px'); +}); + +test('reproduces the invalid early layer-declaration failure mode', () => { + const style = document.head.appendChild(document.createElement('style')); + style.dataset.layerOrderProbe = 'true'; + style.textContent = ` +@layer probe-reset; +@layer probe-theme; +@layer probe-recipes; +@layer probe-structural; +@layer probe-utilities; +@layer probe-reset, probe-theme, probe-luke.sx.priority1, probe-recipes, probe-structural, probe-utilities; +@layer probe-luke.sx.priority1 { .probe-invalid { padding-top: 16px; } } +@layer probe-recipes { .probe-invalid { padding-top: 10px; } } +@layer probe-utilities { .probe-invalid { padding-top: 20px; } } +`; + + const element = mountProbe('probe-invalid'); + + expect(getComputedStyle(element).paddingTop).toBe('16px'); +}); + +test('LoadingSkeleton structural !important beats utilities-layer !important overrides', () => { + const skeletonClass = loadingSkeletonClassName(stylesheetCss); + + const element = document.body.appendChild(document.createElement('div')); + mounted.push(element); + element.className = skeletonClass; + element.innerHTML = 'child'; + + const utilityStyle = document.head.appendChild(document.createElement('style')); + utilityStyle.dataset.layerOrderProbe = 'true'; + utilityStyle.textContent = `@layer utilities { .${skeletonClass}:not([data-skeleton-inline]) > * { background-color: red !important; } }`; + + expect(getComputedStyle(element.firstElementChild!).backgroundColor).not.toBe('rgb(255, 0, 0)'); +}); diff --git a/packages/@luke-ui/react/src/core/styles/layers.css.ts b/packages/@luke-ui/react/src/core/styles/layers.css.ts index e6cf5877..d114f5e5 100644 --- a/packages/@luke-ui/react/src/core/styles/layers.css.ts +++ b/packages/@luke-ui/react/src/core/styles/layers.css.ts @@ -6,12 +6,18 @@ import { globalLayer } from '@vanilla-extract/css'; * - **reset** — Normalize browser defaults (box-sizing, margins, form elements). * - **theme** — Design token custom properties and base typographic defaults. * - **recipes** — Component styles (variants, compound variants). + * - **structural** — Descendant rhythm, skeleton masking, and other retained global selectors. * - **utilities** — One-off overrides; highest-priority layer for escape hatches. + * + * `globalLayer()` keeps Vanilla Extract's layer wiring consistent. The authoritative + * combined order is prepended at build time before other CSS; redundant empty + * `@layer name;` declarations are stripped so they cannot reorder already-created layers. */ export const layers = { reset: globalLayer('reset'), theme: globalLayer('theme'), recipes: globalLayer('recipes'), + structural: globalLayer('structural'), utilities: globalLayer('utilities'), } as const; diff --git a/packages/@luke-ui/react/src/core/styles/layers.test.ts b/packages/@luke-ui/react/src/core/styles/layers.test.ts index cebc9ca3..1671fb39 100644 --- a/packages/@luke-ui/react/src/core/styles/layers.test.ts +++ b/packages/@luke-ui/react/src/core/styles/layers.test.ts @@ -3,6 +3,6 @@ import { layers } from './layers.css.js'; describe('layers', () => { it('declares cascade layers from lowest to highest priority', () => { - expect(Object.keys(layers)).toEqual(['reset', 'theme', 'recipes', 'utilities']); + expect(Object.keys(layers)).toEqual(['reset', 'theme', 'recipes', 'structural', 'utilities']); }); }); diff --git a/packages/@luke-ui/react/src/core/styles/package-exports.test.ts b/packages/@luke-ui/react/src/core/styles/package-exports.test.ts index 3ac4bedd..c4c1756a 100644 --- a/packages/@luke-ui/react/src/core/styles/package-exports.test.ts +++ b/packages/@luke-ui/react/src/core/styles/package-exports.test.ts @@ -13,7 +13,7 @@ const absentExportPaths = [ './icon-size-context', './styles/recipe-engine', './stylesheet', - './stylex-fixture', + './stylex-bundle', './primitives', './tokens', ] as const; diff --git a/packages/@luke-ui/react/src/core/styles/structural.css.ts b/packages/@luke-ui/react/src/core/styles/structural.css.ts new file mode 100644 index 00000000..76d498a6 --- /dev/null +++ b/packages/@luke-ui/react/src/core/styles/structural.css.ts @@ -0,0 +1,10 @@ +import { vars } from '../../theme/contract.css.js'; +import { comboboxSectionScopeClassName } from '../primitives/combobox/section-scope.css.js'; +import { classSelector } from './class-selector.js'; +import { globalStyleInLayer } from './layered-style.css.js'; + +globalStyleInLayer( + 'structural', + `${classSelector(comboboxSectionScopeClassName)} + ${classSelector(comboboxSectionScopeClassName)}`, + { borderBlockStart: `1px solid ${vars.color.border.decorative}` }, +); diff --git a/packages/@luke-ui/react/src/core/styles/stylesheet-compilers.test.ts b/packages/@luke-ui/react/src/core/styles/stylesheet-compilers.test.ts index 475f3f47..0402d78e 100644 --- a/packages/@luke-ui/react/src/core/styles/stylesheet-compilers.test.ts +++ b/packages/@luke-ui/react/src/core/styles/stylesheet-compilers.test.ts @@ -8,9 +8,13 @@ const dist = (file: string) => new URL(`../../../dist/${file}`, import.meta.url) test('ships Vanilla Extract and StyleX rules in one stylesheet', async () => { const stylesheet = await readFile(dist('stylesheet.css'), 'utf8'); - const [vanillaExtract, stylex] = stylesheet.split('/* stylex */'); - expect(vanillaExtract).toContain('@layer reset'); - expect(stylex).toMatch(/outline-color:\s*transparent/); + expect(stylesheet.startsWith('@layer reset, theme, luke.sx.priority')).toBe(true); + expect(stylesheet).toMatch( + /^@layer reset, theme, luke\.sx\.priority\d+(?:, luke\.sx\.priority\d+)*, recipes, structural, utilities;/m, + ); + expect(stylesheet).toMatch(/@layer luke\.sx\.priority\d+/); + expect(stylesheet).toMatch(/outline-color:\s*transparent/); + expect(stylesheet).toMatch(/padding:\s*var\(--luke-space-sp16\)/); }); test('appends StyleX rules to the shared stylesheet instead of a second file', () => { @@ -20,7 +24,7 @@ test('appends StyleX rules to the shared stylesheet instead of a second file', ( test('emits compiled StyleX JavaScript that loads in plain Node', () => { expect(() => { - execFileSync(process.execPath, [fileURLToPath(dist('stylex-fixture.js'))], { + execFileSync(process.execPath, [fileURLToPath(dist('stylex-bundle.js'))], { encoding: 'utf8', }); }).not.toThrow(); diff --git a/packages/@luke-ui/react/src/core/styles/stylesheet-contract.test.ts b/packages/@luke-ui/react/src/core/styles/stylesheet-contract.test.ts index 42da90f9..43532db8 100644 --- a/packages/@luke-ui/react/src/core/styles/stylesheet-contract.test.ts +++ b/packages/@luke-ui/react/src/core/styles/stylesheet-contract.test.ts @@ -6,8 +6,9 @@ import { expect, test } from 'vite-plus/test'; import type { TypeStyle } from '../../theme/contract.js'; import { typeStyles } from '../../theme/contract.js'; -const retainedLayerNames = ['reset', 'theme', 'recipes', 'utilities'] as const; -const retainedLayerNameSet = new Set(retainedLayerNames); +const lukeOwnedLayerNames = ['reset', 'theme', 'recipes', 'structural', 'utilities'] as const; +const lukeOwnedLayerNameSet = new Set(lukeOwnedLayerNames); +const stylexPriorityLayerPattern = /^luke\.sx\.priority\d+$/; type TextClassesByTypography = Record>; const numericLineClampVariants = [2, 3, 4, 5] as const; type NumericLineClampVariant = (typeof numericLineClampVariants)[number]; @@ -17,7 +18,7 @@ type LineClampClasses = { }; test('builds the public stylesheet with the retained layer contract', async () => { - const stylesheet = await readVanillaExtractStylesheet(); + const stylesheet = await readPublicStylesheet(); const icon = await import('@luke-ui/react/icon'); const text = await import('@luke-ui/react/text'); const styles = await import('@luke-ui/react/styles'); @@ -52,8 +53,31 @@ const stylesheetMutations: Array<[string, (css: string) => string]> = [ ['missing stable selector', (css: string) => css.replace('.luke-ui-theme', '.theme-root')], ['extra stable selector', (css: string) => `${css}\n@layer theme { .luke-ui-extra {} }`], [ - 'reordered initial layer declarations', - (css: string) => css.replace('@layer reset;\n@layer theme;', '@layer theme;\n@layer reset;'), + 'reordered authoritative layer declarations', + (css: string) => { + return css.replace( + /^@layer reset, theme, luke\.sx\.priority\d+(?:, luke\.sx\.priority\d+)*, recipes, structural, utilities;/m, + '@layer theme, reset, luke.sx.priority1, recipes, structural, utilities;', + ); + }, + ], + [ + 'early individual layer declarations before authoritative order', + (css: string) => { + return css.replace( + /^@layer reset, theme, luke\.sx\.priority\d+(?:, luke\.sx\.priority\d+)*, recipes, structural, utilities;\n/m, + '@layer reset;\n@layer theme;\n@layer recipes;\n@layer structural;\n@layer utilities;\n@layer reset, theme, luke.sx.priority1, recipes, structural, utilities;\n', + ); + }, + ], + [ + 'early layer block before authoritative order', + (css: string) => { + return css.replace( + /^@layer reset, theme, luke\.sx\.priority\d+(?:, luke\.sx\.priority\d+)*, recipes, structural, utilities;\n/m, + '@layer recipes { .early {} }\n@layer reset, theme, luke.sx.priority1, recipes, structural, utilities;\n', + ); + }, ], ['anonymous layer statement', (css: string) => `${css}\n@layer;`], ['anonymous layer block', (css: string) => `${css}\n@layer { .anonymous {} }`], @@ -92,6 +116,23 @@ const stylesheetMutations: Array<[string, (css: string) => string]> = [ ); }, ], + [ + 'redundant empty layer statements after authoritative order', + (css: string) => { + return css.replace( + /^(@layer reset, theme, luke\.sx\.priority\d+(?:, luke\.sx\.priority\d+)*, recipes, structural, utilities;\n)/m, + '$1@layer recipes;\n', + ); + }, + ], + [ + 'empty transitional recipes layer', + (css: string) => + css.replace( + '@layer recipes {\n .recipe-class { display: inline-flex; }\n}', + '@layer recipes {}', + ), + ], ]; for (const [name, mutate] of stylesheetMutations) { @@ -106,10 +147,8 @@ for (const [name, mutate] of stylesheetMutations) { } test('queries responsive conditions on the logical inline axis', async () => { - const stylesheet = await readVanillaExtractStylesheet(); + const stylesheet = await readPublicStylesheet(); - // The containers are `container-type: inline-size`, which answers inline-axis queries. A - // physical `width` query only agrees with that in a horizontal writing mode. expect(stylesheet).toContain('@container (inline-size >='); expect(stylesheet).not.toContain('@container (width >='); }); @@ -126,14 +165,8 @@ test('recognises escaped class identifiers', () => { }).not.toThrow(); }); -// Unlayered StyleX output follows this comment; the layer contract covers Vanilla Extract only. -async function readVanillaExtractStylesheet(): Promise { - const stylesheet = await readFile( - new URL('../../../dist/stylesheet.css', import.meta.url), - 'utf8', - ); - - return stylesheet.split('/* stylex */')[0] ?? stylesheet; +async function readPublicStylesheet(): Promise { + return readFile(new URL('../../../dist/stylesheet.css', import.meta.url), 'utf8'); } function assertStylesheetContract( @@ -152,10 +185,13 @@ function assertStylesheetContract( ): void { const root = parse(stylesheet); - expect(getInitialLayerOrder(root)).toEqual(retainedLayerNames); + assertEffectiveLayerCreationOrder(root); + assertNoRedundantEmptyLayerStatements(stylesheet); + assertAuthoritativeLayerOrder(getAuthoritativeLayerOrder(root)); assertLayerNames(root); assertRootNodes(root); assertStableSelectors(root); + assertRecipesLayerHasRules(root); assertSentinel(root, 'luke-ui-reset', 'reset', 'box-sizing', 'border-box'); assertSentinel(root, 'luke-ui-theme', 'theme', 'color', 'var(--luke-color-text-primary)'); assertSentinel( @@ -188,6 +224,10 @@ function assertPrivateStylesheetSentinel(root: Root): void { ); }), ).toBe(true); + + const maskRules = collectSkeletonDescendantMaskRules(root); + expect(maskRules.length).toBeGreaterThan(0); + for (const rule of maskRules) expect(getOwningLayer(rule)).toBe('structural'); } function collectSkeletonInlineRules(root: Root): Array { @@ -198,6 +238,26 @@ function collectSkeletonInlineRules(root: Root): Array { return rules; } +function collectSkeletonDescendantMaskRules(root: Root): Array { + const rules: Array = []; + root.walkRules((rule) => { + if (!rule.selector.includes('data-skeleton-inline')) return; + if (!rule.selector.includes('> *')) return; + if ( + rule.nodes.some( + (node) => + node.type === 'decl' && + node.prop === 'background-color' && + node.value === 'var(--luke-color-loading-skeleton)' && + node.important, + ) + ) { + rules.push(rule); + } + }); + return rules; +} + function hasAttributeSelector(rule: Rule, attribute: string): boolean { let matches = false; selectorParser((selectors) => { @@ -216,14 +276,70 @@ function hasAttributeSelector(rule: Rule, attribute: string): boolean { return matches; } -function getInitialLayerOrder(root: Root): Array { - return root.nodes.slice(0, retainedLayerNames.length).map((node) => { - if (node.type !== 'atrule' || node.name !== 'layer' || node.nodes) { - throw new Error('Expected the stylesheet to begin with layer statements.'); +function getAuthoritativeLayerOrder(root: Root): Array { + for (const node of root.nodes) { + if (node.type !== 'atrule' || node.name !== 'layer' || node.nodes) continue; + const params = node.params.trim(); + if (!params.includes(',')) continue; + + return params.split(',').map((name) => name.trim()); + } + + throw new Error('Expected an authoritative combined cascade-layer order statement.'); +} + +function assertEffectiveLayerCreationOrder(root: Root): void { + let sawAuthoritativeOrder = false; + + for (const node of root.nodes) { + if (node.type !== 'atrule' || node.name !== 'layer') continue; + + const params = node.params.trim(); + const isCombinedOrder = !node.nodes && params.includes(','); + + if (isCombinedOrder) { + if (!sawAuthoritativeOrder) { + sawAuthoritativeOrder = true; + continue; + } + + throw new Error( + 'Expected a single authoritative combined cascade-layer order statement at the start of the stylesheet.', + ); } - return node.params.trim(); - }); + if (!sawAuthoritativeOrder) { + throw new Error( + `Layer "${params}" was created before the authoritative combined cascade-layer order statement.`, + ); + } + } + + if (!sawAuthoritativeOrder) { + throw new Error('Expected an authoritative combined cascade-layer order statement.'); + } +} + +function assertNoRedundantEmptyLayerStatements(stylesheet: string): void { + const lines = stylesheet.split('\n'); + for (let index = 1; index < lines.length; index++) { + const line = lines[index]?.trim(); + if (line == null || line === '') continue; + if (/^@layer [^,{]+;$/.test(line)) { + throw new Error(`Redundant empty layer statement after authoritative order: ${line}`); + } + } +} + +function assertAuthoritativeLayerOrder(order: Array): void { + expect(order[0]).toBe('reset'); + expect(order[1]).toBe('theme'); + + const priorityLayers = order.slice(2).filter((name) => stylexPriorityLayerPattern.test(name)); + expect(priorityLayers.length).toBeGreaterThan(0); + expect(priorityLayers.every((name, index) => name === `luke.sx.priority${index + 1}`)).toBe(true); + + expect(order.slice(2 + priorityLayers.length)).toEqual(['recipes', 'structural', 'utilities']); } function assertLayerNames(root: Root): void { @@ -237,7 +353,8 @@ function assertLayerNames(root: Root): void { } for (const name of names) { - if (!retainedLayerNameSet.has(name)) throw atRule.error(`Unexpected cascade layer: ${name}`); + if (lukeOwnedLayerNameSet.has(name) || stylexPriorityLayerPattern.test(name)) continue; + throw atRule.error(`Unexpected cascade layer: ${name}`); } }); } @@ -255,12 +372,23 @@ function assertRootNodes(root: Root): void { if (node.type === 'rule') throw node.error('Root qualified rules are not allowed.'); if (node.type !== 'atrule') throw node.error('Unexpected root stylesheet node.'); if (node.name === 'layer') continue; + if (node.name === 'property') continue; if (node.name === 'keyframes' && node.nodes) continue; throw node.error(`Unexpected root at-rule: @${node.name}`); } } +function assertRecipesLayerHasRules(root: Root): void { + let hasRecipeRule = false; + root.walkRules((rule) => { + if (getOwningLayer(rule) === 'recipes' && rule.nodes.some((node) => node.type === 'decl')) { + hasRecipeRule = true; + } + }); + if (!hasRecipeRule) throw new Error('Expected the transitional recipes layer to contain a rule.'); +} + function assertStableSelectors(root: Root): void { const selectors = new Set(); root.walkRules((rule) => { @@ -401,10 +529,7 @@ function getOwningLayer(rule: Rule): string | undefined { return undefined; } -const validStylesheetFixture = `@layer reset; -@layer theme; -@layer recipes; -@layer utilities; +const validStylesheetFixture = `@layer reset, theme, luke.sx.priority1, recipes, structural, utilities; @layer reset { .luke-ui-reset { box-sizing: border-box; } } @@ -418,9 +543,15 @@ const validStylesheetFixture = `@layer reset; @layer recipes { .recipe-class { display: inline-flex; } } +@layer structural { + .structural-class { margin-block-start: 1px; } +} @layer utilities { .utility-class { display: grid; } } +@layer luke.sx.priority1 { + .stylex-class { outline-color: transparent; } +} @keyframes generated-animation { from { opacity: 0; } to { opacity: 1; } diff --git a/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts b/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts index d719ef30..bde2a1f0 100644 --- a/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts +++ b/packages/@luke-ui/react/src/core/styles/stylesheet-size.test.ts @@ -2,11 +2,10 @@ import { gzipSync } from 'node:zlib'; import { readFile } from 'node:fs/promises'; import { expect, test } from 'vite-plus/test'; -// Fixed budget for the public stylesheet's gzip size, with deliberate headroom above -// current usage — not a record of where the stylesheet happens to sit today. It exists -// to catch a structural regression, like a new responsive utility matrix, not to be kept -// in sync with normal growth. If it fails, investigate what grew; don't raise the ceiling. -const maximumGzipBytes = 12_000; +// Measured from the pull request merge commit 7518d094 at 10259 gzip bytes (level 9), per the +// measurement procedure in issue #550. Ceiling leaves headroom for the remaining component +// migrations. If this fails, investigate what grew; don't raise the ceiling without a reason. +const maximumGzipBytes = 12_500; test('keeps the public stylesheet within its size budget', async () => { const stylesheet = await readFile(new URL('../../../dist/stylesheet.css', import.meta.url)); diff --git a/packages/@luke-ui/react/src/core/styles/stylex-bundle.ts b/packages/@luke-ui/react/src/core/styles/stylex-bundle.ts new file mode 100644 index 00000000..b19ba2ac --- /dev/null +++ b/packages/@luke-ui/react/src/core/styles/stylex-bundle.ts @@ -0,0 +1,2 @@ +import '../../theme/tokens.stylex.js'; +import './stylex-fixture.js'; diff --git a/packages/@luke-ui/react/src/core/styles/stylex-fixture.ts b/packages/@luke-ui/react/src/core/styles/stylex-fixture.ts index 121259fd..b8c03d86 100644 --- a/packages/@luke-ui/react/src/core/styles/stylex-fixture.ts +++ b/packages/@luke-ui/react/src/core/styles/stylex-fixture.ts @@ -1,8 +1,10 @@ import * as stylex from '@stylexjs/stylex'; +import { tokens } from '../../theme/tokens.stylex.js'; /** Internal `stylex.create` call so the package build extracts StyleX. Not a public export. */ export const stylexBuildFixture = stylex.create({ probe: { outlineColor: 'transparent', + padding: tokens.spaceSp16, }, }); diff --git a/packages/@luke-ui/react/src/core/test-utils/render-mount-state.ts b/packages/@luke-ui/react/src/core/test-utils/render-mount-state.ts new file mode 100644 index 00000000..9835e350 --- /dev/null +++ b/packages/@luke-ui/react/src/core/test-utils/render-mount-state.ts @@ -0,0 +1,42 @@ +import { act } from 'react'; +import type { Root } from 'react-dom/client'; + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; + +/** The identity class currently applied to `document.documentElement`, if any. */ +let appliedIdentityClassName: string | undefined; + +export function trackMountedRender(container: HTMLElement, root: Root): void { + mounted.push({ container, root }); +} + +export function untrackMountedRender(container: HTMLElement): void { + const index = mounted.findIndex((entry) => entry.container === container); + if (index !== -1) mounted.splice(index, 1); +} + +export function setAppliedIdentityClassName(className: string | undefined): void { + appliedIdentityClassName = className; +} + +export function getAppliedIdentityClassName(): string | undefined { + return appliedIdentityClassName; +} + +/** + * Unmounts everything rendered by `render`. Registered globally, for both the + * `browser` and `visual` Vitest projects, in `render-setup.ts`. + */ +export function cleanupMountedRenders(): void { + for (const { container, root } of mounted) { + act(() => root.unmount()); + container.remove(); + } + mounted.length = 0; + + if (appliedIdentityClassName != null) { + document.documentElement.classList.remove(appliedIdentityClassName); + appliedIdentityClassName = undefined; + } + delete document.documentElement.dataset.colorMode; +} diff --git a/packages/@luke-ui/react/src/core/test-utils/render-setup.ts b/packages/@luke-ui/react/src/core/test-utils/render-setup.ts index b2ee655a..bcc9ab07 100644 --- a/packages/@luke-ui/react/src/core/test-utils/render-setup.ts +++ b/packages/@luke-ui/react/src/core/test-utils/render-setup.ts @@ -1,5 +1,5 @@ import { afterEach } from 'vite-plus/test'; -import { cleanupMountedRenders } from './render.js'; +import { cleanupMountedRenders } from './render-mount-state.js'; (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/packages/@luke-ui/react/src/core/test-utils/render.browser.test.tsx b/packages/@luke-ui/react/src/core/test-utils/render.browser.test.tsx index 0784f100..d2b5dcc0 100644 --- a/packages/@luke-ui/react/src/core/test-utils/render.browser.test.tsx +++ b/packages/@luke-ui/react/src/core/test-utils/render.browser.test.tsx @@ -1,6 +1,7 @@ import { expect, test } from 'vite-plus/test'; import { themeClassName as paperThemeClassName } from '../../theme/bundles/paper/index.js'; import { themeClassName as tactileThemeClassName } from '../../theme/bundles/tactile/index.js'; +import { cleanupMountedRenders } from './render-mount-state.js'; import { render, visualAppearances } from './render.js'; test('renders every bundled identity and explicit colour mode independently', () => { @@ -34,3 +35,11 @@ test('allows a nested scope to select the opposite colour mode', () => { expect(getComputedStyle(locator.element()).colorScheme).toBe('dark'); expect(getComputedStyle(nestedScope).colorScheme).toBe('light'); }); + +test('does not clean up an individually unmounted render twice', () => { + const { container, unmount } = render(Unmount contract); + + unmount(); + expect(container).not.toBeInTheDocument(); + expect(() => cleanupMountedRenders()).not.toThrow(); +}); diff --git a/packages/@luke-ui/react/src/core/test-utils/render.tsx b/packages/@luke-ui/react/src/core/test-utils/render.tsx index fa7f82a5..1c067c3b 100644 --- a/packages/@luke-ui/react/src/core/test-utils/render.tsx +++ b/packages/@luke-ui/react/src/core/test-utils/render.tsx @@ -17,11 +17,12 @@ import { themeClassName as paperThemeClassName } from '../../theme/bundles/paper import { themeClassName as tactileThemeClassName } from '../../theme/bundles/tactile/index.js'; import { rootClassName, vars } from '../../theme/index.js'; import { IconSpritesheetProvider } from '../icon/icon.js'; - -const mounted: Array<{ container: HTMLElement; root: Root }> = []; - -/** The identity class currently applied to `document.documentElement`, if any. */ -let appliedIdentityClassName: string | undefined; +import { + getAppliedIdentityClassName, + setAppliedIdentityClassName, + trackMountedRender, + untrackMountedRender, +} from './render-mount-state.js'; export type VisualAppearance = { mode: 'light' | 'dark'; @@ -57,18 +58,19 @@ export type RenderResult = { export function render(node: ReactNode, options?: { appearance?: VisualAppearance }): RenderResult { const appearance = options?.appearance ?? defaultVisualAppearance; const identityClassName = identityClassNameFor(appearance.theme); + const appliedIdentityClassName = getAppliedIdentityClassName(); if (appliedIdentityClassName != null) { document.documentElement.classList.remove(appliedIdentityClassName); } document.documentElement.classList.add(identityClassName); - appliedIdentityClassName = identityClassName; + setAppliedIdentityClassName(identityClassName); document.documentElement.dataset.colorMode = appearance.mode; const container = document.body.appendChild(document.createElement('div')); container.className = rootClassName; container.style.backgroundColor = vars.color.surface.canvas; const root = createRoot(container); - mounted.push({ container, root }); + trackMountedRender(container, root); act(() => { root.render({node}); @@ -83,8 +85,7 @@ export function render(node: ReactNode, options?: { appearance?: VisualAppearanc } function unmount(container: HTMLElement, root: Root) { - const index = mounted.findIndex((entry) => entry.container === container); - if (index !== -1) mounted.splice(index, 1); + untrackMountedRender(container); act(() => root.unmount()); container.remove(); } @@ -92,21 +93,3 @@ function unmount(container: HTMLElement, root: Root) { function identityClassNameFor(theme: VisualAppearance['theme']) { return theme === 'tactile' ? tactileThemeClassName : paperThemeClassName; } - -/** - * Unmounts everything rendered by `render`. Registered globally, for both the - * `browser` and `visual` Vitest projects, in `render-setup.ts`. - */ -export function cleanupMountedRenders() { - for (const { container, root } of mounted) { - act(() => root.unmount()); - container.remove(); - } - mounted.length = 0; - - if (appliedIdentityClassName != null) { - document.documentElement.classList.remove(appliedIdentityClassName); - appliedIdentityClassName = undefined; - } - delete document.documentElement.dataset.colorMode; -} diff --git a/packages/@luke-ui/react/src/core/test-utils/visual-setup.ts b/packages/@luke-ui/react/src/core/test-utils/visual-setup.ts index f347eb47..0ba79d94 100644 --- a/packages/@luke-ui/react/src/core/test-utils/visual-setup.ts +++ b/packages/@luke-ui/react/src/core/test-utils/visual-setup.ts @@ -1,9 +1,24 @@ // Freeze CSS animations and transitions so animated UI (e.g. the loading // spinner) screenshots deterministically. The Playwright provider also disables -// animations during capture; this is belt-and-suspenders and covers transitions +// animations during capture; this is the belt-and-suspenders path for transitions // triggered by interactions before the screenshot is taken. // // Also freeze the text-input caret, which blinks on its own timer and isn't a CSS animation. +// +// Overlays skip enter transitions under `prefers-reduced-motion: reduce`. Emulate that +// media feature so zeroed transition durations do not leave `[data-entering]` trays at +// opacity 0 for visual captures. +// +// Load the shared styles during setup so portal layout is ready before a test waits on it. +import '../stylesheet.css.js'; +import '@luke-ui/react/themes/paper/stylesheet.css'; +import '@luke-ui/react/themes/tactile/stylesheet.css'; +import { cdp } from 'vite-plus/test/context'; + +await cdp().send('Emulation.setEmulatedMedia', { + features: [{ name: 'prefers-reduced-motion', value: 'reduce' }], +}); + const freezeMotion = document.createElement('style'); freezeMotion.textContent = ` *, *::before, *::after { @@ -13,5 +28,9 @@ freezeMotion.textContent = ` transition-duration: 0s !important; caret-color: transparent !important; } +[data-entering], [data-exiting] { + opacity: 1 !important; + translate: none !important; +} `; document.head.append(freezeMotion); diff --git a/packages/@luke-ui/react/src/theme/tokens.stylex.test.ts b/packages/@luke-ui/react/src/theme/tokens.stylex.test.ts new file mode 100644 index 00000000..bc7713a9 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/tokens.stylex.test.ts @@ -0,0 +1,33 @@ +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vite-plus/test'; +import { flattenThemeContract } from './contract.js'; + +describe('StyleX tokens', () => { + it('covers every theme contract leaf with a live var(--luke-*) reference', async () => { + const source = await readFile(new URL('./tokens.stylex.ts', import.meta.url), 'utf8'); + const pairs = flattenThemeContract(); + + for (const [path, variable] of pairs) { + const camelKey = toCamelCaseKey(path); + expect(source).toContain(`${camelKey}: 'var(${variable})'`); + } + + expect(source.match(/defineConsts\(\{/g)?.length).toBe(1); + expect(source).not.toMatch(/--luke-[^:]+:\s*#[0-9a-f]{3,8}/i); + }); + + it('does not emit a second token value set in the built stylesheet', async () => { + const stylesheet = await readFile( + new URL('../../dist/stylesheet.css', import.meta.url), + 'utf8', + ); + const stylexSection = stylesheet.split('/* stylex */')[1] ?? ''; + expect(stylexSection).not.toMatch(/:root[^{]*\{[^}]*--luke-/); + }); +}); + +function toCamelCaseKey(path: string): string { + const [first, ...rest] = path.split('.'); + if (first === undefined) throw new Error(`Theme contract path "${path}" is empty`); + return first + rest.map((segment) => segment[0]?.toUpperCase() + segment.slice(1)).join(''); +} diff --git a/packages/@luke-ui/react/src/theme/tokens.stylex.ts b/packages/@luke-ui/react/src/theme/tokens.stylex.ts new file mode 100644 index 00000000..aac3c930 --- /dev/null +++ b/packages/@luke-ui/react/src/theme/tokens.stylex.ts @@ -0,0 +1,190 @@ +// Generated by scripts/generate-stylex-tokens.ts — do not edit. + +import * as stylex from '@stylexjs/stylex'; + +/** Compiler-facing StyleX token surface. Each value is a live `var(--luke-*)` reference. */ +export const tokens = stylex.defineConsts({ + colorSurfaceCanvas: 'var(--luke-color-surface-canvas)', + colorSurfaceRecessed: 'var(--luke-color-surface-recessed)', + colorSurfaceFloating: 'var(--luke-color-surface-floating)', + colorSurfaceOverlay: 'var(--luke-color-surface-overlay)', + colorOverlayBackdrop: 'var(--luke-color-overlay-backdrop)', + colorLoadingSkeleton: 'var(--luke-color-loading-skeleton)', + colorTextPrimary: 'var(--luke-color-text-primary)', + colorTextSecondary: 'var(--luke-color-text-secondary)', + colorTextDisabled: 'var(--luke-color-text-disabled)', + colorBackgroundNeutralSubtleRest: 'var(--luke-color-background-neutral-subtle-rest)', + colorBackgroundNeutralSubtleHover: 'var(--luke-color-background-neutral-subtle-hover)', + colorBackgroundNeutralSubtlePressed: 'var(--luke-color-background-neutral-subtle-pressed)', + colorBackgroundNeutralSolidRest: 'var(--luke-color-background-neutral-solid-rest)', + colorBackgroundNeutralSolidHover: 'var(--luke-color-background-neutral-solid-hover)', + colorBackgroundNeutralSolidPressed: 'var(--luke-color-background-neutral-solid-pressed)', + colorBackgroundAccentSubtleRest: 'var(--luke-color-background-accent-subtle-rest)', + colorBackgroundAccentSubtleHover: 'var(--luke-color-background-accent-subtle-hover)', + colorBackgroundAccentSubtlePressed: 'var(--luke-color-background-accent-subtle-pressed)', + colorBackgroundAccentSolidRest: 'var(--luke-color-background-accent-solid-rest)', + colorBackgroundAccentSolidHover: 'var(--luke-color-background-accent-solid-hover)', + colorBackgroundAccentSolidPressed: 'var(--luke-color-background-accent-solid-pressed)', + colorBackgroundInfoSubtleRest: 'var(--luke-color-background-info-subtle-rest)', + colorBackgroundInfoSubtleHover: 'var(--luke-color-background-info-subtle-hover)', + colorBackgroundInfoSubtlePressed: 'var(--luke-color-background-info-subtle-pressed)', + colorBackgroundInfoSolidRest: 'var(--luke-color-background-info-solid-rest)', + colorBackgroundInfoSolidHover: 'var(--luke-color-background-info-solid-hover)', + colorBackgroundInfoSolidPressed: 'var(--luke-color-background-info-solid-pressed)', + colorBackgroundSuccessSubtleRest: 'var(--luke-color-background-success-subtle-rest)', + colorBackgroundSuccessSubtleHover: 'var(--luke-color-background-success-subtle-hover)', + colorBackgroundSuccessSubtlePressed: 'var(--luke-color-background-success-subtle-pressed)', + colorBackgroundSuccessSolidRest: 'var(--luke-color-background-success-solid-rest)', + colorBackgroundSuccessSolidHover: 'var(--luke-color-background-success-solid-hover)', + colorBackgroundSuccessSolidPressed: 'var(--luke-color-background-success-solid-pressed)', + colorBackgroundWarningSubtleRest: 'var(--luke-color-background-warning-subtle-rest)', + colorBackgroundWarningSubtleHover: 'var(--luke-color-background-warning-subtle-hover)', + colorBackgroundWarningSubtlePressed: 'var(--luke-color-background-warning-subtle-pressed)', + colorBackgroundWarningSolidRest: 'var(--luke-color-background-warning-solid-rest)', + colorBackgroundWarningSolidHover: 'var(--luke-color-background-warning-solid-hover)', + colorBackgroundWarningSolidPressed: 'var(--luke-color-background-warning-solid-pressed)', + colorBackgroundDangerSubtleRest: 'var(--luke-color-background-danger-subtle-rest)', + colorBackgroundDangerSubtleHover: 'var(--luke-color-background-danger-subtle-hover)', + colorBackgroundDangerSubtlePressed: 'var(--luke-color-background-danger-subtle-pressed)', + colorBackgroundDangerSolidRest: 'var(--luke-color-background-danger-solid-rest)', + colorBackgroundDangerSolidHover: 'var(--luke-color-background-danger-solid-hover)', + colorBackgroundDangerSolidPressed: 'var(--luke-color-background-danger-solid-pressed)', + colorForegroundNeutralRest: 'var(--luke-color-foreground-neutral-rest)', + colorForegroundNeutralHover: 'var(--luke-color-foreground-neutral-hover)', + colorForegroundNeutralPressed: 'var(--luke-color-foreground-neutral-pressed)', + colorForegroundNeutralOnSolid: 'var(--luke-color-foreground-neutral-on-solid)', + colorForegroundAccentRest: 'var(--luke-color-foreground-accent-rest)', + colorForegroundAccentHover: 'var(--luke-color-foreground-accent-hover)', + colorForegroundAccentPressed: 'var(--luke-color-foreground-accent-pressed)', + colorForegroundAccentOnSolid: 'var(--luke-color-foreground-accent-on-solid)', + colorForegroundInfoRest: 'var(--luke-color-foreground-info-rest)', + colorForegroundInfoHover: 'var(--luke-color-foreground-info-hover)', + colorForegroundInfoPressed: 'var(--luke-color-foreground-info-pressed)', + colorForegroundInfoOnSolid: 'var(--luke-color-foreground-info-on-solid)', + colorForegroundSuccessRest: 'var(--luke-color-foreground-success-rest)', + colorForegroundSuccessHover: 'var(--luke-color-foreground-success-hover)', + colorForegroundSuccessPressed: 'var(--luke-color-foreground-success-pressed)', + colorForegroundSuccessOnSolid: 'var(--luke-color-foreground-success-on-solid)', + colorForegroundWarningRest: 'var(--luke-color-foreground-warning-rest)', + colorForegroundWarningHover: 'var(--luke-color-foreground-warning-hover)', + colorForegroundWarningPressed: 'var(--luke-color-foreground-warning-pressed)', + colorForegroundWarningOnSolid: 'var(--luke-color-foreground-warning-on-solid)', + colorForegroundDangerRest: 'var(--luke-color-foreground-danger-rest)', + colorForegroundDangerHover: 'var(--luke-color-foreground-danger-hover)', + colorForegroundDangerPressed: 'var(--luke-color-foreground-danger-pressed)', + colorForegroundDangerOnSolid: 'var(--luke-color-foreground-danger-on-solid)', + colorBorderDecorative: 'var(--luke-color-border-decorative)', + colorBorderControl: 'var(--luke-color-border-control)', + colorBorderFocus: 'var(--luke-color-border-focus)', + colorBorderNeutral: 'var(--luke-color-border-neutral)', + colorBorderAccent: 'var(--luke-color-border-accent)', + colorBorderInfo: 'var(--luke-color-border-info)', + colorBorderSuccess: 'var(--luke-color-border-success)', + colorBorderWarning: 'var(--luke-color-border-warning)', + colorBorderDanger: 'var(--luke-color-border-danger)', + depthRecessed: 'var(--luke-depth-recessed)', + depthResting: 'var(--luke-depth-resting)', + depthRaised: 'var(--luke-depth-raised)', + depthFloating: 'var(--luke-depth-floating)', + depthOverlay: 'var(--luke-depth-overlay)', + actionControlFinishRecessed: 'var(--luke-action-control-finish-recessed)', + actionControlFinishResting: 'var(--luke-action-control-finish-resting)', + actionControlFinishRaised: 'var(--luke-action-control-finish-raised)', + fontCaptionBaselineTrim: 'var(--luke-font-caption-baseline-trim)', + fontCaptionCapHeightTrim: 'var(--luke-font-caption-cap-height-trim)', + fontCaptionFontFamily: 'var(--luke-font-caption-font-family)', + fontCaptionFontSize: 'var(--luke-font-caption-font-size)', + fontCaptionFontWeight: 'var(--luke-font-caption-font-weight)', + fontCaptionLetterSpacing: 'var(--luke-font-caption-letter-spacing)', + fontCaptionLineHeight: 'var(--luke-font-caption-line-height)', + fontLabelBaselineTrim: 'var(--luke-font-label-baseline-trim)', + fontLabelCapHeightTrim: 'var(--luke-font-label-cap-height-trim)', + fontLabelFontFamily: 'var(--luke-font-label-font-family)', + fontLabelFontSize: 'var(--luke-font-label-font-size)', + fontLabelFontWeight: 'var(--luke-font-label-font-weight)', + fontLabelLetterSpacing: 'var(--luke-font-label-letter-spacing)', + fontLabelLineHeight: 'var(--luke-font-label-line-height)', + fontBodyBaselineTrim: 'var(--luke-font-body-baseline-trim)', + fontBodyCapHeightTrim: 'var(--luke-font-body-cap-height-trim)', + fontBodyFontFamily: 'var(--luke-font-body-font-family)', + fontBodyFontSize: 'var(--luke-font-body-font-size)', + fontBodyFontWeight: 'var(--luke-font-body-font-weight)', + fontBodyLetterSpacing: 'var(--luke-font-body-letter-spacing)', + fontBodyLineHeight: 'var(--luke-font-body-line-height)', + fontLeadBaselineTrim: 'var(--luke-font-lead-baseline-trim)', + fontLeadCapHeightTrim: 'var(--luke-font-lead-cap-height-trim)', + fontLeadFontFamily: 'var(--luke-font-lead-font-family)', + fontLeadFontSize: 'var(--luke-font-lead-font-size)', + fontLeadFontWeight: 'var(--luke-font-lead-font-weight)', + fontLeadLetterSpacing: 'var(--luke-font-lead-letter-spacing)', + fontLeadLineHeight: 'var(--luke-font-lead-line-height)', + fontHeading4BaselineTrim: 'var(--luke-font-heading4-baseline-trim)', + fontHeading4CapHeightTrim: 'var(--luke-font-heading4-cap-height-trim)', + fontHeading4FontFamily: 'var(--luke-font-heading4-font-family)', + fontHeading4FontSize: 'var(--luke-font-heading4-font-size)', + fontHeading4FontWeight: 'var(--luke-font-heading4-font-weight)', + fontHeading4LetterSpacing: 'var(--luke-font-heading4-letter-spacing)', + fontHeading4LineHeight: 'var(--luke-font-heading4-line-height)', + fontHeading3BaselineTrim: 'var(--luke-font-heading3-baseline-trim)', + fontHeading3CapHeightTrim: 'var(--luke-font-heading3-cap-height-trim)', + fontHeading3FontFamily: 'var(--luke-font-heading3-font-family)', + fontHeading3FontSize: 'var(--luke-font-heading3-font-size)', + fontHeading3FontWeight: 'var(--luke-font-heading3-font-weight)', + fontHeading3LetterSpacing: 'var(--luke-font-heading3-letter-spacing)', + fontHeading3LineHeight: 'var(--luke-font-heading3-line-height)', + fontHeading2BaselineTrim: 'var(--luke-font-heading2-baseline-trim)', + fontHeading2CapHeightTrim: 'var(--luke-font-heading2-cap-height-trim)', + fontHeading2FontFamily: 'var(--luke-font-heading2-font-family)', + fontHeading2FontSize: 'var(--luke-font-heading2-font-size)', + fontHeading2FontWeight: 'var(--luke-font-heading2-font-weight)', + fontHeading2LetterSpacing: 'var(--luke-font-heading2-letter-spacing)', + fontHeading2LineHeight: 'var(--luke-font-heading2-line-height)', + fontHeading1BaselineTrim: 'var(--luke-font-heading1-baseline-trim)', + fontHeading1CapHeightTrim: 'var(--luke-font-heading1-cap-height-trim)', + fontHeading1FontFamily: 'var(--luke-font-heading1-font-family)', + fontHeading1FontSize: 'var(--luke-font-heading1-font-size)', + fontHeading1FontWeight: 'var(--luke-font-heading1-font-weight)', + fontHeading1LetterSpacing: 'var(--luke-font-heading1-letter-spacing)', + fontHeading1LineHeight: 'var(--luke-font-heading1-line-height)', + fontDisplayBaselineTrim: 'var(--luke-font-display-baseline-trim)', + fontDisplayCapHeightTrim: 'var(--luke-font-display-cap-height-trim)', + fontDisplayFontFamily: 'var(--luke-font-display-font-family)', + fontDisplayFontSize: 'var(--luke-font-display-font-size)', + fontDisplayFontWeight: 'var(--luke-font-display-font-weight)', + fontDisplayLetterSpacing: 'var(--luke-font-display-letter-spacing)', + fontDisplayLineHeight: 'var(--luke-font-display-line-height)', + fontFamilyBody: 'var(--luke-font-family-body)', + fontFamilyCode: 'var(--luke-font-family-code)', + fontWeightBody: 'var(--luke-font-weight-body)', + fontWeightLabel: 'var(--luke-font-weight-label)', + fontWeightHeading: 'var(--luke-font-weight-heading)', + fontWeightEmphasis: 'var(--luke-font-weight-emphasis)', + radiusDetail: 'var(--luke-radius-detail)', + radiusControl: 'var(--luke-radius-control)', + radiusSurface: 'var(--luke-radius-surface)', + radiusOverlay: 'var(--luke-radius-overlay)', + radiusFull: 'var(--luke-radius-full)', + spaceSp4: 'var(--luke-space-sp4)', + spaceSp8: 'var(--luke-space-sp8)', + spaceSp12: 'var(--luke-space-sp12)', + spaceSp16: 'var(--luke-space-sp16)', + spaceSp24: 'var(--luke-space-sp24)', + spaceSp32: 'var(--luke-space-sp32)', + spaceSp40: 'var(--luke-space-sp40)', + spaceSp48: 'var(--luke-space-sp48)', + spaceSp64: 'var(--luke-space-sp64)', + controlSizeSmall: 'var(--luke-control-size-small)', + controlSizeMedium: 'var(--luke-control-size-medium)', + controlSizeMinTarget: 'var(--luke-control-size-min-target)', + controlSizeComboboxAction: 'var(--luke-control-size-combobox-action)', + interactionDisabledOpacity: 'var(--luke-interaction-disabled-opacity)', + iconSizeXsmall: 'var(--luke-icon-size-xsmall)', + iconSizeSmall: 'var(--luke-icon-size-small)', + iconSizeMedium: 'var(--luke-icon-size-medium)', + iconSizeLarge: 'var(--luke-icon-size-large)', + motionDurationFeedback: 'var(--luke-motion-duration-feedback)', + motionDurationEnter: 'var(--luke-motion-duration-enter)', + motionDurationExit: 'var(--luke-motion-duration-exit)', + motionEasingStandard: 'var(--luke-motion-easing-standard)', + motionEasingExit: 'var(--luke-motion-easing-exit)', +}); diff --git a/packages/@luke-ui/react/vite.config.ts b/packages/@luke-ui/react/vite.config.ts index e9d0716e..a129d2e1 100644 --- a/packages/@luke-ui/react/vite.config.ts +++ b/packages/@luke-ui/react/vite.config.ts @@ -25,6 +25,40 @@ const assetExports = [ './themes/paper/stylesheet.css', ]; +const stylexLayerConfig = { + before: ['reset', 'theme'], + after: ['recipes', 'structural', 'utilities'], + prefix: 'luke.sx', +} as const; + +function buildAuthoritativeLayerOrder(priorityLayers: Array): string { + return `@layer ${[...stylexLayerConfig.before, ...priorityLayers, ...stylexLayerConfig.after].join(', ')};`; +} + +function stripRedundantEmptyLayerStatements(css: string): string { + return css.replace(/^@layer [^,{]+;\n/gm, ''); +} + +function splitStylexLayerHeader(stylexCss: string): { + authoritativeLayerOrder: string; + stylexBody: string; +} { + const match = stylexCss.match(/^\n?@layer ([^;]+);/); + if (match == null || match[1] == null) { + throw new Error('Expected StyleX to emit a combined cascade-layer order statement.'); + } + + const priorityLayers = match[1] + .split(',') + .map((name) => name.trim()) + .filter((name) => name.startsWith(`${stylexLayerConfig.prefix}.priority`)); + + return { + authoritativeLayerOrder: buildAuthoritativeLayerOrder(priorityLayers), + stylexBody: stylexCss.slice(match[0].length).replace(/^\n/, ''), + }; +} + /** Any JS or TS module the React Compiler can read, including `.mjs`/`.cts` variants. */ const sourceModule = /\.[cm]?[jt]sx?$/; /** Vanilla Extract compiles these to plain style declarations before the plugin sees them. */ @@ -50,7 +84,7 @@ export default defineConfig({ dts: true, entry: { stylesheet: 'src/core/stylesheet.css.ts', - 'stylex-fixture': 'src/core/styles/stylex-fixture.ts', + 'stylex-bundle': 'src/core/styles/stylex-bundle.ts', '*': ['src/exports/*.ts'], 'primitives/*': ['src/exports/primitives/*.ts'], 'themes/*': ['src/exports/themes/*.ts'], @@ -60,7 +94,7 @@ export default defineConfig({ assetExports.map((path) => [path, `./dist/${path.slice(2)}`]), ), // Built for extraction; not consumer subpaths. - exclude: ['stylesheet', 'stylex-fixture'], + exclude: ['stylesheet', 'stylex-bundle'], }, format: ['esm'], hooks: { @@ -145,20 +179,23 @@ function stylexPlugin(): Plugin { return { code: result.code, map: result.map, meta }; }, generateBundle(_options, bundle) { + const stylesheet = bundle['stylesheet.css']; + if (stylesheet?.type !== 'asset') return; + const rules = [...stylexRules.values()].flat(); - if (rules.length === 0) return; + const vanillaCss = stripRedundantEmptyLayerStatements(stylesheet.source.toString()); - const stylesheet = bundle['stylesheet.css']; - if (stylesheet?.type !== 'asset') { - throw new Error('Expected a `stylesheet.css` asset to append StyleX rules to.'); + if (rules.length === 0) { + stylesheet.source = `${buildAuthoritativeLayerOrder([])}\n${vanillaCss}`; + return; } const stylexCss = stylexBabelPlugin.processStylexRules(rules, { - // Unlayered until StyleX joins the cascade-layer contract. - useLayers: false, + useLayers: stylexLayerConfig, }); + const { authoritativeLayerOrder, stylexBody } = splitStylexLayerHeader(stylexCss); - stylesheet.source = `${stylesheet.source.toString()}\n/* stylex */\n${stylexCss}`; + stylesheet.source = `${authoritativeLayerOrder}\n${vanillaCss}\n/* stylex */\n${stylexBody}`; }, }; }