Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/base/brand-guide.gts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class BrandGuideIsolated extends Component<typeof BrandGuide> {
<ThemeDashboard
class='brand-guide'
@themeCss={{@model.cssVariables}}
@themeId={{@model.id}}
@sections={{this.sectionsWithContent}}
@isDarkMode={{this.isDarkMode}}
>
Expand Down
23 changes: 20 additions & 3 deletions packages/base/default-templates/theme-dashboard.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -1072,11 +1077,20 @@ export class ThemeDashboard extends GlimmerComponent<{
version?: string;
isDarkMode?: boolean;
themeCss?: string | null;
themeId?: string | null;
};
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 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(this.args.themeId, 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
Expand All @@ -1097,7 +1111,10 @@ export class ThemeDashboard extends GlimmerComponent<{
>
{{#if @themeCss}}
{{! template-lint-disable require-scoped-style }}
<style>
{{! data-boxel-theme-style marks this as a dedupable theme
stylesheet; the attribute survives serialization, so prerendered
fragments stay recognizable when re-inserted }}
<style data-boxel-theme-style>
{{themeScopedCss this.themeScopeId @themeCss}}
</style>
{{! template-lint-enable require-scoped-style }}
Expand Down
1 change: 1 addition & 0 deletions packages/base/detailed-style-reference.gts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ class Isolated extends Component<typeof DetailedStyleReference> {
<template>
<ThemeDashboard
@themeCss={{@model.cssVariables}}
@themeId={{@model.id}}
@title={{@model.cardTitle}}
@description={{@model.cardDescription}}
@sections={{this.sectionsWithContent}}
Expand Down
27 changes: 20 additions & 7 deletions packages/base/field-component.gts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand All @@ -293,15 +297,24 @@ export function getBoxComponent(
}

let component = class FieldComponent extends Component<BoxComponentSignature> {
// 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.
Expand Down
1 change: 1 addition & 0 deletions packages/base/structured-theme.gts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ class Isolated extends Component<typeof StructuredTheme> {
<ThemeDashboard
class='structured-theme-card'
@themeCss={{@model.cssVariables}}
@themeId={{@model.id}}
@title={{@model.cardTitle}}
@description={{@model.cardDescription}}
@isDarkMode={{this.isDarkMode}}
Expand Down
1 change: 1 addition & 0 deletions packages/base/style-reference.gts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Isolated extends Component<typeof StyleReference> {
<ThemeDashboard
class='style-reference'
@themeCss={{@model.cssVariables}}
@themeId={{@model.id}}
@isDarkMode={{this.isDarkMode}}
>
<:header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ const CardContainer: TemplateOnlyComponent<Signature> = <template>
{{/if}}
{{#if @themeCss}}
{{! template-lint-disable require-scoped-style }}
<style>
{{! data-boxel-theme-style marks this as a dedupable theme stylesheet;
the attribute survives serialization, so prerendered fragments
stay recognizable when re-inserted }}
<style data-boxel-theme-style>
{{themeScopedCss @themeScope @themeCss}}
</style>
{{! template-lint-enable require-scoped-style }}
Expand Down
3 changes: 2 additions & 1 deletion packages/boxel-ui/addon/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -122,6 +122,7 @@ export {
sanitizeHtmlSafe,
substring,
subtract,
themeScope,
themeScopedCss,
toMenuItems,
};
Expand Down
37 changes: 37 additions & 0 deletions packages/boxel-ui/addon/src/helpers/theme-scoped-css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ function sanitizeDeclarations(declarations: string): string {
return sanitizeHtml(declarations).replace(/[{}]/g, '');
}

function fnv1a(text: string, seed: number): string {
let hash = seed;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}

// 64-bit stable fingerprint of the theme CSS for scope values: two 32-bit
// FNV-1a passes with different seeds (the standard offset basis, then the
// upper half of the 64-bit offset basis), hex-encoded fixed-width. Not
// cryptographic, but wide enough that two versions of a theme colliding —
// which would let their scoped rules restyle each other's cards — is not a
// practical concern, where a single 32-bit pass would leave that to chance.
function fingerprint(text: string): string {
return fnv1a(text, 0x811c9dc5) + fnv1a(text, 0xcbf29ce4);
}

// 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}-${fingerprint(cssVariables)}`;
}

export function themeScopedCss(
scope?: string,
cssVariables?: string | null,
Expand Down
55 changes: 54 additions & 1 deletion packages/boxel-ui/test-app/tests/unit/theme-scoped-css-test.ts
Original file line number Diff line number Diff line change
@@ -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}"]`;
Expand Down Expand Up @@ -114,3 +114,56 @@ 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));
});

// Scope values persist in prerendered HTML, so the format is a
// serialization contract: the theme id, a separator, and a fixed-width
// 64-bit hex fingerprint of the css.
test('is the theme id plus a fixed-width 64-bit hex fingerprint', function (assert) {
let suffix = (css: string) =>
themeScope(THEME_ID, css)!.slice(`${THEME_ID}-`.length);
assert.true(
themeScope(THEME_ID, CSS)!.startsWith(`${THEME_ID}-`),
'scope starts with the theme id',
);
assert.true(
/^[0-9a-f]{16}$/.test(suffix(CSS)),
'fingerprint is 16 hex chars',
);
assert.true(
// this css hashes with a leading zero in the first pass, which must be
// padded rather than truncated
/^[0-9a-f]{16}$/.test(suffix(':root { --primary: #000011; }')),
'fingerprint width is stable when a pass hashes below 2^28',
);
});

test('changes when the css or the theme id changes', function (assert) {
assert.notStrictEqual(
themeScope(THEME_ID, CSS),
themeScope(THEME_ID, ':root { --primary: #445566; }'),
'differs across css versions of one theme',
);
assert.notStrictEqual(
themeScope(THEME_ID, CSS),
themeScope('https://example.test/other-theme', CSS),
'differs across themes with identical 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);
});
});
20 changes: 20 additions & 0 deletions packages/host/app/instance-initializers/dedupe-theme-styles.ts
Original file line number Diff line number Diff line change
@@ -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,
};
84 changes: 84 additions & 0 deletions packages/host/app/lib/theme-style-deduper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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.
//
// Theme stylesheets are recognized by the `data-boxel-theme-style` attribute
// their emitters stamp on the <style> tag, rather than by sniffing the CSS
// text (whose leading output varies — a dark-only theme starts with
// `@container`, not the scope selector). The attribute serializes with the
// element, so prerendered fragments stay recognizable when re-inserted.
//
// 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_STYLE_SELECTOR = 'style[data-boxel-theme-style]';

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<HTMLStyleElement>(
THEME_STYLE_SELECTOR,
)) {
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<string>();
for (let el of this.#doc.querySelectorAll<HTMLStyleElement>(
THEME_STYLE_SELECTOR,
)) {
let key = el.textContent ?? '';
let redundant = seen.has(key);
seen.add(key);
if (el.disabled !== redundant) {
el.disabled = redundant;
}
}
}
}
Loading
Loading