diff --git a/packages/skins/src/styles/layout/poster.styles.ts b/packages/skins/src/styles/layout/poster.styles.ts index 3419248da2..623e16971f 100644 --- a/packages/skins/src/styles/layout/poster.styles.ts +++ b/packages/skins/src/styles/layout/poster.styles.ts @@ -4,7 +4,9 @@ export default styles({ file: 'poster.css', prefix: 'media-poster', rules: { + // `` hosts a shadow root, and the skin's image is slotted through it. root: { + shadowHost: true, utilities: [ 'pointer-events-none layer-media', 'transition-opacity duration-media-slower not-data-visible:opacity-0', @@ -18,6 +20,7 @@ export default styles({ }, }, image: { + shadowHost: true, utilities: ['layer-media object-media', '[&:not([src]):not([srcset])]:invisible'], }, }, diff --git a/packages/skins/src/styles/popups/popup.styles.ts b/packages/skins/src/styles/popups/popup.styles.ts index cd3fd971ea..e4a8b95fc1 100644 --- a/packages/skins/src/styles/popups/popup.styles.ts +++ b/packages/skins/src/styles/popups/popup.styles.ts @@ -36,6 +36,8 @@ export default styles({ utilities: ['transition-media-popup data-ending-style:duration-media-instant'], }, surface: { + // Also carried by ``, which hosts a shadow root. + shadowHost: true, utilities: 'bg-media-popover text-media-popover-foreground surface-media after:surface-media-inset', variants: { minimal: 'after:hidden', diff --git a/packages/skins/src/styles/sliders/slider.styles.ts b/packages/skins/src/styles/sliders/slider.styles.ts index b0421da62d..ea4e1fe77e 100644 --- a/packages/skins/src/styles/sliders/slider.styles.ts +++ b/packages/skins/src/styles/sliders/slider.styles.ts @@ -105,6 +105,8 @@ export default styles({ }, }, previewContent: { + // Also carried by ``, which hosts a shadow root. + shadowHost: true, utilities: [ 'absolute max-w-(--media-slider-preview-max-width) -translate-x-1/2 translate-y-media-hidden-preview-offset scale-media-hidden-preview opacity-0', 'origin-bottom blur-media-hidden', diff --git a/packages/skins/src/styles/sliders/thumbnail.styles.ts b/packages/skins/src/styles/sliders/thumbnail.styles.ts index e3904ceb15..736dbbeaac 100644 --- a/packages/skins/src/styles/sliders/thumbnail.styles.ts +++ b/packages/skins/src/styles/sliders/thumbnail.styles.ts @@ -4,7 +4,9 @@ export default styles({ file: 'sliders.css', prefix: 'media-slider-thumbnail', rules: { + // `` hosts a shadow root, and the image and spinner are slotted through it. root: { + shadowHost: true, utilities: [ 'group/thumbnail pointer-events-none overflow-hidden rounded-media-popup bg-media-backdrop/90', 'bottom-[calc(100%+var(--media-slider-preview-offset))]', @@ -20,9 +22,11 @@ export default styles({ }, }, image: { + shadowHost: true, utilities: ['block transition-opacity duration-media-base ease-out', 'group-data-loading/thumbnail:opacity-0'], }, spinnerIcon: { + shadowHost: true, utilities: [ 'absolute top-1/2 left-1/2 z-10 size-media-icon -translate-x-1/2 -translate-y-1/2 opacity-0', 'transition-opacity duration-media-base ease-out', diff --git a/packages/vjsc/src/styles/define.ts b/packages/vjsc/src/styles/define.ts index d90924b44a..14d2f60344 100644 --- a/packages/vjsc/src/styles/define.ts +++ b/packages/vjsc/src/styles/define.ts @@ -16,8 +16,9 @@ export interface StyleRule { /** Also match this class when it is colocated on the configured CSS scope root. */ readonly scopeRoot?: boolean | undefined; /** - * The styled element hosts a shadow root. WebKit does not match `@scope` rules whose subject hosts a shadow root, so - * these rules are emitted outside the scope block with the scope root as an ancestor instead. + * The styled element hosts a shadow root, or is a light-DOM child composed through one. WebKit does not match + * `@scope` rules whose subject hosts a shadow root or is slotted into one, so these rules are also emitted outside + * the scope block with the scope root as an ancestor. */ readonly shadowHost?: boolean | undefined; /** Tailwind utilities shared by every configured variant. */ diff --git a/packages/vjsc/src/styles/render.ts b/packages/vjsc/src/styles/render.ts index 134485f4cc..a2494cf1b0 100644 --- a/packages/vjsc/src/styles/render.ts +++ b/packages/vjsc/src/styles/render.ts @@ -89,22 +89,23 @@ function wrapFileCss(css: string, scope: string | undefined, file: StyleOutputFi } /** - * Keep the rules `@scope` cannot serve outside the scope block. Slotted nodes sit outside a shadow tree's CSS scope, - * and WebKit never matches a scoped rule whose subject hosts a shadow root; those rules take the scope root as an - * ancestor instead. Conditional at-rules retain their conditions when their matching rules move. + * Keep the rules `@scope` cannot serve outside the scope block. Slotted nodes sit outside a shadow tree's CSS scope, so + * their rules move out. WebKit never matches a scoped rule whose subject hosts a shadow root or is slotted into one, so + * a rule on a shadow host class is emitted twice: the scoped rule stays for engines that match it, and a copy with the + * scope root as a zero-specificity ancestor follows for WebKit. The copy never outranks the original, so the cascade + * elsewhere is unchanged. Conditional at-rules retain their conditions when their matching rules move or copy. */ function splitUnscopedRules(css: string, scope: string, shadowHostClasses: ReadonlySet) { let hasSlottedRules = false; let hasShadowHostRules = false; - const isShadowHostRule = (rule: Rule) => isShadowHostStyleRule(rule, shadowHostClasses); + const isShadowHostRule = (rule: Rule) => !isSlottedStyleRule(rule) && isShadowHostStyleRule(rule, shadowHostClasses); const scoped = filterCssRules(css, (rule) => { const slotted = isSlottedStyleRule(rule); - const shadowHost = !slotted && isShadowHostRule(rule); hasSlottedRules ||= slotted; - hasShadowHostRules ||= shadowHost; + hasShadowHostRules ||= isShadowHostRule(rule); - return !slotted && !shadowHost; + return !slotted; }); const slotted = hasSlottedRules ? filterCssRules(css, isSlottedStyleRule) : ''; @@ -198,14 +199,30 @@ function filterNestedRules(rules: readonly Rule[], include: (rule: Rule) => bool return filtered; } -/** A rule whose selectors all start from a class of an element that hosts a shadow root. */ +/** + * A rule with a selector whose subject carries a shadow host class. The subject is the last compound, so a relationship + * selector such as `:where(.owner)[data-x] .subject` counts by its `.subject`, and a pseudo-element on the subject is + * looked past. + */ function isShadowHostStyleRule(rule: Rule, shadowHostClasses: ReadonlySet): boolean { return ( rule.type === 'style' && - rule.value.selectors.every((selector) => selector[0]?.type === 'class' && shadowHostClasses.has(selector[0].name)) + rule.value.selectors.some((selector) => + subjectCompound(selector).some((component) => component.type === 'class' && shadowHostClasses.has(component.name)) + ) ); } +function subjectCompound(selector: Selector): Selector { + let start = 0; + + for (const [index, component] of selector.entries()) { + if (component.type === 'combinator') start = index + 1; + } + + return selector.slice(start).filter((component) => component.type !== 'pseudo-element'); +} + function isSlottedStyleRule(rule: Rule): boolean { return ( rule.type === 'style' && diff --git a/packages/vjsc/src/styles/tests/compile.test.ts b/packages/vjsc/src/styles/tests/compile.test.ts index dc57c60000..975e49271f 100644 --- a/packages/vjsc/src/styles/tests/compile.test.ts +++ b/packages/vjsc/src/styles/tests/compile.test.ts @@ -178,15 +178,19 @@ describe('compileStyles', () => { expect(css).toMatch(/}\s*\.media-poster > slot::slotted/); }); - it('emits shadow host rules outside the scope without changing specificity or conditions', async () => { + it('repeats shadow host rules outside the scope without changing specificity or conditions', async () => { const thumbnail = { - ...rule('image', 'media-thumbnail-image', ['block', 'data-loading:opacity-0', 'sm:flex']), + ...rule('root', 'media-thumbnail', ['block', 'group/thumbnail', 'data-loading:opacity-0', 'sm:flex']), + shadowHost: true, + }; + const image = { + ...rule('image', 'media-thumbnail-image', ['group-data-loading/thumbnail:opacity-0']), shadowHost: true, }; const spinner = rule('spinner', 'media-thumbnail-spinner', ['absolute']); const styles = await compileStyles({ design: await loadDesignSystem(designPath), - styles: resolvedStyles([thumbnail, spinner]), + styles: resolvedStyles([thumbnail, image, spinner]), scope: '.media-skin-video', variants: [], }); @@ -197,12 +201,17 @@ describe('compileStyles', () => { expect(scoped).toContain('@scope (.media-skin-video)'); expect(scoped).toContain('.media-thumbnail-spinner'); - expect(scoped).not.toContain('.media-thumbnail-image'); - expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail-image {'); - expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail-image[data-loading] {'); - expect(unscoped).toMatch( - /@media[^{}]+\{\s*:where\(\.media-skin-video\) \.media-thumbnail-image \{\s*display: flex;/ + expect(scoped).toContain('.media-thumbnail {'); + expect(scoped).toContain('.media-thumbnail[data-loading] {'); + // The relationship keeps its nested scope inside the block, and reads as a plain descendant in the copy. + expect(scoped).toMatch(/@scope \(\.media-thumbnail\) \{\s*&\[data-loading\] \.media-thumbnail-image \{/); + expect(unscoped).not.toContain('.media-thumbnail-spinner'); + expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail {'); + expect(unscoped).toContain(':where(.media-skin-video) .media-thumbnail[data-loading] {'); + expect(unscoped).toContain( + ':where(.media-skin-video) :where(.media-thumbnail)[data-loading] .media-thumbnail-image {' ); + expect(unscoped).toMatch(/@media[^{}]+\{\s*:where\(\.media-skin-video\) \.media-thumbnail \{\s*display: flex;/); }); });