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 docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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 `<html>`. 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.
Expand Down
55 changes: 53 additions & 2 deletions src/__tests__/framework/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,26 @@ function makeColorSettings(overrides: Partial<FrameworkColorSettings> = {}): 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(`<!DOCTYPE html>${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')
Expand Down Expand Up @@ -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')
Expand All @@ -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 `<html>` 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 =
'<section class="theme-inverted"><div class="theme-always-default"></div></section>' +
'<p class="plain"></p><aside class="theme-always-alt"></aside>'

it('switches theme-inverted and theme-always-alt to alt values when <html> has no theme class', () => {
const doc = parseDocument(`<html lang="en"><body>${body}</body></html>`)
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(`<html lang="en" class="theme-alt"><body>${body}</body></html>`)
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)
Expand Down
51 changes: 50 additions & 1 deletion src/__tests__/publisher/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);')
Expand All @@ -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: '<section class="theme-inverted"><p>Band</p></section><p class="plain">Body</p>',
}),
})
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)
Expand Down
13 changes: 10 additions & 3 deletions src/core/framework/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<html>` 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
// `<html>`. 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')
Loading