From f052200d074c6dcf96ea0596737f0c276da7ec4e Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Thu, 23 Apr 2026 12:22:33 +0100 Subject: [PATCH 1/3] fix: preserve CSS variable and gradient references on paste (#5716) Preserve var() references in pasted gradient shorthand (css-data/parse-css.ts) When expanding shorthand properties (e.g. background), unresolved var() tokens were lost. Now they're recovered by probing each longhand with a placeholder, mapping which longhands the var belongs to, and restoring it. Fix border-(--x) Tailwind v4 syntax not recognized (tailwind.ts) borde --- apps/builder/app/shared/html.test.tsx | 374 +- apps/builder/app/shared/html.ts | 67 +- .../app/shared/tailwind/tailwind.test.tsx | 307 +- apps/builder/app/shared/tailwind/tailwind.ts | 274 +- apps/builder/package.json | 9 +- packages/css-data/bin/mdn-data.ts | 25 +- packages/css-data/bin/property-filter.ts | 13 + .../bin/property-var-test-fixtures.ts | 808 + .../css-data/bin/shorthand-test-fixtures.ts | 252 - packages/css-data/package.json | 4 +- .../property-var-test-fixtures.ts | 19025 ++++++++++++++++ .../src/__generated__/shorthand-properties.ts | 4 + .../__generated__/shorthand-test-fixtures.ts | 619 - packages/css-data/src/parse-css.test.ts | 769 +- packages/css-data/src/parse-css.ts | 296 +- .../src/property-parsers/conic-gradient.ts | 33 +- .../property-parsers/gradient-utils.test.ts | 192 + .../src/property-parsers/gradient-utils.ts | 36 + .../src/property-parsers/linear-gradient.ts | 16 +- .../src/property-parsers/radial-gradient.ts | 23 +- packages/css-data/src/shorthands.ts | 168 +- pnpm-lock.yaml | 100 +- 22 files changed, 21617 insertions(+), 1797 deletions(-) create mode 100644 packages/css-data/bin/property-filter.ts create mode 100644 packages/css-data/bin/property-var-test-fixtures.ts delete mode 100644 packages/css-data/bin/shorthand-test-fixtures.ts create mode 100644 packages/css-data/src/__generated__/property-var-test-fixtures.ts delete mode 100644 packages/css-data/src/__generated__/shorthand-test-fixtures.ts create mode 100644 packages/css-data/src/property-parsers/gradient-utils.test.ts diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 7df8db619a63..66ba2e4518d9 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -1,5 +1,6 @@ import { expect, test, describe } from "vitest"; import { $, css, renderTemplate, token, ws } from "@webstudio-is/template"; +import type { WebstudioFragment } from "@webstudio-is/sdk"; import { generateFragmentFromHtml as _generateFragmentFromHtml } from "./html"; // Wrapper that strips skippedSelectors for tests that only compare fragment shape @@ -472,29 +473,42 @@ describe("style tag to tokens", () => { }); test("combine inline style attribute with class token", () => { - const cardToken = token( - "card", - css` - display: flex; - ` + const fragment = generateFragmentFromHtml(` + +
Hello
+ `); + const cardToken = fragment.styleSources.find( + (source) => source.type === "token" && source.name === "card" ); - expect( - generateFragmentFromHtml(` - -
Hello
- `) - ).toEqual( - renderTemplate( - - Hello - - ) + expect(cardToken).toBeDefined(); + expect(fragment.children).toEqual([{ type: "id", value: "0" }]); + expect(fragment.instances).toEqual([ + { + type: "instance", + id: "0", + component: "ws:element", + tag: "div", + children: [{ type: "text", value: "Hello" }], + }, + ]); + expect(fragment.styleSourceSelections).toEqual([ + { instanceId: "0", values: [cardToken!.id, "0:ws:style"] }, + ]); + expect(fragment.styles).toEqual( + expect.arrayContaining([ + { + styleSourceId: cardToken!.id, + breakpointId: "base", + property: "display", + value: { type: "keyword", value: "flex" }, + }, + { + styleSourceId: "0:ws:style", + breakpointId: "base", + property: "color", + value: { type: "keyword", value: "red" }, + }, + ]) ); }); @@ -810,30 +824,51 @@ describe("style tag to tokens", () => { }); test("element with both resolved and unresolved classes plus inline style", () => { - const cardToken = token( - "card", - css` - display: flex; - ` + const fragment = generateFragmentFromHtml(` + +
Hello
+ `); + const cardToken = fragment.styleSources.find( + (source) => source.type === "token" && source.name === "card" ); - expect( - generateFragmentFromHtml(` - -
Hello
- `) - ).toEqual( - renderTemplate( - - Hello - - ) + expect(cardToken).toBeDefined(); + expect(fragment.children).toEqual([{ type: "id", value: "0" }]); + expect(fragment.instances).toEqual([ + { + type: "instance", + id: "0", + component: "ws:element", + tag: "div", + children: [{ type: "text", value: "Hello" }], + }, + ]); + expect(fragment.props).toEqual([ + { + id: "0:class", + instanceId: "0", + name: "class", + type: "string", + value: "external", + }, + ]); + expect(fragment.styleSourceSelections).toEqual([ + { instanceId: "0", values: [cardToken!.id, "0:ws:style"] }, + ]); + expect(fragment.styles).toEqual( + expect.arrayContaining([ + { + styleSourceId: cardToken!.id, + breakpointId: "base", + property: "display", + value: { type: "keyword", value: "flex" }, + }, + { + styleSourceId: "0:ws:style", + breakpointId: "base", + property: "color", + value: { type: "keyword", value: "red" }, + }, + ]) ); }); @@ -2332,7 +2367,7 @@ describe("style tag to tokens", () => { ).toHaveLength(0); }); - test(":root selector stays as html embed", () => { + test(":root selector is extracted as local styles on the root instance, not as html embed", () => { const fragment = generateFragmentFromHtml(` + `); + // no html embed + expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( + false + ); + // style on :root instance + const rootSelection = fragment.styleSourceSelections.find( + (sel) => sel.instanceId === ":root" + ); + expect(rootSelection).toBeDefined(); + const rootStyleSourceId = rootSelection!.values[0]; + const rootStyle = fragment.styles.find( + (s) => s.styleSourceId === rootStyleSourceId && s.property === "color" + ); + expect(rootStyle).toBeDefined(); + expect(rootStyle!.value).toMatchObject({ type: "keyword", value: "red" }); + }); + + test("cross-selector gradient vars stay as var() references in pasted tokens", () => { + const fragment = generateFragmentFromHtml(` +
+ + +
test
+
+ `); + const tokenSource = fragment.styleSources.find( + (source) => source.type === "token" && source.name === "panel" + ); + expect(tokenSource).toBeDefined(); + const backgroundImage = fragment.styles.find( + (style) => + style.styleSourceId === tokenSource!.id && + style.property === "backgroundImage" + ); + expect(backgroundImage?.value).toMatchObject({ + type: "layers", + value: [ + { + type: "unparsed", + value: + "linear-gradient(38deg,var(--tone-1),var(--tone-2),var(--tone-3))", + }, + ], + }); + expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( + false ); - expect(htmlEmbed).toBeDefined(); }); test("universal selector * stays as html embed", () => { @@ -2986,4 +3097,167 @@ describe("style tag to tokens", () => { expect.objectContaining({ type: "var", value: "clr-red" }) ); }); + + describe("preserve border color var() references", () => { + const getTokenStyle = ( + fragment: WebstudioFragment, + tokenName: string, + property: string + ) => { + const tokenSource = fragment.styleSources.find( + (source) => source.type === "token" && source.name === tokenName + ); + expect(tokenSource).toBeDefined(); + return fragment.styles.find( + (style) => + style.styleSourceId === tokenSource!.id && style.property === property + ); + }; + + const getSelection = (fragment: WebstudioFragment, instanceId: string) => { + return fragment.styleSourceSelections.find( + (selection) => selection.instanceId === instanceId + ); + }; + + test("typed arbitrary color utility keeps var reference", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const borderColorStyle = getTokenStyle( + fragment, + "border-[color:var(--border-color)]", + "borderTopColor" + ); + expect(borderColorStyle?.value).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); + }); + + test("css variable shorthand utility keeps var reference", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const borderColorStyle = getTokenStyle( + fragment, + "border-(--border-color)", + "borderTopColor" + ); + expect(borderColorStyle?.value).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); + }); + + test("inline border-color style keeps var reference", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const divInstance = fragment.instances.find( + (instance) => instance.tag === "div" + ); + expect(divInstance).toBeDefined(); + expect(getSelection(fragment, divInstance!.id)?.values).toEqual([ + expect.any(String), + `${divInstance!.id}:ws:style`, + ]); + const localStyle = fragment.styles.find( + (style) => + style.styleSourceId === `${divInstance!.id}:ws:style` && + style.property === "borderTopColor" + ); + expect(localStyle?.value).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); + }); + + test("arbitrary property utility keeps var reference", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const borderColorStyle = getTokenStyle( + fragment, + "[border-color:var(--border-color)]", + "borderTopColor" + ); + expect(borderColorStyle?.value).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); + }); + + test("typed literal utility keeps literal color", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const borderColorStyle = getTokenStyle( + fragment, + "border-[color:#000]", + "borderTopColor" + ); + expect(borderColorStyle?.value).toEqual( + expect.objectContaining({ type: "color", colorSpace: "hex" }) + ); + }); + + test("border side utility with var color keeps var reference", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const borderColorStyle = getTokenStyle( + fragment, + "border-[color:var(--border-color)]", + "borderTopColor" + ); + expect(borderColorStyle?.value).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); + }); + + test("inline border shorthand keeps var color reference", () => { + const fragment = _generateFragmentFromHtml(` + +
+ `); + const divInstance = fragment.instances.find( + (instance) => instance.tag === "div" + ); + expect(divInstance).toBeDefined(); + expect(getSelection(fragment, divInstance!.id)?.values).toEqual([ + expect.any(String), + `${divInstance!.id}:ws:style`, + ]); + const localStyle = fragment.styles.find( + (style) => + style.styleSourceId === `${divInstance!.id}:ws:style` && + style.property === "borderTopColor" + ); + expect(localStyle?.value).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); + }); + }); }); diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index d6361bbd8173..56f179e7d17b 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -27,6 +27,7 @@ import { import { richTextContentTags } from "./content-model"; import { setIsSubsetOf } from "./shim"; import { isAttributeNameSafe } from "@webstudio-is/react-sdk"; +import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk"; import * as csstree from "css-tree"; import { titleCase } from "title-case"; @@ -194,13 +195,19 @@ const classifyRules = ( ): { classRules: Map; nestedClassRules: Map; + rootRules: ParsedStyleDecl[]; hasNonClassRules: boolean; } => { const classRules = new Map(); const nestedClassRules = new Map(); + const rootRules: ParsedStyleDecl[] = []; let hasNonClassRules = false; for (const decl of decls) { + if (decl.selector === ":root") { + rootRules.push(decl); + continue; + } const parsed = parseClassBasedSelector(decl.selector); if (parsed !== undefined) { const selectorState = parsed.states?.[0]; @@ -229,7 +236,7 @@ const classifyRules = ( hasNonClassRules = true; } } - return { classRules, nestedClassRules, hasNonClassRules }; + return { classRules, nestedClassRules, rootRules, hasNonClassRules }; }; /** @@ -245,12 +252,20 @@ const buildLeftoverCss = (cssText: string): string => { const parts: string[] = []; /** Re-use parseClassBasedSelector as single source of truth */ - const isClassBasedSelector = (selector: csstree.CssNode): boolean => - selector.type === "Selector" && - parseClassBasedSelector(csstree.generate(selector)) !== undefined; + const isClassBasedSelector = (selector: csstree.CssNode): boolean => { + if (selector.type !== "Selector") { + return false; + } + const text = csstree.generate(selector); + // :root rules are extracted separately — treat as non-leftover + if (text === ":root") { + return true; + } + return parseClassBasedSelector(text) !== undefined; + }; /** - * Process a Rule: if all selectors are class-based, skip entirely. + * Process a Rule: if all selectors are class-based or :root, skip entirely. * If none are, keep entirely. If mixed, keep only non-class selectors. */ const getLeftoverRule = (node: csstree.Rule): string | undefined => { @@ -537,7 +552,7 @@ export const generateFragmentFromHtml = ( // Parse all CSS and classify rules const { styles: allDecls } = parseCss(allCssText, allCssVars); - const { classRules, nestedClassRules } = classifyRules(allDecls); + const { classRules, nestedClassRules, rootRules } = classifyRules(allDecls); // Track which class names are used by elements — IDs will be assigned later const usedClassNames = new Set(); @@ -561,19 +576,32 @@ export const generateFragmentFromHtml = ( const { classRules: tagClassRules, nestedClassRules: tagNestedRules, + rootRules: tagRootRules, hasNonClassRules: tagHasNonClass, } = classifyRules(parsedDecls); if ( parsedDecls.length === 0 && tagClassRules.size === 0 && - tagNestedRules.size === 0 + tagNestedRules.size === 0 && + tagRootRules.length === 0 ) { // Unparseable CSS — keep original styleTagActions.push({ type: "keep-original" }); - } else if (tagClassRules.size === 0 && tagNestedRules.size === 0) { - // Only non-class rules — keep original + } else if ( + tagClassRules.size === 0 && + tagNestedRules.size === 0 && + tagRootRules.length === 0 + ) { + // Only non-class, non-root element rules — keep original HtmlEmbed styleTagActions.push({ type: "keep-original" }); + } else if ( + tagClassRules.size === 0 && + tagNestedRules.size === 0 && + !tagHasNonClass + ) { + // Only :root rules — extracted to ROOT_INSTANCE_ID, skip HtmlEmbed + styleTagActions.push({ type: "skip" }); } else if (!tagHasNonClass) { // Only class rules — also check for unsupported media like @media print const leftover = buildLeftoverCss(text); @@ -1030,6 +1058,25 @@ export const generateFragmentFromHtml = ( } } + // Inject :root styles as a local style source on ROOT_INSTANCE_ID + if (rootRules.length > 0) { + const rootStyleSourceId = getNewId(); + styleSources.push({ type: "local", id: rootStyleSourceId }); + styleSourceSelections.push({ + instanceId: ROOT_INSTANCE_ID, + values: [rootStyleSourceId], + }); + for (const decl of rootRules) { + styles.push({ + styleSourceId: rootStyleSourceId, + breakpointId: getBaseBreakpointId(), + property: camelCaseProperty(decl.property), + value: decl.value, + ...(decl.state ? { state: decl.state } : {}), + }); + } + } + // Create style source selections for instances that use tokens const selectionsByInstance = new Map( styleSourceSelections.map((sel) => [sel.instanceId, sel]) @@ -1041,7 +1088,7 @@ export const generateFragmentFromHtml = ( if (tokenIds.length > 0) { const existingSelection = selectionsByInstance.get(instanceId); if (existingSelection) { - existingSelection.values.push(...tokenIds); + existingSelection.values = [...tokenIds, ...existingSelection.values]; } else { const newSelection: StyleSourceSelection = { instanceId, diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx index 4e5b6aea856b..4bab3270a968 100644 --- a/apps/builder/app/shared/tailwind/tailwind.test.tsx +++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx @@ -2,6 +2,45 @@ import { describe, expect, test } from "vitest"; import { css, renderTemplate, ws } from "@webstudio-is/template"; import { generateFragmentFromTailwind } from "./tailwind"; +const getBaseStyleValue = ( + fragment: Awaited>, + property: string +) => { + return fragment.styles.find( + (style) => style.breakpointId === "base" && style.property === property + )?.value; +}; + +const hasStyleValue = ( + fragment: Awaited>, + property: string, + predicate: (value: unknown) => boolean, + options: { breakpointId?: string; state?: string } = {} +) => { + const breakpointId = options.breakpointId ?? "base"; + return fragment.styles.some( + (style) => + style.breakpointId === breakpointId && + style.property === property && + style.state === options.state && + predicate(style.value) + ); +}; + +const getStyleValue = ( + fragment: Awaited>, + property: string, + options: { breakpointId?: string; state?: string } = {} +) => { + const breakpointId = options.breakpointId ?? "base"; + return fragment.styles.find( + (style) => + style.breakpointId === breakpointId && + style.property === property && + style.state === options.state + )?.value; +}; + test("extract local styles from tailwind classes", async () => { expect( await generateFragmentFromTailwind( @@ -32,23 +71,25 @@ test("extract local styles from tailwind classes", async () => { }); test("ignore dark mode", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate( - - ) - ) - ).toEqual( + const fragment = await generateFragmentFromTailwind( renderTemplate( - + ) ); + + expect(fragment.breakpoints).toEqual([{ id: "base", label: "" }]); + expect(getStyleValue(fragment, "backgroundColor")).toEqual( + expect.objectContaining({ + type: "color", + colorSpace: "srgb", + components: [1, 1, 1], + }) + ); + expect( + hasStyleValue(fragment, "backgroundColor", (value) => value !== undefined, { + state: ":hover", + }) + ).toBe(false); }); test("ignore empty class", async () => { @@ -99,55 +140,122 @@ test("generate border", async () => { }); test("override border opacity", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate( - - ) + const fragment = await generateFragmentFromTailwind( + renderTemplate( + ) - ).toEqual( + ); + + expect(getBaseStyleValue(fragment, "borderTopStyle")).toEqual( + expect.objectContaining({ type: "keyword", value: "solid" }) + ); + expect(getBaseStyleValue(fragment, "borderTopWidth")).toEqual( + expect.objectContaining({ type: "unit", unit: "px", value: 1 }) + ); + expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual( + expect.objectContaining({ type: "color", alpha: 0.6 }) + ); +}); + +test("keep css variable shorthand border color", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + + ) + ); + + expect(fragment.props.some((prop) => prop.name === "class")).toBe(false); + expect(getBaseStyleValue(fragment, "borderTopStyle")).toEqual( + expect.objectContaining({ type: "keyword", value: "solid" }) + ); + expect(getBaseStyleValue(fragment, "borderTopWidth")).toEqual( + expect.objectContaining({ type: "unit", unit: "px", value: 1 }) + ); + expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); +}); + +test("keep inline border-color var override", async () => { + const fragment = await generateFragmentFromTailwind( renderTemplate( ) ); + + expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); }); -test("generate shadow", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate() +test("keep typed literal border color utility", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate( + ) - ).toEqual( + ); + + expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual( + expect.objectContaining({ type: "color" }) + ); +}); + +test("keep inline border shorthand var color override", async () => { + const fragment = await generateFragmentFromTailwind( renderTemplate( ) ); + + expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual( + expect.objectContaining({ type: "var", value: "border-color" }) + ); +}); + +test("generate shadow", async () => { + const fragment = await generateFragmentFromTailwind( + renderTemplate() + ); + + expect(getStyleValue(fragment, "--tw-shadow")).toEqual( + expect.objectContaining({ + type: "unparsed", + value: expect.stringContaining("0 1px 3px 0"), + }) + ); + expect(getStyleValue(fragment, "boxShadow")).toEqual( + expect.objectContaining({ + type: "layers", + value: expect.arrayContaining([ + expect.objectContaining({ + type: "var", + value: "tw-ring-offset-shadow", + }), + expect.objectContaining({ type: "var", value: "tw-ring-shadow" }), + ]), + }) + ); }); test("preserve or override existing local styles", async () => { @@ -279,30 +387,24 @@ test("preflight does not overwrite h1 inline styles", async () => { }); test("extract states from tailwind classes", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate( - - ) - ) - ).toEqual( + const fragment = await generateFragmentFromTailwind( renderTemplate( ) ); + + expect(getStyleValue(fragment, "backgroundColor")).toEqual( + expect.objectContaining({ type: "color", colorSpace: "oklch" }) + ); + expect( + getStyleValue(fragment, "backgroundColor", { state: ":hover" }) + ).toEqual(expect.objectContaining({ type: "color", colorSpace: "oklch" })); + expect(getStyleValue(fragment, "backgroundColor")).not.toEqual( + getStyleValue(fragment, "backgroundColor", { state: ":hover" }) + ); }); describe("extract breakpoints", () => { @@ -824,39 +926,62 @@ test("generate space without display property", async () => { }); test("generate space with display property", async () => { - expect( - await generateFragmentFromTailwind( - renderTemplate( - <> - - - - ) - ) - ).toEqual( + const fragment = await generateFragmentFromTailwind( renderTemplate( <> - - + + ) ); + + const firstStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "0") + ?.values.at(-1); + const secondStyleSourceId = fragment.styleSourceSelections + .find((selection) => selection.instanceId === "1") + ?.values.at(-1); + + expect( + fragment.styles.some( + (style) => + style.styleSourceId === firstStyleSourceId && + style.breakpointId === "base" && + style.property === "display" && + (style.value as { value?: string }).value === "flex" + ) + ).toBe(true); + expect( + fragment.styles.some( + (style) => + style.styleSourceId === firstStyleSourceId && + style.breakpointId === "base" && + style.property === "columnGap" + ) + ).toBe(true); + expect( + fragment.styles.some( + (style) => + style.styleSourceId === secondStyleSourceId && + style.breakpointId === "base" && + style.property === "display" && + (style.value as { value?: string }).value === "flex" + ) + ).toBe(true); + expect( + fragment.styles.some( + (style) => + style.styleSourceId === secondStyleSourceId && + style.property === "rowGap" + ) + ).toBe(true); + expect( + fragment.styles.some( + (style) => + style.styleSourceId === secondStyleSourceId && + style.property === "display" && + (style.value as { value?: string }).value === "none" && + style.breakpointId !== "base" + ) + ).toBe(true); }); diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts index c5e3b7caf951..a6af940a377f 100644 --- a/apps/builder/app/shared/tailwind/tailwind.ts +++ b/apps/builder/app/shared/tailwind/tailwind.ts @@ -1,6 +1,5 @@ import { createGenerator } from "@unocss/core"; -import { presetLegacyCompat } from "@unocss/preset-legacy-compat"; -import { presetWind3 } from "@unocss/preset-wind3"; +import { presetWind4 } from "@unocss/preset-wind4"; import { camelCaseProperty, extractCssCustomProperties, @@ -195,18 +194,171 @@ const adaptBreakpoints = ( const createUnoGenerator = async () => { return await createGenerator({ presets: [ - presetWind3({ + presetWind4({ // css variables are defined on the same element as styleDecl - preflight: "on-demand", + preflights: { theme: "on-demand", reset: false, property: false }, // dark mode will be ignored by parser dark: "media", }), - // until we support oklch natively - presetLegacyCompat({ legacyColorSpace: true }), ], }); }; +const percentToNumber = (value: string) => { + const trimmed = value.trim(); + if (trimmed.endsWith("%") === false) { + return trimmed; + } + const parsed = Number.parseFloat(trimmed.slice(0, -1)); + if (Number.isNaN(parsed)) { + return trimmed; + } + return (parsed / 100).toString(); +}; + +const appendAlphaToOklch = (oklch: string, alpha: string) => { + const match = oklch.match(/^oklch\((.*)\)$/i); + if (match === null) { + return oklch; + } + return `oklch(${match[1]} / ${alpha})`; +}; + +const hexToRgb = (hex: string) => { + const normalized = + hex.length === 4 + ? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}` + : hex; + const r = Number.parseInt(normalized.slice(1, 3), 16); + const g = Number.parseInt(normalized.slice(3, 5), 16); + const b = Number.parseInt(normalized.slice(5, 7), 16); + return `${r} ${g} ${b}`; +}; + +const normalizeWind4Css = (css: string, finalVars: Map) => { + // Wind4 emits rem media queries (e.g. 40rem). Convert to px so existing + // breakpoint mapping code can keep working unchanged. + let normalized = css.replace( + /(min|max)-width:\s*(-?\d*\.?\d+)rem/g, + (_match, range, rem) => { + const px = Number.parseFloat(rem) * 16; + return `${range}-width: ${px}px`; + } + ); + + normalized = normalized.replace( + /(min|max)-width:\s*calc\((-?\d*\.?\d+)rem\s*-\s*0\.1px\)/g, + (_match, range, rem) => { + const px = Number.parseFloat(rem) * 16 - 0.1; + return `${range}-width: ${px}px`; + } + ); + + // Inline tracked theme variables so parseCss can resolve computed values + // like calc(var(--spacing) * 2) and var(--text-sm-fontSize). + for (const [name, value] of finalVars.entries()) { + normalized = normalized + .replaceAll(`var(${name})`, value) + .replaceAll(`var(${name},`, `var(${value},`); + } + + // Wind4 uses a leading utility var fallback for typography. + normalized = normalized.replace( + /var\(--tw-leading,\s*([^\)]+)\)/g, + (_match, fallback) => fallback.trim() + ); + + // Resolve wind4's color-mix based opacity pipeline into concrete colors that + // parseCss can read as typed color values. + normalized = normalized.replace( + /color-mix\(in\s+(?:srgb|oklab),\s*var\((--colors-[\w-]+)\)\s+([^,]+),\s*transparent\)/g, + (_match, colorVar: string, opacityExpr: string) => { + const color = finalVars.get(colorVar); + if (color === undefined) { + return _match; + } + + const trimmedOpacityExpr = opacityExpr.trim(); + let alpha = trimmedOpacityExpr; + const varMatch = trimmedOpacityExpr.match(/^var\((--[\w-]+)\)$/); + if (varMatch) { + alpha = finalVars.get(varMatch[1]) ?? "1"; + } + alpha = percentToNumber(alpha); + + if (alpha === "1") { + return color; + } + if (color.startsWith("oklch(")) { + return appendAlphaToOklch(color, alpha); + } + if (color.startsWith("#")) { + return `rgb(from ${color} r g b / ${alpha})`; + } + return _match; + } + ); + + // After variable inlining, remaining color-mix declarations may already have + // concrete colors as the first argument (e.g. #fff or oklch(...)). + normalized = normalized.replace( + /color-mix\(in\s+(?:srgb|oklab),\s*(oklch\([^\)]+\)|#[0-9a-fA-F]{3,8})\s+([^,]+),\s*transparent\)/g, + (_match, color: string, opacityExpr: string) => { + const alpha = percentToNumber(opacityExpr.trim()); + if (alpha === "1") { + return color; + } + if (color.startsWith("oklch(")) { + return appendAlphaToOklch(color, alpha); + } + if (color.startsWith("#")) { + return `rgb(from ${color} r g b / ${alpha})`; + } + return _match; + } + ); + + normalized = normalized.replace( + /rgb\(from\s+(#[0-9a-fA-F]{3,8})\s+r\s+g\s+b\s*\/\s*([^\)]+)\)/g, + (_match, hex: string, alpha: string) => { + const rgb = hexToRgb(hex); + return `rgb(${rgb} / ${alpha.trim()})`; + } + ); + + return normalized; +}; + +const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => { + if ( + styleDecl.property.startsWith("border-") === false || + styleDecl.property.endsWith("-color") === false + ) { + return false; + } + + const value = styleDecl.value as { + type?: string; + alpha?: number; + components?: number[]; + }; + if ( + value?.type !== "color" || + value.alpha !== 1 || + Array.isArray(value.components) === false || + value.components.length < 3 + ) { + return false; + } + + const [r, g, b] = value.components; + return ( + Math.abs(r - 229 / 255) < 0.001 && + Math.abs(g - 231 / 255) < 0.001 && + Math.abs(b - 235 / 255) < 0.001 + ); +}; + const parseTailwindClasses = async ( classes: string, userBreakpoints: Breakpoint[], @@ -222,6 +374,13 @@ const parseTailwindClasses = async ( classes = classes .split(" ") .map((item) => { + // Tailwind v4 css variable shorthand: border-(--x) + // UnoCSS doesn't parse this alias directly, so normalize it to + // an explicit arbitrary property utility it understands. + if (/^border-\(--[\w-]+\)$/.test(item)) { + const varName = item.slice("border-(".length, -1); + return `[border-color:var(${varName})]`; + } // styles data cannot express space-x and space-y selectors // with lobotomized owl so replace with gaps if (item.includes("space-x-")) { @@ -246,9 +405,9 @@ const parseTailwindClasses = async ( // two-pass pre-collection to see the correct final value regardless of which // rule a shorthand (e.g. border-color) lives in. const finalVars = extractCssCustomProperties(css); - let normalizedCss = css; + let normalizedCss = normalizeWind4Css(css, finalVars); if (finalVars.size > 0) { - normalizedCss = css.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => { + normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => { const colonIdx = match.indexOf(":"); const propName = match.slice(0, colonIdx).trim(); const finalValue = finalVars.get(propName); @@ -268,9 +427,79 @@ const parseTailwindClasses = async ( parsedStyles.push(...parseCss(reset, new Map()).styles); } parsedStyles.push(...parseCss(normalizedCss, finalVars).styles); + parsedStyles = parsedStyles.map((styleDecl) => { + const value = styleDecl.value as { + type?: string; + unit?: string; + value?: number | string; + fallback?: { type?: string; value?: string }; + }; + + const isOpacityProperty = + styleDecl.property === "opacity" || + styleDecl.property.endsWith("opacity"); + if ( + isOpacityProperty && + value.type === "unit" && + value.unit === "%" && + typeof value.value === "number" + ) { + return { + ...styleDecl, + value: { + type: "unit" as const, + unit: "number" as const, + value: value.value / 100, + }, + }; + } + + if (value.type === "unparsed" && typeof value.value === "string") { + const calcMatch = value.value.match( + /^calc\((-?\d*\.?\d+)rem\*(-?\d*\.?\d+)\)$/ + ); + if (calcMatch) { + return { + ...styleDecl, + value: { + type: "unit", + unit: "rem", + value: + Number.parseFloat(calcMatch[1]) * Number.parseFloat(calcMatch[2]), + }, + }; + } + } + + if ( + value.type === "var" && + value.value === "tw-leading" && + value.fallback?.type === "unparsed" && + typeof value.fallback.value === "string" + ) { + const fallbackMatch = value.fallback.value.match(/^(-?\d*\.?\d+)rem$/); + if (fallbackMatch) { + return { + ...styleDecl, + value: { + type: "unit", + unit: "rem", + value: Number.parseFloat(fallbackMatch[1]), + }, + }; + } + } + + return styleDecl; + }); // skip preflights with ::before, ::after and ::backdrop parsedStyles = parsedStyles.filter( - (styleDecl) => !styleDecl.state?.startsWith("::") + (styleDecl) => + !styleDecl.state?.startsWith("::") && + // Wind4 emits design-token variables in the theme layer. We inline them + // into declarations above, so they don't need to be persisted as styles. + (styleDecl.property.startsWith("--") === false || + styleDecl.property.startsWith("--tw-")) ); // setup base breakpoint for container class to avoid hole in ranges if (hasContainer) { @@ -454,7 +683,32 @@ export const generateFragmentFromTailwind = async ( fragment.breakpoints ); if (parsedStyles.length > 0) { - createOrMergeLocalStyles(prop.instanceId, parsedStyles); + const localStyleSource = getLocalStyleSource(prop.instanceId); + const filteredStyles = parsedStyles.filter((parsedStyleDecl) => { + if ( + localStyleSource === undefined || + isTailwindDefaultBorderColorStyle(parsedStyleDecl) === false + ) { + return true; + } + + const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint); + if (breakpointId === undefined) { + return true; + } + + const existingStyleKey = getStyleDeclKey({ + breakpointId, + styleSourceId: localStyleSource.id, + state: parsedStyleDecl.state, + property: camelCaseProperty(parsedStyleDecl.property), + }); + + // Keep authored inline border colors if present. + return styles.has(existingStyleKey) === false; + }); + + createOrMergeLocalStyles(prop.instanceId, filteredStyles); if (newClasses.length > 0) { props.push({ ...prop, value: newClasses }); } diff --git a/apps/builder/package.json b/apps/builder/package.json index 8ecd9b352c5e..7f4ea59c1ccf 100644 --- a/apps/builder/package.json +++ b/apps/builder/package.json @@ -55,9 +55,7 @@ "@trpc/client": "^10.45.2", "@trpc/server": "^10.45.2", "@tsndr/cloudflare-worker-jwt": "^2.5.3", - "@unocss/core": "66.1.2", - "@unocss/preset-legacy-compat": "66.1.2", - "@unocss/preset-wind3": "66.1.2", + "@unocss/core": "66.6.8", "@vercel/remix": "2.15.3", "@webstudio-is/asset-uploader": "workspace:*", "@webstudio-is/authorization-token": "workspace:*", @@ -72,6 +70,7 @@ "@webstudio-is/http-client": "workspace:*", "@webstudio-is/icons": "workspace:*", "@webstudio-is/image": "workspace:*", + "@webstudio-is/plans": "workspace:*", "@webstudio-is/postgrest": "workspace:*", "@webstudio-is/project": "workspace:*", "@webstudio-is/project-build": "workspace:*", @@ -82,7 +81,6 @@ "@webstudio-is/sdk-components-react-radix": "workspace:*", "@webstudio-is/template": "workspace:*", "@webstudio-is/trpc-interface": "workspace:*", - "@webstudio-is/plans": "workspace:*", "args-tokenizer": "^0.3.0", "bcp-47": "^2.1.0", "change-case": "^5.4.4", @@ -134,12 +132,13 @@ "@types/papaparse": "^5.5.2", "@types/react": "^18.2.70", "@types/react-dom": "^18.2.25", + "@unocss/preset-wind4": "^66.6.8", "@vitest/coverage-v8": "3.1.2", "@webstudio-is/tsconfig": "workspace:*", - "msw": "^2.13.2", "css.escape": "^1.5.1", "fast-glob": "^3.3.2", "html-tags": "^4.0.0", + "msw": "^2.13.2", "react-router-dom": "^6.30.0", "react-test-renderer": "18.3.0-canary-14898b6a9-20240318", "type-fest": "^4.37.0", diff --git a/packages/css-data/bin/mdn-data.ts b/packages/css-data/bin/mdn-data.ts index e35a005a729b..460238c86139 100755 --- a/packages/css-data/bin/mdn-data.ts +++ b/packages/css-data/bin/mdn-data.ts @@ -18,6 +18,7 @@ import type { } from "@webstudio-is/css-engine"; import * as customData from "../src/custom-data"; import { camelCaseProperty } from "../src/parse-css"; +import { supportedExperimentalPropertySet } from "./property-filter"; const units: Record> = { number: [], @@ -234,11 +235,15 @@ const writeToFile = (fileName: string, constant: string, data: unknown) => { writeFileSync(join(targetDir, fileName), content, "utf8"); }; -const supportedExperimentalProperties = [ - "field-sizing", - "text-size-adjust", - "-webkit-tap-highlight-color", - "-webkit-overflow-scrolling", +// Most shorthands are identifiable in MDN data because their `initial` value +// is an array of subproperties. These additional shorthands still expand into +// multiple longhands, but MDN models them with a scalar initial value like +// `normal`, so we need to include them explicitly. +const additionalShorthandProperties = [ + "font-synthesis", + "font-variant", + "text-wrap", + "white-space", ]; // Properties we don't support in this form. @@ -249,11 +254,7 @@ const unsupportedProperties = [ "-webkit-text-stroke-width", // shorthand properties "all", - "font-synthesis", - "font-variant", "overflow", - "white-space", - "text-wrap", "background-position", "border-block-style", "border-block-width", @@ -283,7 +284,7 @@ const filterData = () => { config.status === "standard" && "mdn_url" in config; const isSupportedExperimentalProperty = - supportedExperimentalProperties.includes(property); + supportedExperimentalPropertySet.has(property); if ( isStandardProperty === false && @@ -297,7 +298,9 @@ const filterData = () => { config.animationType !== "discrete" && config.animationType !== "notAnimatable"; - const isShorthandProperty = Array.isArray(config.initial); + const isShorthandProperty = + Array.isArray(config.initial) || + additionalShorthandProperties.includes(property); if (isShorthandProperty) { allShorthands[property] = config; diff --git a/packages/css-data/bin/property-filter.ts b/packages/css-data/bin/property-filter.ts new file mode 100644 index 000000000000..f1fec0c57e18 --- /dev/null +++ b/packages/css-data/bin/property-filter.ts @@ -0,0 +1,13 @@ +// Only a small subset of non-standard MDN properties are intentionally +// included in generated CSS data. Keeping this explicit avoids silently +// widening support whenever MDN adds or reshapes experimental entries. +export const supportedExperimentalProperties = [ + "field-sizing", + "text-size-adjust", + "-webkit-tap-highlight-color", + "-webkit-overflow-scrolling", +] as const; + +export const supportedExperimentalPropertySet = new Set( + supportedExperimentalProperties +); diff --git a/packages/css-data/bin/property-var-test-fixtures.ts b/packages/css-data/bin/property-var-test-fixtures.ts new file mode 100644 index 000000000000..c44e62814b11 --- /dev/null +++ b/packages/css-data/bin/property-var-test-fixtures.ts @@ -0,0 +1,808 @@ +/** + * Generates src/__generated__/property-var-test-fixtures.ts + * + * The fixture is sourced directly from MDN property grammar so both longhands + * and shorthands share one generated matrix. For each property we synthesize + * canonical value patterns, replace every known slot position with `var()`, + * and also emit an all-slots case where every slot becomes its own variable. + * + * Run with: pnpm build:property-var-fixtures + */ + +import { mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import properties from "mdn-data/css/properties.json"; +import syntaxes from "mdn-data/css/syntaxes.json"; +import { definitionSyntax, generate, parse } from "css-tree"; +import { supportedExperimentalPropertySet } from "./property-filter"; + +type DSNode = + | { type: "Keyword"; name: string } + | { type: "String" } + | { type: "AtKeyword"; name: string } + | { type: "Token"; value: string } + | { type: "Comma" } + | { type: "Function"; name: string } + | { type: "Type"; name: string } + | { type: "Property"; name: string } + | { + type: "Multiplier"; + term: DSNode; + max: number; + min: number; + comma: boolean; + } + | { type: "Group"; combinator: string; terms: DSNode[] }; + +const __dir = dirname(fileURLToPath(import.meta.url)); +const targetDir = join(__dir, "../src/__generated__"); + +const autogeneratedHint = + "// This file was generated by pnpm build:property-var-fixtures\n"; + +const unsupportedProperties = ["--*", "all"]; + +const typeSamples: Record = { + angle: "1deg", + attachment: "scroll", + "basic-shape": "circle()", + color: "red", + "color-stop-list": "red, blue", + "custom-ident": "custom-ident", + "dashed-ident": "--custom-ident", + "declaration-value": "1px", + decibel: "1db", + "family-name": "serif", + flex: "1fr", + frequency: "1hz", + "generic-family": "serif", + gradient: "linear-gradient(red, blue)", + ident: "custom-ident", + image: "url(hero.png)", + integer: "1", + length: "1px", + "length-percentage": "1px", + "line-style": "solid", + "line-width": "1px", + number: "1", + percentage: "10%", + position: "center", + "ratio-token": "1/1", + "repeat-style": "no-repeat", + resolution: "1dpi", + semitones: "1st", + shadow: "0 0 1px red", + string: '"x"', + time: "1s", + transform: "translateX(1px)", + "transform-function": "translateX(1px)", + "transform-list": "translateX(1px)", + url: "url(hero.png)", + x: "1", + y: "1", +}; + +const propertyOverrides: Record = {}; + +const propertyPatternOverrides: Record = { + font: [ + { + complexity: 1, + parts: [ + { kind: "slot", label: "font-style", text: "italic" }, + { kind: "text", text: " " }, + { kind: "slot", label: "font-weight", text: "700" }, + { kind: "text", text: " " }, + { kind: "slot", label: "font-size", text: "16px" }, + { kind: "text", text: "/" }, + { kind: "slot", label: "line-height", text: "1.5" }, + { kind: "text", text: " " }, + { kind: "slot", label: "font-family", text: "serif" }, + ], + }, + ], + "grid-template": [ + { + complexity: 1, + parts: [ + { kind: "slot", label: "grid-template-rows", text: "1px" }, + { kind: "text", text: " / " }, + { kind: "slot", label: "grid-template-columns", text: "1fr" }, + ], + }, + { + complexity: 2, + parts: [ + { kind: "slot", label: "grid-template-areas", text: '"a"' }, + { kind: "text", text: " " }, + { kind: "slot", label: "grid-template-row-size", text: "1px" }, + { kind: "text", text: " / " }, + { kind: "slot", label: "grid-template-explicit-columns", text: "1fr" }, + ], + }, + ], + "list-style": [ + { + complexity: 1, + parts: [ + { kind: "slot", label: "list-style-type", text: "disc" }, + { kind: "text", text: " " }, + { kind: "slot", label: "list-style-position", text: "inside" }, + { kind: "text", text: " " }, + { kind: "slot", label: "list-style-image", text: "url(hero.png)" }, + ], + }, + ], +}; + +interface PropertyConfig { + inherited: boolean; + initial: string | string[]; + mdn_url?: string; + status?: string; + syntax: string; +} + +interface SyntaxConfig { + syntax: string; +} + +interface GrammarPart { + kind: "slot" | "text"; + label?: string; + text: string; +} + +interface GrammarPattern { + complexity: number; + parts: GrammarPart[]; +} + +interface PropertyVarCase { + id: string; + positions: string[]; + value: string; + variables: Record; +} + +interface PropertyVarFixture { + cases: PropertyVarCase[]; + kind: "longhand" | "shorthand"; + property: string; + syntax: string; + unsupportedReason?: string; +} + +const propertyData = properties as Record; +const syntaxData = syntaxes as Record; + +const getFilteredProperties = () => { + const filtered: Array<{ + kind: "longhand" | "shorthand"; + property: string; + syntax: string; + }> = []; + + for (const [property, config] of Object.entries(propertyData)) { + if (unsupportedProperties.includes(property)) { + continue; + } + + const isStandardProperty = + config.status === "standard" && "mdn_url" in config; + const isSupportedExperimentalProperty = + supportedExperimentalPropertySet.has(property); + + if ( + isStandardProperty === false && + isSupportedExperimentalProperty === false + ) { + continue; + } + + filtered.push({ + kind: Array.isArray(config.initial) ? "shorthand" : "longhand", + property, + syntax: config.syntax, + }); + } + + return filtered.sort((left, right) => + left.property.localeCompare(right.property) + ); +}; + +const normalizeCssValue = (value: string) => { + try { + return generate(parse(value, { context: "value" })); + } catch { + return; + } +}; + +const mergeTextParts = (parts: GrammarPart[]) => { + const merged: GrammarPart[] = []; + + for (const part of parts) { + const previous = merged.at(-1); + if (part.kind === "text" && previous?.kind === "text") { + previous.text += part.text; + continue; + } + merged.push({ ...part }); + } + + return merged; +}; + +const serializeParts = (parts: GrammarPart[]) => { + return parts.map((part) => part.text).join(""); +}; + +const trimPattern = (pattern: GrammarPattern): GrammarPattern => { + const parts = mergeTextParts(pattern.parts); + const first = parts[0]; + const last = parts.at(-1); + + if (first?.kind === "text") { + first.text = first.text.trimStart(); + } + if (last?.kind === "text") { + last.text = last.text.trimEnd(); + } + + return { + complexity: pattern.complexity, + parts, + }; +}; + +const leadingText = (pattern: GrammarPattern) => { + return pattern.parts[0]?.text ?? ""; +}; + +const trailingText = (pattern: GrammarPattern) => { + return pattern.parts.at(-1)?.text ?? ""; +}; + +const shouldInsertSpace = ({ + left, + right, +}: { + left: GrammarPattern; + right: GrammarPattern; +}) => { + if (left.parts.length === 0 || right.parts.length === 0) { + return false; + } + + const leftText = trailingText(left); + const rightText = leadingText(right); + + if (leftText.endsWith("(") || leftText.endsWith("/")) { + return false; + } + if (rightText.startsWith(")") || rightText.startsWith(",")) { + return false; + } + if (rightText.startsWith("/")) { + return true; + } + + return true; +}; + +const combinePatterns = ({ + left, + right, +}: { + left: GrammarPattern; + right: GrammarPattern; +}): GrammarPattern => { + const parts = [...left.parts]; + + if (shouldInsertSpace({ left, right })) { + parts.push({ kind: "text", text: " " }); + } + + parts.push(...right.parts); + + return trimPattern({ + complexity: left.complexity + right.complexity + 1, + parts, + }); +}; + +const takeBestPatterns = ({ + limit, + patterns, +}: { + limit: number; + patterns: GrammarPattern[]; +}) => { + const deduped = new Map(); + + for (const pattern of patterns) { + const normalized = normalizeCssValue(serializeParts(pattern.parts)); + if (normalized === undefined) { + continue; + } + + const existing = deduped.get(normalized); + if (existing !== undefined && existing.complexity <= pattern.complexity) { + continue; + } + + deduped.set(normalized, trimPattern(pattern)); + } + + return [...deduped.values()] + .sort((left, right) => left.complexity - right.complexity) + .slice(0, limit); +}; + +const literalPattern = ({ + label, + text, +}: { + label?: string; + text: string; +}): GrammarPattern => { + return { + complexity: 1, + parts: [ + label === undefined + ? { kind: "text", text } + : { kind: "slot", label, text }, + ], + }; +}; + +const repeatCount = ({ max, min }: { max: number; min: number }) => { + if (max === 0) { + if (min > 1) { + return min; + } + return 2; + } + if (min === max) { + return max; + } + return Math.max(min, max); +}; + +const sampleForType = (name: string) => { + const direct = typeSamples[name]; + if (direct !== undefined) { + return direct; + } + + if (name.endsWith("()")) { + const functionName = name.slice(0, -2); + const functionSamples: Record = { + "anchor-size": "anchor-size(width, 1px)", + circle: "circle()", + "cross-fade": "cross-fade(url(hero.png), url(hero.png))", + element: "element(#hero)", + image: "image(url(hero.png))", + "image-set": "image-set(url(hero.png) 1x)", + "linear-gradient": "linear-gradient(red, blue)", + matrix: "matrix(1, 0, 0, 1, 0, 0)", + matrix3d: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)", + paint: "paint(worklet)", + path: 'path("M0 0")', + perspective: "perspective(1px)", + rotate: "rotate(1deg)", + rotate3d: "rotate3d(1, 0, 0, 1deg)", + rotateX: "rotateX(1deg)", + rotateY: "rotateY(1deg)", + rotateZ: "rotateZ(1deg)", + scale: "scale(1)", + scale3d: "scale3d(1, 1, 1)", + scaleX: "scaleX(1)", + scaleY: "scaleY(1)", + scaleZ: "scaleZ(1)", + skew: "skew(1deg)", + skewX: "skewX(1deg)", + skewY: "skewY(1deg)", + translate: "translate(1px)", + translate3d: "translate3d(1px, 1px, 1px)", + translateX: "translateX(1px)", + translateY: "translateY(1px)", + translateZ: "translateZ(1px)", + url: "url(hero.png)", + }; + + const sample = functionSamples[functionName]; + if (sample !== undefined) { + return sample; + } + } + + if (name.includes("color")) { + return "red"; + } + if (name.includes("length")) { + return "1px"; + } + if (name.includes("percentage")) { + return "10%"; + } + if (name.includes("time")) { + return "1s"; + } + if (name.includes("angle")) { + return "1deg"; + } + if (name.includes("number") || name.includes("integer")) { + return "1"; + } + if (name.includes("family")) { + return "serif"; + } + if (name.includes("position")) { + return "center"; + } + if (name.includes("image") || name.includes("url")) { + return "url(hero.png)"; + } + if (name.includes("style")) { + return "solid"; + } + if (name.includes("width")) { + return "1px"; + } + if (name.includes("ident")) { + return "custom-ident"; + } + + return; +}; + +const buildPatterns = ({ + depth, + key, + node, + seen, +}: { + depth: number; + key: string; + node: DSNode; + seen: Set; +}): GrammarPattern[] => { + if (node.type === "Keyword") { + return [ + literalPattern({ label: `${key}:keyword:${node.name}`, text: node.name }), + ]; + } + + if (node.type === "String") { + return [literalPattern({ label: `${key}:string`, text: '"x"' })]; + } + + if (node.type === "AtKeyword") { + return [ + literalPattern({ label: `${key}:at-keyword`, text: `@${node.name}` }), + ]; + } + + if (node.type === "Token") { + return [literalPattern({ text: node.value })]; + } + + if (node.type === "Comma") { + return [literalPattern({ text: "," })]; + } + + if (node.type === "Function") { + return [literalPattern({ text: `${node.name}(` })]; + } + + if (node.type === "Type") { + const typeKey = `type:${node.name}`; + if (seen.has(typeKey)) { + const sample = sampleForType(node.name); + if (sample === undefined) { + return []; + } + return [ + literalPattern({ label: `${key}:type:${node.name}`, text: sample }), + ]; + } + + const nestedSyntax = syntaxData[node.name]?.syntax; + if (nestedSyntax !== undefined) { + const nextSeen = new Set(seen); + nextSeen.add(typeKey); + return buildPatterns({ + depth: depth + 1, + key: `${key}:type:${node.name}`, + node: definitionSyntax.parse(nestedSyntax) as DSNode, + seen: nextSeen, + }); + } + + const sample = sampleForType(node.name); + if (sample === undefined) { + return []; + } + return [ + literalPattern({ label: `${key}:type:${node.name}`, text: sample }), + ]; + } + + if (node.type === "Property") { + const propertySyntax = propertyData[node.name]?.syntax; + if (propertySyntax === undefined) { + return []; + } + + const propertyKey = `property:${node.name}`; + if (seen.has(propertyKey)) { + const override = propertyOverrides[node.name]?.[0]; + if (override === undefined) { + return []; + } + return [ + literalPattern({ + label: `${key}:property:${node.name}`, + text: override, + }), + ]; + } + + const nextSeen = new Set(seen); + nextSeen.add(propertyKey); + return buildPatterns({ + depth: depth + 1, + key: `${key}:property:${node.name}`, + node: definitionSyntax.parse(propertySyntax) as DSNode, + seen: nextSeen, + }); + } + + if (node.type === "Multiplier") { + const basePatterns = buildPatterns({ + depth: depth + 1, + key: `${key}:repeat`, + node: node.term, + seen, + }); + if (basePatterns.length === 0) { + return []; + } + + const patterns: GrammarPattern[] = []; + const count = repeatCount({ max: node.max, min: node.min }); + + for (const pattern of basePatterns) { + let combined = pattern; + for (let index = 1; index < count; index += 1) { + const separator = node.comma + ? literalPattern({ text: "," }) + : literalPattern({ text: "" }); + combined = combinePatterns({ left: combined, right: separator }); + combined = combinePatterns({ left: combined, right: pattern }); + } + patterns.push(combined); + } + + return takeBestPatterns({ limit: 4, patterns }); + } + + if (node.type !== "Group") { + return []; + } + + if (node.combinator === "|") { + const branchPatterns = node.terms.flatMap((term: DSNode, index: number) => { + return buildPatterns({ + depth: depth + 1, + key: `${key}:branch:${index}`, + node: term, + seen, + }); + }); + return takeBestPatterns({ + limit: depth < 2 ? 4 : 1, + patterns: branchPatterns, + }); + } + + let current: GrammarPattern[] = [{ complexity: 0, parts: [] }]; + + for (const [index, term] of node.terms.entries()) { + const termPatterns = buildPatterns({ + depth: depth + 1, + key: `${key}:term:${index}`, + node: term, + seen, + }); + if (termPatterns.length === 0) { + return []; + } + + const combined: GrammarPattern[] = []; + for (const left of current) { + for (const right of termPatterns) { + if (left.parts.length === 0) { + combined.push(trimPattern(right)); + continue; + } + combined.push(combinePatterns({ left, right })); + } + } + current = takeBestPatterns({ limit: 6, patterns: combined }); + } + + return current; +}; + +const buildCases = ({ + patterns, + property, +}: { + patterns: GrammarPattern[]; + property: string; +}) => { + const cases = new Map(); + + for (const [patternIndex, pattern] of patterns.entries()) { + const slots = pattern.parts + .map((part, index) => ({ index, part })) + .filter(({ part }) => part.kind === "slot"); + + if (slots.length === 0) { + continue; + } + + for (const [slotOrder, slot] of slots.entries()) { + const variableName = `--slot-${slotOrder + 1}`; + const parts = pattern.parts.map((part, index) => { + if (index !== slot.index || part.kind !== "slot") { + return part; + } + return { + kind: "text" as const, + text: `var(${variableName})`, + }; + }); + const value = normalizeCssValue(serializeParts(parts)); + if (value === undefined) { + continue; + } + cases.set(`${property}:${value}`, { + id: `pattern-${patternIndex + 1}-slot-${slotOrder + 1}`, + positions: [slot.part.label ?? `slot-${slotOrder + 1}`], + value, + variables: { [variableName]: slot.part.text }, + }); + } + + if (slots.length === 1) { + continue; + } + + const variables: Record = {}; + const parts = pattern.parts.map((part, index) => { + const slotOrder = slots.findIndex((slot) => slot.index === index); + if (slotOrder === -1 || part.kind !== "slot") { + return part; + } + const variableName = `--slot-${slotOrder + 1}`; + variables[variableName] = part.text; + return { + kind: "text" as const, + text: `var(${variableName})`, + }; + }); + const value = normalizeCssValue(serializeParts(parts)); + if (value === undefined) { + continue; + } + cases.set(`${property}:${value}`, { + id: `pattern-${patternIndex + 1}-all-slots`, + positions: slots.map( + ({ part }, index) => part.label ?? `slot-${index + 1}` + ), + value, + variables, + }); + } + + return [...cases.values()].sort((left, right) => + left.id.localeCompare(right.id) + ); +}; + +const buildFixtures = () => { + return getFilteredProperties().map(({ kind, property, syntax }) => { + const overridePatterns = propertyOverrides[property]?.flatMap( + (value, index) => { + const normalized = normalizeCssValue(value); + if (normalized === undefined) { + return []; + } + return [ + { + complexity: index + 1, + parts: [{ kind: "text", text: normalized }], + } satisfies GrammarPattern, + ]; + } + ); + + const patterns = + propertyPatternOverrides[property] ?? + (overridePatterns !== undefined && overridePatterns.length > 0 + ? overridePatterns + : buildPatterns({ + depth: 0, + key: property, + node: definitionSyntax.parse(syntax) as DSNode, + seen: new Set([`property:${property}`]), + })); + + const cases = buildCases({ patterns, property }); + if (cases.length > 0) { + return { + cases, + kind, + property, + syntax, + } satisfies PropertyVarFixture; + } + + return { + cases: [], + kind, + property, + syntax, + unsupportedReason: + "No valid grammar-driven var() positions could be synthesized", + } satisfies PropertyVarFixture; + }); +}; + +const fixtures = buildFixtures(); + +const content = + autogeneratedHint + + ` +export interface PropertyVarCase { + id: string; + positions: string[]; + value: string; + variables: Record; +} + +export interface PropertyVarFixture { + cases: PropertyVarCase[]; + kind: "longhand" | "shorthand"; + property: string; + syntax: string; + unsupportedReason?: string; +} + +export const propertyVarTestFixtures: PropertyVarFixture[] = ${JSON.stringify(fixtures, null, 2)} as const; +`; + +mkdirSync(targetDir, { recursive: true }); +writeFileSync( + join(targetDir, "property-var-test-fixtures.ts"), + content, + "utf8" +); + +const caseCount = fixtures.reduce( + (total, fixture) => total + fixture.cases.length, + 0 +); +const unsupportedCount = fixtures.filter( + (fixture) => fixture.cases.length === 0 +).length; + +console.info( + `Written ${fixtures.length} fixtures with ${caseCount} cases to property-var-test-fixtures.ts (${unsupportedCount} unsupported)` +); diff --git a/packages/css-data/bin/shorthand-test-fixtures.ts b/packages/css-data/bin/shorthand-test-fixtures.ts deleted file mode 100644 index b0895a6b7240..000000000000 --- a/packages/css-data/bin/shorthand-test-fixtures.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * Generates src/__generated__/shorthand-test-fixtures.ts - * - * For every shorthand in shorthandProperties we produce two test cases: - * - * • singleToken – a CSS value string that, when used as the shorthand's value, - * expands so that exactly one (or more, if identical) longhand receives it. - * This tests the "cross-rule single-token var() preservation" path. - * - * • multiToken – a CSS value string that spans *all* longhands of the shorthand - * with different values per longhand, so no single longhand string equals the - * full value. This tests the "multi-token var() preservation" path. - * - * Values are derived from properties.ts initial values so the fixture stays in - * sync with the CSS spec data automatically. - * - * Run with: pnpm build:shorthand-fixtures - */ - -import { writeFileSync, mkdirSync } from "node:fs"; -import { join, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { generate, parse } from "css-tree"; -import { toValue } from "@webstudio-is/css-engine"; -import { shorthandProperties } from "../src/__generated__/shorthand-properties"; -import { properties } from "../src/__generated__/properties"; -import { expandShorthands } from "../src/shorthands"; -import type { CssProperty } from "@webstudio-is/css-engine"; - -const __dir = dirname(fileURLToPath(import.meta.url)); -const targetDir = join(__dir, "../src/__generated__"); - -const autogeneratedHint = - "// This file was generated by pnpm build:shorthand-fixtures\n"; - -const longhandProbeOverrides: Record = { - "-webkit-text-stroke": ["1px red", "red", "1px"], - container: ["main / inline-size", "none", "normal"], - "grid-area": ["1 / 2", "auto"], - "grid-column": ["1 / 3", "auto"], - "grid-row": ["1 / 3", "auto"], - "grid-template": ["none / none", "none", "auto / 1fr"], - font: ["16px serif", "bold 18px/1.5 sans-serif", "menu"], -}; - -const fixtureOverrides: Record< - string, - { singleToken: string; multiToken: string } -> = { - "-webkit-text-stroke": { singleToken: "red", multiToken: "1px red" }, - container: { singleToken: "none", multiToken: "main / inline-size" }, - font: { - singleToken: "16px serif", - multiToken: "bold 18px/1.5 sans-serif", - }, - "grid-area": { singleToken: "auto", multiToken: "1 / 2 / 3 / 4" }, - "grid-column": { singleToken: "auto", multiToken: "1 / 3" }, - "grid-row": { singleToken: "auto", multiToken: "1 / 3" }, - "grid-template": { singleToken: "none", multiToken: "none / 1fr" }, -}; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -/** Normalise a CSS value string through css-tree so whitespace/format matches - * what expandShorthands produces internally. */ -const normalizeCss = (value: string): string => { - try { - return generate(parse(value, { context: "value" })); - } catch { - return value; - } -}; - -/** Return the initial CSS string for a longhand that exists in properties.ts, - * or undefined if unknown. */ -const initialFor = (longhand: string): string | undefined => { - const p = properties[longhand as keyof typeof properties]; - if (!p) { - return undefined; - } - return toValue(p.initial); -}; - -/** Given a shorthand name, drive expandShorthands with a placeholder value to - * get the canonical set of longhands it produces. Returns null when the - * shorthand passes straight through (i.e. expandShorthands doesn't know it). */ -const getLonghands = (shorthand: string): string[] | null => { - // Probe with a value that is accepted by virtually every shorthand. - // We go through a few candidates until we get genuine expansion. - const probes = [ - ...(longhandProbeOverrides[shorthand] ?? []), - "none", - "initial", - "0", - "auto", - "normal", - ]; - for (const probe of probes) { - const expanded = [...expandShorthands([[shorthand as CssProperty, probe]])]; - // A passthrough means expandShorthands didn't handle it. - if (expanded.length === 1 && expanded[0][0] === shorthand) { - continue; - } - if (expanded.length > 0) { - return expanded.map(([k]) => k); - } - } - return null; -}; - -// --------------------------------------------------------------------------- -// Build fixture entries -// --------------------------------------------------------------------------- - -export type ShorthandFixture = { - /** The shorthand property name, e.g. "margin". */ - property: string; - /** - * A CSS value string that maps to exactly one specific longhand initial value. - * When used as `margin: var(--v)` with `--v: `, the cross-rule - * path must: - * • preserve var() on whichever longhands match the single value - * • assign concrete initial resets to all other longhands - * - * If ALL longhands share the same initial value, singleToken equals that - * shared value and ALL longhands get var(). - */ - singleToken: string; - /** - * A CSS value string that spans multiple longhands differently. - * When used as `margin: var(--v)` with `--v: `, the cross-rule - * path must assign var() to ALL longhands (because no individual longhand - * string equals the multi-token string). - */ - multiToken: string; - /** All longhand properties this shorthand expands to. */ - longhands: string[]; -}; - -const fixtures: ShorthandFixture[] = []; - -for (const shorthand of shorthandProperties) { - const longhands = getLonghands(shorthand); - if (!longhands || longhands.length === 0) { - // expandShorthands doesn't handle this shorthand; skip so the test can - // explicitly mark it as "passthrough" rather than silently missing it. - fixtures.push({ - property: shorthand, - singleToken: "", - multiToken: "", - longhands: [], - }); - continue; - } - - const override = fixtureOverrides[shorthand]; - if (override) { - fixtures.push({ - property: shorthand, - singleToken: override.singleToken, - multiToken: override.multiToken, - longhands, - }); - continue; - } - - // Collect normalised initial value strings for every known longhand. - const initStrings = longhands.map((l) => { - const raw = initialFor(l); - return raw !== undefined ? normalizeCss(raw) : null; - }); - - // --- singleToken: use the initial value of the first longhand that has one. - const singleToken = - initStrings.find((s): s is string => s !== null) ?? - // absolute fallback – should not be reached for any supported shorthand - "none"; - - // --- multiToken: we need a value that, when used as the shorthand, - // produces different strings per longhand (so no individual longhand - // string equals the full shorthand string). - // - // Strategy: expand with a concrete value through expandShorthands and - // re-join the distinct longhand values. If we can reconstruct a valid - // shorthand value from the longhand initials via a known concrete - // shorthand value, we use it. Otherwise we pick a value where the - // longest longhand value is different from the shortest, making the - // "full string ≠ any single expanded string" invariant hold. - // - // For properties where every longhand has the same initial (margin, - // padding, …) we make multiToken a two-value variant so they differ. - const allSame = - initStrings.every((s) => s !== null) && - new Set(initStrings as string[]).size === 1; - - let multiToken: string; - if (allSame && initStrings[0] !== null) { - // Build a two-value form, e.g. "0px 8px" for margin. - // The two values must be genuinely different. - const base = initStrings[0] as string; - // Pick an alternative that is obviously different. - const alt = - base === "0px" - ? "8px" - : base === "none" - ? "url(#x)" - : base === "auto" - ? "0px" - : "16px"; - multiToken = `${base} ${alt}`; - } else { - // Join all distinct non-null initial values. Even though this may not be - // valid CSS for the shorthand, it will: - // a) not parse as a valid shorthand via expandShorthands, OR - // b) parse and give genuinely different per-longhand values. - // Either way the multi-token cross-rule path kicks in correctly. - const distinct = [ - ...new Set(initStrings.filter((s): s is string => s !== null)), - ]; - multiToken = - distinct.length >= 2 - ? distinct.join(" ") - : `${distinct[0] ?? "none"} extra`; - } - - fixtures.push({ property: shorthand, singleToken, multiToken, longhands }); -} - -// --------------------------------------------------------------------------- -// Write output -// --------------------------------------------------------------------------- - -const content = - autogeneratedHint + - ` -export type ShorthandFixture = { - property: string; - singleToken: string; - multiToken: string; - longhands: string[]; -}; - -export const shorthandTestFixtures: ShorthandFixture[] = ${JSON.stringify(fixtures, null, 2)} as const; -`; - -mkdirSync(targetDir, { recursive: true }); -writeFileSync(join(targetDir, "shorthand-test-fixtures.ts"), content, "utf8"); -console.info( - `Written ${fixtures.length} fixtures to shorthand-test-fixtures.ts` -); diff --git a/packages/css-data/package.json b/packages/css-data/package.json index 09909241e362..0796bb76f673 100644 --- a/packages/css-data/package.json +++ b/packages/css-data/package.json @@ -10,8 +10,8 @@ "build:html.css": "tsx ./bin/html.css.ts && prettier --write ./src/__generated__/html.ts", "build:mdn-data": "tsx ./bin/mdn-data.ts ./src/__generated__ && prettier --write \"./src/__generated__/\" \"../css-engine/src/__generated__/\"", "build:descriptions": "tsx ./bin/property-value-descriptions.ts && prettier --write ./src/__generated__/property-value-descriptions.ts", - "build:shorthand-fixtures": "tsx ./bin/shorthand-test-fixtures.ts && prettier --write ./src/__generated__/shorthand-test-fixtures.ts", - "build:all": "pnpm build:html.css && pnpm build:mdn-data && pnpm build:descriptions && pnpm build:shorthand-fixtures", + "build:property-var-fixtures": "tsx ./bin/property-var-test-fixtures.ts && prettier --write ./src/__generated__/property-var-test-fixtures.ts", + "build:all": "pnpm build:html.css && pnpm build:mdn-data && pnpm build:descriptions && pnpm build:property-var-fixtures", "test": "vitest run" }, "bin": { diff --git a/packages/css-data/src/__generated__/property-var-test-fixtures.ts b/packages/css-data/src/__generated__/property-var-test-fixtures.ts new file mode 100644 index 000000000000..8ba4c4368092 --- /dev/null +++ b/packages/css-data/src/__generated__/property-var-test-fixtures.ts @@ -0,0 +1,19025 @@ +// This file was generated by pnpm build:property-var-fixtures + +export interface PropertyVarCase { + id: string; + positions: string[]; + value: string; + variables: Record; +} + +export interface PropertyVarFixture { + cases: PropertyVarCase[]; + kind: "longhand" | "shorthand"; + property: string; + syntax: string; + unsupportedReason?: string; +} + +export const propertyVarTestFixtures: PropertyVarFixture[] = [ + { + cases: [ + { + id: "pattern-2-slot-1", + positions: [ + "-webkit-line-clamp:branch:1:type:integer:term:0:type:number-token", + ], + value: "var(--slot-1)", + variables: { + "--slot-1": "1", + }, + }, + ], + kind: "longhand", + property: "-webkit-line-clamp", + syntax: "none | ", + }, + { + cases: [ + { + id: "pattern-2-slot-1", + positions: ["-webkit-overflow-scrolling:branch:1:keyword:touch"], + value: "var(--slot-1)", + variables: { + "--slot-1": "touch", + }, + }, + ], + kind: "longhand", + property: "-webkit-overflow-scrolling", + syntax: "auto | touch", + }, + { + cases: [ + { + id: "pattern-1-slot-1", + positions: [ + "-webkit-tap-highlight-color:term:0:type:color:branch:0:type:color-base:branch:0:type:hex-color", + ], + value: "var(--slot-1)", + variables: { + "--slot-1": "red", + }, + }, + ], + kind: "longhand", + property: "-webkit-tap-highlight-color", + syntax: "", + }, + { + cases: [ + { + id: "pattern-1-slot-1", + positions: [ + "-webkit-text-fill-color:term:0:type:color:branch:0:type:color-base:branch:0:type:hex-color", + ], + value: "var(--slot-1)", + variables: { + "--slot-1": "red", + }, + }, + ], + kind: "longhand", + property: "-webkit-text-fill-color", + syntax: "", + }, + { + cases: [ + { + id: "pattern-1-all-slots", + positions: [ + "-webkit-text-stroke:term:0:type:length", + "-webkit-text-stroke:term:1:type:color:branch:0:type:color-base:branch:0:type:hex-color", + ], + value: "var(--slot-1) var(--slot-2)", + variables: { + "--slot-1": "1px", + "--slot-2": "red", + }, + }, + { + id: "pattern-1-slot-1", + positions: ["-webkit-text-stroke:term:0:type:length"], + value: "var(--slot-1) red", + variables: { + "--slot-1": "1px", + }, + }, + { + id: "pattern-1-slot-2", + positions: [ + "-webkit-text-stroke:term:1:type:color:branch:0:type:color-base:branch:0:type:hex-color", + ], + value: "1px var(--slot-2)", + variables: { + "--slot-2": "red", + }, + }, + ], + kind: "shorthand", + property: "-webkit-text-stroke", + syntax: " || ", + }, + { + cases: [ + { + id: "pattern-1-slot-1", + positions: [ + "-webkit-text-stroke-color:term:0:type:color:branch:0:type:color-base:branch:0:type:hex-color", + ], + value: "var(--slot-1)", + variables: { + "--slot-1": "red", + }, + }, + ], + kind: "longhand", + property: "-webkit-text-stroke-color", + syntax: "", + }, + { + cases: [ + { + id: "pattern-1-slot-1", + positions: ["-webkit-text-stroke-width:term:0:type:length"], + value: "var(--slot-1)", + variables: { + "--slot-1": "1px", + }, + }, + ], + kind: "longhand", + property: "-webkit-text-stroke-width", + syntax: "", + }, + { + cases: [ + { + id: "pattern-2-slot-1", + positions: [ + "accent-color:branch:1:type:color:branch:0:type:color-base:branch:0:type:hex-color", + ], + value: "var(--slot-1)", + variables: { + "--slot-1": "red", + }, + }, + ], + kind: "longhand", + property: "accent-color", + syntax: "auto | ", + }, + { + cases: [ + { + id: "pattern-2-slot-1", + positions: [ + "align-content:branch:2:type:content-distribution:branch:0:keyword:space-between", + ], + value: "var(--slot-1)", + variables: { + "--slot-1": "space-between", + }, + }, + { + id: "pattern-3-slot-1", + positions: [ + "align-content:branch:1:type:baseline-position:term:0:repeat:branch:0:keyword:first", + ], + value: "var(--slot-1) baseline", + variables: { + "--slot-1": "first", + }, + }, + { + id: "pattern-3-slot-2", + positions: [ + "align-content:branch:1:type:baseline-position:term:1:keyword:baseline", + ], + value: "first var(--slot-2)", + variables: { + "--slot-2": "baseline", + }, + }, + { + id: "pattern-4-all-slots", + positions: [ + "align-content:branch:3:term:0:repeat:type:overflow-position:branch:0:keyword:unsafe", + "align-content:branch:3:term:1:type:content-position:branch:0:keyword:center", + ], + value: "var(--slot-1) var(--slot-2)", + variables: { + "--slot-1": "unsafe", + "--slot-2": "center", + }, + }, + { + id: "pattern-4-slot-1", + positions: [ + "align-content:branch:3:term:0:repeat:type:overflow-position:branch:0:keyword:unsafe", + ], + value: "var(--slot-1) center", + variables: { + "--slot-1": "unsafe", + }, + }, + { + id: "pattern-4-slot-2", + positions: [ + "align-content:branch:3:term:1:type:content-position:branch:0:keyword:center", + ], + value: "unsafe var(--slot-2)", + variables: { + "--slot-2": "center", + }, + }, + ], + kind: "longhand", + property: "align-content", + syntax: + "normal | | | ? ", + }, + { + cases: [ + { + id: "pattern-3-slot-1", + positions: ["align-items:branch:4:keyword:anchor-center"], + value: "var(--slot-1)", + variables: { + "--slot-1": "anchor-center", + }, + }, + { + id: "pattern-4-all-slots", + positions: [ + "align-items:branch:2:type:baseline-position:term:0:repeat:branch:0:keyword:first", + "align-items:branch:2:type:baseline-position:term:1:keyword:baseline", + ], + value: "var(--slot-1) var(--slot-2)", + variables: { + "--slot-1": "first", + "--slot-2": "baseline", + }, + }, + { + id: "pattern-4-slot-1", + positions: [ + "align-items:branch:2:type:baseline-position:term:0:repeat:branch:0:keyword:first", + ], + value: "var(--slot-1) baseline", + variables: { + "--slot-1": "first", + }, + }, + { + id: "pattern-4-slot-2", + positions: [ + "align-items:branch:2:type:baseline-position:term:1:keyword:baseline", + ], + value: "first var(--slot-2)", + variables: { + "--slot-2": "baseline", + }, + }, + ], + kind: "longhand", + property: "align-items", + syntax: + "normal | stretch | | [ ? ] | anchor-center", + }, + { + cases: [ + { + id: "pattern-4-slot-1", + positions: ["align-self:branch:5:keyword:anchor-center"], + value: "var(--slot-1)", + variables: { + "--slot-1": "anchor-center", + }, + }, + ], + kind: "longhand", + property: "align-self", + syntax: + "auto | normal | stretch | | ? | anchor-center", + }, + { + cases: [ + { + id: "pattern-4-slot-1", + positions: ["alignment-baseline:branch:3:keyword:middle"], + value: "var(--slot-1)", + variables: { + "--slot-1": "middle", + }, + }, + ], + kind: "longhand", + property: "alignment-baseline", + syntax: + "baseline | alphabetic | ideographic | middle | central | mathematical | text-before-edge | text-after-edge", + }, + { + cases: [ + { + id: "pattern-1-all-slots", + positions: [ + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + "animation:term:0:repeat:type:single-animation:term:1:type:easing-function:branch:0:type:linear-easing-function:branch:0:keyword:linear", + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + "animation:term:0:repeat:type:single-animation:term:3:type:single-animation-iteration-count:branch:0:keyword:infinite", + "animation:term:0:repeat:type:single-animation:term:4:type:single-animation-direction:branch:0:keyword:normal", + "animation:term:0:repeat:type:single-animation:term:5:type:single-animation-fill-mode:branch:0:keyword:none", + "animation:term:0:repeat:type:single-animation:term:6:type:single-animation-play-state:branch:0:keyword:running", + "animation:term:0:repeat:type:single-animation:term:7:branch:0:keyword:none", + "animation:term:0:repeat:type:single-animation:term:8:type:single-animation-timeline:branch:0:keyword:auto", + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + "animation:term:0:repeat:type:single-animation:term:1:type:easing-function:branch:0:type:linear-easing-function:branch:0:keyword:linear", + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + "animation:term:0:repeat:type:single-animation:term:3:type:single-animation-iteration-count:branch:0:keyword:infinite", + "animation:term:0:repeat:type:single-animation:term:4:type:single-animation-direction:branch:0:keyword:normal", + "animation:term:0:repeat:type:single-animation:term:5:type:single-animation-fill-mode:branch:0:keyword:none", + "animation:term:0:repeat:type:single-animation:term:6:type:single-animation-play-state:branch:0:keyword:running", + "animation:term:0:repeat:type:single-animation:term:7:branch:0:keyword:none", + "animation:term:0:repeat:type:single-animation:term:8:type:single-animation-timeline:branch:0:keyword:auto", + ], + value: + "var(--slot-1),var(--slot-2) var(--slot-3) var(--slot-4),var(--slot-5) var(--slot-6) var(--slot-7) var(--slot-8) var(--slot-9) var(--slot-10) var(--slot-11),var(--slot-12),var(--slot-13) var(--slot-14) var(--slot-15),var(--slot-16) var(--slot-17) var(--slot-18) var(--slot-19) var(--slot-20) var(--slot-21) var(--slot-22)", + variables: { + "--slot-1": "auto", + "--slot-2": "auto", + "--slot-3": "linear", + "--slot-4": "1s", + "--slot-5": "1s", + "--slot-6": "infinite", + "--slot-7": "normal", + "--slot-8": "none", + "--slot-9": "running", + "--slot-10": "none", + "--slot-11": "auto", + "--slot-12": "auto", + "--slot-13": "auto", + "--slot-14": "linear", + "--slot-15": "1s", + "--slot-16": "1s", + "--slot-17": "infinite", + "--slot-18": "normal", + "--slot-19": "none", + "--slot-20": "running", + "--slot-21": "none", + "--slot-22": "auto", + }, + }, + { + id: "pattern-1-slot-1", + positions: [ + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + ], + value: + "var(--slot-1),auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-1": "auto", + }, + }, + { + id: "pattern-1-slot-10", + positions: [ + "animation:term:0:repeat:type:single-animation:term:7:branch:0:keyword:none", + ], + value: + "auto,auto linear 1s,1s infinite normal none running var(--slot-10) auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-10": "none", + }, + }, + { + id: "pattern-1-slot-11", + positions: [ + "animation:term:0:repeat:type:single-animation:term:8:type:single-animation-timeline:branch:0:keyword:auto", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none var(--slot-11),auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-11": "auto", + }, + }, + { + id: "pattern-1-slot-12", + positions: [ + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,var(--slot-12),auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-12": "auto", + }, + }, + { + id: "pattern-1-slot-13", + positions: [ + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,var(--slot-13) linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-13": "auto", + }, + }, + { + id: "pattern-1-slot-14", + positions: [ + "animation:term:0:repeat:type:single-animation:term:1:type:easing-function:branch:0:type:linear-easing-function:branch:0:keyword:linear", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto var(--slot-14) 1s,1s infinite normal none running none auto", + variables: { + "--slot-14": "linear", + }, + }, + { + id: "pattern-1-slot-15", + positions: [ + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear var(--slot-15),1s infinite normal none running none auto", + variables: { + "--slot-15": "1s", + }, + }, + { + id: "pattern-1-slot-16", + positions: [ + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,var(--slot-16) infinite normal none running none auto", + variables: { + "--slot-16": "1s", + }, + }, + { + id: "pattern-1-slot-17", + positions: [ + "animation:term:0:repeat:type:single-animation:term:3:type:single-animation-iteration-count:branch:0:keyword:infinite", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s var(--slot-17) normal none running none auto", + variables: { + "--slot-17": "infinite", + }, + }, + { + id: "pattern-1-slot-18", + positions: [ + "animation:term:0:repeat:type:single-animation:term:4:type:single-animation-direction:branch:0:keyword:normal", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite var(--slot-18) none running none auto", + variables: { + "--slot-18": "normal", + }, + }, + { + id: "pattern-1-slot-19", + positions: [ + "animation:term:0:repeat:type:single-animation:term:5:type:single-animation-fill-mode:branch:0:keyword:none", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal var(--slot-19) running none auto", + variables: { + "--slot-19": "none", + }, + }, + { + id: "pattern-1-slot-2", + positions: [ + "animation:term:0:repeat:type:single-animation:term:0:property:animation-duration:term:0:repeat:branch:0:keyword:auto", + ], + value: + "auto,var(--slot-2) linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-2": "auto", + }, + }, + { + id: "pattern-1-slot-20", + positions: [ + "animation:term:0:repeat:type:single-animation:term:6:type:single-animation-play-state:branch:0:keyword:running", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none var(--slot-20) none auto", + variables: { + "--slot-20": "running", + }, + }, + { + id: "pattern-1-slot-21", + positions: [ + "animation:term:0:repeat:type:single-animation:term:7:branch:0:keyword:none", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running var(--slot-21) auto", + variables: { + "--slot-21": "none", + }, + }, + { + id: "pattern-1-slot-22", + positions: [ + "animation:term:0:repeat:type:single-animation:term:8:type:single-animation-timeline:branch:0:keyword:auto", + ], + value: + "auto,auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none var(--slot-22)", + variables: { + "--slot-22": "auto", + }, + }, + { + id: "pattern-1-slot-3", + positions: [ + "animation:term:0:repeat:type:single-animation:term:1:type:easing-function:branch:0:type:linear-easing-function:branch:0:keyword:linear", + ], + value: + "auto,auto var(--slot-3) 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-3": "linear", + }, + }, + { + id: "pattern-1-slot-4", + positions: [ + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + ], + value: + "auto,auto linear var(--slot-4),1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-4": "1s", + }, + }, + { + id: "pattern-1-slot-5", + positions: [ + "animation:term:0:repeat:type:single-animation:term:2:property:animation-delay:term:0:repeat:type:time", + ], + value: + "auto,auto linear 1s,var(--slot-5) infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-5": "1s", + }, + }, + { + id: "pattern-1-slot-6", + positions: [ + "animation:term:0:repeat:type:single-animation:term:3:type:single-animation-iteration-count:branch:0:keyword:infinite", + ], + value: + "auto,auto linear 1s,1s var(--slot-6) normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-6": "infinite", + }, + }, + { + id: "pattern-1-slot-7", + positions: [ + "animation:term:0:repeat:type:single-animation:term:4:type:single-animation-direction:branch:0:keyword:normal", + ], + value: + "auto,auto linear 1s,1s infinite var(--slot-7) none running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-7": "normal", + }, + }, + { + id: "pattern-1-slot-8", + positions: [ + "animation:term:0:repeat:type:single-animation:term:5:type:single-animation-fill-mode:branch:0:keyword:none", + ], + value: + "auto,auto linear 1s,1s infinite normal var(--slot-8) running none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-8": "none", + }, + }, + { + id: "pattern-1-slot-9", + positions: [ + "animation:term:0:repeat:type:single-animation:term:6:type:single-animation-play-state:branch:0:keyword:running", + ], + value: + "auto,auto linear 1s,1s infinite normal none var(--slot-9) none auto,auto,auto linear 1s,1s infinite normal none running none auto", + variables: { + "--slot-9": "running", + }, + }, + ], + kind: "shorthand", + property: "animation", + syntax: "#", + }, + { + cases: [ + { + id: "pattern-1-all-slots", + positions: [ + "animation-composition:term:0:repeat:type:single-animation-composition:branch:0:keyword:replace", + "animation-composition:term:0:repeat:type:single-animation-composition:branch:0:keyword:replace", + ], + value: "var(--slot-1),var(--slot-2)", + variables: { + "--slot-1": "replace", + "--slot-2": "replace", + }, + }, + { + id: "pattern-1-slot-1", + positions: [ + "animation-composition:term:0:repeat:type:single-animation-composition:branch:0:keyword:replace", + ], + value: "var(--slot-1),replace", + variables: { + "--slot-1": "replace", + }, + }, + { + id: "pattern-1-slot-2", + positions: [ + "animation-composition:term:0:repeat:type:single-animation-composition:branch:0:keyword:replace", + ], + value: "replace,var(--slot-2)", + variables: { + "--slot-2": "replace", + }, + }, + ], + kind: "longhand", + property: "animation-composition", + syntax: "#", + }, + { + cases: [ + { + id: "pattern-1-all-slots", + positions: [ + "animation-delay:term:0:repeat:type:time", + "animation-delay:term:0:repeat:type:time", + ], + value: "var(--slot-1),var(--slot-2)", + variables: { + "--slot-1": "1s", + "--slot-2": "1s", + }, + }, + { + id: "pattern-1-slot-1", + positions: ["animation-delay:term:0:repeat:type:time"], + value: "var(--slot-1),1s", + variables: { + "--slot-1": "1s", + }, + }, + { + id: "pattern-1-slot-2", + positions: ["animation-delay:term:0:repeat:type:time"], + value: "1s,var(--slot-2)", + variables: { + "--slot-2": "1s", + }, + }, + ], + kind: "longhand", + property: "animation-delay", + syntax: "