[READ-ONLY] Mirror of https://github.com/lukebennett88/luke-ui. luke-ui.netlify.app/
0

Configure Feed

Select the types of activity you want to include in your feed.

T4d: Migrate loading skeleton recipe to Panda (#199)

* Migrate loading skeleton recipe to Panda

* Ship Panda CSS in public stylesheet

* Stabilize docs prerendering on Netlify

* Provide icon context in docs theme tests

authored by

Luke Bennett and committed by
GitHub
(Jul 19, 2026, 2:23 PM +1000) a3fa686a d6f499c6

+401 -263
+4 -4
docs/STYLING.md
··· 77 77 | `recipes` | Component styles, variants, and compound variants. | 78 78 | `utilities` | One-off layout and override escape hatches. | 79 79 80 - Use `styleInLayer`, `recipeInLayer`, and `globalStyleInLayer` from `styles/layered-style.css.ts`. 81 - These helpers keep styles inside a named layer. 80 + Vanilla Extract styles declare their layer directly with `@layer`. Panda config recipes emit into 81 + the `recipes` layer, and Panda global rules use `globalCss` with an inline `@layer` key. 82 82 83 83 Overrides that should beat component recipes belong in the `utilities` layer. Use `!important` only 84 84 when a style must also beat consumer un-layered styles or inline styles. Layers cannot beat those. 85 85 86 - `LoadingSkeleton` uses `!important` inside the `utilities` layer because it must force placeholder 86 + `LoadingSkeleton` uses `!important` inside the `recipes` layer because it must force placeholder 87 87 styles onto arbitrary wrapped children. 88 88 89 89 Reduced-motion handling belongs near the animation. The global `prefers-reduced-motion` rule lives 90 90 in the `reset` layer, so it cannot disable animations declared in `recipes` or `utilities`. Animated 91 91 recipes should add their own `@media (prefers-reduced-motion: reduce)` override. See 92 - `recipes/loading-skeleton.css.ts` for an example. 92 + `recipes/loading-skeleton.recipe.ts` for an example. 93 93 94 94 ## Recipes 95 95
+16
apps/docs/vite-config.test.ts
··· 1 + import { expect, test } from 'vite-plus/test'; 2 + import { getPrerenderOptions, staticPrerenderPages } from './vite.config.js'; 3 + 4 + test('keeps required static outputs while serializing prerendering', () => { 5 + const prerender = getPrerenderOptions('/storybook'); 6 + 7 + expect(staticPrerenderPages).toEqual([ 8 + { path: '/api/search' }, 9 + { path: '/llms.txt' }, 10 + { path: '/llms-full.txt' }, 11 + { path: '/playground/preview' }, 12 + ]); 13 + expect(prerender.concurrency).toBe(1); 14 + expect(prerender.filter({ path: '/playground/preview' })).toBe(true); 15 + expect(prerender.filter({ path: '/storybook/index.html' })).toBe(false); 16 + });
+21 -14
apps/docs/vite.config.ts
··· 30 30 31 31 const contentDocsDir = fileURLToPath(new URL('./content/docs/', import.meta.url)); 32 32 33 + // Search and LLM endpoints are static docs artifacts; preview is explicit because iframe sources aren't link-crawled. 34 + export const staticPrerenderPages = [ 35 + { path: '/api/search' }, 36 + { path: '/llms.txt' }, 37 + { path: '/llms-full.txt' }, 38 + { path: '/playground/preview' }, 39 + ]; 40 + 41 + export function getPrerenderOptions(storybookPath: string) { 42 + return { 43 + crawlLinks: true, 44 + // The generated preview server is unreliable under Netlify's default 45 + // parallelism, so serialize its requests. 46 + concurrency: 1, 47 + enabled: true, 48 + filter: (page: { path: string }) => !page.path.startsWith(storybookPath), 49 + }; 50 + } 51 + 33 52 async function getMarkdownPrerenderPages(): Promise<Array<{ path: string }>> { 34 53 const files: Array<string> = []; 35 54 ··· 127 146 mdx(await import('./source.config')), 128 147 tailwindcss(), 129 148 tanstackStart({ 130 - pages: [ 131 - { path: '/api/search' }, 132 - { path: '/llms.txt' }, 133 - { path: '/llms-full.txt' }, 134 - // The preview page is loaded via an iframe src, which the link 135 - // crawler does not follow, so it must be prerendered explicitly. 136 - { path: '/playground/preview' }, 137 - ...markdownPrerenderPages, 138 - ], 139 - prerender: { 140 - crawlLinks: true, 141 - enabled: true, 142 - filter: (page) => !page.path.startsWith(storybookPath), 143 - }, 149 + pages: [...staticPrerenderPages, ...markdownPrerenderPages], 150 + prerender: getPrerenderOptions(storybookPath), 144 151 }), 145 152 react(), 146 153 netlify(),
+1 -1
apps/docs/vitest.config.ts
··· 19 19 test: { 20 20 environment: 'node', 21 21 exclude: ['**/node_modules/**', '**/*.browser.test.*'], 22 - include: ['src/**/*.test.ts'], 22 + include: ['src/**/*.test.ts', 'vite-config.test.ts'], 23 23 name: 'unit', 24 24 }, 25 25 },
+2 -1
packages/@luke-ui/react/package.json
··· 48 48 "access": "public" 49 49 }, 50 50 "scripts": { 51 - "build": "pnpm run build:tsdown", 51 + "build": "pnpm run build:tsdown && pnpm run build:stylesheet", 52 + "build:stylesheet": "tsx scripts/assemble-stylesheet.ts --output=dist/stylesheet.css", 52 53 "build:storybook": "storybook build", 53 54 "build:tsdown": "vp pack", 54 55 "check:format": "vp fmt . --check",
+14 -1
packages/@luke-ui/react/panda.config.ts
··· 7 7 import { iconButtonRecipe } from './src/recipes/icon-button.recipe.js'; 8 8 import { iconRecipe } from './src/recipes/icon.recipe.js'; 9 9 import { linkRecipe } from './src/recipes/link.recipe.js'; 10 + import { 11 + loadingSkeletonGlobalCss, 12 + loadingSkeletonKeyframes, 13 + loadingSkeletonRecipe, 14 + } from './src/recipes/loading-skeleton.recipe.js'; 10 15 import { loadingSpinnerRecipe } from './src/recipes/loading-spinner.recipe.js'; 11 16 import { textInputRecipe } from './src/recipes/text-input.recipe.js'; 12 17 import { textRecipe } from './src/recipes/text.recipe.js'; ··· 87 92 hover: '&:is(:hover, [data-hover])', 88 93 focus: '&:is(:focus, [data-focus])', 89 94 }, 95 + globalCss: loadingSkeletonGlobalCss, 90 96 91 97 // The alias layer plus the T2 box-slice placeholder spacing, kept so the box 92 98 // `utilities`/`staticCss` below still resolve (transitional; removed later). 93 99 theme: { 94 - recipes: { button: buttonRecipe, icon: iconRecipe, link: linkRecipe, text: textRecipe }, 100 + recipes: { 101 + button: buttonRecipe, 102 + icon: iconRecipe, 103 + link: linkRecipe, 104 + loadingSkeleton: loadingSkeletonRecipe, 105 + text: textRecipe, 106 + }, 95 107 keyframes: { 108 + ...loadingSkeletonKeyframes, 96 109 spin: { to: { transform: 'rotate(360deg)' } }, 97 110 rubberBand: { 98 111 '0%': { strokeDasharray: '2 100' },
+8
packages/turbo-generators/src/component-creation-plan.test.ts
··· 46 46 expect( 47 47 plan.files.find((file) => file.path.endsWith('/recipes/status-badge.css.ts'))?.contents, 48 48 ).not.toContain('StatusBadgeVariants'); 49 + const recipe = plan.files.find((file) => { 50 + return file.path.endsWith('/recipes/status-badge.css.ts'); 51 + })?.contents; 52 + 53 + expect(recipe).toContain("import { recipe } from '@vanilla-extract/recipes';"); 54 + expect(recipe).toContain("'@layer': {\n\t\t\trecipes: {"); 55 + expect(recipe).not.toContain('recipeInLayer'); 56 + expect(recipe).not.toContain('layered-style'); 49 57 50 58 const story = plan.files.find((file) => { 51 59 return file.path.endsWith('/status-badge/status-badge.stories.tsx');
+7 -3
packages/turbo-generators/src/component-creation-plan.ts
··· 275 275 } 276 276 277 277 function renderRecipe(input: { camelName: string; pascalName: string }): string { 278 - return `import { recipeInLayer } from '../styles/layered-style.css.js'; 278 + return `import { recipe } from '@vanilla-extract/recipes'; 279 279 280 - export const ${input.camelName} = recipeInLayer('recipes', { 280 + export const ${input.camelName} = recipe({ 281 281 \tbase: { 282 - \t\tdisplay: 'inline-flex', 282 + \t\t'@layer': { 283 + \t\t\trecipes: { 284 + \t\t\t\tdisplay: 'inline-flex', 285 + \t\t\t}, 286 + \t\t}, 283 287 \t}, 284 288 }); 285 289 `;
+5 -1
apps/docs/src/components/theme-controls.browser.test.tsx
··· 1 1 import '../styles/app.css'; 2 2 import '@luke-ui/react/themes/paper.css'; 3 3 import '@luke-ui/react/themes/tactile.css'; 4 + import { IconSpritesheetProvider } from '@luke-ui/react/icon'; 5 + import spriteSheetHref from '@luke-ui/react/spritesheet.svg?url&no-inline'; 4 6 import { paperThemeClassName } from '@luke-ui/react/themes'; 5 7 import { ThemeProvider } from 'next-themes'; 6 8 import { act } from 'react'; ··· 165 167 defaultTheme={options.defaultTheme ?? 'light'} 166 168 enableSystem={options.enableSystem ?? false} 167 169 > 168 - <DocsThemeRoot>{children}</DocsThemeRoot> 170 + <IconSpritesheetProvider href={spriteSheetHref}> 171 + <DocsThemeRoot>{children}</DocsThemeRoot> 172 + </IconSpritesheetProvider> 169 173 </ThemeProvider>, 170 174 ); 171 175 });
+89 -16
packages/@luke-ui/react/scripts/assemble-stylesheet.ts
··· 3 3 /** 4 4 * Stylesheet assembler (T2). 5 5 * 6 - * Combines Panda's ejected output into a single preview stylesheet whose cascade 6 + * Combines Panda's ejected output into a single stylesheet whose cascade 7 7 * layers match the canonical Luke UI order. Panda has no native `box` layer, so 8 8 * we take Panda's `utilities` output (currently the sole box slice) and re-wrap 9 9 * it as `@layer box`. ··· 31 31 * `assembled-stylesheet.test.ts` pins this contract against the generated 32 32 * output. 33 33 * 34 - * VE still owns reset/base/global, so Panda's reset.css and global.css are 35 - * deliberately excluded here. Panda's tokens.css is included: it is the 36 - * Panda→Luke token alias bridge that recipe and box CSS depend on. 34 + * VE still owns reset/base styles, so Panda's reset.css is deliberately 35 + * excluded. Panda global rules that explicitly target `@layer recipes` are 36 + * included alongside config recipes. Panda's tokens.css is the Panda→Luke 37 + * token alias bridge that recipe and box CSS depend on. 37 38 */ 38 39 39 40 import { existsSync, readFileSync, writeFileSync } from 'node:fs'; 40 41 import { fileURLToPath } from 'node:url'; 42 + import { gzipSync } from 'node:zlib'; 41 43 import { lukeLayerOrder } from '../src/styles/layer-order.js'; 42 44 43 45 const packageRoot = fileURLToPath(new URL('..', import.meta.url)); ··· 45 47 const vanillaExtractStylesheet = `${packageRoot}dist/stylesheet.css`; 46 48 const assembledOutput = `${packageRoot}styled-system/assembled.css`; 47 49 50 + // Panda assembly brings the public stylesheet to about 87.2 kB raw / 9.8 kB gzip. 51 + // Retain modest headroom for future migrated recipes without masking large regressions. 52 + const maximumPublicStylesheetRawBytes = 92_000; 53 + const maximumPublicStylesheetGzipBytes = 10_500; 54 + 48 55 export interface AssembleOptions { 49 56 /** 50 - * Append VE's shipped `dist/stylesheet.css` verbatim (preview only). Skipped 51 - * silently when the build artifact is absent so the assembler still succeeds 52 - * from `generate` output alone. 57 + * Append VE's emitted stylesheet verbatim. Skipped silently when the build 58 + * artifact is absent so the assembler still succeeds from `generate` output 59 + * alone. 53 60 */ 54 61 includeVanillaExtract?: boolean; 55 62 /** Override the Panda split-CSS directory (defaults to package styled-system/styles). */ ··· 96 103 sections.push(recipes); 97 104 } 98 105 106 + // `globalCss` emits through Panda's base stylesheet even when a rule 107 + // declares its own layer. Keep explicitly recipe-layered global rules at 108 + // the top level so they retain the intended cascade position. 109 + const globalPath = `${stylesDir}/global.css`; 110 + if (existsSync(globalPath)) { 111 + const recipeGlobals = extractLayer(readFileSync(globalPath, 'utf8'), 'recipes'); 112 + if (recipeGlobals) sections.push(recipeGlobals); 113 + } 114 + 99 115 // Panda utilities = the box slice plus recipe compound-variant atomics (see header). 100 116 // Re-wrap the `@layer utilities` block as `@layer box`. Compounds stay cascade-correct there because box sits above recipes. 101 117 const utilitiesPath = `${stylesDir}/utilities.css`; ··· 105 121 sections.push(box); 106 122 } 107 123 108 - // Optional VE preview: appended verbatim, purely to eyeball the combined 109 - // result. Never required, because the build owns the real VE stylesheet. 124 + // VE owns reset, theme, and the remaining unmigrated recipes. Append its 125 + // emitted output after Panda's layer-order declaration so the public 126 + // stylesheet contains each system exactly once without changing layer order. 110 127 if (options.includeVanillaExtract) { 111 128 if (existsSync(vePath)) { 112 - sections.push(`/* --- vanilla-extract dist/stylesheet.css (preview) --- */`); 129 + sections.push(`/* --- vanilla-extract --- */`); 113 130 sections.push(readFileSync(vePath, 'utf8').trim()); 114 131 } else { 115 - sections.push( 116 - `/* vanilla-extract dist/stylesheet.css not found (build artifact); skipped */`, 117 - ); 132 + sections.push(`/* vanilla-extract stylesheet not found; skipped */`); 118 133 } 119 134 } 120 135 121 136 return `${sections.join('\n\n')}\n`; 122 137 } 123 138 139 + function extractLayer(css: string, layer: string): string | undefined { 140 + const start = css.indexOf(`@layer ${layer} {`); 141 + if (start === -1) return undefined; 142 + 143 + let depth = 0; 144 + for (let index = start; index < css.length; index += 1) { 145 + const character = css[index]; 146 + if (character === '{') depth += 1; 147 + if (character !== '}' || depth === 0) continue; 148 + depth -= 1; 149 + if (depth === 0) return css.slice(start, index + 1).trim(); 150 + } 151 + 152 + throw new Error(`Unclosed @layer ${layer} block in Panda global CSS.`); 153 + } 154 + 124 155 function main(): void { 125 - const css = assembleStylesheet({ includeVanillaExtract: true }); 126 - writeFileSync(assembledOutput, css, 'utf8'); 127 - process.stdout.write(`Wrote styled-system/assembled.css (${css.length} bytes)\n`); 156 + const output = process.argv.find((argument) => argument.startsWith('--output=')); 157 + const outputPath = output ? `${packageRoot}${output.slice('--output='.length)}` : assembledOutput; 158 + const css = assembleStylesheet({ includeVanillaExtract: Boolean(output) }); 159 + 160 + if (output) assertPublicStylesheet(css); 161 + writeFileSync(outputPath, css, 'utf8'); 162 + process.stdout.write(`Wrote ${outputPath.slice(packageRoot.length)} (${css.length} bytes)\n`); 163 + } 164 + 165 + function assertPublicStylesheet(css: string): void { 166 + const requiredSections = [ 167 + '@layer reset, base, tokens, recipes, box, utilities;', 168 + '@layer tokens {', 169 + '@layer recipes {', 170 + '@layer box {', 171 + '/* --- vanilla-extract --- */', 172 + ]; 173 + 174 + for (const section of requiredSections) { 175 + if (!css.includes(section)) throw new Error(`Public stylesheet is missing ${section}.`); 176 + } 177 + 178 + const maskingSelector = '.loading-skeleton:not([data-skeleton-inline]) > * {'; 179 + const maskingStart = css.indexOf(maskingSelector); 180 + const maskingEnd = css.indexOf('}', maskingStart); 181 + if ( 182 + maskingStart === -1 || 183 + !css.slice(maskingStart, maskingEnd).includes('overflow: hidden !important') 184 + ) { 185 + throw new Error('Public stylesheet is missing loading skeleton block masking.'); 186 + } 187 + 188 + const rawBytes = Buffer.byteLength(css); 189 + if (rawBytes > maximumPublicStylesheetRawBytes) { 190 + throw new Error( 191 + `Public stylesheet is ${rawBytes} bytes; maximum is ${maximumPublicStylesheetRawBytes}.`, 192 + ); 193 + } 194 + 195 + const gzipBytes = gzipSync(css, { level: 9 }).byteLength; 196 + if (gzipBytes > maximumPublicStylesheetGzipBytes) { 197 + throw new Error( 198 + `Public stylesheet is ${gzipBytes} gzip bytes; maximum is ${maximumPublicStylesheetGzipBytes}.`, 199 + ); 200 + } 128 201 } 129 202 130 203 if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
+2 -2
packages/@luke-ui/react/src/loading-skeleton/index.tsx
··· 1 1 import type { ComponentProps, ElementType, JSX, ReactNode } from 'react'; 2 2 import { createContext, isValidElement, useContext } from 'react'; 3 - import * as styles from '../recipes/loading-skeleton.css.js'; 3 + import * as styles from '../recipes/loading-skeleton.js'; 4 4 import { vars } from '../theme/contract.css.js'; 5 5 import type { Prettify } from '../types/prettify.js'; 6 6 import { useSynchronizeAnimations } from '../use-synchronize-animations/use-synchronize-animations.js'; ··· 77 77 <Component 78 78 {...spanProps} 79 79 aria-hidden 80 - className={cx(styles.loadingSkeleton, className)} 80 + className={cx(styles.loadingSkeleton(), className)} 81 81 data-skeleton-inline={isInline ? '' : undefined} 82 82 inert 83 83 style={radius ? { ...style, [styles.skeletonRadiusVar]: vars.radius[radius] } : style}
+1 -1
packages/@luke-ui/react/src/recipes/index.ts
··· 7 7 export { iconButton } from '../recipes/icon-button.js'; 8 8 export type { LinkVariants } from '../recipes/link.js'; 9 9 export { link } from '../recipes/link.js'; 10 - export { loadingSkeleton } from '../recipes/loading-skeleton.css.js'; 10 + export { loadingSkeleton } from '../recipes/loading-skeleton.js'; 11 11 export type { LoadingSpinnerVariants } from '../recipes/loading-spinner.js'; 12 12 export { loadingSpinner } from '../recipes/loading-spinner.js'; 13 13 export type {
+58 -2
packages/@luke-ui/react/src/recipes/loading-skeleton.browser.test.ts
··· 5 5 import { contrastRatio, parseColor } from '../theme/color.js'; 6 6 import { themeRootClassName } from '../theme/index.js'; 7 7 import { paperThemeClassName, tactileThemeClassName } from '../themes/index.js'; 8 - import { loadingSkeleton } from './loading-skeleton.css.js'; 8 + import { loadingSkeleton } from './loading-skeleton.js'; 9 9 10 10 let root: HTMLElement | undefined; 11 11 ··· 39 39 expect(style.backgroundColor).not.toBe('rgba(0, 0, 0, 0)'); 40 40 }); 41 41 42 + test('block skeleton masking overrides wrapped content and its descendants', () => { 43 + const inlineSkeleton = mountInlineSkeleton(); 44 + const { child, nested } = mountBlockSkeleton(); 45 + const skeletonSurface = getComputedStyle(inlineSkeleton); 46 + const childStyle = getComputedStyle(child); 47 + const nestedStyle = getComputedStyle(nested); 48 + const overlayStyle = getComputedStyle(child, '::after'); 49 + 50 + expect(childStyle.backgroundColor).toBe(skeletonSurface.backgroundColor); 51 + expect(childStyle.backgroundClip).toBe('border-box'); 52 + expect(childStyle.backgroundImage).toBe('none'); 53 + expect(childStyle.borderStyle).toBe('none'); 54 + expect(childStyle.boxDecorationBreak).toBe('clone'); 55 + expect(childStyle.boxShadow).toBe('none'); 56 + expect(childStyle.color).toBe(skeletonSurface.color); 57 + expect(childStyle.cursor).toBe('default'); 58 + expect(childStyle.outlineStyle).toBe('none'); 59 + expect(childStyle.outlineWidth).toBe('0px'); 60 + expect(childStyle.overflow).toBe('hidden'); 61 + expect(childStyle.position).toBe('relative'); 62 + expect(childStyle.pointerEvents).toBe('none'); 63 + expect(childStyle.userSelect).toBe('none'); 64 + expect(nestedStyle.backgroundColor).toBe(skeletonSurface.backgroundColor); 65 + expect(nestedStyle.backgroundImage).toBe('none'); 66 + expect(nestedStyle.color).toBe(skeletonSurface.color); 67 + expect(overlayStyle.backgroundColor).toBe(skeletonSurface.backgroundColor); 68 + expect(overlayStyle.content).toBe('""'); 69 + expect(overlayStyle.inset).toBe('-1px'); 70 + expect(overlayStyle.position).toBe('absolute'); 71 + }); 72 + 42 73 for (const theme of themeCases) { 43 74 for (const mode of ['light', 'dark'] as const) { 44 75 test(`${theme.name} ${mode} keeps both pulse extremes distinct from the canvas`, async () => { ··· 63 94 root.dataset.colorMode = mode; 64 95 root.style.backgroundColor = 'var(--luke-color-surface-canvas)'; 65 96 const skeleton = root.appendChild(document.createElement('span')); 66 - skeleton.className = loadingSkeleton; 97 + skeleton.className = loadingSkeleton(); 67 98 skeleton.dataset.skeletonInline = ''; 68 99 return skeleton; 100 + } 101 + 102 + function mountBlockSkeleton() { 103 + const skeleton = requireRoot().appendChild(document.createElement('span')); 104 + skeleton.className = loadingSkeleton(); 105 + const child = skeleton.appendChild(document.createElement('div')); 106 + child.style.backgroundColor = 'red'; 107 + child.style.backgroundClip = 'content-box'; 108 + child.style.backgroundImage = 'linear-gradient(blue, green)'; 109 + child.style.border = '1px solid blue'; 110 + child.style.boxDecorationBreak = 'slice'; 111 + child.style.boxShadow = '1px 1px blue'; 112 + child.style.color = 'blue'; 113 + child.style.cursor = 'pointer'; 114 + child.style.outline = '1px solid yellow'; 115 + child.style.overflow = 'visible'; 116 + child.style.position = 'static'; 117 + child.style.pointerEvents = 'auto'; 118 + child.style.userSelect = 'text'; 119 + const nested = child.appendChild(document.createElement('span')); 120 + nested.style.backgroundColor = 'red'; 121 + nested.style.backgroundImage = 'linear-gradient(blue, green)'; 122 + nested.style.color = 'blue'; 123 + 124 + return { child, nested }; 69 125 } 70 126 71 127 function requireRoot() {
-102
packages/@luke-ui/react/src/recipes/loading-skeleton.css.ts
··· 1 - import type { StyleRule } from '@vanilla-extract/css'; 2 - import { fallbackVar, keyframes } from '@vanilla-extract/css'; 3 - import { globalStyleInLayer, styleInLayer } from '../styles/layered-style.css.js'; 4 - import { vars } from '../theme/contract.css.js'; 5 - 6 - /** 7 - * @internal 8 - */ 9 - export const skeletonAnimationName = keyframes({ 10 - '0%': { filter: 'brightness(1)' }, 11 - '10%': { filter: 'brightness(1)' }, 12 - '50%': { filter: 'brightness(0.88)' }, 13 - '60%': { filter: 'brightness(0.88)' }, 14 - '100%': { filter: 'brightness(1)' }, 15 - }); 16 - 17 - /** @internal */ 18 - export const skeletonRadiusVar = '--luke-loading-skeleton-radius'; 19 - 20 - // Forced onto every skeleton surface so an arbitrary wrapped component reads as a flat placeholder shape. 21 - // `!important` is deliberate: cascade layers alone can't beat consumers' un-layered or inline styles, and the 22 - // skeleton must always win over its children. The casts silence csstype on keyword-only properties, which don't 23 - // admit the `!important` suffix in their type. 24 - const surface = { 25 - backgroundClip: 'border-box !important', 26 - backgroundColor: `${vars.color.loadingSkeleton} !important`, 27 - backgroundImage: 'none !important', 28 - border: 'none !important', 29 - // Text spanning multiple lines keeps its radius on every line fragment. 30 - boxDecorationBreak: 'clone !important' as 'clone', 31 - boxShadow: 'none !important', 32 - color: 'transparent !important', 33 - cursor: 'default !important', 34 - outline: 'none !important', 35 - pointerEvents: 'none !important' as 'none', 36 - userSelect: 'none !important' as 'none', 37 - } as const satisfies StyleRule; 38 - 39 - const forcedColorsSurface = { 40 - backgroundColor: 'CanvasText !important', 41 - forcedColorAdjust: 'none !important' as 'none', 42 - } as const satisfies StyleRule; 43 - 44 - // Not `!important`: reduced-motion overrides (below) and animation syncing must stay able to adjust it. 45 - const pulse = { 46 - animationDelay: '0.5s', 47 - animationDuration: '2s', 48 - animationIterationCount: 'infinite', 49 - animationName: skeletonAnimationName, 50 - animationTimingFunction: vars.motion.easing.standard, 51 - '@media': { 52 - '(forced-colors: active)': { 53 - ...forcedColorsSurface, 54 - animationName: 'none', 55 - }, 56 - // The global reduced-motion reset lives in the lowest layer, so it can't win against this rule. 57 - '(prefers-reduced-motion: reduce)': { 58 - animationName: 'none', 59 - }, 60 - }, 61 - } as const satisfies StyleRule; 62 - 63 - /** Vanilla-extract class for the `LoadingSkeleton` component's styles. */ 64 - export const loadingSkeleton = styleInLayer('utilities', { 65 - selectors: { 66 - // Inline mode: the element itself is the skeleton (used when wrapping text). 67 - '&[data-skeleton-inline]': { 68 - ...surface, 69 - ...pulse, 70 - borderRadius: fallbackVar(`var(${skeletonRadiusVar})`, vars.radius.detail), 71 - }, 72 - // Block mode: the wrapper is invisible; skeleton styles apply to its direct children. 73 - '&:not([data-skeleton-inline])': { 74 - display: 'contents', 75 - }, 76 - }, 77 - }); 78 - 79 - globalStyleInLayer('utilities', `${loadingSkeleton}:not([data-skeleton-inline]) > *`, { 80 - ...surface, 81 - ...pulse, 82 - overflow: 'hidden !important', 83 - position: 'relative !important' as 'relative', 84 - }); 85 - 86 - globalStyleInLayer('utilities', `${loadingSkeleton}:not([data-skeleton-inline]) > * *`, { 87 - '@media': { 88 - '(forced-colors: active)': forcedColorsSurface, 89 - }, 90 - ...surface, 91 - }); 92 - 93 - // A pseudo-element painted over the child covers visuals the forced styles can't reach (nested backgrounds, 94 - // rounded corners); `inset: -1px` also covers the child's border box edges. 95 - globalStyleInLayer('utilities', `${loadingSkeleton}:not([data-skeleton-inline]) > *::after`, { 96 - ...surface, 97 - ...pulse, 98 - borderRadius: `var(${skeletonRadiusVar}, 0px)`, 99 - content: '""', 100 - inset: '-1px', 101 - position: 'absolute', 102 - });
+89
packages/@luke-ui/react/src/recipes/loading-skeleton.recipe.ts
··· 1 + import { defineGlobalStyles, defineKeyframes, defineRecipe } from '@pandacss/dev'; 2 + import type { ColorToken } from '../../styled-system/tokens/index.mjs'; 3 + import type { SystemStyleObject } from '../../styled-system/types/system-types.d.mts'; 4 + 5 + const skeletonAnimationName = 'loadingSkeletonPulse'; 6 + const skeletonRadiusVar = '--luke-loading-skeleton-radius'; 7 + const loadingSkeletonColor = 'loadingSkeleton' satisfies ColorToken; 8 + 9 + const forcedColorsSurface = { 10 + backgroundColor: 'CanvasText !important', 11 + forcedColorAdjust: 'none !important', 12 + } as const satisfies SystemStyleObject; 13 + 14 + const surface = { 15 + backgroundClip: 'border-box !important', 16 + backgroundColor: `${loadingSkeletonColor} !important`, 17 + backgroundImage: 'none !important', 18 + border: 'none !important', 19 + boxDecorationBreak: 'clone !important', 20 + boxShadow: 'none !important', 21 + color: 'transparent !important', 22 + cursor: 'default !important', 23 + outlineColor: 'transparent !important', 24 + outlineStyle: 'none !important', 25 + outlineWidth: '0 !important', 26 + pointerEvents: 'none !important', 27 + userSelect: 'none !important', 28 + } as const satisfies SystemStyleObject; 29 + 30 + const pulse = { 31 + '@media (forced-colors: active)': { 32 + ...forcedColorsSurface, 33 + animationName: 'none', 34 + }, 35 + '@media (prefers-reduced-motion: reduce)': { 36 + animationName: 'none', 37 + }, 38 + animationDelay: '0.5s', 39 + animationDuration: '2s', 40 + animationIterationCount: 'infinite', 41 + animationName: skeletonAnimationName, 42 + animationTimingFunction: 'standard', 43 + } as const satisfies SystemStyleObject; 44 + 45 + export const loadingSkeletonRecipe = defineRecipe({ 46 + className: 'loading-skeleton', 47 + description: 'Placeholder surface that mirrors the layout of loading content.', 48 + base: { 49 + '&[data-skeleton-inline]': { 50 + ...surface, 51 + ...pulse, 52 + borderRadius: `var(${skeletonRadiusVar}, var(--radii-detail))`, 53 + }, 54 + '&:not([data-skeleton-inline])': { display: 'contents' }, 55 + }, 56 + }); 57 + 58 + export const loadingSkeletonGlobalCss = defineGlobalStyles({ 59 + '@layer recipes': { 60 + '.loading-skeleton:not([data-skeleton-inline]) > *': { 61 + ...surface, 62 + ...pulse, 63 + overflow: 'hidden !important', 64 + position: 'relative !important', 65 + }, 66 + '.loading-skeleton:not([data-skeleton-inline]) > * *': { 67 + '@media (forced-colors: active)': forcedColorsSurface, 68 + ...surface, 69 + }, 70 + '.loading-skeleton:not([data-skeleton-inline]) > *::after': { 71 + ...surface, 72 + ...pulse, 73 + borderRadius: `var(${skeletonRadiusVar}, 0px)`, 74 + content: '""', 75 + inset: '-1px', 76 + position: 'absolute', 77 + }, 78 + }, 79 + }); 80 + 81 + export const loadingSkeletonKeyframes = defineKeyframes({ 82 + [skeletonAnimationName]: { 83 + '0%': { filter: 'brightness(1)' }, 84 + '10%': { filter: 'brightness(1)' }, 85 + '50%': { filter: 'brightness(0.88)' }, 86 + '60%': { filter: 'brightness(0.88)' }, 87 + '100%': { filter: 'brightness(1)' }, 88 + }, 89 + });
+4
packages/@luke-ui/react/src/recipes/loading-skeleton.ts
··· 1 + export { loadingSkeleton } from '../../styled-system/recipes/loading-skeleton.mjs'; 2 + 3 + export const skeletonAnimationName = 'loadingSkeletonPulse'; 4 + export const skeletonRadiusVar = '--luke-loading-skeleton-radius';
+6
packages/@luke-ui/react/src/styles/assembled-stylesheet.test.ts
··· 68 68 } 69 69 }); 70 70 71 + it('keeps loading skeleton masking globals inside @layer recipes', () => { 72 + const maskingDeclaration = 'overflow: hidden !important'; 73 + expect(recipesBlock).toContain(maskingDeclaration); 74 + expect(boxBlock).not.toContain(maskingDeclaration); 75 + }); 76 + 71 77 it('keeps the recipe compound-variant atomics inside @layer box', () => { 72 78 // The solid/neutral compound variant is the only source of this 73 79 // declaration; its atomic class rides Panda's `utilities` output into
-36
packages/@luke-ui/react/src/styles/layered-style.css.ts
··· 1 - import type { GlobalStyleRule, StyleRule } from '@vanilla-extract/css'; 2 - import { globalStyle as vanillaGlobalStyle, style as vanillaStyle } from '@vanilla-extract/css'; 3 - import type { DistributiveOmit } from '../types/distributive-omit.js'; 4 - import type { LayerName } from './layers.css.js'; 5 - import { layers } from './layers.css.js'; 6 - 7 - type LayeredStyleRule = DistributiveOmit<StyleRule, '@layer'>; 8 - type LayeredGlobalStyleRule = DistributiveOmit<GlobalStyleRule, '@layer'>; 9 - 10 - function withLayer(layer: LayerName, rule: LayeredStyleRule): StyleRule { 11 - return { 12 - '@layer': { 13 - [layers[layer]]: rule, 14 - }, 15 - }; 16 - } 17 - 18 - function withLayerGlobal(layer: LayerName, rule: LayeredGlobalStyleRule): GlobalStyleRule { 19 - return { 20 - '@layer': { 21 - [layers[layer]]: rule, 22 - }, 23 - }; 24 - } 25 - 26 - export function globalStyleInLayer( 27 - layer: LayerName, 28 - selector: string, 29 - rule: LayeredGlobalStyleRule, 30 - ): void { 31 - vanillaGlobalStyle(selector, withLayerGlobal(layer, rule)); 32 - } 33 - 34 - export function styleInLayer(layer: LayerName, rule: LayeredStyleRule, debugId?: string): string { 35 - return vanillaStyle(withLayer(layer, rule), debugId); 36 - }
-2
packages/@luke-ui/react/src/styles/layers.css.ts
··· 14 14 recipes: globalLayer('recipes'), 15 15 utilities: globalLayer('utilities'), 16 16 } as const; 17 - 18 - export type LayerName = keyof typeof layers;
+59 -59
packages/@luke-ui/react/src/styles/reset.css.ts
··· 1 + import { globalStyle } from '@vanilla-extract/css'; 1 2 import { vars } from '../theme/contract.css.js'; 2 3 import { classSelector, lukeUiClassNames } from './class-names.js'; 3 4 import { focusRing } from './focus-ring.js'; 4 - import { globalStyleInLayer } from './layered-style.css.js'; 5 5 6 6 const root = classSelector(lukeUiClassNames.resetRoot); 7 7 8 - globalStyleInLayer('reset', `${root}, ${root} *, ${root} *::before, ${root} *::after`, { 9 - boxSizing: 'border-box', 8 + globalStyle(`${root}, ${root} *, ${root} *::before, ${root} *::after`, { 9 + '@layer': { reset: { boxSizing: 'border-box' } }, 10 10 }); 11 11 12 - globalStyleInLayer('reset', `${root} :where(blockquote, dl, dd, figure, p)`, { 13 - margin: 0, 12 + globalStyle(`${root} :where(blockquote, dl, dd, figure, p)`, { 13 + '@layer': { reset: { margin: 0 } }, 14 14 }); 15 15 16 - globalStyleInLayer('reset', `${root} :where(h1, h2, h3, h4, h5, h6)`, { 17 - font: 'unset', 18 - margin: 0, 16 + globalStyle(`${root} :where(h1, h2, h3, h4, h5, h6)`, { 17 + '@layer': { reset: { font: 'unset', margin: 0 } }, 19 18 }); 20 19 21 - globalStyleInLayer('reset', `${root} :where(ol, ul)`, { 22 - listStyle: 'none', 23 - margin: 0, 24 - padding: 0, 20 + globalStyle(`${root} :where(ol, ul)`, { 21 + '@layer': { reset: { listStyle: 'none', margin: 0, padding: 0 } }, 25 22 }); 26 23 27 - globalStyleInLayer('reset', `${root} :where(table)`, { 28 - borderCollapse: 'collapse', 29 - borderSpacing: 0, 24 + globalStyle(`${root} :where(table)`, { 25 + '@layer': { reset: { borderCollapse: 'collapse', borderSpacing: 0 } }, 30 26 }); 31 27 32 - globalStyleInLayer('reset', `${root} :where(caption, th)`, { 33 - textAlign: 'inherit', 28 + globalStyle(`${root} :where(caption, th)`, { 29 + '@layer': { reset: { textAlign: 'inherit' } }, 34 30 }); 35 31 36 - globalStyleInLayer('reset', `${root} :where(th, td)`, { 37 - padding: 0, 32 + globalStyle(`${root} :where(th, td)`, { '@layer': { reset: { padding: 0 } } }); 33 + 34 + globalStyle(`${root} :where(button, select, label)`, { 35 + '@layer': { reset: { WebkitTapHighlightColor: 'transparent' } }, 38 36 }); 39 37 40 - globalStyleInLayer('reset', `${root} :where(button, select, label)`, { 41 - WebkitTapHighlightColor: 'transparent', 42 - }); 43 - 44 - globalStyleInLayer( 45 - 'reset', 38 + globalStyle( 46 39 `${root} :where(button, select, input, textarea, [type='button'], [type='reset'], [type='submit'])`, 47 40 { 48 - font: 'inherit', 41 + '@layer': { reset: { font: 'inherit' } }, 49 42 }, 50 43 ); 51 44 52 - globalStyleInLayer( 53 - 'reset', 54 - `${root} :where(button, [type='button'], [type='reset'], [type='submit'])`, 55 - { 56 - backgroundColor: 'transparent', 57 - borderColor: 'transparent', 58 - borderStyle: 'none', 59 - borderWidth: 0, 60 - color: 'inherit', 61 - padding: 0, 62 - }, 63 - ); 64 - 65 - globalStyleInLayer('reset', `${root} :where(input, textarea, select)`, { 66 - color: 'inherit', 67 - margin: 0, 68 - }); 69 - 70 - globalStyleInLayer('reset', `${root} :where(:disabled, [data-disabled="true"])`, { 71 - cursor: 'not-allowed', 72 - }); 73 - 74 - globalStyleInLayer('reset', `${root} :where(:focus-visible)`, { 75 - ...focusRing(vars.color.border.focus), 76 - 77 - '@media': { 78 - '(forced-colors: active)': { 79 - outlineColor: 'Highlight', 45 + globalStyle(`${root} :where(button, [type='button'], [type='reset'], [type='submit'])`, { 46 + '@layer': { 47 + reset: { 48 + backgroundColor: 'transparent', 49 + borderColor: 'transparent', 50 + borderStyle: 'none', 51 + borderWidth: 0, 52 + color: 'inherit', 53 + padding: 0, 80 54 }, 81 55 }, 82 56 }); 83 57 84 - globalStyleInLayer('reset', `${root}, ${root} *, ${root} *::before, ${root} *::after`, { 85 - '@media': { 86 - '(prefers-reduced-motion: reduce)': { 87 - animation: 'none', 88 - transition: 'none', 58 + globalStyle(`${root} :where(input, textarea, select)`, { 59 + '@layer': { reset: { color: 'inherit', margin: 0 } }, 60 + }); 61 + 62 + globalStyle(`${root} :where(:disabled, [data-disabled="true"])`, { 63 + '@layer': { reset: { cursor: 'not-allowed' } }, 64 + }); 65 + 66 + globalStyle(`${root} :where(:focus-visible)`, { 67 + '@layer': { 68 + reset: { 69 + ...focusRing(vars.color.border.focus), 70 + 71 + '@media': { 72 + '(forced-colors: active)': { 73 + outlineColor: 'Highlight', 74 + }, 75 + }, 76 + }, 77 + }, 78 + }); 79 + 80 + globalStyle(`${root}, ${root} *, ${root} *::before, ${root} *::after`, { 81 + '@layer': { 82 + reset: { 83 + '@media': { 84 + '(prefers-reduced-motion: reduce)': { 85 + animation: 'none', 86 + transition: 'none', 87 + }, 88 + }, 89 89 }, 90 90 }, 91 91 });
+5 -12
packages/@luke-ui/react/src/styles/stylesheet-size.test.ts
··· 1 - import { gzipSync } from 'node:zlib'; 2 - import { readFile } from 'node:fs/promises'; 3 1 import { expect, test } from 'vite-plus/test'; 2 + import packageJson from '../../package.json' with { type: 'json' }; 4 3 5 - // Keep the public bundle below its pre-Sprinkles baseline while leaving room for normal growth. 6 - const maximumRawBytes = 85_000; 7 - const maximumGzipBytes = 8_800; 8 - 9 - test('keeps the public stylesheet within its size budget', async () => { 10 - const stylesheet = await readFile(new URL('../../dist/stylesheet.css', import.meta.url)); 11 - 12 - expect(stylesheet.byteLength, 'raw stylesheet size').toBeLessThanOrEqual(maximumRawBytes); 13 - expect(gzipSync(stylesheet, { level: 9 }).byteLength, 'gzip stylesheet size').toBeLessThanOrEqual( 14 - maximumGzipBytes, 4 + test('build assembles the public stylesheet after Vanilla Extract emits its CSS', () => { 5 + expect(packageJson.scripts.build).toBe('pnpm run build:tsdown && pnpm run build:stylesheet'); 6 + expect(packageJson.scripts['build:stylesheet']).toBe( 7 + 'tsx scripts/assemble-stylesheet.ts --output=dist/stylesheet.css', 15 8 ); 16 9 });
+10 -6
packages/@luke-ui/react/src/styles/theme-root.css.ts
··· 1 + import { globalStyle } from '@vanilla-extract/css'; 1 2 import { vars } from '../theme/contract.css.js'; 2 3 import { classSelector, lukeUiClassNames } from './class-names.js'; 3 - import { globalStyleInLayer } from './layered-style.css.js'; 4 4 5 - globalStyleInLayer('theme', classSelector(lukeUiClassNames.themeRoot), { 6 - accentColor: vars.color.intent.accent.surface.solid, 7 - color: vars.color.text.primary, 8 - fontFamily: vars.font.family, 9 - ...vars.font[300], 5 + globalStyle(classSelector(lukeUiClassNames.themeRoot), { 6 + '@layer': { 7 + theme: { 8 + accentColor: vars.color.intent.accent.surface.solid, 9 + color: vars.color.text.primary, 10 + fontFamily: vars.font.family, 11 + ...vars.font[300], 12 + }, 13 + }, 10 14 });