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')