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
2 changes: 1 addition & 1 deletion docs/features/canvas-iframe-per-frame.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Five `<style>` elements are injected per iframe (three from `ClassStyleInjector`
| Injector | `id` attribute | Cascade layer | Purpose |
|---|---|---|---|
| `EditorChromeInjector` | `instatic-editor-chrome` | **unlayered** | Editor chrome: placeholder, slot-instance, unknown-module styles. Copies safe required design tokens from parent `:root` onto iframe `:root`. The editor UI font, admin text-size tokens, and admin spacing tokens are forwarded as **chrome-namespaced** `--chrome-font-sans`, `--chrome-text-*`, and `--chrome-space-*` aliases, not as `--font-sans`, `--text-*`, or `--space-*`, because the injector is unlayered and would otherwise clobber the site's own Framework tokens. |
| `ClassStyleInjector` | `mc-classes` | `@layer user-authored` | Publisher reset + framework CSS + class registry CSS |
| `ClassStyleInjector` | `mc-classes` | `@layer user-authored` | Publisher reset + framework CSS (incl. the opt-in `html { font-size }` rule) + class registry CSS |
| `ClassStyleInjector` | `mc-classes-preview` | `@layer user-authored` | Higher-specificity preview rule (doubled selector) while a property control is hovered. Empty for state-pseudo rules — those use `mc-classes-force-state` instead. |
| `ClassStyleInjector` | `mc-classes-force-state` | `@layer user-authored` | Force-paints the active state-pseudo rule (`.btn:hover`, `.card:focus`, etc.) onto the selected node via a doubled `[data-node-id]` selector so the state is visible/editable without physically triggering it. Mirrors the full `contextStyles` emission per breakpoint and condition. |
| `UserStylesheetInjector` | `mc-user-styles` | `@layer user-authored` | User-uploaded stylesheets (verbatim, unscoped) |
Expand Down
2 changes: 1 addition & 1 deletion docs/features/publisher.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ class registry, which wins over framework, which wins over reset.

```text
reset-<hash>.css = PUBLISHER_RESET_CSS ← reset.ts (cross-browser baseline)
framework-<hash>.css = buildSiteFrameworkCss(site) ← frameworkCss.ts (spacing, typography, …)
framework-<hash>.css = buildSiteFrameworkCss(site) ← frameworkCss.ts (root font size when opted in, spacing, typography, …)
+ collectModuleCSS(via CssCollector) ← deduped per-moduleId CSS
style-<hash>.css = collectClassCSS(site) ← user-defined StyleRule entries, incl. raw @keyframes
userStyles-<hash>.css = collectUserStylesheetCss(site, page) ← author stylesheets, scoped to this page
Expand Down
6 changes: 5 additions & 1 deletion docs/features/site-shell.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,11 @@ type SiteSettings = {
- `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.
- `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.
- `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`), `treeShakeGeneratedFrameworkUtilities` flag, and `emitRootFontSize`.

**Root font size.** Every rem token is converted against `rootFontSize` (10 in the Core preset), so the browser's root must match or each token renders `16 / rootFontSize` times larger than the scale says. With `emitRootFontSize: true`, `isRem` on, and `rootFontSize` not 16, the framework root CSS starts with `html { font-size: <rootFontSize / 16 * 100>%; }` (`62.5%` for 10). A percentage keeps a visitor's browser font-size preference working. The rule comes from `composeFrameworkRootCss` in `src/core/framework/generate.ts`, the one composer behind both `buildFrameworkPlan` (publisher) and `generateFrameworkRootCss` (canvas iframe), so editor and published page agree; it sits in `framework.css` before any authored CSS, so an authored `html` rule still wins. The editor chrome outside the canvas iframes never receives it.

`emitRootFontSize` is optional with no fallback, and absent means off: sites saved before the field existed never had the rule and their pages were tuned to the larger rendering, so they keep rendering byte for byte as before. The Core preset (`buildCoreFrameworkSettings`) sets it to `true`, so a framework created through onboarding or **Manage framework** gets it. `mergeCoreFrameworkSettings` keeps an existing framework's preferences, and when it has none it adopts the preset's with `emitRootFontSize: false`. Site settings → Publishing → **Set the root font size** toggles it per site.

The Colors / Framework Scale / Typography panels write to these sub-trees. All values are emitted into the published `framework.css` by `buildSiteFrameworkCss(site)` via `buildFrameworkPlan(settings)`, which returns the merged `:root` variable block and the locked utility classes from a single ordered traversal per family. (`generateFrameworkRootCss` / `generateFrameworkUtilityClasses` remain for single-output callers such as the canvas preview and the editor's class reconciler.)

Expand Down
119 changes: 119 additions & 0 deletions src/__tests__/framework/rootFontSize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* Framework root font size: every rem token is converted against
* `preferences.rootFontSize`, so the framework must also set
* `html { font-size }` or the tokens render 16 / rootFontSize times too large.
*
* The rule is opt-in (`emitRootFontSize`) so sites saved before the field
* existed keep rendering byte-for-byte as they did; the Core preset opts new
* frameworks in. The canvas and the publisher share one generator, so both are
* asserted here.
*/
import { describe, expect, it } from 'bun:test'
import type { FrameworkPreferencesSettings, FrameworkSettings } from '@core/framework-schema'
import {
applyFrameworkPreset,
buildCoreFrameworkSettings,
generateFrameworkRootCss,
mergeCoreFrameworkSettings,
} from '@core/framework'
import { buildSiteFrameworkCss } from '@core/publisher'
import { createCanvasClassCssMemo } from '@site/canvas/canvasClassCss'
import { makeSite } from '../publisher/helpers'

const ROOT_62_5 = 'html {\n font-size: 62.5%;\n}'

/** Preferences exactly as a site saved before `emitRootFontSize` existed stores them. */
const LEGACY_PREFERENCES: FrameworkPreferencesSettings = {
rootFontSize: 10,
minScreenWidth: 320,
maxScreenWidth: 1400,
isRem: true,
treeShakeGeneratedFrameworkUtilities: true,
}

function siteWithFramework(framework: FrameworkSettings | undefined) {
const site = makeSite()
site.settings.framework = framework
return site
}

function canvasCss(framework: FrameworkSettings) {
const generate = createCanvasClassCssMemo()
return generate(
{},
[],
[],
framework.colors,
framework.typography,
framework.spacing,
framework.preferences,
null,
)
}

describe('framework root font size', () => {
it('a new Core framework sets html font-size to 62.5% in the published CSS and the canvas', () => {
const framework = applyFrameworkPreset(undefined, 'full')!
expect(framework.preferences?.emitRootFontSize).toBe(true)

const published = buildSiteFrameworkCss(siteWithFramework(framework))
expect(published).toContain(ROOT_62_5)
// Before the `:root` tokens and every utility, so any authored `html`
// rule (later in the cascade) still wins.
expect(published.indexOf(ROOT_62_5)).toBeLessThan(published.indexOf(':root {'))

expect(canvasCss(framework)).toContain(ROOT_62_5)
})

it('the variables-only preset opts in too', () => {
const framework = applyFrameworkPreset(undefined, 'variables')!
expect(generateFrameworkRootCss(framework)).toStartWith(ROOT_62_5)
})

it('an existing site whose preferences predate the flag renders exactly as before', () => {
const legacy = buildCoreFrameworkSettings({ includeUtilities: true })
legacy.preferences = { ...LEGACY_PREFERENCES }

const published = buildSiteFrameworkCss(siteWithFramework(legacy))
expect(published).not.toContain('font-size: 62.5%')
expect(published).not.toMatch(/^html\s*\{/m)
expect(canvasCss(legacy)).not.toMatch(/^html\s*\{/m)
})

it('a framework with no stored preferences, or no framework at all, emits nothing new', () => {
const noPreferences = buildCoreFrameworkSettings({ includeUtilities: false })
delete noPreferences.preferences
expect(generateFrameworkRootCss(noPreferences)).not.toMatch(/^html\s*\{/m)
expect(buildSiteFrameworkCss(siteWithFramework(undefined))).toBe('')
})

it('re-applying the preset keeps an existing site off', () => {
const withLegacyPreferences = buildCoreFrameworkSettings({ includeUtilities: false })
withLegacyPreferences.preferences = { ...LEGACY_PREFERENCES }
const reapplied = applyFrameworkPreset(withLegacyPreferences, 'full')!
expect(reapplied.preferences?.emitRootFontSize).toBeUndefined()
expect(generateFrameworkRootCss(reapplied)).not.toMatch(/^html\s*\{/m)

// A framework built up by hand (no stored preferences) adopts the preset's
// preferences on merge, but not the root font-size rule.
const handBuilt = buildCoreFrameworkSettings({ includeUtilities: false })
delete handBuilt.preferences
const merged = mergeCoreFrameworkSettings(handBuilt, { includeUtilities: true })
expect(merged.preferences?.emitRootFontSize).toBe(false)
expect(generateFrameworkRootCss(merged)).not.toMatch(/^html\s*\{/m)
})

it('derives the percentage from rootFontSize and skips px scales and a 16px root', () => {
const framework = buildCoreFrameworkSettings({ includeUtilities: false })
const withPreferences = (patch: Partial<FrameworkPreferencesSettings>) => ({
...framework,
preferences: { ...LEGACY_PREFERENCES, emitRootFontSize: true, ...patch },
})

expect(generateFrameworkRootCss(withPreferences({ rootFontSize: 12 }))).toStartWith(
'html {\n font-size: 75%;\n}',
)
expect(generateFrameworkRootCss(withPreferences({ rootFontSize: 16 }))).not.toMatch(/^html\s*\{/m)
expect(generateFrameworkRootCss(withPreferences({ isRem: false }))).not.toMatch(/^html\s*\{/m)
})
})
2 changes: 2 additions & 0 deletions src/__tests__/framework/typographySpacing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,8 @@ describe('framework/preferences', () => {
maxScreenWidth: 1920,
isRem: false,
treeShakeGeneratedFrameworkUtilities: false,
// Absent from the stored settings, so the default (off) is layered in.
emitRootFontSize: false,
})
})

Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/persistence/validateSiteRoundTrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ describe('validateSite — round-trip with representative fixture', () => {
expect(result.settings.framework?.preferences?.rootFontSize).toBe(10)
expect(result.settings.framework?.preferences?.isRem).toBe(true)
expect(result.settings.framework?.preferences?.treeShakeGeneratedFrameworkUtilities).toBe(true)
// The fixture predates emitRootFontSize: it stays absent (off), never defaulted on.
expect(result.settings.framework?.preferences?.emitRootFontSize).toBeUndefined()
})

it('preserves an opted-in emitRootFontSize preference', () => {
const raw = loadFixture() as {
settings: { framework: { preferences: Record<string, unknown> } }
}
raw.settings.framework.preferences.emitRootFontSize = true
const result = validateSite(raw)
expect(result.settings.framework?.preferences?.emitRootFontSize).toBe(true)
})
})

Expand Down
21 changes: 21 additions & 0 deletions src/admin/modals/Settings/sections/PublishingSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export function PublishingSection() {

const frameworkPreferences = resolveFrameworkPreferences(site.settings.framework?.preferences)
const treeShakeId = 'publishing-tree-shake-framework-utilities'
const rootFontSizeId = 'publishing-emit-root-font-size'
const { rootFontSize } = frameworkPreferences
const rootFontSizePercent = Number(((rootFontSize / 16) * 100).toFixed(4))

return (
<div>
Expand Down Expand Up @@ -73,6 +76,24 @@ export function PublishingSection() {
}
/>
</div>
<div className={s.toggleRow}>
<div className={s.toggleRowContent}>
<label htmlFor={rootFontSizeId} className={s.toggleRowLabel}>
Set the root font size
</label>
<p className={s.toggleRowDesc}>
Sets <code>html {'{'} font-size: {rootFontSizePercent}% {'}'}</code> so 1rem is{' '}
{rootFontSize}px and framework type and spacing render at the sizes the scale
shows, in the editor and on the published site. Turning this on for an existing
site makes every rem size smaller, so review your pages after switching.
</p>
</div>
<Switch
id={rootFontSizeId}
checked={frameworkPreferences.emitRootFontSize}
onCheckedChange={(value) => updateFrameworkPreferences({ emitRootFontSize: value })}
/>
</div>
</div>
</section>
</div>
Expand Down
11 changes: 11 additions & 0 deletions src/core/framework-schema/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,17 @@ export const FrameworkPreferencesSettingsSchema = Type.Object({
isRem: withFallback(Type.Boolean(), true),
/** Whether generated framework utility CSS is tree-shaken to used class IDs. */
treeShakeGeneratedFrameworkUtilities: withFallback(Type.Boolean(), true),
/**
* Whether framework CSS sets `html { font-size }` to `rootFontSize` (as a
* percentage of the browser default) so rem tokens render at the size the
* scale says. Only acts when `isRem` is on and `rootFontSize` is not 16.
*
* Optional with NO fallback on purpose: sites created before this field
* existed never emitted the rule, and their pages were tuned to the larger
* rem rendering. Absent means off, so those sites keep rendering exactly as
* before. The Core preset sets it to `true` for new frameworks.
*/
emitRootFontSize: Type.Optional(Type.Boolean()),
})

export type FrameworkPreferencesSettings = Static<typeof FrameworkPreferencesSettingsSchema>
Expand Down
6 changes: 5 additions & 1 deletion src/core/framework/coreFrameworkPreset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
* • Spacing — `data/defaults.ts` (SPACING_CALCULATOR_INITIAL_STATE) —
* already mirrored by `buildDefaultSpacingSettings`.
* • Preferences — `data/defaults.ts` (DEFAULT_PREFERENCES): rootFontSize 10,
* screen 320–1400, rem.
* screen 320–1400, rem. Instatic adds `emitRootFontSize`
* so the 10px root is actually set on `<html>`.
*
* Two import modes (`includeUtilities`):
* • `true` — the FULL framework: every token's utility classes
Expand Down Expand Up @@ -203,6 +204,9 @@ function buildCoreFrameworkPreferences(
// utility class, not just the ones already assigned in the tree. Variables-
// only has no utility classes, so the flag is moot — keep it on (default).
treeShakeGeneratedFrameworkUtilities: !options.includeUtilities,
// The scale above converts px to rem against a 10px root, so the framework
// also sets `html { font-size: 62.5% }` to make that true in the browser.
emitRootFontSize: true,
}
}

Expand Down
31 changes: 26 additions & 5 deletions src/core/framework/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
generateFrameworkSpacingVariables,
} from './spacing'
import { resolveFrameworkPreferences } from './preferences'
import type { FrameworkPreferences } from './scale'
import { formatCssVariableBlock } from './cssVariables'

export interface FrameworkGenerationSettings {
Expand All @@ -43,15 +44,35 @@ interface FrameworkPlan {
}

/**
* Compose the `:root` block from the color variable sets and the scale
* variables. Shared by `generateFrameworkRootCss` (single output) and
* `buildFrameworkPlan` (both outputs) so they stay byte-identical.
* The `html { font-size }` rule that makes `1rem` equal `rootFontSize` px, the
* size every rem token was converted against. Written as a percentage of the
* browser default (10 → `62.5%`) rather than a px value so a visitor's own
* browser font-size preference still scales the page.
*
* Empty unless the site opted in (`emitRootFontSize`), tokens are in rem, and
* the root differs from the 16px browser default.
*/
function generateFrameworkRootFontSizeCss(preferences: FrameworkPreferences): string {
if (!preferences.emitRootFontSize || !preferences.isRem) return ''
if (preferences.rootFontSize === 16) return ''
const percent = Number(((preferences.rootFontSize / 16) * 100).toFixed(4))
return `html {\n font-size: ${percent}%;\n}`
}

/**
* Compose the framework root CSS: the root font-size rule, then the `:root`
* block from the color variable sets and the scale variables, then the color
* theme scopes. Shared by `generateFrameworkRootCss` (single output, the
* canvas) and `buildFrameworkPlan` (both outputs, the publisher) so the editor
* and the published page stay byte-identical.
*/
function composeFrameworkRootCss(
preferences: FrameworkPreferences,
colorVariables: FrameworkColorVariableSets,
scaleVariables: FrameworkScaleVariable[],
): string {
return [
generateFrameworkRootFontSizeCss(preferences),
formatCssVariableBlock(':root', [...colorVariables.light, ...scaleVariables]),
formatFrameworkColorThemeCss(colorVariables),
]
Expand All @@ -63,7 +84,7 @@ export function generateFrameworkRootCss(
settings: FrameworkGenerationSettings | null | undefined,
): string {
const preferences = resolveFrameworkPreferences(settings?.preferences)
return composeFrameworkRootCss(generateFrameworkColorVariableSets(settings?.colors), [
return composeFrameworkRootCss(preferences, generateFrameworkColorVariableSets(settings?.colors), [
...generateFrameworkTypographyVariables(settings?.typography, preferences),
...generateFrameworkSpacingVariables(settings?.spacing, preferences),
])
Expand Down Expand Up @@ -97,7 +118,7 @@ export function buildFrameworkPlan(
const spacing = generateFrameworkSpacingPlan(settings?.spacing, preferences)

return {
rootCss: composeFrameworkRootCss(colors.variableSets, [
rootCss: composeFrameworkRootCss(preferences, colors.variableSets, [
...typography.variables,
...spacing.variables,
]),
Expand Down
9 changes: 7 additions & 2 deletions src/core/framework/manageFramework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,13 @@ export function mergeCoreFrameworkSettings(
next.spacing = mergeScaleFamily(next.spacing, preset.spacing)

// Preferences — existing wins wholesale when present (it is fully populated
// after schema parse); otherwise adopt the preset's.
next.preferences = existing.preferences ?? preset.preferences
// after schema parse); otherwise adopt the preset's. An existing framework
// without stored preferences has been rendering with the defaults, which
// never set the root font size, so adopting the preset must not switch
// `emitRootFontSize` on: that would resize every rem token on a live site.
if (!existing.preferences && preset.preferences) {
next.preferences = { ...preset.preferences, emitRootFontSize: false }
}

return next
}
Expand Down
Loading
Loading