diff --git a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx
index 67a0ed251e08..6e7d860d9675 100644
--- a/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx
+++ b/apps/builder/app/shared/copy-paste/plugin-webflow/plugin-webflow.test.tsx
@@ -3288,6 +3288,7 @@ describe("Styles", () => {
background-image: linear-gradient(350deg,hsl(256.3636363636363 72.13% 23.92%/0.00),hsl(256.2162162162162 72.55% 80.00%/1.00) 49%,#bba7f1);
-webkit-background-clip: text;
background-clip: text;
+ -webkit-text-fill-color: transparent;
color: transparent
}
}"
diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx
index 4471ffcd8cb0..74b235aa2d1d 100644
--- a/apps/builder/app/shared/html.test.tsx
+++ b/apps/builder/app/shared/html.test.tsx
@@ -150,6 +150,32 @@ test("generate props from number and boolean aria attributes", () => {
);
});
+test("skip webstudio runtime attributes from pasted html", () => {
+ const fragment = generateFragmentFromHtml(`
+ View solutions
+ `);
+
+ expect(fragment.props).toEqual([
+ expect.objectContaining({ name: "href", value: "#services" }),
+ ]);
+ expect(fragment.props.some((prop) => prop.name.startsWith("data-ws-"))).toBe(
+ false
+ );
+ expect(fragment.instances[0]).toEqual(
+ expect.objectContaining({
+ tag: "a",
+ children: [{ type: "text", value: "View solutions " }],
+ })
+ );
+});
+
test("wrap text with span when spotted outside of rich text", () => {
expect(
generateFragmentFromHtml(`
diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts
index 0577c249dae8..8d8ee11aaca1 100644
--- a/apps/builder/app/shared/html.ts
+++ b/apps/builder/app/shared/html.ts
@@ -26,7 +26,10 @@ import {
} from "@webstudio-is/css-data";
import { richTextContentTags } from "./content-model";
import { setIsSubsetOf } from "./shim";
-import { isAttributeNameSafe } from "@webstudio-is/react-sdk";
+import {
+ isAttributeNameSafe,
+ textContentAttribute,
+} from "@webstudio-is/react-sdk";
import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk";
import * as csstree from "css-tree";
import { titleCase } from "title-case";
@@ -34,6 +37,7 @@ import { titleCase } from "title-case";
type ElementNode = DefaultTreeAdapterMap["element"];
const spaceRegex = /^\s*$/;
+const wsAttributePrefix = "data-ws-";
const getAttributeType = (
attribute: (typeof ariaAttributes)[number]
@@ -742,7 +746,15 @@ export const generateFragmentFromHtml = (
delete instance.tag;
}
instances.set(instance.id, instance);
+ const wsTextContentAttr = node.attrs.find(
+ (attr) => attr.name === textContentAttribute
+ );
for (const attr of node.attrs) {
+ // Webstudio runtime metadata can appear when users copy rendered canvas
+ // DOM. Do not import it as user-authored attributes.
+ if (attr.name.startsWith(wsAttributePrefix)) {
+ continue;
+ }
// skip attributes which cannot be rendered in jsx
if (!isAttributeNameSafe(attr.name)) {
continue;
@@ -887,6 +899,21 @@ export const generateFragmentFromHtml = (
}
}
}
+ if (
+ wsTextContentAttr !== undefined &&
+ node.tagName !== "textarea" &&
+ node.childNodes.every((childNode) =>
+ defaultTreeAdapter.isTextNode(childNode)
+ )
+ ) {
+ if (wsTextContentAttr.value !== "") {
+ instance.children.push({
+ type: "text",
+ value: wsTextContentAttr.value,
+ });
+ }
+ return { type: "id" as const, value: instance.id };
+ }
let spaceAttachedToPrev = false;
for (let index = 0; index < node.childNodes.length; index += 1) {
const childNode = node.childNodes[index];
diff --git a/apps/builder/app/shared/tailwind/tailwind.test.tsx b/apps/builder/app/shared/tailwind/tailwind.test.tsx
index d58fb71df53d..502511c939a9 100644
--- a/apps/builder/app/shared/tailwind/tailwind.test.tsx
+++ b/apps/builder/app/shared/tailwind/tailwind.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { css, renderTemplate, ws } from "@webstudio-is/template";
-import { generateFragmentFromTailwind } from "./tailwind";
+import { __testing__, generateFragmentFromTailwind } from "./tailwind";
const getBaseStyleValue = (
fragment: Awaited>,
@@ -41,6 +41,24 @@ const getStyleValue = (
)?.value;
};
+test("normalize unocss output for webstudio parser", () => {
+ const { css: normalizedCss } = __testing__.normalizeUnoCssForWebstudio(`
+ @property --un-shadow { syntax: "*"; inherits: false; initial-value: 0 0 #0000; }
+ @property --un-from-opacity { syntax: ""; inherits: false; initial-value: 100%; }
+ .rounded-full { border-radius: calc(infinity * 1px); }
+ .from-brand { --un-gradient-from: color-mix(in oklab, #F5A623 var(--un-from-opacity), transparent); }
+ .bg-gradient { background-image: linear-gradient(to bottom right in oklab, var(--un-gradient-from) 0%, #E8920A 100%); }
+ .shadow { box-shadow: var(--un-shadow); }
+ `);
+
+ expect(normalizedCss).toContain("--tw-shadow");
+ expect(normalizedCss).toContain("border-radius: 9999px");
+ expect(normalizedCss).toMatch(
+ /linear-gradient\(to bottom right,\s*#F5A623 0%, #E8920A 100%\)/
+ );
+ expect(normalizedCss).toContain("box-shadow: 0 0 #0000");
+});
+
test("extract local styles from tailwind classes", async () => {
expect(
await generateFragmentFromTailwind(
@@ -81,7 +99,6 @@ test("ignore dark mode", async () => {
expect(getStyleValue(fragment, "backgroundColor")).toEqual(
expect.objectContaining({
type: "color",
- colorSpace: "srgb",
components: [1, 1, 1],
})
);
@@ -264,13 +281,288 @@ test("generate shadow", async () => {
type: "layers",
value: expect.arrayContaining([
expect.objectContaining({
- type: "var",
- value: "tw-ring-offset-shadow",
+ type: "shadow",
+ offsetY: expect.objectContaining({ unit: "px", value: 1 }),
+ blur: expect.objectContaining({ unit: "px", value: 3 }),
+ }),
+ expect.objectContaining({
+ type: "shadow",
+ offsetY: expect.objectContaining({ unit: "px", value: 1 }),
+ blur: expect.objectContaining({ unit: "px", value: 2 }),
+ spread: expect.objectContaining({ unit: "px", value: -1 }),
+ }),
+ ]),
+ })
+ );
+});
+
+test("generate arbitrary gradient, full radius and shadow", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+ Free consultation
+
+ )
+ );
+
+ expect(fragment.props.some((prop) => prop.name === "class")).toBe(false);
+ expect(getStyleValue(fragment, "backgroundImage")).toEqual(
+ expect.objectContaining({
+ type: "layers",
+ value: expect.arrayContaining([
+ expect.objectContaining({
+ value: expect.stringContaining("#F5A623"),
+ }),
+ ]),
+ })
+ );
+ expect(getStyleValue(fragment, "borderTopLeftRadius")).toEqual(
+ expect.objectContaining({ type: "unit", unit: "px", value: 9999 })
+ );
+ expect(getStyleValue(fragment, "boxShadow")).toEqual(
+ expect.objectContaining({
+ type: "layers",
+ value: expect.arrayContaining([
+ expect.objectContaining({
+ type: "shadow",
+ offsetY: expect.objectContaining({ unit: "px", value: 4 }),
+ blur: expect.objectContaining({ unit: "px", value: 20 }),
+ color: expect.objectContaining({ alpha: 0.35 }),
+ }),
+ ]),
+ })
+ );
+});
+
+test("generate clipped gradient text", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+ Your solar plant
+
+ )
+ );
+
+ expect(fragment.props.some((prop) => prop.name === "class")).toBe(false);
+ expect(getStyleValue(fragment, "color")).toEqual(
+ expect.objectContaining({ type: "keyword", value: "transparent" })
+ );
+ expect(getStyleValue(fragment, "backgroundClip")).toEqual(
+ expect.objectContaining({
+ type: "layers",
+ value: expect.arrayContaining([
+ expect.objectContaining({ type: "keyword", value: "text" }),
+ ]),
+ })
+ );
+ expect(getStyleValue(fragment, "backgroundImage")).toEqual(
+ expect.objectContaining({
+ type: "layers",
+ value: expect.arrayContaining([
+ expect.objectContaining({
+ value: expect.stringContaining("#F97316"),
+ }),
+ ]),
+ })
+ );
+});
+
+test("generate gradient background with via color stop", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ );
+
+ expect(fragment.props.some((prop) => prop.name === "class")).toBe(false);
+ expect(getStyleValue(fragment, "backgroundImage")).toEqual(
+ expect.objectContaining({
+ type: "layers",
+ value: expect.arrayContaining([
+ expect.objectContaining({
+ value:
+ "linear-gradient(to bottom right,#0A2830 0%,#0D4F5C 50%,#0A3040 100%)",
}),
- expect.objectContaining({ type: "var", value: "tw-ring-shadow" }),
]),
})
);
+ expect(getStyleValue(fragment, "paddingBlockStart")).toEqual(
+ expect.objectContaining({ type: "unit", unit: "rem", value: 6 })
+ );
+ expect(getStyleValue(fragment, "paddingInlineStart")).toEqual(
+ expect.objectContaining({ type: "unit", unit: "rem", value: 1 })
+ );
+});
+
+test("input padding utilities override preflight reset", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+
+
+
+ )
+ );
+
+ const inputStyleSourceId = fragment.styleSourceSelections
+ .find((selection) => selection.instanceId === "1")
+ ?.values.at(-1);
+ const inputStyles = fragment.styles.filter(
+ (style) => style.styleSourceId === inputStyleSourceId
+ );
+
+ expect(inputStyles).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ property: "width",
+ value: expect.objectContaining({ unit: "%", value: 100 }),
+ }),
+ expect.objectContaining({
+ property: "paddingInlineStart",
+ value: expect.objectContaining({ unit: "rem", value: 1 }),
+ }),
+ expect.objectContaining({
+ property: "paddingBlockStart",
+ value: expect.objectContaining({ unit: "rem", value: 0.75 }),
+ }),
+ ])
+ );
+ expect(
+ inputStyles.some((style) =>
+ ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].includes(
+ style.property
+ )
+ )
+ ).toBe(false);
+ expect(getStyleValue(fragment, "gridTemplateColumns")).toEqual(
+ expect.objectContaining({
+ type: "unparsed",
+ value: "repeat(2,minmax(0,1fr))",
+ })
+ );
+});
+
+test("axis padding utilities do not clear unrelated inline padding", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+ )
+ );
+
+ expect(getStyleValue(fragment, "paddingTop")).toEqual(
+ expect.objectContaining({ type: "unit", unit: "rem", value: 2 })
+ );
+ expect(getStyleValue(fragment, "paddingInlineStart")).toEqual(
+ expect.objectContaining({ type: "unit", unit: "rem", value: 1 })
+ );
+ expect(getStyleValue(fragment, "paddingRight")).toBeUndefined();
+});
+
+test("space-y form children stretch by default", async () => {
+ const fragment = await generateFragmentFromTailwind(
+ renderTemplate(
+
+
+
+
+
+
+
+
+
+ Send request
+
+
+
+
+ )
+ );
+
+ const cardStyleSourceId = fragment.styleSourceSelections
+ .find((selection) => selection.instanceId === "1")
+ ?.values.at(-1);
+ const formStyleSourceId = fragment.styleSourceSelections
+ .find((selection) => selection.instanceId === "2")
+ ?.values.at(-1);
+
+ expect(fragment.styles).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ styleSourceId: cardStyleSourceId,
+ property: "maxWidth",
+ value: expect.objectContaining({ unit: "rem", value: 48 }),
+ }),
+ expect.objectContaining({
+ styleSourceId: cardStyleSourceId,
+ property: "marginInlineStart",
+ value: expect.objectContaining({ type: "keyword", value: "auto" }),
+ }),
+ expect.objectContaining({
+ styleSourceId: formStyleSourceId,
+ property: "display",
+ value: expect.objectContaining({ type: "keyword", value: "flex" }),
+ }),
+ expect.objectContaining({
+ styleSourceId: formStyleSourceId,
+ property: "flexDirection",
+ value: expect.objectContaining({ type: "keyword", value: "column" }),
+ }),
+ ])
+ );
+ expect(
+ fragment.styles.some(
+ (style) =>
+ style.styleSourceId === formStyleSourceId &&
+ style.property === "alignItems"
+ )
+ ).toBe(false);
});
test("preserve or override existing local styles", async () => {
@@ -438,16 +730,16 @@ describe("extract breakpoints", () => {
max-width: 479px */
- @media (max-width: 479px) {
+ /* base -> max-width: 639px */
+ @media (max-width: 639px) {
opacity: 0.1;
}
/* min-width: 640px -> max-width: 767px */
@media (max-width: 767px) {
opacity: 0.2;
}
- /* min-width: 768px -> max-width: 991px */
- @media (max-width: 991px) {
+ /* min-width: 768px -> max-width: 1023px */
+ @media (max-width: 1023px) {
opacity: 0.3;
}
/* min-width: 1024px -> base */
@@ -456,8 +748,8 @@ describe("extract breakpoints", () => {
@media (min-width: 1280px) {
opacity: 0.5;
}
- /* min-width: 1536px -> min-width: 1440px */
- @media (min-width: 1440px) {
+ /* min-width: 1536px -> min-width: 1536px */
+ @media (min-width: 1536px) {
opacity: 0.6;
}
`}
@@ -481,7 +773,7 @@ describe("extract breakpoints", () => {
{
{
{
@media (min-width: 1280px) {
color: red;
}
- @media (max-width: 479px) {
+ @media (max-width: 639px) {
max-width: none;
}
@media (max-width: 767px) {
max-width: 640px;
}
- @media (max-width: 991px) {
+ @media (max-width: 1023px) {
max-width: 768px;
}
max-width: 1024px;
@media (min-width: 1280px) {
max-width: 1280px;
}
- @media (min-width: 1440px) {
+ @media (min-width: 1536px) {
max-width: 1536px;
}
width: 100%;
@@ -662,13 +954,13 @@ describe("extract breakpoints", () => {
{
color: red;
}
color: green;
- @media (max-width: 479px) {
+ @media (max-width: 639px) {
opacity: 0.1;
}
@media (max-width: 767px) {
@@ -741,7 +1033,7 @@ describe("extract breakpoints", () => {
ws:tag="div"
ws:style={css`
opacity: 0.1;
- @media (max-width: 479px) {
+ @media (max-width: 639px) {
&:hover {
opacity: unset;
}
@@ -770,20 +1062,20 @@ describe("extract breakpoints", () => {
{
{
{
);
// container should only create max-width breakpoints, not 1280/1440/1920 min-width ones
expect(fragment.breakpoints).toEqual([
- { id: "0", label: "479", maxWidth: 479 },
+ { id: "0", label: "639", maxWidth: 639 },
{ id: "1", label: "767", maxWidth: 767 },
- { id: "2", label: "991", maxWidth: 991 },
+ { id: "2", label: "1023", maxWidth: 1023 },
{ id: "base", label: "" },
]);
});
@@ -892,9 +1184,9 @@ describe("extract breakpoints", () => {
)
);
- // sm: should only create 479 max-width and base, not all breakpoints
+ // sm: should only create 639 max-width and base, not all breakpoints
expect(fragment.breakpoints).toEqual([
- { id: "0", label: "479", maxWidth: 479 },
+ { id: "0", label: "639", maxWidth: 639 },
{ id: "base", label: "" },
]);
});
@@ -928,7 +1220,6 @@ test("generate space without display property", async () => {
ws:style={css`
display: flex;
flex-direction: column;
- align-items: start;
@media (max-width: 767px) {
row-gap: 1rem;
}
diff --git a/apps/builder/app/shared/tailwind/tailwind.ts b/apps/builder/app/shared/tailwind/tailwind.ts
index d65d03326bb9..857a9c69d123 100644
--- a/apps/builder/app/shared/tailwind/tailwind.ts
+++ b/apps/builder/app/shared/tailwind/tailwind.ts
@@ -20,26 +20,14 @@ import { preflight } from "./__generated__/preflight";
// breakpoints used to map tailwind classes to webstudio breakpoints
// includes both min-width (desktop-first) and max-width (mobile-first) breakpoints
const tailwindBreakpoints: Breakpoint[] = [
- { id: "1920", label: "1920", minWidth: 1920 },
- { id: "1440", label: "1440", minWidth: 1440 },
+ { id: "1536", label: "1536", minWidth: 1536 },
{ id: "1280", label: "1280", minWidth: 1280 },
{ id: "base", label: "" },
- { id: "991", label: "991", maxWidth: 991 },
+ { id: "1023", label: "1023", maxWidth: 1023 },
{ id: "767", label: "767", maxWidth: 767 },
- { id: "479", label: "479", maxWidth: 479 },
+ { id: "639", label: "639", maxWidth: 639 },
];
-const tailwindToWebstudioMappings: Record = {
- 639.9: 479,
- 640: 480,
- 767.9: 767,
- 1023.9: 991,
- 1024: 992,
- 1279.9: 1279,
- 1535.9: 1439,
- 1536: 1440,
-};
-
type StyleDecl = Omit;
type StyleBreakpoint = {
@@ -143,6 +131,10 @@ const rangesToBreakpoints = (
return breakpoints;
};
+const normalizeMediaQueryWidth = (value: number, direction: "min" | "max") => {
+ return direction === "min" ? Math.ceil(value) : Math.floor(value);
+};
+
const adaptBreakpoints = (
parsedStyles: StyleDecl[],
userBreakpoints: Breakpoint[]
@@ -164,13 +156,17 @@ const adaptBreakpoints = (
) {
continue;
}
- if (mediaQuery?.minWidth) {
- mediaQuery.minWidth =
- tailwindToWebstudioMappings[mediaQuery.minWidth] ?? mediaQuery.minWidth;
+ if (mediaQuery?.minWidth !== undefined) {
+ mediaQuery.minWidth = normalizeMediaQueryWidth(
+ mediaQuery.minWidth,
+ "min"
+ );
}
- if (mediaQuery?.maxWidth) {
- mediaQuery.maxWidth =
- tailwindToWebstudioMappings[mediaQuery.maxWidth] ?? mediaQuery.maxWidth;
+ if (mediaQuery?.maxWidth !== undefined) {
+ mediaQuery.maxWidth = normalizeMediaQueryWidth(
+ mediaQuery.maxWidth,
+ "max"
+ );
}
const groupKey = `${styleDecl.property}:${styleDecl.state ?? ""}`;
let group = breakpointGroups.get(groupKey);
@@ -235,7 +231,85 @@ const hexToRgb = (hex: string) => {
return `${r} ${g} ${b}`;
};
-const normalizeWind4Css = (css: string, finalVars: Map) => {
+const extractPropertyInitialValues = (css: string) => {
+ const values = new Map();
+ for (const match of css.matchAll(/@property\s+(--[\w-]+)\s*\{([^{}]*)\}/g)) {
+ const initialValue = match[2].match(/initial-value\s*:\s*([^;]+)\s*;?/);
+ if (initialValue) {
+ values.set(match[1], initialValue[1].trim());
+ }
+ }
+ return values;
+};
+
+const findMatchingParen = (text: string, openIndex: number) => {
+ let depth = 0;
+ for (let index = openIndex; index < text.length; index += 1) {
+ const char = text[index];
+ if (char === "(") {
+ depth += 1;
+ } else if (char === ")") {
+ depth -= 1;
+ if (depth === 0) {
+ return index;
+ }
+ }
+ }
+};
+
+const splitCssVarArguments = (args: string) => {
+ let depth = 0;
+ for (let index = 0; index < args.length; index += 1) {
+ const char = args[index];
+ if (char === "(") {
+ depth += 1;
+ } else if (char === ")") {
+ depth -= 1;
+ } else if (char === "," && depth === 0) {
+ return [args.slice(0, index).trim(), args.slice(index + 1).trim()];
+ }
+ }
+ return [args.trim()];
+};
+
+const resolveCssVars = (
+ value: string,
+ vars: Map,
+ seen = new Set()
+): string => {
+ let result = "";
+ let index = 0;
+ while (index < value.length) {
+ const varIndex = value.indexOf("var(", index);
+ if (varIndex === -1) {
+ result += value.slice(index);
+ break;
+ }
+ result += value.slice(index, varIndex);
+ const openIndex = varIndex + "var".length;
+ const closeIndex = findMatchingParen(value, openIndex);
+ if (closeIndex === undefined) {
+ result += value.slice(varIndex);
+ break;
+ }
+ const args = value.slice(openIndex + 1, closeIndex);
+ const [name, fallback] = splitCssVarArguments(args);
+ const replacement = vars.get(name);
+ if (replacement !== undefined && seen.has(name) === false) {
+ seen.add(name);
+ result += resolveCssVars(replacement, vars, seen);
+ seen.delete(name);
+ } else if (fallback !== undefined) {
+ result += resolveCssVars(fallback, vars, seen);
+ } else {
+ result += value.slice(varIndex, closeIndex + 1);
+ }
+ index = closeIndex + 1;
+ }
+ return result;
+};
+
+const normalizeUnoCssValues = (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(
@@ -254,13 +328,9 @@ const normalizeWind4Css = (css: string, finalVars: Map) => {
}
);
- // 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},`);
- }
+ // Inline tracked theme and utility variables so parseCss can resolve computed
+ // values like calc(var(--spacing) * 2), gradients, and shadow fallbacks.
+ normalized = resolveCssVars(normalized, finalVars);
// Wind4 uses a leading utility var fallback for typography.
normalized = normalized.replace(
@@ -268,6 +338,8 @@ const normalizeWind4Css = (css: string, finalVars: Map) => {
(_match, fallback) => fallback.trim()
);
+ normalized = normalized.replaceAll("calc(infinity * 1px)", "9999px");
+
// Resolve wind4's color-mix based opacity pipeline into concrete colors that
// parseCss can read as typed color values.
normalized = normalized.replace(
@@ -326,9 +398,52 @@ const normalizeWind4Css = (css: string, finalVars: Map) => {
}
);
+ // Tailwind v4 emits gradients like `linear-gradient(to bottom right in oklab, ...)`.
+ // Keep imported gradients broadly renderable and parseable by dropping the
+ // interpolation color space from the direction argument.
+ normalized = normalized.replace(
+ /linear-gradient\((to\s+(?:top|bottom|left|right)(?:\s+(?:top|bottom|left|right))?|[-+]?\d*\.?\d+(?:deg|rad|grad|turn))\s+in\s+(?:srgb|srgb-linear|display-p3|a98-rgb|prophoto-rgb|rec2020|lab|oklab|lch|oklch|xyz(?:-d50|-d65)?),/gi,
+ "linear-gradient($1,"
+ );
+
return normalized;
};
+const normalizeUnoCssForWebstudio = (generatedCss: string) => {
+ // UnoCSS uses the --un-* namespace. Keep generated CSS in Tailwind's
+ // namespace so custom properties match familiar Tailwind output and existing
+ // Webstudio styles.
+ const css = generatedCss.replaceAll("--un-", "--tw-");
+
+ // Normalize CSS custom property values: when the same var is declared in
+ // multiple utility-class rules, replace every occurrence with the value from
+ // the LAST declaration (the final cascaded value). This allows per-rule
+ // two-pass pre-collection to see the correct final value regardless of which
+ // rule a shorthand (e.g. border-color) lives in.
+ const finalVars = new Map([
+ ...extractPropertyInitialValues(css),
+ ...extractCssCustomProperties(css),
+ ]);
+
+ let normalizedCss = normalizeUnoCssValues(css, finalVars);
+ if (finalVars.size > 0) {
+ normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => {
+ const colonIdx = match.indexOf(":");
+ const propName = match.slice(0, colonIdx).trim();
+ const finalValue = finalVars.get(propName);
+ return finalValue !== undefined
+ ? `${propName}: ${resolveCssVars(finalValue, finalVars)}`
+ : match;
+ });
+ }
+
+ return { css: normalizedCss, vars: finalVars };
+};
+
+export const __testing__ = {
+ normalizeUnoCssForWebstudio,
+};
+
const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => {
if (
styleDecl.property.startsWith("border-") === false ||
@@ -359,6 +474,37 @@ const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => {
);
};
+const stylePropertyGroups: Record> = {
+ margin: new Set([
+ "marginTop",
+ "marginRight",
+ "marginBottom",
+ "marginLeft",
+ "marginBlockStart",
+ "marginBlockEnd",
+ "marginInlineStart",
+ "marginInlineEnd",
+ ]),
+ padding: new Set([
+ "paddingTop",
+ "paddingRight",
+ "paddingBottom",
+ "paddingLeft",
+ "paddingBlockStart",
+ "paddingBlockEnd",
+ "paddingInlineStart",
+ "paddingInlineEnd",
+ ]),
+};
+
+const getStylePropertyGroup = (property: string) => {
+ for (const [groupName, properties] of Object.entries(stylePropertyGroups)) {
+ if (properties.has(property)) {
+ return groupName;
+ }
+ }
+};
+
const parseTailwindClasses = async (
classes: string,
userBreakpoints: Breakpoint[],
@@ -406,23 +552,9 @@ const parseTailwindClasses = async (
})
.join(" ");
const generated = await generator.generate(classes);
- // use tailwind prefix instead of unocss one
- const css = generated.css.replaceAll("--un-", "--tw-");
- // Normalize CSS custom property values: when the same var is declared in
- // multiple utility-class rules, replace every occurrence with the value from
- // the LAST declaration (the final cascaded value). This allows per-rule
- // 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 = normalizeWind4Css(css, finalVars);
- if (finalVars.size > 0) {
- normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => {
- const colonIdx = match.indexOf(":");
- const propName = match.slice(0, colonIdx).trim();
- const finalValue = finalVars.get(propName);
- return finalValue !== undefined ? `${propName}: ${finalValue}` : match;
- });
- }
+ const { css: normalizedCss, vars: finalVars } = normalizeUnoCssForWebstudio(
+ generated.css
+ );
let parsedStyles: StyleDecl[] = [];
// @todo probably builtin in v4
if (normalizedCss.includes("border")) {
@@ -534,10 +666,6 @@ const parseTailwindClasses = async (
{
property: "flex-direction",
value: { type: "keyword", value: "column" },
- },
- {
- property: "align-items",
- value: { type: "keyword", value: "start" },
}
);
}
@@ -618,6 +746,7 @@ export const generateFragmentFromTailwind = async (
const styles = new Map(
fragment.styles.map((item) => [getStyleDeclKey(item), item])
);
+ const preflightStyleDeclKeys = new Set();
const getLocalStyleSource = (instanceId: Instance["id"]) => {
const styleSourceSelection = styleSourceSelections.get(instanceId);
const lastStyleSourceId = styleSourceSelection?.values.at(-1);
@@ -647,6 +776,7 @@ export const generateFragmentFromTailwind = async (
) => {
const localStyleSource =
getLocalStyleSource(instanceId) ?? createLocalStyleSource(instanceId);
+ const clearedPropertyGroups = new Set();
for (const parsedStyleDecl of newStyles) {
const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint);
// ignore unknown breakpoints
@@ -666,8 +796,41 @@ export const generateFragmentFromTailwind = async (
if (skipExisting && styles.has(styleDeclKey)) {
continue;
}
+ if (skipExisting === false) {
+ const propertyGroup = getStylePropertyGroup(styleDecl.property);
+ const groupProperties =
+ propertyGroup === undefined
+ ? undefined
+ : stylePropertyGroups[propertyGroup];
+ const groupKey =
+ propertyGroup === undefined
+ ? undefined
+ : `${styleDecl.styleSourceId}:${styleDecl.breakpointId}:${
+ styleDecl.state ?? ""
+ }:${propertyGroup}`;
+ if (
+ groupProperties !== undefined &&
+ groupKey !== undefined &&
+ clearedPropertyGroups.has(groupKey) === false
+ ) {
+ clearedPropertyGroups.add(groupKey);
+ for (const property of groupProperties) {
+ const groupStyleDeclKey = getStyleDeclKey({
+ ...styleDecl,
+ property,
+ });
+ if (preflightStyleDeclKeys.has(groupStyleDeclKey)) {
+ styles.delete(groupStyleDeclKey);
+ preflightStyleDeclKeys.delete(groupStyleDeclKey);
+ }
+ }
+ }
+ }
styles.delete(styleDeclKey);
styles.set(styleDeclKey, styleDecl);
+ if (skipExisting) {
+ preflightStyleDeclKeys.add(styleDeclKey);
+ }
}
};
diff --git a/packages/css-engine/src/core/prefixer.test.ts b/packages/css-engine/src/core/prefixer.test.ts
index ca7eec40ba7a..cde93a438d7a 100644
--- a/packages/css-engine/src/core/prefixer.test.ts
+++ b/packages/css-engine/src/core/prefixer.test.ts
@@ -14,6 +14,33 @@ test("prefix background-clip", () => {
);
});
+test("prefix text fill color for clipped text", () => {
+ expect(
+ prefixStyles(
+ new Map([
+ ["color", { type: "keyword", value: "transparent" }],
+ [
+ "background-clip",
+ { type: "layers", value: [{ type: "keyword", value: "text" }] },
+ ],
+ ])
+ )
+ ).toEqual(
+ new Map([
+ ["-webkit-text-fill-color", { type: "keyword", value: "transparent" }],
+ ["color", { type: "keyword", value: "transparent" }],
+ [
+ "-webkit-background-clip",
+ { type: "layers", value: [{ type: "keyword", value: "text" }] },
+ ],
+ [
+ "background-clip",
+ { type: "layers", value: [{ type: "keyword", value: "text" }] },
+ ],
+ ])
+ );
+});
+
test("prefix user-select", () => {
expect(
prefixStyles(new Map([["user-select", { type: "keyword", value: "none" }]]))
diff --git a/packages/css-engine/src/core/prefixer.ts b/packages/css-engine/src/core/prefixer.ts
index bb40662796cc..1fe2678ea5dd 100644
--- a/packages/css-engine/src/core/prefixer.ts
+++ b/packages/css-engine/src/core/prefixer.ts
@@ -1,7 +1,21 @@
import type { StyleMap } from "./rules";
+import type { CssProperty, StyleValue } from "../schema";
+
+const isKeyword = (value: StyleValue, keyword: string) => {
+ if (value.type === "keyword") {
+ return value.value === keyword;
+ }
+ if (value.type === "layers") {
+ return value.value.some((layer) => isKeyword(layer, keyword));
+ }
+ return false;
+};
export const prefixStyles = (styleMap: StyleMap) => {
const newStyleMap: StyleMap = new Map();
+ const backgroundClip = styleMap.get("background-clip");
+ const hasTextBackgroundClip =
+ backgroundClip !== undefined && isKeyword(backgroundClip, "text");
for (const [property, value] of styleMap) {
// chrome started to support unprefixed background-clip in December 2023
// https://caniuse.com/background-clip-text
@@ -25,6 +39,13 @@ export const prefixStyles = (styleMap: StyleMap) => {
if (property === "backdrop-filter") {
newStyleMap.set("-webkit-backdrop-filter", value);
}
+ if (
+ property === "color" &&
+ hasTextBackgroundClip &&
+ isKeyword(value, "transparent")
+ ) {
+ newStyleMap.set("-webkit-text-fill-color" as CssProperty, value);
+ }
// Safari and FF do not support this property and strip it from the CSS
// For polyfill to work we need to set it as a CSS property