diff --git a/docs/STYLING.md b/docs/STYLING.md index 6c620d4a..8f3045d1 100644 --- a/docs/STYLING.md +++ b/docs/STYLING.md @@ -2,9 +2,10 @@ ## Setup -Luke UI ships static CSS. Consumers import `@luke-ui/react/stylesheet.css` and apply -`themeRootClassName` from `@luke-ui/react/theme` near the app root. Import one bundled theme -stylesheet and apply its identity class to the same element. Neither step injects styles at runtime. +Luke UI ships one static stylesheet for its reset, theme root, recipes, and utilities. Consumers +import `@luke-ui/react/stylesheet.css` and apply `themeRootClassName` from `@luke-ui/react/theme` +near the app root. Import one bundled theme stylesheet and apply its identity class to the same +element. Neither step injects styles at runtime. ## Structure @@ -80,6 +81,9 @@ specificity. Use `styleInLayer`, `recipeInLayer`, and `globalStyleInLayer` from `styles/layered-style.css.ts`. These helpers keep styles inside a named layer. +Text's Capsize trim declarations use logical properties for the pseudo-element margins and are +emitted through `recipeInLayer`, so they remain owned by `recipes` with the rest of the Text recipe. + 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. diff --git a/packages/@luke-ui/react/README.md b/packages/@luke-ui/react/README.md index dd332a4a..e840b790 100644 --- a/packages/@luke-ui/react/README.md +++ b/packages/@luke-ui/react/README.md @@ -13,6 +13,9 @@ pnpm add @luke-ui/react Import the component stylesheet and one bundled theme stylesheet. Apply the theme root and identity classes to the same element. +The shared stylesheet owns reset, theme-root, recipe, and utility rules in `reset`, `theme`, +`recipes`, `utilities` order. + ```tsx import '@luke-ui/react/stylesheet.css'; import '@luke-ui/react/themes/tactile.css'; diff --git a/packages/@luke-ui/react/package.json b/packages/@luke-ui/react/package.json index 08e1f636..1b36cb6b 100644 --- a/packages/@luke-ui/react/package.json +++ b/packages/@luke-ui/react/package.json @@ -110,6 +110,8 @@ "pixelmatch": "catalog:", "playwright": "catalog:", "pngjs": "catalog:", + "postcss": "catalog:", + "postcss-selector-parser": "catalog:", "publint": "catalog:", "react": "catalog:", "react-dom": "catalog:", diff --git a/packages/@luke-ui/react/src/recipes/index.ts b/packages/@luke-ui/react/src/recipes/index.ts index f9eba261..a9b29cb7 100644 --- a/packages/@luke-ui/react/src/recipes/index.ts +++ b/packages/@luke-ui/react/src/recipes/index.ts @@ -1,3 +1,8 @@ +// VE build-time side-effect: brings the stylesheet root module (reset, theme, +// primitives) into the Rollup graph so they are extracted into stylesheet.css. +// VE strips this import from the compiled JS output at build time. +import '../stylesheet.css.js'; + export type { ButtonVariants } from '../recipes/button.css.js'; export { button } from '../recipes/button.css.js'; export { field, fieldLabel, fieldMessage } from '../recipes/field.css.js'; diff --git a/packages/@luke-ui/react/src/recipes/text.browser.test.ts b/packages/@luke-ui/react/src/recipes/text.browser.test.ts index 4864bbe1..0df484ce 100644 --- a/packages/@luke-ui/react/src/recipes/text.browser.test.ts +++ b/packages/@luke-ui/react/src/recipes/text.browser.test.ts @@ -93,6 +93,17 @@ test('an explicit numeric variant survives font inheritance', () => { test('all size steps use the generated Capsize trims for every curated font', () => { const families = ['inter', 'apple-system', 'dm-sans'] as const; const sizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; + const expectedTypography = { + '100': { fontSize: '12px', lineHeight: '16px' }, + '200': { fontSize: '14px', lineHeight: '20px' }, + '300': { fontSize: '16px', lineHeight: '24px' }, + '400': { fontSize: '18px', lineHeight: '26px' }, + '500': { fontSize: '20px', lineHeight: '28px' }, + '600': { fontSize: '24px', lineHeight: '30px' }, + '700': { fontSize: '28px', lineHeight: '36px' }, + '800': { fontSize: '35px', lineHeight: '40px' }, + '900': { fontSize: '60px', lineHeight: '60px' }, + } as const; const representativeTrims = new Set(); for (const fontFamily of families) { @@ -117,6 +128,8 @@ test('all size steps use the generated Capsize trims for every curated font', () const computedFontFamily = normalizeFontFamily(style.fontFamily); expect(computedFontFamily).toContain(computedFamilyIdentity[fontFamily]); + expect(style.fontSize).toBe(expectedTypography[size].fontSize); + expect(style.lineHeight).toBe(expectedTypography[size].lineHeight); expect(before.content).toBe('""'); expect(after.content).toBe('""'); expect(Number.parseFloat(before.marginBottom)).toBeCloseTo( diff --git a/packages/@luke-ui/react/src/recipes/text.css.ts b/packages/@luke-ui/react/src/recipes/text.css.ts index d2ad4dc7..820b6c0d 100644 --- a/packages/@luke-ui/react/src/recipes/text.css.ts +++ b/packages/@luke-ui/react/src/recipes/text.css.ts @@ -1,4 +1,3 @@ -import { createTextStyle } from '@capsizecss/vanilla-extract'; import type { ComplexStyleRule } from '@vanilla-extract/css'; import type { RecipeVariants } from '@vanilla-extract/recipes'; import { styleInLayer, recipeInLayer } from '../styles/layered-style.css.js'; @@ -112,14 +111,40 @@ const sizeVariants = Object.fromEntries( const sizeStepCompoundVariants = sizes.map((size) => { const { baselineTrim, capHeightTrim, fontSize, lineHeight } = vars.font[size]; return { - style: createTextStyle( - { baselineTrim, capHeightTrim, fontSize, lineHeight }, - `text_typography_${size}`, - ), - variants: { shouldDisableTrim: false, size }, + style: createLayeredTextStyle({ baselineTrim, capHeightTrim, fontSize, lineHeight }), + variants: { shouldDisableTrim: false, size } as const, }; }); +function createLayeredTextStyle({ + baselineTrim, + capHeightTrim, + fontSize, + lineHeight, +}: { + baselineTrim: string; + capHeightTrim: string; + fontSize: string; + lineHeight: string; +}) { + return { + fontSize, + lineHeight, + selectors: { + '&::before': { + content: "''", + display: 'table', + marginBlockEnd: capHeightTrim, + }, + '&::after': { + content: "''", + display: 'table', + marginBlockStart: baselineTrim, + }, + }, + } satisfies ComplexStyleRule; +} + /** Vanilla-extract recipe for the `Text` primitive's styles. */ export const text = recipeInLayer('recipes', { base, diff --git a/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx b/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx new file mode 100644 index 00000000..d8ede47c --- /dev/null +++ b/packages/@luke-ui/react/src/styles/stylesheet-contract.browser.test.tsx @@ -0,0 +1,70 @@ +import '@luke-ui/react/stylesheet.css'; +import '@luke-ui/react/themes/tactile.css'; +import { Icon, IconSpritesheetProvider } from '@luke-ui/react/icon'; +import { themeRootClassName } from '@luke-ui/react/theme'; +import { tactileThemeClassName } from '@luke-ui/react/themes'; +import { act } from 'react'; +import type { Root } from 'react-dom/client'; +import { createRoot } from 'react-dom/client'; +import { afterEach, expect, test } from 'vite-plus/test'; + +const mounted: Array<{ container: HTMLElement; root: Root }> = []; +const iconCases = [ + { expectedSize: '24px', id: 'default-icon' }, + { expectedSize: '16px', id: 'xsmall-icon', size: 'xsmall' }, + { expectedSize: '20px', id: 'small-icon', size: 'small' }, + { expectedSize: '24px', id: 'medium-icon', size: 'medium' }, + { expectedSize: '32px', id: 'large-icon', size: 'large' }, +] as const; + +afterEach(() => { + for (const { container, root } of mounted) { + act(() => root.unmount()); + container.remove(); + } + mounted.length = 0; +}); + +test('applies the public reset, theme, and icon-size contracts', () => { + const { container } = mountFixture(); + const resetTarget = container.querySelector('#reset-target'); + if (!(resetTarget instanceof HTMLElement)) throw new Error('Expected reset target.'); + + const themeStyles = getComputedStyle(container); + expect(getComputedStyle(resetTarget).boxSizing).toBe('border-box'); + expect(themeStyles.color).toBe(themeStyles.getPropertyValue('--luke-color-text-primary')); + expect(themeStyles.fontSize).toBe(themeStyles.getPropertyValue('--luke-font-300-font-size')); + expect(themeStyles.lineHeight).toBe(themeStyles.getPropertyValue('--luke-font-300-line-height')); + + for (const iconCase of iconCases) { + const icon = container.querySelector(`#${iconCase.id}`); + if (!(icon instanceof SVGElement)) throw new Error('Expected icon.'); + + const styles = getComputedStyle(icon); + expect(styles.blockSize).toBe(iconCase.expectedSize); + expect(styles.inlineSize).toBe(iconCase.expectedSize); + } +}); + +function mountFixture() { + const container = document.body.appendChild(document.createElement('div')); + container.className = `${themeRootClassName} ${tactileThemeClassName}`; + const root = createRoot(container); + mounted.push({ container, root }); + + act(() => { + root.render( + +
+ + + + + +
+
, + ); + }); + + return { container }; +} diff --git a/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts b/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts new file mode 100644 index 00000000..b466786a --- /dev/null +++ b/packages/@luke-ui/react/src/styles/stylesheet-contract.test.ts @@ -0,0 +1,290 @@ +import { readFile } from 'node:fs/promises'; +import { parse } from 'postcss'; +import type { AtRule, Root, Rule } from 'postcss'; +import selectorParser from 'postcss-selector-parser'; +import { expect, test } from 'vite-plus/test'; + +const retainedLayerNames = ['reset', 'theme', 'recipes', 'utilities'] as const; +const retainedLayerNameSet = new Set(retainedLayerNames); +const textSizes = ['100', '200', '300', '400', '500', '600', '700', '800', '900'] as const; +type TextSize = (typeof textSizes)[number]; +type TextClassesBySize = Record>; + +test('builds the public stylesheet with the retained layer contract', async () => { + const stylesheet = await readFile(new URL('../../dist/stylesheet.css', import.meta.url), 'utf8'); + const recipes = await import('@luke-ui/react/recipes'); + const styles = await import('@luke-ui/react/styles'); + const recipeClasses = recipes.icon().split(' '); + const textClassesBySize = Object.fromEntries( + textSizes.map((size) => [size, recipes.text({ size }).split(' ')]), + ) as TextClassesBySize; + const utilityClasses = styles.createSprinkles({ display: 'grid' }).className?.split(' ') ?? []; + + expect(() => + assertStylesheetContract(stylesheet, { recipeClasses, textClassesBySize, utilityClasses }), + ).not.toThrow(); +}); + +test.each([ + ['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;'), + ], + ['anonymous layer statement', (css: string) => `${css}\n@layer;`], + ['anonymous layer block', (css: string) => `${css}\n@layer { .anonymous {} }`], + ['unknown layer', (css: string) => `${css}\n@layer components;`], + ['nested layer', (css: string) => `${css}\n@layer recipes { @layer utilities {} }`], + ['root qualified rule', (css: string) => `${css}\n.root-rule { color: red; }`], + ['lookalike layer at-rule', (css: string) => `${css}\n@layered {}`], + [ + 'representative recipe class moved to the wrong layer', + (css: string) => + css.replace( + '@layer recipes {\n .recipe-class { display: inline-flex; }\n}', + '@layer utilities {\n .recipe-class { display: inline-flex; }\n}', + ), + ], + [ + 'representative utility class moved to the wrong layer', + (css: string) => + css.replace( + '@layer utilities {\n .utility-class { display: grid; }\n}', + '@layer recipes {\n .utility-class { display: grid; }\n}', + ), + ], + [ + 'representative retained-layer content removed', + (css: string) => css.replace(' .recipe-class { display: inline-flex; }\n', ''), + ], + [ + 'class-like text in an attribute value', + (css: string) => + css.replace( + '.recipe-class { display: inline-flex; }', + '[data-class=".recipe-class"] { display: inline-flex; }', + ), + ], +])('rejects a stylesheet with a %s', (_name, mutate) => { + expect(() => + assertStylesheetContract(mutate(validStylesheetFixture), { + recipeClasses: ['recipe-class'], + utilityClasses: ['utility-class'], + }), + ).toThrow(/.+/); +}); + +test('recognises escaped class identifiers', () => { + expect(() => + assertStylesheetContract(validStylesheetFixture.replaceAll('recipe-class', 'recipe\\:class'), { + recipeClasses: ['recipe:class'], + utilityClasses: ['utility-class'], + }), + ).not.toThrow(); +}); + +function assertStylesheetContract( + stylesheet: string, + { + recipeClasses, + textClassesBySize, + utilityClasses, + }: { + recipeClasses: Array; + textClassesBySize?: TextClassesBySize; + utilityClasses: Array; + }, +): void { + const root = parse(stylesheet); + + expect(getInitialLayerOrder(root)).toEqual(retainedLayerNames); + assertLayerNames(root); + assertRootNodes(root); + assertStableSelectors(root); + assertSentinel(root, 'luke-ui-reset', 'reset', 'box-sizing', 'border-box'); + assertSentinel(root, 'luke-ui-theme', 'theme', 'color', 'var(--luke-color-text-primary)'); + assertSentinel(root, 'luke-ui-theme', 'theme', 'font-family', 'var(--luke-font-family)'); + + for (const className of recipeClasses) assertClassOwnership(root, className, 'recipes'); + for (const className of utilityClasses) assertClassOwnership(root, className, 'utilities'); + if (textClassesBySize) assertTextTrimOwnership(root, textClassesBySize); +} + +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.'); + } + + return node.params.trim(); + }); +} + +function assertLayerNames(root: Root): void { + root.walkAtRules('layer', (atRule) => { + if (atRule.parent?.type !== 'root') + throw atRule.error('Nested cascade layers are not allowed.'); + + const names = getLayerNames(atRule); + if (atRule.nodes && names.length !== 1) { + throw atRule.error('Layer blocks must have exactly one name.'); + } + + for (const name of names) { + if (!retainedLayerNameSet.has(name)) throw atRule.error(`Unexpected cascade layer: ${name}`); + } + }); +} + +function getLayerNames(atRule: AtRule): Array { + const params = atRule.params.trim(); + if (!params) throw atRule.error('Anonymous cascade layers are not allowed.'); + + return params.split(',').map((name) => name.trim()); +} + +function assertRootNodes(root: Root): void { + for (const node of root.nodes) { + if (node.type === 'comment') continue; + 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 === 'keyframes' && node.nodes) continue; + + throw node.error(`Unexpected root at-rule: @${node.name}`); + } +} + +function assertStableSelectors(root: Root): void { + const selectors = new Set(); + root.walkRules((rule) => { + for (const className of getClassNames(rule)) { + if (className.startsWith('luke-ui-')) selectors.add(`.${className}`); + } + }); + + expect(selectors).toEqual(new Set(['.luke-ui-reset', '.luke-ui-theme'])); +} + +function assertSentinel( + root: Root, + className: string, + layerName: string, + property: string, + value: string, +): void { + const rules = getRulesForClass(root, className); + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) expect(getOwningLayer(rule)).toBe(layerName); + expect( + rules.some((rule) => + rule.nodes.some( + (node) => node.type === 'decl' && node.prop === property && node.value === value, + ), + ), + ).toBe(true); +} + +function assertClassOwnership(root: Root, className: string, layerName: string): void { + const rules = getRulesForClass(root, className); + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) expect(getOwningLayer(rule)).toBe(layerName); + expect(rules.some((rule) => rule.nodes.some((node) => node.type === 'decl'))).toBe(true); +} + +function assertTextTrimOwnership(root: Root, textClassesBySize: TextClassesBySize): void { + for (const size of textSizes) { + const rules = textClassesBySize[size].flatMap((className) => getRulesForClass(root, className)); + assertPseudoDeclaration( + rules, + '::before', + 'margin-block-end', + `var(--luke-font-${size}-cap-height-trim)`, + ); + assertPseudoDeclaration( + rules, + '::after', + 'margin-block-start', + `var(--luke-font-${size}-baseline-trim)`, + ); + } +} + +function assertPseudoDeclaration( + rules: Array, + pseudo: string, + property: string, + value: string, +): void { + const matchingRules = rules.filter( + (rule) => + hasPseudo(rule, pseudo) && + rule.nodes.some( + (node) => node.type === 'decl' && node.prop === property && node.value === value, + ), + ); + expect(matchingRules.length).toBeGreaterThan(0); + for (const rule of matchingRules) expect(getOwningLayer(rule)).toBe('recipes'); +} + +function getRulesForClass(root: Root, className: string): Array { + const rules: Array = []; + root.walkRules((rule) => { + if (getClassNames(rule).has(className)) rules.push(rule); + }); + return rules; +} + +function getClassNames(rule: Rule): Set { + const classNames = new Set(); + selectorParser((selectors) => { + selectors.walkClasses((classNode) => { + classNames.add(classNode.value); + }); + }).processSync(rule.selector); + return classNames; +} + +function hasPseudo(rule: Rule, pseudo: string): boolean { + let hasMatchingPseudo = false; + selectorParser((selectors) => { + selectors.walkPseudos((pseudoNode) => { + if (pseudoNode.value === pseudo) hasMatchingPseudo = true; + }); + }).processSync(rule.selector); + return hasMatchingPseudo; +} + +function getOwningLayer(rule: Rule): string | undefined { + let parent = rule.parent; + while (parent && parent.type !== 'root') { + if (parent.type === 'atrule' && parent.name === 'layer') return parent.params.trim(); + parent = parent.parent; + } + return undefined; +} + +const validStylesheetFixture = `@layer reset; +@layer theme; +@layer recipes; +@layer utilities; +@layer reset { + .luke-ui-reset { box-sizing: border-box; } +} +@layer theme { + .luke-ui-theme { + color: var(--luke-color-text-primary); + font-family: var(--luke-font-family); + } +} +@layer recipes { + .recipe-class { display: inline-flex; } +} +@layer utilities { + .utility-class { display: grid; } +} +@keyframes generated-animation { + from { opacity: 0; } + to { opacity: 1; } +}`; diff --git a/packages/@luke-ui/react/src/theme/build-theme.test.ts b/packages/@luke-ui/react/src/theme/build-theme.test.ts index 05efc300..076f87e4 100644 --- a/packages/@luke-ui/react/src/theme/build-theme.test.ts +++ b/packages/@luke-ui/react/src/theme/build-theme.test.ts @@ -244,19 +244,59 @@ describe('buildTheme defaults', () => { expect(css).toContain('--luke-color-border-focus: oklch('); }); - it('emits Capsize trim values for each curated font family', () => { - const trimValues = (fontFamily: 'inter' | 'apple-system' | 'dm-sans') => { - const css = buildTheme({ ...minimalFoundation, typography: { fontFamily } }); + it('preserves every Capsize trim for each curated font family and size', () => { + const expectedTrims = { + 'apple-system': { + '100': { baselineTrim: '-0.2887em', capHeightTrim: '-0.34em' }, + '200': { baselineTrim: '-0.3364em', capHeightTrim: '-0.3876em' }, + '300': { baselineTrim: '-0.3721em', capHeightTrim: '-0.4233em' }, + '400': { baselineTrim: '-0.3443em', capHeightTrim: '-0.3956em' }, + '500': { baselineTrim: '-0.3221em', capHeightTrim: '-0.3733em' }, + '600': { baselineTrim: '-0.2471em', capHeightTrim: '-0.2983em' }, + '700': { baselineTrim: '-0.2649em', capHeightTrim: '-0.3162em' }, + '800': { baselineTrim: '-0.1935em', capHeightTrim: '-0.2448em' }, + '900': { baselineTrim: '-0.1221em', capHeightTrim: '-0.1733em' }, + }, + 'dm-sans': { + '100': { baselineTrim: '-0.3257em', capHeightTrim: '-0.3077em' }, + '200': { baselineTrim: '-0.3733em', capHeightTrim: '-0.3553em' }, + '300': { baselineTrim: '-0.409em', capHeightTrim: '-0.391em' }, + '400': { baselineTrim: '-0.3812em', capHeightTrim: '-0.3632em' }, + '500': { baselineTrim: '-0.359em', capHeightTrim: '-0.341em' }, + '600': { baselineTrim: '-0.284em', capHeightTrim: '-0.266em' }, + '700': { baselineTrim: '-0.3019em', capHeightTrim: '-0.2839em' }, + '800': { baselineTrim: '-0.2304em', capHeightTrim: '-0.2124em' }, + '900': { baselineTrim: '-0.159em', capHeightTrim: '-0.141em' }, + }, + inter: { + '100': { baselineTrim: '-0.3029em', capHeightTrim: '-0.3029em' }, + '200': { baselineTrim: '-0.3505em', capHeightTrim: '-0.3505em' }, + '300': { baselineTrim: '-0.3862em', capHeightTrim: '-0.3862em' }, + '400': { baselineTrim: '-0.3585em', capHeightTrim: '-0.3585em' }, + '500': { baselineTrim: '-0.3362em', capHeightTrim: '-0.3362em' }, + '600': { baselineTrim: '-0.2612em', capHeightTrim: '-0.2612em' }, + '700': { baselineTrim: '-0.2791em', capHeightTrim: '-0.2791em' }, + '800': { baselineTrim: '-0.2077em', capHeightTrim: '-0.2077em' }, + '900': { baselineTrim: '-0.1362em', capHeightTrim: '-0.1362em' }, + }, + } as const; + + for (const [fontFamily, trims] of Object.entries(expectedTrims)) { + const css = buildTheme({ + ...minimalFoundation, + typography: { fontFamily: fontFamily as keyof typeof expectedTrims }, + }); const identity = splitBlocks(css).identity; - return { - baseline: extractValue(identity, '--luke-font-300-baseline-trim'), - capHeight: extractValue(identity, '--luke-font-300-cap-height-trim'), - }; - }; - const values = [trimValues('inter'), trimValues('apple-system'), trimValues('dm-sans')]; - expect(new Set(values.map(({ baseline }) => baseline)).size).toBe(3); - expect(new Set(values.map(({ capHeight }) => capHeight)).size).toBe(3); + for (const [size, expected] of Object.entries(trims)) { + expect(extractValue(identity, `--luke-font-${size}-cap-height-trim`)).toBe( + expected.capHeightTrim, + ); + expect(extractValue(identity, `--luke-font-${size}-baseline-trim`)).toBe( + expected.baselineTrim, + ); + } + } }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ba698a9..648b7196 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,6 +153,12 @@ catalogs: pngjs: specifier: ^7.0.0 version: 7.0.0 + postcss: + specifier: ^8.5.16 + version: 8.5.16 + postcss-selector-parser: + specifier: ^7.1.4 + version: 7.1.4 publint: specifier: ^0.3.21 version: 0.3.21 @@ -468,6 +474,12 @@ importers: pngjs: specifier: 'catalog:' version: 7.0.0 + postcss: + specifier: 'catalog:' + version: 8.5.16 + postcss-selector-parser: + specifier: 'catalog:' + version: 7.1.4 publint: specifier: 'catalog:' version: 0.3.21 @@ -4775,6 +4787,11 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + cssfilter@0.0.10: resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} @@ -7025,6 +7042,10 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + postcss-values-parser@6.0.2: resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==} engines: {node: '>=10'} @@ -12529,6 +12550,8 @@ snapshots: css.escape@1.5.1: {} + cssesc@3.0.0: {} + cssfilter@0.0.10: {} csso@5.0.5: @@ -15315,6 +15338,11 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + postcss-values-parser@6.0.2(postcss@8.5.16): dependencies: color-name: 1.1.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9ce68abe..7f85fa62 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -94,6 +94,8 @@ catalog: node-html-parser: ^8.0.4 playwright: '*' pixelmatch: ^7.1.0 + postcss: ^8.5.16 + postcss-selector-parser: ^7.1.4 pngjs: ^7.0.0 publint: ^0.3.21 react: ^19.2.7