diff --git a/README.md b/README.md index 03785a0..0062e43 100644 --- a/README.md +++ b/README.md @@ -192,12 +192,52 @@ The loader appends `export const knightedCss = "/* compiled css */"` to the modu #### CSS Modules and stable selectors -CSS Modules hash class names after the loader extracts selectors, so the stylesheet captured by `?knighted-css` never sees those hashed tokens. Provide a second, stable selector (class or data attribute) alongside the module-generated one so both the DOM and the Lit host share a common hook. A minimal example: +CSS Modules hash class names after the loader extracts selectors, so the stylesheet captured by `?knighted-css` never sees those hashed tokens. Provide a second, stable selector (class or data attribute) alongside the module-generated one so both the DOM and the loader share a common hook. A minimal example: ```tsx
``` +Sass/Less projects can import the shared mixins directly: + +```scss +@use '@knighted/css/stable' as knighted; + +.button { + @include knighted.stable('button') { + // declarations duplicated for .button and .knighted-button + } +} +``` + +Set `$knighted-stable-namespace: 'acme'` before the `@use` statement to change the default prefix, or override per call with `$namespace: 'storybook'`. Additional helpers let you emit only the fallback selector (`@include knighted.stable-only('token')`) or supply explicit `@at-root` selectors when nesting is inconvenient (`@include knighted.stable-at-root('.card', 'card')`). + +For runtime usage (vanilla-extract, CSS Modules, JSX utilities), pull in the TypeScript helpers: + +```ts +import { stableClassName } from '@knighted/css/stableSelectors' + +function Badge() { + return +} +``` + +`stableClass('token')` returns a class name you can drop straight into `className`, and `createStableClassFactory({ namespace: 'docs' })` gives you a scoped generator to reuse across components. Need the literal CSS selector? Call `stableSelector('token')`. All helpers sanitize tokens automatically so the emitted hooks stay deterministic. + +Need a zero-JS approach? Import the optional layer helper and co-locate your fallback selectors: + +```css +@import '@knighted/css/stable/stable.css'; + +@layer knighted.stable { + .knighted-alert { + /* declarations */ + } +} +``` + +Override the namespace via `:root { --knighted-stable-namespace: 'acme'; }` if you want a different prefix in pure CSS. + #### TypeScript support for loader queries Loader query types ship directly with `@knighted/css`. Reference them once in your project—either by adding `"types": ["@knighted/css/loader-queries"]` to `tsconfig.json` or dropping `/// ` into a global `.d.ts`—and the following ambient modules become available everywhere: diff --git a/package-lock.json b/package-lock.json index 6d4cb57..9df1652 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11041,7 +11041,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "license": "MIT", "dependencies": { "dependency-tree": "^11.2.0", @@ -11068,7 +11068,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.3", + "@knighted/css": "1.0.0-rc.4", "@knighted/jsx": "^1.2.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index c7a740d..b33def0 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.", "type": "module", "main": "./dist/css.js", @@ -32,6 +32,18 @@ "./loader-queries": { "types": "./loader-queries.d.ts", "default": "./loader-queries.d.ts" + }, + "./stableSelectors": { + "types": "./dist/stableSelectors.d.ts", + "import": "./dist/stableSelectors.js", + "require": "./dist/cjs/stableSelectors.cjs" + }, + "./stable": { + "sass": "./stable/_index.scss", + "default": "./stable/_index.scss" + }, + "./stable/stable.css": { + "default": "./stable/stable.css" } }, "keywords": [ @@ -80,7 +92,8 @@ "files": [ "dist", "loader-queries.d.ts", - "types.d.ts" + "types.d.ts", + "stable" ], "author": "KCM ", "license": "MIT", diff --git a/packages/css/src/css.ts b/packages/css/src/css.ts index 38a6fe5..9c8122d 100644 --- a/packages/css/src/css.ts +++ b/packages/css/src/css.ts @@ -1,5 +1,5 @@ import path from 'node:path' -import { promises as fs } from 'node:fs' +import { existsSync, promises as fs } from 'node:fs' import dependencyTree from 'dependency-tree' import type { Options as DependencyTreeOpts } from 'dependency-tree' @@ -228,10 +228,31 @@ async function compileSass( const sass = sassModule const result = sass.compile(filePath, { style: 'expanded', + loadPaths: buildSassLoadPaths(filePath), }) return result.css } +// Ensure Sass can resolve bare module specifiers by walking node_modules folders. +function buildSassLoadPaths(filePath: string): string[] { + const loadPaths = new Set() + let cursor = path.dirname(filePath) + const root = path.parse(cursor).root + + while (true) { + loadPaths.add(cursor) + loadPaths.add(path.join(cursor, 'node_modules')) + if (cursor === root) break + cursor = path.dirname(cursor) + } + + const cwd = process.cwd() + loadPaths.add(cwd) + loadPaths.add(path.join(cwd, 'node_modules')) + + return Array.from(loadPaths).filter(dir => dir && existsSync(dir)) +} + async function compileLess(filePath: string, peerResolver?: PeerLoader): Promise { const mod = await optionalPeer('less', 'Less', peerResolver) const less = unwrapModuleNamespace(mod) diff --git a/packages/css/src/stableSelectors.ts b/packages/css/src/stableSelectors.ts new file mode 100644 index 0000000..eceb2be --- /dev/null +++ b/packages/css/src/stableSelectors.ts @@ -0,0 +1,57 @@ +const DEFAULT_NAMESPACE = 'knighted' + +export interface StableSelectorOptions { + namespace?: string +} + +export interface StableClassNameOptions extends StableSelectorOptions { + token?: string + join?: (values: string[]) => string +} + +const defaultJoin = (values: string[]) => values.filter(Boolean).join(' ') + +const normalizeToken = (token: string): string => { + const sanitized = token + .trim() + .replace(/\s+/g, '-') + .replace(/[^A-Za-z0-9_-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + return sanitized.length ? sanitized : 'stable' +} + +export function stableToken(token: string, options?: StableSelectorOptions): string { + const normalized = normalizeToken(token) + const namespace = options?.namespace?.trim() ?? DEFAULT_NAMESPACE + if (!namespace) { + return normalized + } + return `${namespace}-${normalized}` +} + +export function stableClass(token: string, options?: StableSelectorOptions): string { + return stableToken(token, options) +} + +export function stableSelector(token: string, options?: StableSelectorOptions): string { + return `.${stableToken(token, options)}` +} + +export function createStableClassFactory(options?: StableSelectorOptions) { + return (token: string) => stableClass(token, options) +} + +export function stableClassName>( + styles: T, + key: keyof T | string, + options?: StableClassNameOptions, +): string { + const hashed = styles[key as keyof T] ?? '' + const token = options?.token ?? String(key) + const stable = stableClass(token, options) + const join = options?.join ?? defaultJoin + return join([hashed, stable]) +} + +export const stableClassFromModule = stableClassName diff --git a/packages/css/stable/_index.scss b/packages/css/stable/_index.scss new file mode 100644 index 0000000..2c0eccc --- /dev/null +++ b/packages/css/stable/_index.scss @@ -0,0 +1,57 @@ +// Knighted stable selector mixins +// Usage: @use '@knighted/css/stable' as knighted; +// .button { @include knighted.stable('button') { ... } } + +@use 'sass:meta'; + +$knighted-stable-namespace: 'knighted' !default; + +@function _knighted-normalize-token($token) { + @if meta.type-of($token) == 'number' { + @return $token; + } + @return $token; +} + +@function stable-token($token, $namespace: $knighted-stable-namespace) { + $normalized-token: _knighted-normalize-token($token); + @if $namespace == '' { + @return $normalized-token; + } + @return '#{$namespace}-#{$normalized-token}'; +} + +@function stable-class($token, $namespace: $knighted-stable-namespace) { + $token-value: stable-token($token, $namespace); + @return '.#{$token-value}'; +} + +@mixin stable($token, $namespace: $knighted-stable-namespace) { + @if not & { + @error 'The knighted.stable mixin must be used within a selector context so that "&" is defined.'; + } + + $stable-selector: stable-class($token, $namespace); + @at-root #{&}, + #{$stable-selector} { + @content; + } +} + +@mixin stable-at-root($selector, $token, $namespace: $knighted-stable-namespace) { + $stable-selector: stable-class($token, $namespace); + @at-root #{$selector}, + #{$stable-selector} { + @content; + } +} + +@function stable-class-name($token, $namespace: $knighted-stable-namespace) { + @return stable-token($token, $namespace); +} + +@mixin stable-only($token, $namespace: $knighted-stable-namespace) { + @at-root #{stable-class($token, $namespace)} { + @content; + } +} diff --git a/packages/css/stable/stable.css b/packages/css/stable/stable.css new file mode 100644 index 0000000..dff2c45 --- /dev/null +++ b/packages/css/stable/stable.css @@ -0,0 +1,15 @@ +@layer knighted.stable; + +:root { + --knighted-stable-namespace: 'knighted'; +} + +/* +Usage: + @import '@knighted/css/stable/stable.css'; + @layer knighted.stable { + .knighted-button { + declarations go here; + } + } +*/ diff --git a/packages/css/test/stable-mixins.test.ts b/packages/css/test/stable-mixins.test.ts new file mode 100644 index 0000000..44d9642 --- /dev/null +++ b/packages/css/test/stable-mixins.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import * as sass from 'sass' + +const testDir = fileURLToPath(new URL('.', import.meta.url)) +const packageRoot = path.resolve(testDir, '..') +const loadPaths = [packageRoot] + +test('stable mixin duplicates the current selector', () => { + const source = `@use 'stable' as knighted; +.button { @include knighted.stable('button') { color: teal; } }` + const { css } = sass.compileString(source, { style: 'expanded', loadPaths }) + assert.match(css, /.button,\s*\.knighted-button\s*{[^}]*color: teal;/) +}) + +test('stable-only emits only the deterministic selector', () => { + const source = `@use 'stable' as knighted; +@include knighted.stable-only('card') { border: 1px solid red; }` + const { css } = sass.compileString(source, { style: 'expanded', loadPaths }) + assert.match(css, /^\.knighted-card\s*{[^}]*border: 1px solid red;/m) +}) diff --git a/packages/css/test/stableSelectors.test.ts b/packages/css/test/stableSelectors.test.ts new file mode 100644 index 0000000..bb430c9 --- /dev/null +++ b/packages/css/test/stableSelectors.test.ts @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + createStableClassFactory, + stableClass, + stableClassFromModule, + stableClassName, + stableSelector, + stableToken, +} from '../src/stableSelectors.ts' + +test('stableToken applies default namespace and sanitizes tokens', () => { + const result = stableToken(' hero button ') + assert.equal(result, 'knighted-hero-button') +}) + +test('stableToken allows overriding namespace', () => { + const result = stableToken('cta', { namespace: 'acme' }) + assert.equal(result, 'acme-cta') +}) + +test('stableToken omits namespace when trimmed value is empty', () => { + const result = stableToken('cta', { namespace: ' ' }) + assert.equal(result, 'cta') +}) + +test('stableClass returns a class name without dot', () => { + assert.equal(stableClass('badge'), 'knighted-badge') +}) + +test('createStableClassFactory memoizes namespace preference', () => { + const scoped = createStableClassFactory({ namespace: 'storybook' }) + assert.equal(scoped('chip'), 'storybook-chip') +}) + +test('stableClassName combines hashed class with stable selector', () => { + const styles = { badge: 'badge__hashed' } + const combined = stableClassName(styles, 'badge') + assert.equal(combined, 'badge__hashed knighted-badge') +}) + +test('stableClassName falls back when hashed class is missing', () => { + const styles = { badge: 'badge__hashed' } + const combined = stableClassName(styles, 'missing', { token: 'pill' }) + assert.equal(combined, 'knighted-pill') +}) + +test('stableClassFromModule is an alias', () => { + const styles = { title: 'title__hash' } + const combined = stableClassFromModule(styles, 'title', { namespace: 'docs' }) + assert.equal(combined, 'title__hash docs-title') +}) + +test('stableSelector returns a CSS selector string', () => { + assert.equal(stableSelector('badge'), '.knighted-badge') +}) diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 156b60f..20df54f 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -14,7 +14,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.3", + "@knighted/css": "1.0.0-rc.4", "@knighted/jsx": "^1.2.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/src/lit-react/app.css b/packages/playwright/src/lit-react/app.css index bcfcd08..7b2c243 100644 --- a/packages/playwright/src/lit-react/app.css +++ b/packages/playwright/src/lit-react/app.css @@ -1,3 +1,11 @@ +@import '@knighted/css/stable/stable.css'; + +@layer knighted.stable { + .knighted-layer-glow { + box-shadow: 0 25px 55px rgba(14, 165, 233, 0.35); + } +} + .readme-stage { display: flex; flex-direction: column; diff --git a/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.module.css b/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.module.css new file mode 100644 index 0000000..3acc7cf --- /dev/null +++ b/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.module.css @@ -0,0 +1,28 @@ +.cardShell { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.25rem 1.35rem; + border-radius: 22px; + position: relative; + min-height: 170px; + color: #0f172a; +} + +.cardChip { + align-self: flex-start; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + padding: 0.3rem 0.75rem; + border-radius: 999px; + background: rgba(15, 23, 42, 0.08); +} + +.cardCopy { + margin: 0; + font-size: 0.95rem; + line-height: 1.4; + color: rgba(15, 23, 42, 0.85); +} diff --git a/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.scss b/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.scss new file mode 100644 index 0000000..66b7b6d --- /dev/null +++ b/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.scss @@ -0,0 +1,19 @@ +@use '@knighted/css/stable' as knighted; + +@include knighted.stable-only('stable-card') { + background: linear-gradient(130deg, #a5f3fc 0%, #fef08a 50%, #f9a8d4 100%); + border: 1px solid rgba(15, 23, 42, 0.12); + box-shadow: + 0 25px 55px rgba(14, 165, 233, 0.35), + inset 0 0 0 1px rgba(255, 255, 255, 0.35); +} + +@include knighted.stable-only('stable-chip') { + color: #0f172a; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +@include knighted.stable-only('stable-body') { + color: rgba(15, 23, 42, 0.85); +} diff --git a/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.tsx b/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.tsx new file mode 100644 index 0000000..8e3fa37 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/stable-selectors-card/stable-selectors-card.tsx @@ -0,0 +1,25 @@ +import './stable-selectors-card.scss' + +import * as styles from './stable-selectors-card.module.css' +import { stableClass, stableClassName } from '@knighted/css/stableSelectors' + +export const STABLE_SELECTORS_TEST_ID = 'dialect-stable-selectors' + +export function StableSelectorsCard() { + const shellClass = [ + stableClassName(styles, 'cardShell', { token: 'stable-card' }), + stableClass('layer-glow'), + ].join(' ') + + return ( +
+ + Stable selectors + +

+ Hash-stable class names ride alongside hashed CSS modules so the `?knighted-css` + build can keep Lit-hosted styles in sync. +

+
+ ) +} diff --git a/packages/playwright/src/lit-react/cards/vanilla-stable-card/vanilla-stable-card.css.ts b/packages/playwright/src/lit-react/cards/vanilla-stable-card/vanilla-stable-card.css.ts new file mode 100644 index 0000000..6073cdf --- /dev/null +++ b/packages/playwright/src/lit-react/cards/vanilla-stable-card/vanilla-stable-card.css.ts @@ -0,0 +1,73 @@ +import { type ComplexStyleRule, globalStyle, style } from '@vanilla-extract/css' +import { stableClass, stableSelector } from '@knighted/css/stableSelectors' + +const shellRecipe: ComplexStyleRule = { + display: 'flex', + flexDirection: 'column', + gap: '0.85rem', + padding: '1.35rem 1.5rem', + borderRadius: '26px', + color: '#0f172a', + background: 'linear-gradient(120deg, #ede9fe 0%, #cffafe 45%, #fef3c7 100%)', + boxShadow: + '0 18px 45px rgba(15, 23, 42, 0.18), inset 0 0 0 1px rgba(255, 255, 255, 0.4)', + position: 'relative', + overflow: 'hidden', +} + +const chipRecipe: ComplexStyleRule = { + alignSelf: 'flex-start', + padding: '0.35rem 0.8rem', + borderRadius: '999px', + fontSize: '0.7rem', + letterSpacing: '0.18em', + textTransform: 'uppercase', + fontWeight: 700, + background: 'rgba(15, 23, 42, 0.08)', + color: '#0f172a', +} + +const copyRecipe: ComplexStyleRule = { + margin: 0, + fontSize: '0.95rem', + lineHeight: 1.5, + color: 'rgba(15, 23, 42, 0.82)', +} + +export const vanillaStableShellClass = style(shellRecipe) +export const vanillaStableChipClass = style(chipRecipe) +export const vanillaStableCopyClass = style(copyRecipe) + +const shellStableClass = stableClass('vanilla-stable-shell') +const chipStableClass = stableClass('vanilla-stable-chip') +const copyStableClass = stableClass('vanilla-stable-copy') + +export const vanillaStableShellStableClass = shellStableClass +export const vanillaStableChipStableClass = chipStableClass +export const vanillaStableCopyStableClass = copyStableClass + +globalStyle(stableSelector('vanilla-stable-shell'), shellRecipe) +globalStyle(stableSelector('vanilla-stable-chip'), chipRecipe) +globalStyle(stableSelector('vanilla-stable-copy'), copyRecipe) + +globalStyle(`${stableSelector('vanilla-stable-shell')}::after`, { + content: "''", + position: 'absolute', + inset: '8px', + borderRadius: '22px', + background: + 'linear-gradient(135deg, rgba(124, 58, 237, 0.18), rgba(14, 165, 233, 0.15))', + pointerEvents: 'none', +}) + +globalStyle(`${stableSelector('vanilla-stable-chip')}::after`, { + content: 'attr(data-token)', + marginLeft: '0.4rem', + fontSize: '0.65rem', + letterSpacing: '0.1em', + color: '#6366f1', +}) + +globalStyle(`${stableSelector('vanilla-stable-shell')} strong`, { + color: '#7c3aed', +}) diff --git a/packages/playwright/src/lit-react/cards/vanilla-stable-card/vanilla-stable-card.tsx b/packages/playwright/src/lit-react/cards/vanilla-stable-card/vanilla-stable-card.tsx new file mode 100644 index 0000000..eda3b48 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/vanilla-stable-card/vanilla-stable-card.tsx @@ -0,0 +1,28 @@ +import { + vanillaStableChipClass, + vanillaStableChipStableClass, + vanillaStableCopyClass, + vanillaStableCopyStableClass, + vanillaStableShellClass, + vanillaStableShellStableClass, +} from './vanilla-stable-card.css.js' + +export const VANILLA_STABLE_TEST_ID = 'dialect-vanilla-stable' + +export function VanillaStableSelectorsCard() { + const shellClass = [vanillaStableShellClass, vanillaStableShellStableClass].join(' ') + const chipClass = [vanillaStableChipClass, vanillaStableChipStableClass].join(' ') + const copyClass = [vanillaStableCopyClass, vanillaStableCopyStableClass].join(' ') + + return ( +
+ + vanilla extract · stable selectors + +

+ We stitch deterministic selectors onto vanilla-extract classes so + Lit hosts can target them without needing the hashed identifiers. +

+
+ ) +} diff --git a/packages/playwright/src/lit-react/showcase.tsx b/packages/playwright/src/lit-react/showcase.tsx index b84c13d..bdee26e 100644 --- a/packages/playwright/src/lit-react/showcase.tsx +++ b/packages/playwright/src/lit-react/showcase.tsx @@ -9,6 +9,16 @@ import { CSS_MODULES_TEST_ID, } from './cards/css-modules-card/css-modules-card.js' import { knightedCss as cssModulesCss } from './cards/css-modules-card/css-modules-card.js?knighted-css' +import { + StableSelectorsCard, + STABLE_SELECTORS_TEST_ID, +} from './cards/stable-selectors-card/stable-selectors-card.js' +import { knightedCss as stableSelectorsCss } from './cards/stable-selectors-card/stable-selectors-card.js?knighted-css' +import { + VanillaStableSelectorsCard, + VANILLA_STABLE_TEST_ID, +} from './cards/vanilla-stable-card/vanilla-stable-card.js' +import { knightedCss as vanillaStableCss } from './cards/vanilla-stable-card/vanilla-stable-card.js?knighted-css' import { SassCard, SASS_TEST_ID } from './cards/sass-card/sass-card.js' import { knightedCss as sassCss } from './cards/sass-card/sass-card.js?knighted-css' import { ScssCard, SCSS_TEST_ID } from './cards/scss-card/scss-card.js' @@ -30,6 +40,16 @@ type HostManagedDialect = { } const cards: DialectCard[] = [ + { + id: STABLE_SELECTORS_TEST_ID, + css: stableSelectorsCss, + Component: StableSelectorsCard, + }, + { + id: VANILLA_STABLE_TEST_ID, + css: vanillaStableCss, + Component: VanillaStableSelectorsCard, + }, { id: SCSS_TEST_ID, css: scssCss, Component: ScssCard }, { id: SASS_TEST_ID, css: sassCss, Component: SassCard }, { id: CSS_MODULES_TEST_ID, css: cssModulesCss, Component: CssModulesCard }, diff --git a/packages/playwright/test/lit-react.spec.ts b/packages/playwright/test/lit-react.spec.ts index abd7e69..1248620 100644 --- a/packages/playwright/test/lit-react.spec.ts +++ b/packages/playwright/test/lit-react.spec.ts @@ -7,6 +7,8 @@ const dialectCases = [ { id: 'dialect-scss', property: 'color' }, { id: 'dialect-sass-indented', property: 'color' }, { id: 'dialect-less', property: 'color' }, + { id: 'dialect-stable-selectors', property: 'color' }, + { id: 'dialect-vanilla-stable', property: 'color' }, { id: 'dialect-css-modules', property: 'color' }, { id: 'dialect-vanilla', property: 'color' }, ] @@ -99,6 +101,59 @@ test.describe('Lit + React wrapper demo', () => { }) } + test('stable selector card exposes deterministic hooks', async ({ page }) => { + const card = page.getByTestId('dialect-stable-selectors') + await expect(card).toBeVisible() + const metrics = await card.evaluate(node => { + const el = node as HTMLElement + const style = getComputedStyle(el) + const chip = el.querySelector('.knighted-stable-chip') as HTMLElement | null + const chipStyle = chip ? getComputedStyle(chip) : null + return { + background: style.getPropertyValue('background-image').trim(), + chipCase: chipStyle?.getPropertyValue('text-transform').trim() ?? null, + } + }) + + expect(metrics.background).toContain('linear-gradient') + expect(metrics.chipCase).toBe('uppercase') + }) + + test('vanilla-extract stable selectors expose deterministic hooks', async ({ + page, + }) => { + const card = page.getByTestId('dialect-vanilla-stable') + await expect(card).toBeVisible() + const metrics = await card.evaluate(node => { + const el = node as HTMLElement + const stableShell = el.querySelector( + '.knighted-vanilla-stable-shell', + ) as HTMLElement | null + const stableChip = el.querySelector( + '.knighted-vanilla-stable-chip', + ) as HTMLElement | null + const stableCopy = el.querySelector( + '.knighted-vanilla-stable-copy', + ) as HTMLElement | null + const shellStyle = stableShell + ? getComputedStyle(stableShell) + : getComputedStyle(el) + return { + gradient: shellStyle.getPropertyValue('background-image').trim(), + chipCase: stableChip + ? getComputedStyle(stableChip).getPropertyValue('text-transform').trim() + : null, + copyColor: stableCopy + ? getComputedStyle(stableCopy).getPropertyValue('color').trim() + : null, + } + }) + + expect(metrics.gradient).toContain('linear-gradient') + expect(metrics.chipCase).toBe('uppercase') + expect(metrics.copyColor).toBe('rgba(15, 23, 42, 0.82)') + }) + test('vanilla-extract sprinkles compose utility classes within Lit demo', async ({ page, }) => { diff --git a/packages/playwright/test/styles.spec.ts b/packages/playwright/test/styles.spec.ts index 0cb0d46..3eee2fa 100644 --- a/packages/playwright/test/styles.spec.ts +++ b/packages/playwright/test/styles.spec.ts @@ -5,6 +5,8 @@ const cases = [ { id: 'dialect-scss', property: 'color' }, { id: 'dialect-sass-indented', property: 'color' }, { id: 'dialect-less', property: 'color' }, + { id: 'dialect-stable-selectors', property: 'color' }, + { id: 'dialect-vanilla-stable', property: 'color' }, { id: 'dialect-css-modules', property: 'color' }, { id: 'dialect-vanilla', property: 'color' }, ] @@ -30,6 +32,31 @@ for (const item of cases) { }) } +test('vanilla-extract stable selectors expose deterministic hooks', async ({ page }) => { + const card = page.getByTestId('dialect-vanilla-stable') + await expect(card).toBeVisible() + const metrics = await card.evaluate(node => { + const el = node as HTMLElement + const stableChip = el.querySelector( + '.knighted-vanilla-stable-chip', + ) as HTMLElement | null + const stableCopy = el.querySelector( + '.knighted-vanilla-stable-copy', + ) as HTMLElement | null + return { + chipCase: stableChip + ? getComputedStyle(stableChip).getPropertyValue('text-transform').trim() + : null, + copyColor: stableCopy + ? getComputedStyle(stableCopy).getPropertyValue('color').trim() + : null, + } + }) + + expect(metrics.chipCase).toBe('uppercase') + expect(metrics.copyColor).toBe('rgba(15, 23, 42, 0.82)') +}) + test('vanilla-extract sprinkles compose utility classes', async ({ page }) => { test.skip(true, 'CI flake: text-transform computed as none in headless runs') const el = page.getByTestId('dialect-vanilla')