From c860ecf8c00c86f73c85b5f26cce8874de11b011 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Tue, 13 Oct 2026 16:16:57 +0200 Subject: [PATCH] fix(framework): make theme-inverted sections take the alt colors The color theme scopes hung the default branch off :root.theme-default, a class nothing ever put on , so theme-inverted and theme-always-alt never matched on published pages or in the canvas. The default branch now keys on :root:not(.theme-alt), which matches every page as rendered today and keeps a runtime toggle to the one theme-alt class. --- docs/features/site-shell.md | 1 + src/__tests__/framework/colors.test.ts | 55 +++++++++++++++++++++++++- src/__tests__/publisher/render.test.ts | 51 +++++++++++++++++++++++- src/core/framework/colors.ts | 13 ++++-- 4 files changed, 114 insertions(+), 6 deletions(-) diff --git a/docs/features/site-shell.md b/docs/features/site-shell.md index c0bcc632c..69f6dc4fa 100644 --- a/docs/features/site-shell.md +++ b/docs/features/site-shell.md @@ -133,6 +133,7 @@ type SiteSettings = { `framework` holds the structured design token system (`src/core/framework/`). When present it carries: - `colors.tokens` — `FrameworkColorToken[]`, each with a slug (becomes a CSS var like `--primary`), light/dark values, utility generation flags (text/background/border/fill), shade/tint variant counts. Slugs are normalized by `normalizeFrameworkColorSlug` (trim, lowercase, strip leading `--`, replace non-alphanumeric runs with `-`). When two tokens normalize to the same root slug, the second receives a `-2` suffix, the third `-3`, and so on — resolved in generation order via `buildColorSlugMap` so the earlier token keeps the base name. + - **Theme scopes.** The light value is the default theme and goes into `:root`. A token with dark mode on (the Colors panel's "Alt color") also emits its alt value under theme scopes (`formatFrameworkColorThemeCss`, `src/core/framework/colors.ts`). The page is in the default theme unless `` carries `theme-alt`, so nothing has to add a root class: an element with `theme-inverted` takes the alt values (the default values when the page is in `theme-alt`), and `theme-always-alt` / `theme-always-default` pin one theme wherever they sit, including inside an inverted section. A runtime toggle flips the whole page by adding or removing `theme-alt` on ``. Sites with no dark-mode token emit no theme scopes at all. - `typography` — `FrameworkTypographySettings` with fluid scale groups, each emitting `font-size` vars + optional utility classes. - `spacing` — `FrameworkSpacingSettings` with fluid spacing scale groups, each emitting spacing vars + optional utility classes. - `preferences` — root font size (`rootFontSize`, constrained `>= 1` at schema level to guard the `px → rem` divisor), fluid clamp screen-width anchors (`minScreenWidth`, `maxScreenWidth`), `isRem` (emit `rem` vs `px`), and `treeShakeGeneratedFrameworkUtilities` flag. diff --git a/src/__tests__/framework/colors.test.ts b/src/__tests__/framework/colors.test.ts index 332a89f0c..16ccd384c 100644 --- a/src/__tests__/framework/colors.test.ts +++ b/src/__tests__/framework/colors.test.ts @@ -35,6 +35,26 @@ function makeColorSettings(overrides: Partial = {}): Fra } } +/** The selector list of every rule in `css` whose body declares `declaration`. */ +function selectorsDeclaring(css: string, declaration: string): string { + const selectors: string[] = [] + for (const match of css.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { + if (match[2].includes(declaration)) selectors.push(match[1].trim()) + } + if (selectors.length === 0) throw new Error(`No rule declares ${declaration}`) + return selectors.join(',\n') +} + +function parseDocument(html: string): Document { + return new DOMParser().parseFromString(`${html}`, 'text/html') +} + +function element(doc: Document, selector: string): Element { + const found = doc.querySelector(selector) + if (!found) throw new Error(`No element matches ${selector}`) + return found +} + describe('framework color generation', () => { it('normalizes color slugs to Core Framework-compatible names', () => { expect(normalizeFrameworkColorSlug(' Primary Color ')).toBe('primary-color') @@ -149,11 +169,12 @@ describe('framework color generation', () => { expect(sets.light.some((variable) => variable.name === '--fancy-d-1')).toBe(false) }) - it('emits theme scopes with theme-default and theme-alt class names', () => { + it('emits theme scopes keyed on the theme-alt root class', () => { const css = generateFrameworkRootCss({ colors: makeColorSettings() }) expect(css).toContain(':root.theme-alt') - expect(css).toContain(':root.theme-default .theme-inverted') + expect(css).toContain(':root:not(.theme-alt) .theme-inverted') expect(css).toContain(':root.theme-alt .theme-inverted .theme-always-alt') + expect(css).not.toContain(':root.theme-default') expect(css).not.toContain('theme-dark') expect(css).not.toContain('theme-light') expect(css).not.toContain('color-scheme: dark') @@ -162,6 +183,36 @@ describe('framework color generation', () => { expect(css).toContain('--primary: hsla(238, 100%, 42%, 1);') }) + // The theme scopes must resolve against a document whose `` carries no + // theme class, which is what every published page and every canvas frame is. + // They used to hang the default branch off `:root.theme-default`, a class + // nothing ever set, so `theme-inverted` sections never switched. + describe('theme scopes against a real document', () => { + const css = generateFrameworkRootCss({ colors: makeColorSettings() }) + const altScope = selectorsDeclaring(css, '--primary: hsla(238, 100%, 42%, 1);') + const defaultScope = selectorsDeclaring(css, '--primary: hsla(238, 100%, 62%, 1);') + const body = + '
' + + '

' + + it('switches theme-inverted and theme-always-alt to alt values when has no theme class', () => { + const doc = parseDocument(`${body}`) + expect(element(doc, '.theme-inverted').matches(altScope)).toBe(true) + expect(element(doc, '.theme-always-alt').matches(altScope)).toBe(true) + expect(element(doc, '.theme-inverted .theme-always-default').matches(defaultScope)).toBe(true) + expect(doc.documentElement.matches(altScope)).toBe(false) + expect(element(doc, '.plain').matches(altScope)).toBe(false) + }) + + it('flips the page to alt values and inverted sections back to default under theme-alt', () => { + const doc = parseDocument(`${body}`) + expect(doc.documentElement.matches(altScope)).toBe(true) + expect(element(doc, '.theme-inverted').matches(defaultScope)).toBe(true) + expect(element(doc, '.theme-inverted').matches(altScope)).toBe(false) + expect(element(doc, '.theme-always-default').matches(defaultScope)).toBe(true) + }) + }) + it('generates locked utility classes with stable ids and variant names', () => { const settings = makeColorSettings() const classes = generateFrameworkColorUtilityClasses(settings) diff --git a/src/__tests__/publisher/render.test.ts b/src/__tests__/publisher/render.test.ts index 7f7fddf3d..7a7de093c 100644 --- a/src/__tests__/publisher/render.test.ts +++ b/src/__tests__/publisher/render.test.ts @@ -886,7 +886,7 @@ describe('publishPage', () => { const { html } = publishPage(page, proj, registry) expect(html).toContain(':root.theme-alt') - expect(html).toContain(':root.theme-default .theme-inverted') + expect(html).toContain(':root:not(.theme-alt) .theme-inverted') expect(html).not.toContain('theme-dark') expect(html).not.toContain('theme-light') expect(html).toContain('--primary: hsla(238, 100%, 62%, 1);') @@ -896,6 +896,55 @@ describe('publishPage', () => { expect(html).not.toContain('cf-theme') }) + it('a theme-inverted section on a published page takes the alt color values', () => { + const bandDef = makeModule('test.band', { + render: () => ({ + html: '

Band

Body

', + }), + }) + const page = makePage({ root: { moduleId: 'test.band' } }) + const proj = makeSite({ + pages: [page], + settings: { + ...makeSite().settings, + framework: { + colors: { + tokens: [ + { + id: 'bg-token', + category: 'Background', + slug: 'bg-body', + lightValue: 'hsla(0, 0%, 100%, 1)', + darkValue: 'hsla(0, 0%, 5%, 1)', + darkModeEnabled: true, + generateUtilities: { text: false, background: true, border: false, fill: false }, + generateTransparent: false, + generateShades: { enabled: false, count: 0 }, + generateTints: { enabled: false, count: 0 }, + order: 0, + createdAt: 1, + updatedAt: 1, + }, + ], + }, + }, + }, + }) + + const { html } = publishPage(page, proj, makeRegistry({ 'test.band': bandDef })) + const doc = new DOMParser().parseFromString(html, 'text/html') + const css = [...doc.querySelectorAll('style')].map((style) => style.textContent).join('\n') + const altSelectors = [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)] + .filter((match) => match[2].includes('--bg-body: hsla(0, 0%, 5%, 1);')) + .map((match) => match[1].trim()) + .join(',\n') + + expect(altSelectors).not.toBe('') + expect(doc.querySelector('.theme-inverted')?.matches(altSelectors)).toBe(true) + expect(doc.querySelector('.plain')?.matches(altSelectors)).toBe(false) + expect(doc.documentElement.matches(altSelectors)).toBe(false) + }) + it('CSP: every published page includes Content-Security-Policy meta tag (Constraint #227)', () => { const page = makePage({ root: { moduleId: 'base.text', props: { text: 'CSP test' } } }) const { html } = publishPage(page, site, registry) diff --git a/src/core/framework/colors.ts b/src/core/framework/colors.ts index 690c19df6..d6765e7df 100644 --- a/src/core/framework/colors.ts +++ b/src/core/framework/colors.ts @@ -482,15 +482,22 @@ function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)) } +// Theme scopes. The page is in the default theme unless `` carries +// `theme-alt`. Nothing adds a class for the default case, so a section marked +// `theme-inverted` (or `theme-always-alt`) takes the alt values on published +// pages and in canvas frames alike, neither of which puts a theme class on +// ``. Keying the default branch on `:not(.theme-alt)` rather than a +// `theme-default` root class also keeps a runtime toggle to one class: adding +// `theme-alt` flips the page, and every inverted section back to default. const DEFAULT_THEME_OVERRIDE_SELECTOR = [ ':root.theme-alt .theme-inverted', ':root.theme-alt .theme-always-default', - ':root.theme-default .theme-inverted .theme-always-default', + ':root:not(.theme-alt) .theme-inverted .theme-always-default', ].join(',\n') const ALT_THEME_SELECTOR = [ ':root.theme-alt', - ':root.theme-default .theme-inverted', - ':root.theme-default .theme-always-alt', + ':root:not(.theme-alt) .theme-inverted', + ':root:not(.theme-alt) .theme-always-alt', ':root.theme-alt .theme-inverted .theme-always-alt', ].join(',\n')