@@ -27,6 +27,7 @@ import {
2727import { richTextContentTags } from "./content-model" ;
2828import { setIsSubsetOf } from "./shim" ;
2929import { isAttributeNameSafe } from "@webstudio-is/react-sdk" ;
30+ import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk" ;
3031import * as csstree from "css-tree" ;
3132import { titleCase } from "title-case" ;
3233
@@ -194,13 +195,19 @@ const classifyRules = (
194195) : {
195196 classRules : Map < string , ParsedStyleDecl [ ] > ;
196197 nestedClassRules : Map < string , NestedClassRule > ;
198+ rootRules : ParsedStyleDecl [ ] ;
197199 hasNonClassRules : boolean ;
198200} => {
199201 const classRules = new Map < string , ParsedStyleDecl [ ] > ( ) ;
200202 const nestedClassRules = new Map < string , NestedClassRule > ( ) ;
203+ const rootRules : ParsedStyleDecl [ ] = [ ] ;
201204 let hasNonClassRules = false ;
202205
203206 for ( const decl of decls ) {
207+ if ( decl . selector === ":root" ) {
208+ rootRules . push ( decl ) ;
209+ continue ;
210+ }
204211 const parsed = parseClassBasedSelector ( decl . selector ) ;
205212 if ( parsed !== undefined ) {
206213 const selectorState = parsed . states ?. [ 0 ] ;
@@ -229,7 +236,7 @@ const classifyRules = (
229236 hasNonClassRules = true ;
230237 }
231238 }
232- return { classRules, nestedClassRules, hasNonClassRules } ;
239+ return { classRules, nestedClassRules, rootRules , hasNonClassRules } ;
233240} ;
234241
235242/**
@@ -245,12 +252,20 @@ const buildLeftoverCss = (cssText: string): string => {
245252 const parts : string [ ] = [ ] ;
246253
247254 /** Re-use parseClassBasedSelector as single source of truth */
248- const isClassBasedSelector = ( selector : csstree . CssNode ) : boolean =>
249- selector . type === "Selector" &&
250- parseClassBasedSelector ( csstree . generate ( selector ) ) !== undefined ;
255+ const isClassBasedSelector = ( selector : csstree . CssNode ) : boolean => {
256+ if ( selector . type !== "Selector" ) {
257+ return false ;
258+ }
259+ const text = csstree . generate ( selector ) ;
260+ // :root rules are extracted separately — treat as non-leftover
261+ if ( text === ":root" ) {
262+ return true ;
263+ }
264+ return parseClassBasedSelector ( text ) !== undefined ;
265+ } ;
251266
252267 /**
253- * Process a Rule: if all selectors are class-based, skip entirely.
268+ * Process a Rule: if all selectors are class-based or :root , skip entirely.
254269 * If none are, keep entirely. If mixed, keep only non-class selectors.
255270 */
256271 const getLeftoverRule = ( node : csstree . Rule ) : string | undefined => {
@@ -537,7 +552,7 @@ export const generateFragmentFromHtml = (
537552
538553 // Parse all CSS and classify rules
539554 const { styles : allDecls } = parseCss ( allCssText , allCssVars ) ;
540- const { classRules, nestedClassRules } = classifyRules ( allDecls ) ;
555+ const { classRules, nestedClassRules, rootRules } = classifyRules ( allDecls ) ;
541556
542557 // Track which class names are used by elements — IDs will be assigned later
543558 const usedClassNames = new Set < string > ( ) ;
@@ -561,19 +576,32 @@ export const generateFragmentFromHtml = (
561576 const {
562577 classRules : tagClassRules ,
563578 nestedClassRules : tagNestedRules ,
579+ rootRules : tagRootRules ,
564580 hasNonClassRules : tagHasNonClass ,
565581 } = classifyRules ( parsedDecls ) ;
566582
567583 if (
568584 parsedDecls . length === 0 &&
569585 tagClassRules . size === 0 &&
570- tagNestedRules . size === 0
586+ tagNestedRules . size === 0 &&
587+ tagRootRules . length === 0
571588 ) {
572589 // Unparseable CSS — keep original
573590 styleTagActions . push ( { type : "keep-original" } ) ;
574- } else if ( tagClassRules . size === 0 && tagNestedRules . size === 0 ) {
575- // Only non-class rules — keep original
591+ } else if (
592+ tagClassRules . size === 0 &&
593+ tagNestedRules . size === 0 &&
594+ tagRootRules . length === 0
595+ ) {
596+ // Only non-class, non-root element rules — keep original HtmlEmbed
576597 styleTagActions . push ( { type : "keep-original" } ) ;
598+ } else if (
599+ tagClassRules . size === 0 &&
600+ tagNestedRules . size === 0 &&
601+ ! tagHasNonClass
602+ ) {
603+ // Only :root rules — extracted to ROOT_INSTANCE_ID, skip HtmlEmbed
604+ styleTagActions . push ( { type : "skip" } ) ;
577605 } else if ( ! tagHasNonClass ) {
578606 // Only class rules — also check for unsupported media like @media print
579607 const leftover = buildLeftoverCss ( text ) ;
@@ -1030,6 +1058,25 @@ export const generateFragmentFromHtml = (
10301058 }
10311059 }
10321060
1061+ // Inject :root styles as a local style source on ROOT_INSTANCE_ID
1062+ if ( rootRules . length > 0 ) {
1063+ const rootStyleSourceId = getNewId ( ) ;
1064+ styleSources . push ( { type : "local" , id : rootStyleSourceId } ) ;
1065+ styleSourceSelections . push ( {
1066+ instanceId : ROOT_INSTANCE_ID ,
1067+ values : [ rootStyleSourceId ] ,
1068+ } ) ;
1069+ for ( const decl of rootRules ) {
1070+ styles . push ( {
1071+ styleSourceId : rootStyleSourceId ,
1072+ breakpointId : getBaseBreakpointId ( ) ,
1073+ property : camelCaseProperty ( decl . property ) ,
1074+ value : decl . value ,
1075+ ...( decl . state ? { state : decl . state } : { } ) ,
1076+ } ) ;
1077+ }
1078+ }
1079+
10331080 // Create style source selections for instances that use tokens
10341081 const selectionsByInstance = new Map (
10351082 styleSourceSelections . map ( ( sel ) => [ sel . instanceId , sel ] )
@@ -1041,7 +1088,7 @@ export const generateFragmentFromHtml = (
10411088 if ( tokenIds . length > 0 ) {
10421089 const existingSelection = selectionsByInstance . get ( instanceId ) ;
10431090 if ( existingSelection ) {
1044- existingSelection . values . push ( ... tokenIds ) ;
1091+ existingSelection . values = [ ... tokenIds , ... existingSelection . values ] ;
10451092 } else {
10461093 const newSelection : StyleSourceSelection = {
10471094 instanceId,
0 commit comments