Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Fix import fidelity: rgba color tokens and import-from-anywhere
Two losses found by comparing an imported template against its source:

- The framework color engine only parsed hex and hsl/hsla. Imported
  tokens authored as rgba() (--rule, --ink-2, ...) silently emitted NO
  CSS variable, so every `var(--rule)` reference lost its declaration —
  user-visibly, all borders on the demo template vanished. parseColor
  now handles rgb()/rgba() (comma and space syntax, % alpha), and an
  unparseable BASE value (oklch(), color-mix(), named) emits verbatim
  instead of dropping — emission stays injection-safe because
  formatCssVariableBlock sanitises every value. Derived shade/tint/
  transparent variants still skip what they can't model.

- The Site Import modal is a global surface (Spotlight, any workspace),
  but static-site analysis resolves HTML through the module registry,
  which only the site editor's chunk populated. Importing from the Data
  workspace failed with "Module base.container is not registered". The
  modal now registers @modules/base itself, riding its own lazy chunk.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
  • Loading branch information
DavidBabinec and claude committed Jun 10, 2026
commit 49cb502cb2c429369abc694cbf124471c4eec250
2 changes: 1 addition & 1 deletion docs/features/site-import.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ All URL-shaped values inside `pages[].nodeFragment` props, imported `htmlAttribu
| **HTML attributes** | Safe extra attributes on ordinary elements (`id`, ARIA, `role`, custom attrs, `data-*`, etc.) | Stored as `props.htmlAttributes` on base container/text/link/button/image modules so CSS selectors, anchors, classic scripts, accessibility attributes, and template runtime hooks such as `data-bg-src`, `data-aos`, and `data-bs-*` survive import. Users edit the same bag in the Properties panel's Attributes view. `class` is handled by the selector registry, inline `style` becomes `node.inlineStyles`, event handlers are stripped, and reserved Instatic/editor `data-*` names are not imported. Local asset URLs inside these attributes are uploaded and rewritten. |
| **Style rules** | All rules from linked CSS files and their unconditional local `@import` graph | `expandLinkedCssImports` follows bundled local CSS imports first, then `cssToStyleRules` maps selector declaration blocks to `NewStyleRule` entries (class or ambient kind) and stores supported stylesheet-level rules such as `@keyframes` as ambient raw CSS rules |
| **Media** | Uploadable images, videos, and fonts — including unreferenced files in the bundle | `buildAssetPlan` collects referenced assets and sweeps uploadable unreferenced files. Source companions such as `.scss`, sourcemaps, PHP mailers, `desktop.ini`, and README files are excluded before upload. |
| **Color tokens** | CSS custom properties on `:root` / `html` / `body` that look like colours | `extractRootColorTokens` pulls them into `ImportColorToken[]`; they become framework palette tokens. A `--<slug>` that collides with an existing colour token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Color tokens** | CSS custom properties on `:root` / `html` / `body` that look like colours | `extractRootColorTokens` pulls them into `ImportColorToken[]`; they become framework palette tokens. The framework parses hex, rgb/rgba, and hsl/hsla into channels (deriving shades/tints/transparent steps); any other authored value (oklch(), color-mix(), …) still emits its base `--<slug>` verbatim so `var(--x)` references never break. A `--<slug>` that collides with an existing colour token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Fonts** | Self-hosted `@font-face` families with at least one bundled file, plus trusted Google CSS2 imports | `buildFontFamilies` in `assetPlan.ts` picks the best bundled format (woff2 → woff → ttf → otf); `extractGoogleFontImports` turns Google CSS2 `@import` rules into install requests. Commit uploads custom files via `tx.addFonts`, installs Google families through the CMS Google-font installer, then merges those returned `FontEntry` records via `tx.addInstalledFonts` |
| **Font tokens** | Root `--font-*` variables with font-family stacks | `extractRootFontTokens` pulls them into `ImportFontToken[]`; committed via `tx.addFontTokens` after fonts so matching imported families can be assigned. A `--font-*` that collides with an existing font token surfaces as a `TokenConflict` (rename / skip / overwrite) |
| **Scripts** | Executable inline scripts and JS files linked by imported HTML | Preserved in source order and committed via `tx.addScripts` with page scope from the source HTML. Classic scripts remain plain `<script>` assets and bypass bundling; `type="module"` scripts keep module semantics. Non-executable script data such as `application/json`, import maps, and templates is skipped. |
Expand Down
51 changes: 51 additions & 0 deletions src/__tests__/framework/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,57 @@ describe('framework color generation', () => {
expect(sets.dark.find((variable) => variable.name === '--primary-50')?.value).toBe('hsla(238, 100%, 42%, 0.5)')
})

it('parses rgb()/rgba() base values — imported tokens emit and derive variants', () => {
// Imported sites routinely author tokens as rgba(); dropping them severed
// every `var(--rule)`-style reference (e.g. all borders on the demo
// template). rgb/rgba now parses into channels like hex/hsl.
const sets = generateFrameworkColorVariableSets(makeColorSettings({
tokens: [{
...makeColorSettings().tokens[0],
id: 'rule-token',
slug: 'rule',
lightValue: 'rgba(255, 255, 255, 0.14)',
darkModeEnabled: false,
}],
}))

const base = sets.light.find((variable) => variable.name === '--rule')
expect(base?.value).toBe('hsla(0, 0%, 100%, 0.14)')
// Derived variants work too — the value parsed into channels.
expect(sets.light.find((variable) => variable.name === '--rule-20')?.value).toBe('hsla(0, 0%, 100%, 0.2)')
expect(sets.light.some((variable) => variable.name === '--rule-d-1')).toBe(true)

// Space syntax + percentage alpha.
const spaceSets = generateFrameworkColorVariableSets(makeColorSettings({
tokens: [{
...makeColorSettings().tokens[0],
id: 'space-token',
slug: 'space',
lightValue: 'rgb(5 5 5 / 78%)',
darkModeEnabled: false,
}],
}))
expect(spaceSets.light.find((variable) => variable.name === '--space')?.value).toBe('hsla(0, 0%, 1.96%, 0.78)')
})

it('emits unparseable base values verbatim instead of silently dropping the variable', () => {
const sets = generateFrameworkColorVariableSets(makeColorSettings({
tokens: [{
...makeColorSettings().tokens[0],
id: 'oklch-token',
slug: 'fancy',
lightValue: 'oklch(0.7 0.1 200)',
darkModeEnabled: false,
}],
}))

// The base variable carries the authored value (sanitised at emission by
// formatCssVariableBlock); derived variants are skipped.
expect(sets.light.find((variable) => variable.name === '--fancy')?.value).toBe('oklch(0.7 0.1 200)')
expect(sets.light.some((variable) => variable.name === '--fancy-20')).toBe(false)
expect(sets.light.some((variable) => variable.name === '--fancy-d-1')).toBe(false)
})

it('emits theme scopes with theme-default and theme-alt class names', () => {
const css = generateFrameworkRootCss({ colors: makeColorSettings() })
expect(css).toContain(':root.theme-alt')
Expand Down
17 changes: 13 additions & 4 deletions src/__tests__/framework/cssVariableSanitization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,21 @@ describe('framework :root variable sanitisation', () => {
expect(hslCss).toContain('--primary: hsla(238, 100%, 62%, 1);')
})

it('skips an unparseable color (no variable emitted, not raw-passed)', () => {
it('emits an unparseable base value verbatim; derived variants are skipped', () => {
// The base `--<slug>` must always emit — dropping it severs every
// `var(--<slug>)` reference in imported CSS. Safety holds because
// `formatCssVariableBlock` sanitises each value at emission.
const sets = generateFrameworkColorVariableSets(colorToken('not-a-color'))
expect(sets.light).toHaveLength(0)
expect(sets.light.map((variable) => variable.name)).toEqual(['--primary'])
expect(sets.light[0]?.value).toBe('not-a-color')
const css = colorRootCss(colorToken('not-a-color'))
expect(css).not.toContain('not-a-color')
expect(css).not.toContain('--primary')
expect(css).toContain('--primary: not-a-color;')
})

it('a malicious unparseable base value is neutralised at emission, never raw-passed', () => {
const css = colorRootCss(colorToken('red; } body { display: none'))
expect(css).not.toContain('display: none')
expect(css).not.toContain('}')
})
})

Expand Down
6 changes: 6 additions & 0 deletions src/admin/modals/SiteImport/SiteImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
* history snapshot, so Cmd+Z reverts the entire import in one press.
*/

// Static-site analysis maps HTML onto base modules (`importHtml` resolves
// every element through the module registry). The modal is a global surface —
// openable from Spotlight or any workspace — so it must register the base
// modules itself rather than rely on the site editor's chunk having loaded.
// This rides the modal's own lazy chunk; it adds nothing to the shell bundle.
import '@modules/base'
import { useState, type ReactNode } from 'react'
import { nanoid } from 'nanoid'
import { Dialog } from '@ui/components/Dialog'
Expand Down
52 changes: 46 additions & 6 deletions src/core/framework/colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const TRANSPARENT_STEPS = [5, 10, 20, 30, 40, 50, 60, 70, 80, 90] as const
const UTILITY_ORDER: FrameworkColorUtilityType[] = ['text', 'background', 'border', 'fill']
const HSLA_RE = /^hsla?\(\s*([-+]?\d*\.?\d+)(?:deg)?\s*,\s*([-+]?\d*\.?\d+)%\s*,\s*([-+]?\d*\.?\d+)%(?:\s*,\s*([-+]?\d*\.?\d+))?\s*\)$/i
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i
// rgb()/rgba(), both comma and space syntax, alpha as number or percentage.
const RGBA_RE = /^rgba?\(\s*(\d*\.?\d+)\s*[, ]\s*(\d*\.?\d+)\s*[, ]\s*(\d*\.?\d+)\s*(?:[,/]\s*(\d*\.?\d+%?))?\s*\)$/i

export function normalizeFrameworkColorSlug(input: string): string {
const slug = input
Expand Down Expand Up @@ -127,8 +129,9 @@ function colorVariableSetsFromPlan(plan: ColorTokenPlan[]): FrameworkColorVariab

for (const { token, slug, variants } of plan) {
for (const variant of variants) {
// An unparseable color (e.g. a non-hex/hsl format or garbage) yields null
// and emits no variable — we never raw-pass an unvalidated string.
// A derived variant (transparent/shade/tint) of an unparseable color
// yields null and emits no variable; the base variant falls back to the
// authored value (sanitised at emission by `formatCssVariableBlock`).
const lightVariable = toVariable(token, slug, variant, token.lightValue)
if (lightVariable) light.push(lightVariable)
if (token.darkModeEnabled) {
Expand Down Expand Up @@ -264,7 +267,11 @@ function buildColorVariants(token: FrameworkColorToken): FrameworkColorVariant[]
id: 'base',
suffix: '',
variableName: (slug) => `--${slug}`,
value: (base) => normalizeColorValue(base),
// The base variable must always emit: an unparseable-but-authored value
// (oklch(), color-mix(), named color) passes through verbatim so every
// `var(--<slug>)` reference keeps resolving. Derived variants below
// still skip when the value can't be modelled.
value: (base) => normalizeColorValue(base) ?? verbatimColorValue(base),
},
]

Expand Down Expand Up @@ -343,14 +350,29 @@ function utilityStyles(
}

// All three color transforms validate at the boundary: they return null for any
// value `parseColor` can't understand (anything that isn't hex or hsl/hsla —
// e.g. rgb()/oklch()/named/color-mix). Callers skip null rather than raw-passing
// an unvalidated string into the emitted `:root {}` block.
// value `parseColor` can't understand (anything that isn't hex, rgb/rgba, or
// hsl/hsla — e.g. oklch()/named/color-mix). Derived variants (transparent
// steps, shades, tints) skip null — there is no meaningful way to derive them.
// The BASE variable instead falls back to the authored value verbatim (see
// `buildColorVariants`): a token the engine can't model must still emit its
// `--<slug>`, or every `var(--<slug>)` reference in imported CSS silently
// loses its declaration. Emission is injection-safe either way —
// `formatCssVariableBlock` runs every value through `sanitiseCssValue`.
function normalizeColorValue(value: string): string | null {
const channels = parseColor(value)
return channels ? formatHsla(channels) : null
}

/**
* Verbatim fallback for base variables whose value `parseColor` can't model
* (oklch(), color-mix(), named colors, …). Returns the trimmed authored value,
* or null for empty input.
*/
function verbatimColorValue(value: string): string | null {
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
}

function withAlpha(value: string, alpha: number): string | null {
const channels = parseColor(value)
if (!channels) return null
Expand Down Expand Up @@ -389,6 +411,24 @@ function parseColor(value: string): ColorChannels | null {
return rgbToHsl(...hexToRgb(hexMatch[1]))
}

const rgbaMatch = input.match(RGBA_RE)
if (rgbaMatch) {
const alphaRaw = rgbaMatch[4]
const alpha = alphaRaw === undefined
? 1
: alphaRaw.endsWith('%')
? clamp(Number(alphaRaw.slice(0, -1)) / 100, 0, 1)
: clamp(Number(alphaRaw), 0, 1)
return {
...rgbToHsl(
clamp(Number(rgbaMatch[1]), 0, 255),
clamp(Number(rgbaMatch[2]), 0, 255),
clamp(Number(rgbaMatch[3]), 0, 255),
),
a: alpha,
}
}

return null
}

Expand Down