From 4f238debba42666aae988bd6b2921b84eb914eb5 Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Wed, 8 Jul 2026 19:26:25 -0400 Subject: [PATCH 1/4] Scope theme stylesheets by theme id plus content hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards sharing a theme previously each emitted their own copy of the theme stylesheet under a per-card scope, so the CSS payload scaled with rendered card instances — especially costly for compositions embedding many themed cards, where every prerendered fragment carries the copies. Deriving the scope from the theme card's id plus an fnv1a hash of its CSS makes all cards on one theme emit byte-identical style elements (harmless as duplicate rules, dedupable by consumers), while the hash keeps scopes from different versions of a theme distinct so cached prerendered fragments from before and after a theme edit cannot restyle each other's cards. The theme dashboard's inner dark-toggle re-scope now also derives from content instead of guidFor, which could repeat across prerender jobs. Co-Authored-By: Claude Fable 5 --- .../default-templates/theme-dashboard.gts | 18 ++++- packages/base/field-component.gts | 27 +++++-- packages/boxel-ui/addon/src/helpers.ts | 3 +- .../addon/src/helpers/theme-scoped-css.ts | 29 ++++++++ .../tests/unit/theme-scoped-css-test.ts | 38 +++++++++- .../host/tests/acceptance/theme-card-test.gts | 74 +++++++++++++++++++ 6 files changed, 178 insertions(+), 11 deletions(-) diff --git a/packages/base/default-templates/theme-dashboard.gts b/packages/base/default-templates/theme-dashboard.gts index d00770b237..f7df12acc1 100644 --- a/packages/base/default-templates/theme-dashboard.gts +++ b/packages/base/default-templates/theme-dashboard.gts @@ -18,7 +18,12 @@ import { FieldContainer, BoxelInput, } from '@cardstack/boxel-ui/components'; -import { bool, cn, themeScopedCss } from '@cardstack/boxel-ui/helpers'; +import { + bool, + cn, + themeScope, + themeScopedCss, +} from '@cardstack/boxel-ui/helpers'; import { DEFAULT_THEME_SCALE } from '../structured-theme-variables'; @@ -1076,7 +1081,16 @@ export class ThemeDashboard extends GlimmerComponent<{ Blocks: { default: []; header: []; navBar: [] }; Element: HTMLElement; }> { - private themeScopeId = guidFor(this); + // Content-derived rather than guidFor: this markup can be persisted as + // prerendered HTML, where the scoped rules are page-global. Equal scopes + // are only safe when their declarations are equal too, which the content + // hash guarantees; a per-process guid can repeat across prerender jobs + // with different themes. The guid fallback is never stamped (the scope + // renders only when @themeCss is present) — it just keeps the value + // defined. + private get themeScopeId() { + return themeScope('theme-dashboard', this.args.themeCss) ?? guidFor(this); + } // The data-theme wrapper drives the preview's light/dark toggle through the // ambient `--boxel-color-scheme` signal, flipping the semantic tokens to the diff --git a/packages/base/field-component.gts b/packages/base/field-component.gts index e8a279acc1..50708b8a69 100644 --- a/packages/base/field-component.gts +++ b/packages/base/field-component.gts @@ -28,7 +28,7 @@ import { } from '@cardstack/runtime-common'; import type { ComponentLike } from '@glint/template'; import { CardContainer } from '@cardstack/boxel-ui/components'; -import { coalesce } from '@cardstack/boxel-ui/helpers'; +import { coalesce, themeScope } from '@cardstack/boxel-ui/helpers'; import Modifier from 'ember-modifier'; import { isEqual, flatMap } from 'lodash-es'; import { initSharedState } from './shared-state'; @@ -280,6 +280,10 @@ export function getBoxComponent( return cardDef?.cardTheme != null; } + function themeId(cardDef?: CardDef) { + return isThemeCard(cardDef) ? cardDef.id : cardDef?.cardTheme?.id; + } + function getCssImports(card?: CardDef) { // for cards like Theme card and its descendants, directly use the `cssImports` field; // for all other cards, get imports via the Theme card linked from cardInfo @@ -293,15 +297,24 @@ export function getBoxComponent( } let component = class FieldComponent extends Component { - // Scopes this card's theme stylesheet. Derived from the card id so the - // scope stays stable in persisted prerendered HTML — a per-process guid + // Scopes this card's theme stylesheet. Derived from the theme card's id + // plus a hash of its CSS (see themeScope) so every card sharing a theme + // emits an identical stylesheet instead of one copy per card, while + // scopes stay stable in persisted prerendered HTML — a per-process guid // can repeat across prerender jobs, and since the scoped style rules are - // page-global, two cached cards with the same scope would capture each - // other's theme variables. The guid fallback only covers unsaved cards, - // which are never persisted. + // page-global, two cached cards with the same scope but different theme + // CSS would capture each other's theme variables. The guid fallback only + // covers unsaved theme cards previewing their own CSS, which are never + // persisted. private get themeScopeId() { let value = model.value; - return (isCard(value) && value.id) || guidFor(this); + if (isCard(value)) { + let scope = themeScope(themeId(value), themeCss(value)); + if (scope) { + return scope; + } + } + return guidFor(this); } // Compute merged configuration for this field based on the owning instance. diff --git a/packages/boxel-ui/addon/src/helpers.ts b/packages/boxel-ui/addon/src/helpers.ts index 7c21895eb6..4e5a3038eb 100644 --- a/packages/boxel-ui/addon/src/helpers.ts +++ b/packages/boxel-ui/addon/src/helpers.ts @@ -48,7 +48,7 @@ import { entriesToCssRuleMap, normalizeCssRuleMap, } from './helpers/theme-css.ts'; -import { themeScopedCss } from './helpers/theme-scoped-css.ts'; +import { themeScope, themeScopedCss } from './helpers/theme-scoped-css.ts'; import { and, bool, @@ -122,6 +122,7 @@ export { sanitizeHtmlSafe, substring, subtract, + themeScope, themeScopedCss, toMenuItems, }; diff --git a/packages/boxel-ui/addon/src/helpers/theme-scoped-css.ts b/packages/boxel-ui/addon/src/helpers/theme-scoped-css.ts index 6ef7fd8e62..247da67e2d 100644 --- a/packages/boxel-ui/addon/src/helpers/theme-scoped-css.ts +++ b/packages/boxel-ui/addon/src/helpers/theme-scoped-css.ts @@ -19,6 +19,35 @@ function sanitizeDeclarations(declarations: string): string { return sanitizeHtml(declarations).replace(/[{}]/g, ''); } +// FNV-1a 32-bit, hex-encoded. Not cryptographic — just a stable fingerprint +// of the theme CSS for scope values. +function fnv1a(text: string): string { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16); +} + +// Derives a `data-boxel-theme-scope` value from a theme's identity plus a +// fingerprint of its CSS. Every card sharing a theme gets the same scope, so +// their emitted stylesheets are byte-identical — harmless as duplicate rules, +// and dedupable by consumers. The content hash keeps scopes from *different +// versions* of a theme distinct: prerendered fragments are cached at +// different times, so a page can mix a card captured before a theme edit with +// one captured after, and because the scoped rules are page-global, equal +// scopes with unequal declarations would restyle each other's cards. +export function themeScope( + themeId: string | null | undefined, + cssVariables: string | null | undefined, +): string | undefined { + if (!themeId || !cssVariables) { + return undefined; + } + return `${themeId}-${fnv1a(cssVariables)}`; +} + export function themeScopedCss( scope?: string, cssVariables?: string | null, diff --git a/packages/boxel-ui/test-app/tests/unit/theme-scoped-css-test.ts b/packages/boxel-ui/test-app/tests/unit/theme-scoped-css-test.ts index b2fc20c1bd..188ed29e53 100644 --- a/packages/boxel-ui/test-app/tests/unit/theme-scoped-css-test.ts +++ b/packages/boxel-ui/test-app/tests/unit/theme-scoped-css-test.ts @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; -import { themeScopedCss } from '@cardstack/boxel-ui/helpers'; +import { themeScope, themeScopedCss } from '@cardstack/boxel-ui/helpers'; const SCOPE = 'ember123'; const SELECTOR = `[data-boxel-theme-scope="${SCOPE}"]`; @@ -114,3 +114,39 @@ module('Unit | theme-scoped-css', function () { assert.true(el.matches(selector), 'escaped selector matches the element'); }); }); + +module('Unit | theme-scope', function () { + const THEME_ID = 'https://example.test/starry-night-theme'; + const CSS = ':root { --primary: #112233; }'; + + test('is deterministic for the same theme id and css', function (assert) { + assert.strictEqual(themeScope(THEME_ID, CSS), themeScope(THEME_ID, CSS)); + }); + + test('starts with the theme id', function (assert) { + assert.true(themeScope(THEME_ID, CSS)!.startsWith(`${THEME_ID}-`)); + }); + + test('changes when the css changes', function (assert) { + assert.notStrictEqual( + themeScope(THEME_ID, CSS), + themeScope(THEME_ID, ':root { --primary: #445566; }'), + ); + }); + + test('differs across themes with identical css', function (assert) { + assert.notStrictEqual( + themeScope(THEME_ID, CSS), + themeScope('https://example.test/other-theme', CSS), + ); + }); + + test('returns undefined without a theme id or css', function (assert) { + assert.strictEqual(themeScope(undefined, CSS), undefined); + assert.strictEqual(themeScope(null, CSS), undefined); + assert.strictEqual(themeScope('', CSS), undefined); + assert.strictEqual(themeScope(THEME_ID, undefined), undefined); + assert.strictEqual(themeScope(THEME_ID, null), undefined); + assert.strictEqual(themeScope(THEME_ID, ''), undefined); + }); +}); diff --git a/packages/host/tests/acceptance/theme-card-test.gts b/packages/host/tests/acceptance/theme-card-test.gts index 0e40de8b89..1037223d9b 100644 --- a/packages/host/tests/acceptance/theme-card-test.gts +++ b/packages/host/tests/acceptance/theme-card-test.gts @@ -444,6 +444,30 @@ module('Acceptance | theme-card-test', function (hooks) { }, }, }, + 'checkbox-forest-green-2.json': { + data: { + meta: { + adoptsFrom: { + name: 'CheckboxCard', + module: `${testRealmURL}checkbox-card`, + }, + }, + type: 'card', + attributes: { + isChecked: false, + cardInfo: { + name: 'Second Forest Green Checkbox', + }, + }, + relationships: { + 'cardInfo.theme': { + links: { + self: `${testRealmURL}forest-green-theme`, + }, + }, + }, + }, + }, 'checkbox-forest-green.json': { data: { meta: { @@ -1083,5 +1107,55 @@ module('Acceptance | theme-card-test', function (hooks) { 'checkbox size matches --theme-body-font-size', ); }); + + test('cards sharing a theme emit identical scoped stylesheets', async function (assert) { + let cardId = `${testRealmURL}checkbox-forest-green`; + let otherCardId = `${testRealmURL}checkbox-forest-green-2`; + await visitOperatorMode({ + stacks: [ + [{ id: cardId, format: 'isolated' }], + [{ id: otherCardId, format: 'isolated' }], + ], + }); + + let themeStyleText = (id: string) => { + let container = document.querySelector(`[data-test-card="${id}"]`); + return [...container!.querySelectorAll('style')] + .map((style) => style.textContent?.trim()) + .filter((text) => text?.includes('data-boxel-theme-scope')) + .join(''); + }; + + let scope = document + .querySelector(`[data-test-card="${cardId}"]`)! + .getAttribute('data-boxel-theme-scope'); + let otherScope = document + .querySelector(`[data-test-card="${otherCardId}"]`)! + .getAttribute('data-boxel-theme-scope'); + assert.ok(scope, 'theme scope is set'); + assert.true( + scope!.startsWith(`${testRealmURL}forest-green-theme-`), + 'scope derives from the theme card id plus a content hash', + ); + assert.strictEqual( + scope, + otherScope, + 'cards sharing a theme share a scope', + ); + assert.ok( + themeStyleText(cardId).length, + 'theme stylesheet is rendered within the card container', + ); + assert.strictEqual( + themeStyleText(cardId), + themeStyleText(otherCardId), + 'cards sharing a theme emit byte-identical theme stylesheets', + ); + assert.strictEqual( + computedProperty(`[data-test-card="${otherCardId}"]`, '--primary'), + '#2e7d32', + 'the second card still resolves the theme variables', + ); + }); }); }); From 93fb688051efc4ba1f7f87c7ba05604d505f7eeb Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Thu, 9 Jul 2026 12:57:09 -0400 Subject: [PATCH 2/4] dedupe identical themes --- .../dedupe-theme-styles.ts | 20 +++++ packages/host/app/lib/theme-style-deduper.ts | 85 +++++++++++++++++++ .../host/tests/acceptance/theme-card-test.gts | 35 +++++++- 3 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 packages/host/app/instance-initializers/dedupe-theme-styles.ts create mode 100644 packages/host/app/lib/theme-style-deduper.ts diff --git a/packages/host/app/instance-initializers/dedupe-theme-styles.ts b/packages/host/app/instance-initializers/dedupe-theme-styles.ts new file mode 100644 index 0000000000..cec2e07e2e --- /dev/null +++ b/packages/host/app/instance-initializers/dedupe-theme-styles.ts @@ -0,0 +1,20 @@ +import type ApplicationInstance from '@ember/application/instance'; +import { registerDestructor } from '@ember/destroyable'; + +import { ThemeStyleDeduper } from '../lib/theme-style-deduper'; + +// Cards sharing a theme each carry a byte-identical copy of the theme +// stylesheet (self-contained prerendered fragments require this); only one +// copy per theme needs to be active in the live DOM. +export function initialize(appInstance: ApplicationInstance): void { + if (typeof document === 'undefined') { + return; + } + let deduper = new ThemeStyleDeduper(); + deduper.start(); + registerDestructor(appInstance, () => deduper.stop()); +} + +export default { + initialize, +}; diff --git a/packages/host/app/lib/theme-style-deduper.ts b/packages/host/app/lib/theme-style-deduper.ts new file mode 100644 index 0000000000..b4305a8c90 --- /dev/null +++ b/packages/host/app/lib/theme-style-deduper.ts @@ -0,0 +1,85 @@ +// Every themed CardContainer emits its own copy of the theme stylesheet, and +// prerendered fragments carry the copies baked in, so each fragment is +// self-contained. Copies that share a `data-boxel-theme-scope` are +// byte-identical (the scope embeds a hash of the theme CSS), so only one +// needs to participate in style matching. This deduper watches the document +// and disables the redundant copies, promoting a survivor whenever the +// active copy leaves the DOM or its content changes. +// +// It disables via the `disabled` property (not an attribute), so serializing +// a container's HTML — which is how prerendered fragments are captured — +// still yields the full stylesheet. + +const THEME_SCOPE_SELECTOR_PREFIX = '[data-boxel-theme-scope='; + +function isThemeStyle(el: HTMLStyleElement): boolean { + return ( + el.textContent?.trimStart().startsWith(THEME_SCOPE_SELECTOR_PREFIX) ?? false + ); +} + +export class ThemeStyleDeduper { + #observer: MutationObserver | undefined; + #doc: Document | undefined; + #scheduled = false; + + start(doc: Document = document): void { + if (this.#observer) { + return; + } + this.#doc = doc; + this.#observer = new MutationObserver(() => this.#schedule()); + this.#observer.observe(doc.documentElement, { + childList: true, + subtree: true, + characterData: true, + }); + this.#sync(); + } + + stop(): void { + this.#observer?.disconnect(); + this.#observer = undefined; + if (this.#doc) { + for (let el of this.#doc.querySelectorAll('style')) { + if (isThemeStyle(el)) { + el.disabled = false; + } + } + } + this.#doc = undefined; + } + + #schedule(): void { + if (this.#scheduled) { + return; + } + this.#scheduled = true; + queueMicrotask(() => { + this.#scheduled = false; + this.#sync(); + }); + } + + // One full pass: group theme style elements by content and keep only the + // first copy (in document order) of each group enabled. A full pass keeps + // the logic immune to reordering, removal of the active copy, and content + // rewrites (a theme edit changes the scope hash, forming a new group). + #sync(): void { + if (!this.#doc) { + return; + } + let seen = new Set(); + for (let el of this.#doc.querySelectorAll('style')) { + if (!isThemeStyle(el)) { + continue; + } + let key = el.textContent!; + let redundant = seen.has(key); + seen.add(key); + if (el.disabled !== redundant) { + el.disabled = redundant; + } + } + } +} diff --git a/packages/host/tests/acceptance/theme-card-test.gts b/packages/host/tests/acceptance/theme-card-test.gts index 1037223d9b..a31014f1bf 100644 --- a/packages/host/tests/acceptance/theme-card-test.gts +++ b/packages/host/tests/acceptance/theme-card-test.gts @@ -1108,7 +1108,7 @@ module('Acceptance | theme-card-test', function (hooks) { ); }); - test('cards sharing a theme emit identical scoped stylesheets', async function (assert) { + test('cards sharing a theme emit identical scoped stylesheets with only one active copy', async function (assert) { let cardId = `${testRealmURL}checkbox-forest-green`; let otherCardId = `${testRealmURL}checkbox-forest-green-2`; await visitOperatorMode({ @@ -1151,10 +1151,41 @@ module('Acceptance | theme-card-test', function (hooks) { themeStyleText(otherCardId), 'cards sharing a theme emit byte-identical theme stylesheets', ); + + let copies = () => + [...document.querySelectorAll('style')].filter((style) => + style.textContent?.includes( + `data-boxel-theme-scope="${testRealmURL}forest-green-theme-`, + ), + ); + assert.strictEqual(copies().length, 2, 'each card emits its own copy'); + assert.strictEqual( + copies().filter((style) => !style.disabled).length, + 1, + 'exactly one copy participates in style matching', + ); + assert.strictEqual( + computedProperty(`[data-test-card="${cardId}"]`, '--primary'), + '#2e7d32', + 'the first card resolves the theme variables', + ); + assert.strictEqual( + computedProperty(`[data-test-card="${otherCardId}"]`, '--primary'), + '#2e7d32', + 'the second card resolves the theme variables', + ); + + // when the active copy leaves the DOM, a survivor is promoted + copies() + .find((style) => !style.disabled)! + .remove(); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.strictEqual(copies().length, 1, 'one copy remains'); + assert.false(copies()[0].disabled, 'the surviving copy is re-enabled'); assert.strictEqual( computedProperty(`[data-test-card="${otherCardId}"]`, '--primary'), '#2e7d32', - 'the second card still resolves the theme variables', + 'the theme stays applied after the active copy is removed', ); }); }); From 1c2ee1b21adbac1132bacb366d6eaec8873cde1f Mon Sep 17 00:00:00 2001 From: Burcu Noyan Date: Thu, 9 Jul 2026 13:07:24 -0400 Subject: [PATCH 3/4] Scope ThemeDashboard by theme card id; widen CSS fingerprint to 64 bits ThemeDashboard previously derived its theme scope from the constant 'theme-dashboard', so distinct themes were separated only by the CSS hash and the dashboard could never share a scope with the same theme rendered through field-component. Callers now pass @themeId={{@model.id}} and the guid fallback covers unsaved theme cards previewing their own CSS, matching field-component's semantics. The scope's CSS fingerprint grows from one 32-bit FNV-1a pass to two passes with different seeds, concatenated as fixed-width hex. Scope equality implies declaration equality is the invariant that keeps mixed-vintage prerendered fragments from restyling each other; at 32 bits a collision between two versions of a theme was left to chance, at 64 it is not a practical concern. --- packages/base/brand-guide.gts | 1 + .../default-templates/theme-dashboard.gts | 12 ++++---- packages/base/detailed-style-reference.gts | 1 + packages/base/structured-theme.gts | 1 + packages/base/style-reference.gts | 1 + .../addon/src/helpers/theme-scoped-css.ts | 20 +++++++++---- .../tests/unit/theme-scoped-css-test.ts | 29 +++++++++++++++---- .../host/tests/acceptance/theme-card-test.gts | 19 ++++++++++++ 8 files changed, 66 insertions(+), 18 deletions(-) diff --git a/packages/base/brand-guide.gts b/packages/base/brand-guide.gts index daa5957ce5..2e1b982d94 100644 --- a/packages/base/brand-guide.gts +++ b/packages/base/brand-guide.gts @@ -75,6 +75,7 @@ class BrandGuideIsolated extends Component { diff --git a/packages/base/default-templates/theme-dashboard.gts b/packages/base/default-templates/theme-dashboard.gts index f7df12acc1..2b857004dc 100644 --- a/packages/base/default-templates/theme-dashboard.gts +++ b/packages/base/default-templates/theme-dashboard.gts @@ -1077,19 +1077,19 @@ export class ThemeDashboard extends GlimmerComponent<{ version?: string; isDarkMode?: boolean; themeCss?: string | null; + themeId?: string | null; }; Blocks: { default: []; header: []; navBar: [] }; Element: HTMLElement; }> { // Content-derived rather than guidFor: this markup can be persisted as // prerendered HTML, where the scoped rules are page-global. Equal scopes - // are only safe when their declarations are equal too, which the content - // hash guarantees; a per-process guid can repeat across prerender jobs - // with different themes. The guid fallback is never stamped (the scope - // renders only when @themeCss is present) — it just keeps the value - // defined. + // are only safe when their declarations are equal too, which the theme id + // plus content hash guarantees; a per-process guid can repeat across + // prerender jobs with different themes. The guid fallback only covers + // unsaved theme cards previewing their own CSS, which are never persisted. private get themeScopeId() { - return themeScope('theme-dashboard', this.args.themeCss) ?? guidFor(this); + return themeScope(this.args.themeId, this.args.themeCss) ?? guidFor(this); } // The data-theme wrapper drives the preview's light/dark toggle through the diff --git a/packages/base/detailed-style-reference.gts b/packages/base/detailed-style-reference.gts index 277ba03b82..5a82663fe3 100644 --- a/packages/base/detailed-style-reference.gts +++ b/packages/base/detailed-style-reference.gts @@ -143,6 +143,7 @@ class Isolated extends Component {