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(`
+
+ `);
+ 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..d58fb71df53d 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,137 @@ 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 border side typed arbitrary var color utility", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ );
+
+ expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
+ expect.objectContaining({ type: "var", value: "border-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 +402,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 +941,76 @@ 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);
+});
+
+test("keep typed arbitrary var border color utility", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ );
+ expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
+ expect.objectContaining({ type: "var", value: "border-color" })
+ );
});
diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts
index c5e3b7caf951..751eab54122d 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,22 @@ 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.
+ // TODO: remove workaround once fixed https://github.com/unocss/unocss/issues/5188
+ if (/^border-\(--[\w-]+\)$/.test(item)) {
+ const varName = item.slice("border-(".length, -1);
+ return `[border-color:var(${varName})]`;
+ }
+ // Tailwind v4 typed arbitrary border color: border-[color:value]
+ // UnoCSS wind4 incorrectly maps this to border-width. Rewrite to the
+ // explicit arbitrary property form so it resolves to border-color.
+ // TODO: remove workaround once fixed https://github.com/unocss/unocss/issues/5188
+ const borderColorMatch = item.match(/^border-\[color:(.+)\]$/);
+ if (borderColorMatch) {
+ return `[border-color:${borderColorMatch[1]}]`;
+ }
// styles data cannot express space-x and space-y selectors
// with lobotomized owl so replace with gaps
if (item.includes("space-x-")) {
@@ -246,9 +414,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 +436,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 +692,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/cleanup.sh b/cleanup.sh
new file mode 100755
index 000000000000..ad64ca097f9f
--- /dev/null
+++ b/cleanup.sh
@@ -0,0 +1,18 @@
+#!/bin/bash
+
+
+set -euo pipefail
+
+echo "Removing dependency, build, and cache directories..."
+
+find . -name 'node_modules' -type d -prune -exec rm -rf '{}' +
+find . -name 'lib' -type d -prune -exec rm -rf '{}' +
+find . -name 'build' -type d -prune -exec rm -rf '{}' +
+find . -name 'dist' -type d -prune -exec rm -rf '{}' +
+find . -name '.vite' -type d -prune -exec rm -rf '{}' +
+find . -name '.cache' -type d -prune -exec rm -rf '{}' +
+find . -name '.pnpm-store' -type d -prune -exec rm -rf '{}' +
+find . -name '.eslintcache' -type f -delete
+find . -name '*.tsbuildinfo' -type f -delete
+
+echo "Cleanup completed."
\ No newline at end of file
diff --git a/package.json b/package.json
index 38b32990e56e..10967c530589 100644
--- a/package.json
+++ b/package.json
@@ -21,7 +21,8 @@
"local:version-snapshot": "pnpm -r exec pnpm version prepatch --preid $(cat /dev/urandom | LC_ALL=C tr -dc 'a-z' | fold -w 8 | head -n 1)",
"local:publish-snapshot": "pnpm -r publish --access public --no-git-checks --registry http://localhost:4873",
"local:dangerously-undo-version-snapshot": "git restore --source=$(git branch --show-current) '**/*/package.json'",
- "local:release": "pnpm build && pnpm dts && pnpm local:version-snapshot && pnpm local:publish-snapshot && pnpm local:dangerously-undo-version-snapshot && echo \"now execute\npnpm up -r -L '@webstudio-is/*' --registry http://localhost:4873\""
+ "local:release": "pnpm build && pnpm dts && pnpm local:version-snapshot && pnpm local:publish-snapshot && pnpm local:dangerously-undo-version-snapshot && echo \"now execute\npnpm up -r -L '@webstudio-is/*' --registry http://localhost:4873\"",
+ "clean:deep": "bash ./cleanup.sh"
},
"simple-git-hooks": {
"pre-commit": "./node_modules/.bin/nano-staged"
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..e82289443002
--- /dev/null
+++ b/packages/css-data/bin/property-var-test-fixtures.ts
@@ -0,0 +1,836 @@
+/**
+ * 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;
+};
+
+/**
+ * Derive a semantic CSS variable name from a slot's grammar position label.
+ * The label looks like "border:term:2:type:color:branch:0:type:hex-color".
+ * We extract the first `type:XXX` segment to build `--slot-XXX`.
+ * When multiple slots in the same pattern share the same type we append
+ * a counter suffix: `--slot-color`, `--slot-color-2`, etc.
+ * Falling back to `--slot-N` when no type can be extracted.
+ */
+const slotVarName = (
+ label: string | undefined,
+ fallbackIndex: number,
+ used: Map
+): string => {
+ const typeMatch = label?.match(/(?:^|:)type:([^:]+)/);
+ const base = typeMatch
+ ? `--slot-${typeMatch[1]}`
+ : `--slot-${fallbackIndex + 1}`;
+ const count = (used.get(base) ?? 0) + 1;
+ used.set(base, count);
+ return count === 1 ? base : `${base}-${count}`;
+};
+
+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 used = new Map();
+ // pre-register names for earlier slots so this slot gets the right suffix
+ for (let i = 0; i < slotOrder; i++) {
+ slotVarName(slots[i].part.label, i, used);
+ }
+ const variableName = slotVarName(slot.part.label, slotOrder, used);
+ 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 allUsed = new Map();
+ 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 = slotVarName(part.label, slotOrder, allUsed);
+ 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..ddde64bc3424
--- /dev/null
+++ b/packages/css-data/src/__generated__/property-var-test-fixtures.ts
@@ -0,0 +1,19617 @@
+// 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-1-slot-1",
+ positions: ["-webkit-line-clamp:branch:0:keyword:none"],
+ value: "var(--slot-1)",
+ variables: {
+ "--slot-1": "none",
+ },
+ },
+ {
+ id: "pattern-2-slot-1",
+ positions: [
+ "-webkit-line-clamp:branch:1:type:integer:term:0:type:number-token",
+ ],
+ value: "var(--slot-integer)",
+ variables: {
+ "--slot-integer": "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-color)",
+ variables: {
+ "--slot-color": "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-color)",
+ variables: {
+ "--slot-color": "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-length) var(--slot-color)",
+ variables: {
+ "--slot-length": "1px",
+ "--slot-color": "red",
+ },
+ },
+ {
+ id: "pattern-1-slot-1",
+ positions: ["-webkit-text-stroke:term:0:type:length"],
+ value: "var(--slot-length) red",
+ variables: {
+ "--slot-length": "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-color)",
+ variables: {
+ "--slot-color": "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-color)",
+ variables: {
+ "--slot-color": "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-length)",
+ variables: {
+ "--slot-length": "1px",
+ },
+ },
+ ],
+ kind: "longhand",
+ property: "-webkit-text-stroke-width",
+ syntax: "",
+ },
+ {
+ cases: [
+ {
+ id: "pattern-1-slot-1",
+ positions: ["accent-color:branch:0:keyword:auto"],
+ value: "var(--slot-1)",
+ variables: {
+ "--slot-1": "auto",
+ },
+ },
+ {
+ 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-color)",
+ variables: {
+ "--slot-color": "red",
+ },
+ },
+ ],
+ kind: "longhand",
+ property: "accent-color",
+ syntax: "auto | ",
+ },
+ {
+ cases: [
+ {
+ id: "pattern-1-slot-1",
+ positions: ["align-content:branch:0:keyword:normal"],
+ value: "var(--slot-1)",
+ variables: {
+ "--slot-1": "normal",
+ },
+ },
+ {
+ id: "pattern-2-slot-1",
+ positions: [
+ "align-content:branch:2:type:content-distribution:branch:0:keyword:space-between",
+ ],
+ value: "var(--slot-content-distribution)",
+ variables: {
+ "--slot-content-distribution": "space-between",
+ },
+ },
+ {
+ id: "pattern-3-all-slots",
+ positions: [
+ "align-content:branch:1:type:baseline-position:term:0:repeat:branch:0:keyword:first",
+ "align-content:branch:1:type:baseline-position:term:1:keyword:baseline",
+ ],
+ value: "var(--slot-baseline-position) var(--slot-baseline-position-2)",
+ variables: {
+ "--slot-baseline-position": "first",
+ "--slot-baseline-position-2": "baseline",
+ },
+ },
+ {
+ id: "pattern-3-slot-1",
+ positions: [
+ "align-content:branch:1:type:baseline-position:term:0:repeat:branch:0:keyword:first",
+ ],
+ value: "var(--slot-baseline-position) baseline",
+ variables: {
+ "--slot-baseline-position": "first",
+ },
+ },
+ {
+ id: "pattern-3-slot-2",
+ positions: [
+ "align-content:branch:1:type:baseline-position:term:1:keyword:baseline",
+ ],
+ value: "first var(--slot-baseline-position-2)",
+ variables: {
+ "--slot-baseline-position-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-overflow-position) var(--slot-content-position)",
+ variables: {
+ "--slot-overflow-position": "unsafe",
+ "--slot-content-position": "center",
+ },
+ },
+ {
+ id: "pattern-4-slot-1",
+ positions: [
+ "align-content:branch:3:term:0:repeat:type:overflow-position:branch:0:keyword:unsafe",
+ ],
+ value: "var(--slot-overflow-position) center",
+ variables: {
+ "--slot-overflow-position": "unsafe",
+ },
+ },
+ {
+ id: "pattern-4-slot-2",
+ positions: [
+ "align-content:branch:3:term:1:type:content-position:branch:0:keyword:center",
+ ],
+ value: "unsafe var(--slot-content-position)",
+ variables: {
+ "--slot-content-position": "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-baseline-position) var(--slot-baseline-position-2)",
+ variables: {
+ "--slot-baseline-position": "first",
+ "--slot-baseline-position-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-baseline-position) baseline",
+ variables: {
+ "--slot-baseline-position": "first",
+ },
+ },
+ {
+ id: "pattern-4-slot-2",
+ positions: [
+ "align-items:branch:2:type:baseline-position:term:1:keyword:baseline",
+ ],
+ value: "first var(--slot-baseline-position-2)",
+ variables: {
+ "--slot-baseline-position-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-single-animation),var(--slot-single-animation-2) var(--slot-single-animation-3) var(--slot-single-animation-4),var(--slot-single-animation-5) var(--slot-single-animation-6) var(--slot-single-animation-7) var(--slot-single-animation-8) var(--slot-single-animation-9) var(--slot-single-animation-10) var(--slot-single-animation-11),var(--slot-single-animation-12),var(--slot-single-animation-13) var(--slot-single-animation-14) var(--slot-single-animation-15),var(--slot-single-animation-16) var(--slot-single-animation-17) var(--slot-single-animation-18) var(--slot-single-animation-19) var(--slot-single-animation-20) var(--slot-single-animation-21) var(--slot-single-animation-22)",
+ variables: {
+ "--slot-single-animation": "auto",
+ "--slot-single-animation-2": "auto",
+ "--slot-single-animation-3": "linear",
+ "--slot-single-animation-4": "1s",
+ "--slot-single-animation-5": "1s",
+ "--slot-single-animation-6": "infinite",
+ "--slot-single-animation-7": "normal",
+ "--slot-single-animation-8": "none",
+ "--slot-single-animation-9": "running",
+ "--slot-single-animation-10": "none",
+ "--slot-single-animation-11": "auto",
+ "--slot-single-animation-12": "auto",
+ "--slot-single-animation-13": "auto",
+ "--slot-single-animation-14": "linear",
+ "--slot-single-animation-15": "1s",
+ "--slot-single-animation-16": "1s",
+ "--slot-single-animation-17": "infinite",
+ "--slot-single-animation-18": "normal",
+ "--slot-single-animation-19": "none",
+ "--slot-single-animation-20": "running",
+ "--slot-single-animation-21": "none",
+ "--slot-single-animation-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-single-animation),auto linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation": "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-single-animation-10) auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-11),auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-12),auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-13) linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-14) 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-15),1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-16) infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-17) normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-18) none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-19) running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-2) linear 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-20) none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-21) auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-22)",
+ variables: {
+ "--slot-single-animation-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-single-animation-3) 1s,1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-4),1s infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-5) infinite normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-6) normal none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-7) none running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-8) running none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-9) none auto,auto,auto linear 1s,1s infinite normal none running none auto",
+ variables: {
+ "--slot-single-animation-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-single-animation-composition),var(--slot-single-animation-composition-2)",
+ variables: {
+ "--slot-single-animation-composition": "replace",
+ "--slot-single-animation-composition-2": "replace",
+ },
+ },
+ {
+ id: "pattern-1-slot-1",
+ positions: [
+ "animation-composition:term:0:repeat:type:single-animation-composition:branch:0:keyword:replace",
+ ],
+ value: "var(--slot-single-animation-composition),replace",
+ variables: {
+ "--slot-single-animation-composition": "replace",
+ },
+ },
+ {
+ id: "pattern-1-slot-2",
+ positions: [
+ "animation-composition:term:0:repeat:type:single-animation-composition:branch:0:keyword:replace",
+ ],
+ value: "replace,var(--slot-single-animation-composition-2)",
+ variables: {
+ "--slot-single-animation-composition-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-time),var(--slot-time-2)",
+ variables: {
+ "--slot-time": "1s",
+ "--slot-time-2": "1s",
+ },
+ },
+ {
+ id: "pattern-1-slot-1",
+ positions: ["animation-delay:term:0:repeat:type:time"],
+ value: "var(--slot-time),1s",
+ variables: {
+ "--slot-time": "1s",
+ },
+ },
+ {
+ id: "pattern-1-slot-2",
+ positions: ["animation-delay:term:0:repeat:type:time"],
+ value: "1s,var(--slot-time-2)",
+ variables: {
+ "--slot-time-2": "1s",
+ },
+ },
+ ],
+ kind: "longhand",
+ property: "animation-delay",
+ syntax: "