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 const generateFragmentFromHtml = (html: string) => { const { skippedSelectors: _skipped, ...fragment } = _generateFragmentFromHtml(html); return fragment; }; test("generate instances from html", () => { expect( generateFragmentFromHtml(`

It works!

Webstudio is great.

`) ).toEqual( renderTemplate( It works! Webstudio is great. one two ) ); }); test("generate multiple root instances from html", () => { expect( generateFragmentFromHtml(`

One

Two

`) ).toEqual( renderTemplate( <> One Two ) ); }); test("handle broken html", () => { expect( generateFragmentFromHtml(`

One

`) ).toEqual( renderTemplate( <> One ) ); }); test("handle non-html", () => { expect(generateFragmentFromHtml("")).toEqual(renderTemplate(<>)); expect(generateFragmentFromHtml("It works!")).toEqual(renderTemplate(<>)); }); test("ignore custom elements", () => { expect( generateFragmentFromHtml(`
`) ).toEqual(renderTemplate()); }); test("ignore not allowed tags", () => { expect( generateFragmentFromHtml(`
test
`) ).toEqual(renderTemplate()); }); test("generate props from html attributes", () => { expect( generateFragmentFromHtml(`
`) ).toEqual( renderTemplate( My Button ) ); }); test("generate props from number and boolean html attributes", () => { expect( generateFragmentFromHtml(` `) ).toEqual( renderTemplate( My Button ) ); }); test("generate props from number and boolean aria attributes", () => { expect( generateFragmentFromHtml(` `) ).toEqual( renderTemplate( My Button ) ); }); 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(`
div
article
`) ).toEqual( renderTemplate( div article ) ); expect( generateFragmentFromHtml(`
div
`) ).toEqual( renderTemplate( div ) ); }); test("do not wrap text with span when spotted near link", () => { expect( generateFragmentFromHtml(`
divlink
`) ).toEqual( renderTemplate( div link ) ); }); test("collapse any spacing characters inside text", () => { expect( generateFragmentFromHtml(`
line another line
`) ).toEqual( renderTemplate({"line another line"}) ); }); test("collapse any spacing characters inside rich text", () => { expect( generateFragmentFromHtml(`
line another line text
`) ).toEqual( renderTemplate( {"line "} another line text ) ); }); test("preserve leading space in inline element following text without trailing space", () => { expect(generateFragmentFromHtml(`

About Us

`)).toEqual( renderTemplate( {"About"} {" Us"} ) ); }); test("generate style attribute as local styles", () => { expect( generateFragmentFromHtml(`
`) ).toEqual( renderTemplate( ) ); }); test("generate nested css math functions as unparsed local styles", () => { const value = "clamp(1rem, calc(1rem + (2rem - 1rem) * ((100vw - 20rem) / (80rem - 20rem))), 2rem)"; const fragment = generateFragmentFromHtml(`
One clamp div
`); expect(fragment.styles).toEqual( expect.arrayContaining([ expect.objectContaining({ property: "fontSize", value: { type: "unparsed", value: "clamp(1rem,calc(1rem + (2rem - 1rem)*((100vw - 20rem)/(80rem - 20rem))),2rem)", }, }), ]) ); }); test("script as html embed", () => { expect(generateFragmentFromHtml(``)).toEqual( renderTemplate( <$.HtmlEmbed ws:label="Script" clientOnly={true} code={``} /> ) ); }); test("style as html embed", () => { expect(generateFragmentFromHtml(``)).toEqual( renderTemplate(<$.HtmlEmbed code={``} ws:label="Style" />) ); }); test("generate textarea element", () => { expect( generateFragmentFromHtml(`
`) ).toEqual( renderTemplate( ) ); }); test("generate select element", () => { expect( generateFragmentFromHtml(`
`) ).toEqual( renderTemplate( One Two One Two ) ); }); test("generate Image component instead of img element", () => { expect( generateFragmentFromHtml(`
`) ).toEqual( renderTemplate( <$.Image src="./my-url" /> ) ); }); test("strip unsupported attribute names", () => { expect( generateFragmentFromHtml(` `) ).toEqual( renderTemplate( <> Expand Toggle ) ); }); describe("style tag to tokens", () => { test("extract single class from style tag as token", () => { const cardToken = token( "card", css` display: flex; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("extract multiple classes from style tag as tokens", () => { const cardToken = token( "card", css` display: flex; padding: 16px; ` ); const titleToken = token( "title", css` font-size: 24px; font-weight: bold; ` ); expect( generateFragmentFromHtml(`

Hello

`) ).toEqual( renderTemplate( Hello ) ); }); test("reuse same token when class appears on multiple elements", () => { const cardToken = token( "card", css` display: flex; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("preserve unresolved class names as class prop", () => { const cardToken = token( "card", css` display: flex; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("remove class prop entirely when all classes resolved to tokens", () => { const fragment = generateFragmentFromHtml(`
Hello
`); expect(fragment.props.find((p) => p.name === "class")).toBeUndefined(); }); test("combine inline style attribute with class token", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const cardToken = fragment.styleSources.find( (source) => source.type === "token" && source.name === "card" ); 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" }, }, ]) ); }); test("no html embed when all style rules are class rules", () => { const fragment = generateFragmentFromHtml(`

Hello

`); expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); test("keep style tag as html embed when it has no class rules", () => { expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( <> <$.HtmlEmbed ws:label="Style" code={``} /> Hello ) ); }); test("extract class rules and keep remaining rules as html embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // token for .card should be created const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // remaining #hero rule should stay as HtmlEmbed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); const codeProp = fragment.props.find( (p) => p.instanceId === htmlEmbed!.id && p.name === "code" ); expect(codeProp).toBeDefined(); expect(codeProp!.type === "string" && codeProp!.value).toContain("#hero"); // class prop should not be set since .card was resolved expect(fragment.props.find((p) => p.name === "class")).toBeUndefined(); }); test("extract classes from multiple style tags", () => { const cardToken = token( "card", css` display: flex; ` ); const titleToken = token( "title", css` font-size: 24px; ` ); expect( generateFragmentFromHtml(`

Hello

`) ).toEqual( renderTemplate( Hello ) ); }); test("resolve nested selectors - descendant", () => { const cardInnerToken = token( "card__inner", css` color: red; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("resolve nested selectors - child combinator", () => { const parentChildToken = token( "parent__child", css` margin: 0; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("ignore element selectors in style tag", () => { // element selectors like `div { }` should not become tokens expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( <> <$.HtmlEmbed ws:label="Style" code={``} /> Hello ) ); }); test("handle multiple classes on one element with multiple tokens", () => { const cardToken = token( "card", css` display: flex; ` ); const largeToken = token( "large", css` font-size: 32px; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("handle class with multiple properties", () => { const cardToken = token( "card", css` display: flex; gap: 16px; padding: 24px; border-radius: 8px; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("empty style tag produces no html embed", () => { expect( generateFragmentFromHtml(`
Hello
`) ).toEqual(renderTemplate(Hello)); }); test("class attribute without any style tag stays as prop", () => { // existing behavior: class is just a prop when no style tag is present expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("style tag with only whitespace produces no html embed", () => { expect( generateFragmentFromHtml(`
Hello
`) ).toEqual(renderTemplate(Hello)); }); test("extract token and apply to nested structure", () => { const wrapperToken = token( "wrapper", css` max-width: 1200px; margin-left: auto; margin-right: auto; ` ); const gridToken = token( "grid", css` display: grid; gap: 16px; ` ); const itemToken = token( "item", css` padding: 8px; ` ); expect( generateFragmentFromHtml(`
One
Two
Three
`) ).toEqual( renderTemplate( One Two Three ) ); }); test("element with both resolved and unresolved classes plus inline style", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const cardToken = fragment.styleSources.find( (source) => source.type === "token" && source.name === "card" ); 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" }, }, ]) ); }); test("token names preserve original class names", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenNames = fragment.styleSources .filter((s) => s.type === "token") .map((s) => (s as { name: string }).name); expect(tokenNames).toContain("my-component"); expect(tokenNames).toContain("text-lg"); expect(tokenNames).toContain("bg_primary"); }); test("class defined in style tag but not used by any element", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // all class rules become tokens, even if not referenced by elements const tokenNames = fragment.styleSources .filter((s) => s.type === "token") .map((s) => (s as { name: string }).name); expect(tokenNames).toContain("used"); expect(tokenNames).toContain("unused"); // only "used" is assigned to the instance const divInstance = fragment.instances.find((i) => i.tag === "div"); const sel = fragment.styleSourceSelections.find( (s) => s.instanceId === divInstance?.id ); const usedToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "used" ); const unusedToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "unused" ); expect(sel?.values).toContain(usedToken?.id); expect(sel?.values).not.toContain(unusedToken?.id); // no HtmlEmbed since all rules are class rules expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); test("duplicate class rules across multiple style tags are merged", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // should create a single token with both properties const tokens = fragment.styleSources.filter((s) => s.type === "token"); expect(tokens).toHaveLength(1); const tokenId = tokens[0].id; const tokenStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenId ); const properties = tokenStyles.map((s) => s.property); expect(properties).toContain("display"); expect(properties).toContain("paddingTop"); }); test("script tag still becomes html embed alongside token extraction", () => { const cardToken = token( "card", css` display: flex; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( <> <$.HtmlEmbed ws:label="Script" clientOnly={true} code={``} /> Hello ) ); }); test("style tag with pseudo-class creates token with state", () => { const fragment = generateFragmentFromHtml(` `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn" ); expect(tokenSource).toBeDefined(); // base style const baseStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "backgroundColor" && s.state === undefined ); expect(baseStyle).toBeDefined(); // hover state style const hoverStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "backgroundColor" && s.state === ":hover" ); expect(hoverStyle).toBeDefined(); }); test("style tag with media query creates token with breakpoint styles", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "container" ); expect(tokenSource).toBeDefined(); // should have styles on different breakpoints const tokenStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id ); const breakpointIds = new Set(tokenStyles.map((s) => s.breakpointId)); // should have at least 2 different breakpoints (base + media query) expect(breakpointIds.size).toBeGreaterThanOrEqual(2); }); test("handle real-world html snippet with style tag", () => { const heroToken = token( "hero", css` display: flex; flex-direction: column; align-items: center; padding: 64px; ` ); const heroTitleToken = token( "hero-title", css` font-size: 48px; font-weight: bold; line-height: 1.2; ` ); const heroTextToken = token( "hero-text", css` font-size: 18px; color: gray; ` ); expect( generateFragmentFromHtml(`

Welcome

Build something amazing

`) ).toEqual( renderTemplate( Welcome Build something amazing ) ); }); test("create tokens from style-only paste without HTML elements", () => { const result = generateFragmentFromHtml(` `); expect(result.children).toEqual([]); expect(result.instances).toEqual([]); expect(result.styleSources).toEqual([ { type: "token", id: expect.any(String), name: "card" }, ]); expect(result.styles).toEqual([ { styleSourceId: result.styleSources[0].id, breakpointId: expect.any(String), property: "display", value: { type: "keyword", value: "flex" }, }, { styleSourceId: result.styleSources[0].id, breakpointId: expect.any(String), property: "paddingTop", value: { type: "unit", unit: "px", value: 16 }, }, { styleSourceId: result.styleSources[0].id, breakpointId: expect.any(String), property: "paddingRight", value: { type: "unit", unit: "px", value: 16 }, }, { styleSourceId: result.styleSources[0].id, breakpointId: expect.any(String), property: "paddingBottom", value: { type: "unit", unit: "px", value: 16 }, }, { styleSourceId: result.styleSources[0].id, breakpointId: expect.any(String), property: "paddingLeft", value: { type: "unit", unit: "px", value: 16 }, }, ]); expect(result.styleSourceSelections).toEqual([]); }); test("create multiple tokens from style-only paste", () => { const result = generateFragmentFromHtml(` `); expect(result.children).toEqual([]); expect(result.styleSources).toEqual([ { type: "token", id: expect.any(String), name: "card" }, { type: "token", id: expect.any(String), name: "title" }, ]); expect(result.styles).toHaveLength(2); }); test("create tokens with media queries from style-only paste", () => { const result = generateFragmentFromHtml(` `); expect(result.children).toEqual([]); expect(result.styleSources).toEqual([ { type: "token", id: expect.any(String), name: "card" }, ]); // base style + breakpoint style expect(result.styles).toHaveLength(2); expect(result.breakpoints).toHaveLength(2); // base + 768px }); test("style-only paste with non-class rules creates leftover embed", () => { const result = generateFragmentFromHtml(` `); // card token created expect(result.styleSources).toEqual([ { type: "token", id: expect.any(String), name: "card" }, ]); // leftover non-class rule kept as HtmlEmbed expect(result.instances).toHaveLength(1); expect(result.instances[0].component).toBe("HtmlEmbed"); }); describe("media queries", () => { test("extract class with min-width media query into breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // base style const baseStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "display" && s.state === undefined ); expect(baseStyle).toBeDefined(); // breakpoint style const bpStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "flexDirection" ); expect(bpStyle).toBeDefined(); expect(bpStyle!.breakpointId).not.toBe(baseStyle!.breakpointId); // breakpoint should be created with minWidth const bp = fragment.breakpoints.find( (b) => b.id === bpStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.minWidth).toBe(768); }); test("extract class with max-width media query into breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "container" ); expect(tokenSource).toBeDefined(); const bpStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "paddingTop" && s.breakpointId !== "base" ); expect(bpStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === bpStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.maxWidth).toBe(480); }); test("extract class across multiple media queries", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); const tokenStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id ); const breakpointIds = new Set(tokenStyles.map((s) => s.breakpointId)); // base + 768px + 1024px = 3 breakpoints expect(breakpointIds.size).toBe(3); }); test("media query with non-class selectors stays as html embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( true ); expect( fragment.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); }); test("media query with mixed class and non-class selectors", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // .card in media query should become token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // #hero in media query should remain as html embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); }); test("same class in base and media query produces single token", () => { const fragment = generateFragmentFromHtml(` `); const tokens = fragment.styleSources.filter((s) => s.type === "token"); expect(tokens).toHaveLength(1); expect(tokens[0].type === "token" && tokens[0].name).toBe("btn"); }); test("multiple classes in same media query", () => { const fragment = generateFragmentFromHtml(`

Hello

`); const tokens = fragment.styleSources.filter((s) => s.type === "token"); expect(tokens).toHaveLength(2); const cardToken = tokens.find( (s) => s.type === "token" && s.name === "card" ); const titleToken = tokens.find( (s) => s.type === "token" && s.name === "title" ); expect(cardToken).toBeDefined(); expect(titleToken).toBeDefined(); }); }); describe("pseudo-classes and pseudo-elements", () => { test("extract :hover pseudo-class as state", () => { const fragment = generateFragmentFromHtml(` Click `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "link" ); expect(tokenSource).toBeDefined(); const hoverStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.state === ":hover" ); expect(hoverStyle).toBeDefined(); }); test("extract :focus pseudo-class as state", () => { const fragment = generateFragmentFromHtml(` `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "input" ); expect(tokenSource).toBeDefined(); const focusStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.state === ":focus" ); expect(focusStyle).toBeDefined(); }); test("extract multiple pseudo-classes on same class", () => { const fragment = generateFragmentFromHtml(` `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn" ); expect(tokenSource).toBeDefined(); const states = fragment.styles .filter((s) => s.styleSourceId === tokenSource!.id && s.state) .map((s) => s.state); expect(states).toContain(":hover"); expect(states).toContain(":focus"); expect(states).toContain(":active"); }); test("extract ::before pseudo-element as state", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "decorated" ); expect(tokenSource).toBeDefined(); const beforeStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.state === "::before" ); expect(beforeStyle).toBeDefined(); }); test("extract ::after pseudo-element as state", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "clearfix" ); expect(tokenSource).toBeDefined(); const afterStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id && s.state === "::after" ); expect(afterStyles.length).toBeGreaterThanOrEqual(1); }); test("pseudo-class on non-class selector ignored (stays in embed)", () => { const fragment = generateFragmentFromHtml(` Link `); expect( fragment.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( true ); }); test("pseudo-class combined with media query", () => { const fragment = generateFragmentFromHtml(` `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn" ); expect(tokenSource).toBeDefined(); // should have hover styles on base breakpoint const baseHover = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.state === ":hover" && s.breakpointId === "base" ); expect(baseHover).toBeDefined(); // should have hover styles on 768px breakpoint const bpHover = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.state === ":hover" && s.breakpointId !== "base" ); expect(bpHover).toBeDefined(); }); test("::placeholder pseudo-element as state", () => { const fragment = generateFragmentFromHtml(` `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "input" ); expect(tokenSource).toBeDefined(); const placeholderStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.state === "::placeholder" ); expect(placeholderStyle).toBeDefined(); }); }); describe("at-rules and special CSS", () => { test("@keyframes stays as html embed", () => { const fragment = generateFragmentFromHtml(`
Loading
`); // .spinner class should still become a token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "spinner" ); expect(tokenSource).toBeDefined(); // @keyframes should remain as html embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); const codeProp = fragment.props.find( (p) => p.instanceId === htmlEmbed!.id && p.name === "code" ); expect(codeProp).toBeDefined(); expect(codeProp!.type === "string" && codeProp!.value).toContain( "@keyframes" ); }); test("@font-face stays as html embed", () => { const fragment = generateFragmentFromHtml(`

Hello

`); // .text class should become a token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "text" ); expect(tokenSource).toBeDefined(); // @font-face should remain as html embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); const codeProp = fragment.props.find( (p) => p.instanceId === htmlEmbed!.id && p.name === "code" ); expect(codeProp).toBeDefined(); expect(codeProp!.type === "string" && codeProp!.value).toContain( "@font-face" ); }); test("@media print stays as html embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // base .card should become a token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // @media print should remain as html embed since parseCss ignores print const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); }); test("grouped class selectors like .a, .b are extracted as separate tokens", () => { const fragment = generateFragmentFromHtml(` One Two `); const primaryToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "primary" ); const secondaryToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "secondary" ); expect(primaryToken).toBeDefined(); expect(secondaryToken).toBeDefined(); // both should have the same style const primaryStyle = fragment.styles.find( (s) => s.styleSourceId === primaryToken!.id ); const secondaryStyle = fragment.styles.find( (s) => s.styleSourceId === secondaryToken!.id ); expect(primaryStyle?.property).toBe("fontWeight"); expect(secondaryStyle?.property).toBe("fontWeight"); }); test("grouped selector with class + non-class stays partially in embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // .card should become token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // #hero part should remain as html embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); }); }); describe("condition breakpoints", () => { test("@media (prefers-color-scheme: dark) creates condition breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // should have base styles const baseStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "backgroundColor" && s.state === undefined ); expect(baseStyle).toBeDefined(); // should have dark mode styles on condition breakpoint const darkStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "backgroundColor" && s.breakpointId !== baseStyle!.breakpointId ); expect(darkStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === darkStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.condition).toBe("prefers-color-scheme:dark"); expect(bp!.minWidth).toBeUndefined(); expect(bp!.maxWidth).toBeUndefined(); }); test("@media (orientation: portrait) creates condition breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "layout" ); expect(tokenSource).toBeDefined(); const portraitStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "flexDirection" && s.breakpointId !== "base" ); expect(portraitStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === portraitStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.condition).toBe("orientation:portrait"); }); test("@media (hover: hover) creates condition breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "interactive" ); expect(tokenSource).toBeDefined(); const hoverStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "cursor" && s.breakpointId !== "base" ); expect(hoverStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === hoverStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.condition).toBe("hover:hover"); }); test("@media (prefers-reduced-motion: reduce) creates condition breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "animated" ); expect(tokenSource).toBeDefined(); const reduceStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "transitionProperty" && s.breakpointId !== "base" ); expect(reduceStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === reduceStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.condition).toBe("prefers-reduced-motion:reduce"); }); test("combined min-width and max-width uses range breakpoint", () => { const fragment = generateFragmentFromHtml(`
Tablet content
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "tablet-only" ); expect(tokenSource).toBeDefined(); const tabletStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "display" && s.breakpointId !== "base" ); expect(tabletStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === tabletStyle!.breakpointId ); expect(bp).toBeDefined(); // Combined width queries use both minWidth and maxWidth expect(bp!.minWidth).toBe(768); expect(bp!.maxWidth).toBe(1024); expect(bp!.condition).toBeUndefined(); }); test("overlapping min/max-width breakpoints get rounded to non-overlapping values", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "a" ); expect(tokenSource).toBeDefined(); const tokenStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" ); // base + 2 range breakpoints = 3 styles expect(tokenStyles).toHaveLength(3); const rangeBreakpoints = tokenStyles .filter((s) => s.breakpointId !== "base") .map((s) => fragment.breakpoints.find((b) => b.id === s.breakpointId)) .filter(Boolean) .sort((a, b) => (a!.minWidth ?? 0) - (b!.minWidth ?? 0)); expect(rangeBreakpoints).toHaveLength(2); // ranges should not overlap: first.maxWidth < second.minWidth const first = rangeBreakpoints[0]!; const second = rangeBreakpoints[1]!; expect(first.maxWidth).toBeDefined(); expect(second.minWidth).toBeDefined(); expect(first.maxWidth!).toBeLessThan(second.minWidth!); }); test("non-overlapping range breakpoints stay unchanged", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "a" ); expect(tokenSource).toBeDefined(); const rangeBreakpoints = fragment.breakpoints .filter((b) => b.minWidth !== undefined && b.maxWidth !== undefined) .sort((a, b) => (a.minWidth ?? 0) - (b.minWidth ?? 0)); expect(rangeBreakpoints).toHaveLength(2); // values should remain exactly as authored since they don't overlap expect(rangeBreakpoints[0].minWidth).toBe(400); expect(rangeBreakpoints[0].maxWidth).toBe(700); expect(rangeBreakpoints[1].minWidth).toBe(800); expect(rangeBreakpoints[1].maxWidth).toBe(1200); }); test("adjacent range breakpoints (touching) stay unchanged", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const rangeBreakpoints = fragment.breakpoints .filter((b) => b.minWidth !== undefined && b.maxWidth !== undefined) .sort((a, b) => (a.minWidth ?? 0) - (b.minWidth ?? 0)); expect(rangeBreakpoints).toHaveLength(2); // touching but not overlapping — keep as is expect(rangeBreakpoints[0].maxWidth).toBe(799); expect(rangeBreakpoints[1].minWidth).toBe(800); }); test("overlapping range: first.maxWidth rounded down to second.minWidth - 1", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const rangeBreakpoints = fragment.breakpoints .filter((b) => b.minWidth !== undefined && b.maxWidth !== undefined) .sort((a, b) => (a.minWidth ?? 0) - (b.minWidth ?? 0)); expect(rangeBreakpoints).toHaveLength(2); // first range's maxWidth should be adjusted to not overlap second's minWidth expect(rangeBreakpoints[0].minWidth).toBe(600); expect(rangeBreakpoints[0].maxWidth!).toBeLessThan( rangeBreakpoints[1].minWidth! ); // second range should keep its original values expect(rangeBreakpoints[1].minWidth).toBe(900); expect(rangeBreakpoints[1].maxWidth).toBe(1400); }); test("three overlapping ranges all get adjusted", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const rangeBreakpoints = fragment.breakpoints .filter((b) => b.minWidth !== undefined && b.maxWidth !== undefined) .sort((a, b) => (a.minWidth ?? 0) - (b.minWidth ?? 0)); expect(rangeBreakpoints).toHaveLength(3); // no pair should overlap expect(rangeBreakpoints[0].maxWidth!).toBeLessThan( rangeBreakpoints[1].minWidth! ); expect(rangeBreakpoints[1].maxWidth!).toBeLessThan( rangeBreakpoints[2].minWidth! ); }); test("exact duplicate range breakpoints get deduplicated", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // same range should reuse a single breakpoint const rangeBreakpoints = fragment.breakpoints.filter( (b) => b.minWidth !== undefined && b.maxWidth !== undefined ); expect(rangeBreakpoints).toHaveLength(1); expect(rangeBreakpoints[0].minWidth).toBe(768); expect(rangeBreakpoints[0].maxWidth).toBe(1024); }); test("range breakpoint overlapping with simple min-width breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "a" ); expect(tokenSource).toBeDefined(); const tokenStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" ); // base + min-width + range = 3 breakpoints expect(tokenStyles).toHaveLength(3); // simple min-width breakpoint should exist const minWidthBp = fragment.breakpoints.find( (b) => b.minWidth !== undefined && b.maxWidth === undefined && b.condition === undefined ); expect(minWidthBp).toBeDefined(); // range breakpoint should exist and not overlap min-width const rangeBp = fragment.breakpoints.find( (b) => b.minWidth !== undefined && b.maxWidth !== undefined ); expect(rangeBp).toBeDefined(); // range's maxWidth must be less than the min-width breakpoint's minWidth // so they don't overlap expect(rangeBp!.maxWidth!).toBeLessThan(minWidthBp!.minWidth!); }); test("range breakpoint overlapping with simple max-width breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "a" ); expect(tokenSource).toBeDefined(); const tokenStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" ); // base + max-width + range = 3 breakpoints expect(tokenStyles).toHaveLength(3); // simple max-width breakpoint should exist const maxWidthBp = fragment.breakpoints.find( (b) => b.maxWidth !== undefined && b.minWidth === undefined && b.condition === undefined ); expect(maxWidthBp).toBeDefined(); // range breakpoint should exist and not overlap max-width const rangeBp = fragment.breakpoints.find( (b) => b.minWidth !== undefined && b.maxWidth !== undefined ); expect(rangeBp).toBeDefined(); // range's minWidth must be greater than the max-width breakpoint's maxWidth expect(rangeBp!.minWidth!).toBeGreaterThan(maxWidthBp!.maxWidth!); }); test("completely contained range gets clamped", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const rangeBreakpoints = fragment.breakpoints .filter((b) => b.minWidth !== undefined && b.maxWidth !== undefined) .sort((a, b) => (a.minWidth ?? 0) - (b.minWidth ?? 0)); expect(rangeBreakpoints).toHaveLength(2); // the outer range should be split/adjusted so inner doesn't overlap expect(rangeBreakpoints[0].maxWidth!).toBeLessThan( rangeBreakpoints[1].minWidth! ); }); test("combined width and feature uses condition breakpoint", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "widget" ); expect(tokenSource).toBeDefined(); const landscapeStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" && s.breakpointId !== "base" ); expect(landscapeStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === landscapeStyle!.breakpointId ); expect(bp).toBeDefined(); // Mixed width + feature query becomes a condition expect(bp!.condition).toBeDefined(); }); test("multiple condition features combined", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "accessible" ); expect(tokenSource).toBeDefined(); const conditionStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" && s.breakpointId !== "base" ); expect(conditionStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === conditionStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.condition).toBeDefined(); // condition should contain both features expect(bp!.condition).toContain("prefers-color-scheme"); expect(bp!.condition).toContain("prefers-contrast"); }); test("same condition breakpoint reused for multiple classes", () => { const fragment = generateFragmentFromHtml(`

Hello

`); const cardToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); const textToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "text" ); expect(cardToken).toBeDefined(); expect(textToken).toBeDefined(); // both dark styles should reference the same breakpoint const cardDarkStyle = fragment.styles.find( (s) => s.styleSourceId === cardToken!.id && s.property === "backgroundColor" && s.breakpointId !== "base" ); const textDarkStyle = fragment.styles.find( (s) => s.styleSourceId === textToken!.id && s.property === "color" && s.breakpointId !== "base" ); expect(cardDarkStyle).toBeDefined(); expect(textDarkStyle).toBeDefined(); expect(cardDarkStyle!.breakpointId).toBe(textDarkStyle!.breakpointId); }); }); describe("nested at-rules", () => { test("nested @media rules extract class with flattened condition", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // nested @media should flatten into a combined condition const nestedStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "backgroundColor" ); expect(nestedStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === nestedStyle!.breakpointId ); expect(bp).toBeDefined(); // flattened condition should include both queries expect(bp!.condition).toBeDefined(); }); test("deeply nested @media still extracts class tokens", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); const deepStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "paddingTop" && s.breakpointId !== "base" ); expect(deepStyle).toBeDefined(); const bp = fragment.breakpoints.find( (b) => b.id === deepStyle!.breakpointId ); expect(bp).toBeDefined(); expect(bp!.condition).toBeDefined(); }); test("nested @media with same feature type combines correctly", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "responsive" ); expect(tokenSource).toBeDefined(); // outer @media (min-width: 768px) should have simple breakpoint const outerStyle = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" && s.breakpointId !== "base" && fragment.breakpoints.find( (b) => b.id === s.breakpointId && b.minWidth === 768 ) ); expect(outerStyle).toBeDefined(); // nested @media (min-width: 1024px) inside @media (min-width: 768px) // should still produce a breakpoint (the effective min-width is 1024px) const innerStyles = fragment.styles.filter( (s) => s.styleSourceId === tokenSource!.id && s.property === "fontSize" && s.breakpointId !== "base" && s.breakpointId !== outerStyle!.breakpointId ); expect(innerStyles).toHaveLength(1); }); test("@supports nested inside @media keeps class in embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // base class should become token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "grid" ); expect(tokenSource).toBeDefined(); // @supports should remain as html embed (not a media query) const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); }); }); describe("selector edge cases", () => { test("class with compound selector .card.active is extracted as combo token", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // compound selectors become combo tokens (Webflow-style naming) const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card.active" ); expect(tokenSource).toBeDefined(); // no leftover for pure class rule expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); test("class with attribute selector .btn[disabled] is extracted with state", () => { const fragment = generateFragmentFromHtml(` `); const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn" ); expect(tokenSource).toBeDefined(); // the style should have [disabled] state const style = fragment.styles.find( (s) => s.styleSourceId === tokenSource!.id ); expect(style).toBeDefined(); expect(style!.state).toBe("[disabled]"); // no leftover expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); test("class with sibling combinator .a + .b is not extracted", () => { const fragment = generateFragmentFromHtml(`
A
B
`); expect( fragment.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); }); test("class with general sibling .a ~ .b is not extracted", () => { const fragment = generateFragmentFromHtml(`
A
B
`); expect( fragment.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); }); test(":root selector is extracted as local styles on the root instance, not as html embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // .card should become token const tokenSource = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(tokenSource).toBeDefined(); // :root should NOT produce an html embed expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); // :root styles should be on the root instance selection 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(); }); test(":root only styles are extracted as local styles on root instance", () => { const fragment = generateFragmentFromHtml(` `); // no html embed expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); // style on :root instance const rootSelection = fragment.styleSourceSelections.find( (sel) => sel.instanceId === ":root" ); expect(rootSelection).toBeDefined(); const rootStyleSourceId = rootSelection!.values[0]; const rootStyle = fragment.styles.find( (s) => s.styleSourceId === rootStyleSourceId && s.property === "color" ); expect(rootStyle).toBeDefined(); expect(rootStyle!.value).toMatchObject({ type: "keyword", value: "red" }); }); test("cross-selector gradient vars stay as var() references in pasted tokens", () => { const fragment = generateFragmentFromHtml(`
test
`); const tokenSource = fragment.styleSources.find( (source) => source.type === "token" && source.name === "panel" ); expect(tokenSource).toBeDefined(); const backgroundImage = fragment.styles.find( (style) => style.styleSourceId === tokenSource!.id && style.property === "backgroundImage" ); expect(backgroundImage?.value).toMatchObject({ type: "layers", value: [ { type: "unparsed", value: "linear-gradient(38deg,var(--tone-1),var(--tone-2),var(--tone-3))", }, ], }); expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); test("universal selector * stays as html embed", () => { const fragment = generateFragmentFromHtml(`
Hello
`); expect( fragment.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( true ); }); }); describe("compound class selectors", () => { test("compound .card.active extracts as combo token when element has both classes", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const cardToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); const comboToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card.active" ); expect(cardToken).toBeDefined(); expect(comboToken).toBeDefined(); // instance should reference both tokens const sel = fragment.styleSourceSelections.find( (s) => s.values.includes(cardToken!.id) && s.values.includes(comboToken!.id) ); expect(sel).toBeDefined(); }); test("compound .a.b.c extracts as triple combo token", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const comboToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "a.b.c" ); expect(comboToken).toBeDefined(); const style = fragment.styles.find( (s) => s.styleSourceId === comboToken!.id && s.property === "color" ); expect(style).toBeDefined(); }); test("compound class not matched when element is missing a class", () => { const fragment = generateFragmentFromHtml(`
Hello
`); // token is created for all class rules, even if not applied to elements const comboToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card.active" ); expect(comboToken).toBeDefined(); // but NOT assigned to the div (missing "active" class) const divInstance = fragment.instances.find((i) => i.tag === "div"); const sel = fragment.styleSourceSelections.find( (s) => s.instanceId === divInstance?.id ); // No selection for this instance, or if one exists, it shouldn't contain the combo token expect(sel?.values ?? []).not.toContain(comboToken?.id); }); test("compound class with :hover pseudo extracts correctly", () => { const fragment = generateFragmentFromHtml(` `); const comboToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn.primary" ); expect(comboToken).toBeDefined(); const style = fragment.styles.find( (s) => s.styleSourceId === comboToken!.id ); expect(style).toBeDefined(); expect(style!.state).toBe(":hover"); }); test("compound class inside @media query creates breakpoint style", () => { const fragment = generateFragmentFromHtml(` `); const comboToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card.featured" ); expect(comboToken).toBeDefined(); // Should have both base and breakpoint styles const comboStyles = fragment.styles.filter( (s) => s.styleSourceId === comboToken!.id ); expect(comboStyles.length).toBe(2); const bpIds = new Set(comboStyles.map((s) => s.breakpointId)); expect(bpIds.size).toBe(2); }); }); describe("attribute selectors as state", () => { test(".btn[disabled] extracts with [disabled] state", () => { const fragment = generateFragmentFromHtml(` `); const btnToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn" ); expect(btnToken).toBeDefined(); const baseStyle = fragment.styles.find( (s) => s.styleSourceId === btnToken!.id && s.state === undefined ); expect(baseStyle).toBeDefined(); expect(baseStyle!.property).toBe("paddingTop"); const stateStyle = fragment.styles.find( (s) => s.styleSourceId === btnToken!.id && s.state === "[disabled]" ); expect(stateStyle).toBeDefined(); expect(stateStyle!.property).toBe("opacity"); }); test(".input[readonly] extracts with [readonly] state", () => { const fragment = generateFragmentFromHtml(` `); const inputToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "input" ); expect(inputToken).toBeDefined(); const stateStyle = fragment.styles.find( (s) => s.styleSourceId === inputToken!.id && s.state === "[readonly]" ); expect(stateStyle).toBeDefined(); }); test("attribute selector combined with pseudo keeps pseudo state", () => { const fragment = generateFragmentFromHtml(` `); const btnToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "btn" ); expect(btnToken).toBeDefined(); const hoverStyle = fragment.styles.find( (s) => s.styleSourceId === btnToken!.id && s.state === ":hover" ); expect(hoverStyle).toBeDefined(); const disabledStyle = fragment.styles.find( (s) => s.styleSourceId === btnToken!.id && s.state === "[disabled]" ); expect(disabledStyle).toBeDefined(); }); }); describe("mixed comma selectors (partial extraction)", () => { test(".card, div {} extracts .card token, keeps div in leftover", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const cardToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); expect(cardToken).toBeDefined(); // div part should stay as html embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); const codeProp = fragment.props.find( (p) => p.instanceId === htmlEmbed!.id && p.name === "code" ); expect(codeProp).toBeDefined(); // The leftover should only contain the div selector, not .card expect(codeProp!.type === "string" && codeProp!.value).toContain("div"); expect(codeProp!.type === "string" && codeProp!.value).not.toMatch( /\.card/ ); }); test(".a, .b, #id {} extracts .a and .b, keeps #id in leftover", () => { const fragment = generateFragmentFromHtml(`
A
B
`); const tokenA = fragment.styleSources.find( (s) => s.type === "token" && s.name === "a" ); const tokenB = fragment.styleSources.find( (s) => s.type === "token" && s.name === "b" ); expect(tokenA).toBeDefined(); expect(tokenB).toBeDefined(); // #id should stay in embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); }); test(".a, .b {} with all class selectors produces no leftover", () => { const fragment = generateFragmentFromHtml(`
A
B
`); // Both should become tokens expect( fragment.styleSources.filter((s) => s.type === "token") ).toHaveLength(2); // No leftover embed expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); }); describe("breakpoint labels", () => { test("condition breakpoint gets human-readable label", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const condBp = fragment.breakpoints.find((b) => b.condition); expect(condBp).toBeDefined(); // capitalCase transforms "prefers-color-scheme:dark" → "Prefers Color Scheme Dark" expect(condBp!.label).toBe("Prefers Color Scheme Dark"); }); test("width breakpoint gets readable label", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const bp = fragment.breakpoints.find((b) => b.minWidth === 768); expect(bp).toBeDefined(); expect(bp!.label).toBe("≥ 768px"); }); test("range breakpoint gets combined label", () => { const fragment = generateFragmentFromHtml(`
Hello
`); const bp = fragment.breakpoints.find( (b) => b.minWidth === 768 && b.maxWidth === 1024 ); expect(bp).toBeDefined(); expect(bp!.label).toBe("≥ 768px and ≤ 1024px"); }); }); describe("complex real-world scenarios", () => { test("responsive card component with hover states", () => { const fragment = generateFragmentFromHtml(`

Title

Description

`); // both tokens should be created const cardToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ); const titleToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "card-title" ); expect(cardToken).toBeDefined(); expect(titleToken).toBeDefined(); // card should have hover state expect( fragment.styles.some( (s) => s.styleSourceId === cardToken!.id && s.state === ":hover" ) ).toBe(true); // card should have breakpoint styles const cardBreakpoints = new Set( fragment.styles .filter((s) => s.styleSourceId === cardToken!.id && !s.state) .map((s) => s.breakpointId) ); expect(cardBreakpoints.size).toBeGreaterThanOrEqual(2); // title should have breakpoint styles const titleBreakpoints = new Set( fragment.styles .filter((s) => s.styleSourceId === titleToken!.id) .map((s) => s.breakpointId) ); expect(titleBreakpoints.size).toBeGreaterThanOrEqual(2); // no HtmlEmbed (all rules are class-based) expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe( false ); }); test("html with mixed class rules, id rules, keyframes, and media queries", () => { const fragment = generateFragmentFromHtml(`

Hello

`); // class tokens should be extracted expect( fragment.styleSources.find( (s) => s.type === "token" && s.name === "card" ) ).toBeDefined(); expect( fragment.styleSources.find( (s) => s.type === "token" && s.name === "fade-in" ) ).toBeDefined(); // @keyframes and #app should remain as html embed const htmlEmbed = fragment.instances.find( (i) => i.component === "HtmlEmbed" ); expect(htmlEmbed).toBeDefined(); const codeProp = fragment.props.find( (p) => p.instanceId === htmlEmbed!.id && p.name === "code" ); expect(codeProp!.type === "string" && codeProp!.value).toContain( "@keyframes" ); expect(codeProp!.type === "string" && codeProp!.value).toContain("#app"); }); test("navigation component with multiple states and breakpoints", () => { const fragment = generateFragmentFromHtml(` `); const navToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "nav" ); const linkToken = fragment.styleSources.find( (s) => s.type === "token" && s.name === "nav-link" ); expect(navToken).toBeDefined(); expect(linkToken).toBeDefined(); // nav-link should have hover, focus, and ::after states const linkStates = new Set( fragment.styles .filter((s) => s.styleSourceId === linkToken!.id && s.state) .map((s) => s.state) ); expect(linkStates.has(":hover")).toBe(true); expect(linkStates.has(":focus")).toBe(true); expect(linkStates.has("::after")).toBe(true); // both should have breakpoint styles const navBreakpoints = new Set( fragment.styles .filter((s) => s.styleSourceId === navToken!.id) .map((s) => s.breakpointId) ); expect(navBreakpoints.size).toBeGreaterThanOrEqual(2); // nav-link token should be reused for both elements const linkSelections = fragment.styleSourceSelections.filter((sel) => sel.values.includes(linkToken!.id) ); expect(linkSelections).toHaveLength(2); }); }); describe("nested selectors", () => { test("resolve descendant selector to matching element", () => { const cardInnerToken = token( "card__inner", css` color: red; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("resolve child combinator to direct child", () => { const parentChildToken = token( "parent__child", css` margin: 0; ` ); expect( generateFragmentFromHtml(`
Hello
`) ).toEqual( renderTemplate( Hello ) ); }); test("child combinator does not match non-direct child", () => { const result = _generateFragmentFromHtml(`
Hello
`); // .child is nested inside an intermediate div, not a direct child of .parent expect(result.skippedSelectors).toEqual([".parent > .child"]); // no token created for the nested rule expect( result.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); }); test("skip unresolved nested selector and report it", () => { const result = _generateFragmentFromHtml(`
No title class here
`); expect(result.skippedSelectors).toEqual([".card .title"]); expect( result.styleSources.filter((s) => s.type === "token") ).toHaveLength(0); }); test("style-only paste with nested selector — all skipped", () => { const result = _generateFragmentFromHtml(` `); expect(result.skippedSelectors).toEqual([".card .title"]); expect(result.styleSources).toHaveLength(0); }); test("mixed simple and nested selectors", () => { const result = _generateFragmentFromHtml(`

Hello

`); expect(result.skippedSelectors).toEqual([]); // .card token + .card__title nested token const tokenNames = result.styleSources .filter((s) => s.type === "token") .map((s) => s.name); expect(tokenNames).toContain("card"); expect(tokenNames).toContain("card__title"); }); test("multiple ancestors: .a .b .c", () => { const result = _generateFragmentFromHtml(`
Hi
`); expect(result.skippedSelectors).toEqual([]); const tokenNames = result.styleSources .filter((s) => s.type === "token") .map((s) => s.name); expect(tokenNames).toContain("a__b__c"); }); test("nested selector with media query", () => { const result = _generateFragmentFromHtml(`

Hello

`); expect(result.skippedSelectors).toEqual([]); const nestedToken = result.styleSources.find( (s) => s.type === "token" && s.name === "card__title" ); expect(nestedToken).toBeDefined(); // should have styles at base breakpoint and 768px breakpoint const nestedStyles = result.styles.filter( (s) => s.styleSourceId === nestedToken!.id ); const breakpointIds = new Set(nestedStyles.map((s) => s.breakpointId)); expect(breakpointIds.size).toBe(2); }); test("sibling combinators stay as leftover", () => { const result = _generateFragmentFromHtml(`
A
B
`); // sibling selector is not class-based → stays as HtmlEmbed expect(result.instances.some((i) => i.component === "HtmlEmbed")).toBe( true ); }); }); test("resolves cross-rule CSS variable in background shorthand", () => { // --clr-red is defined in .my-parent but used in .my-child. // background: var(--clr-red) must expand to background-color with the // var() reference preserved — NOT inlined to the concrete color #f00. const result = _generateFragmentFromHtml(`
test
`); const childToken = result.styleSources.find( (s) => s.type === "token" && s.name === "my-child" ); expect(childToken).toBeDefined(); const bgColor = result.styles.find( (s) => s.styleSourceId === childToken!.id && s.property === "backgroundColor" ); // Must be a var() reference, not a concrete inlined color expect(bgColor?.value).toEqual( 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" }) ); }); }); });