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/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ Opens the rail-selected panel:

Property controls are driven by the selected node's module schema (`src/core/module-engine/`).

At the top of the Properties Panel, the selector picker is the single entry point for CSS rules that affect the selected element. Assigned class rules render as removable `TagPill` chips and are stored on `node.classIds`; matching ambient rules render as non-removable `TagPill` chips because they apply by selector matching, not assignment. The dropdown searches both class rules and ambient selectors, is capped to the picker width budget, and ellipsizes long selector labels instead of expanding across the editor. Ambient rows that do not match the selected canvas element stay visible but disabled with the mismatch reason, and selector-shaped input such as `.hero .title`, `h1`, or `a:hover` creates an ambient rule instead of a class.
At the top of the Properties Panel, the selector picker is the single entry point for CSS rules that affect the selected element. Assigned class rules render as removable `TagPill` chips and are stored on `node.classIds`; matching ambient rules render as non-removable `TagPill` chips because they apply by selector matching, not assignment. Ambient rules that match only through a universal subject (`*`, `body.x *`, `*::before`) never render as pills — they style every element in their scope and stay reachable through the dropdown and the Selectors panel instead. The dropdown searches both class rules and ambient selectors, is capped to the picker width budget, and ellipsizes long selector labels instead of expanding across the editor. Ambient rows that do not match the selected canvas element stay visible but disabled with the mismatch reason, and selector-shaped input such as `.hero .title`, `h1`, or `a:hover` creates an ambient rule instead of a class.

The Typography panel stores Google/custom font assets and editable font tokens together under `site.settings.fonts`. Installed font assets own the self-hosted `@font-face` files; font tokens own the builder-facing variables such as `--font-primary`, the assigned font asset, and the fallback stack. The property-panel `font-family` control is a rich picker: token rows write `var(--font-primary)` so the selected node or class keeps following future token swaps, direct font rows write a concrete family stack as an escape hatch, and the text input still accepts manual values.

Expand Down
76 changes: 51 additions & 25 deletions docs/features/site-import.md

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion docs/reference/css-class-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Only `kind: 'class'` rules are assigned via `classIds`. Ambient rules (`kind: 'a
The right Properties Panel exposes this through a unified selector picker:

- assigned class-kind rules appear as removable `TagPill` chips and add / remove entries from `node.classIds`;
- matching ambient rules appear as non-removable `TagPill` chips because they affect the selected element through CSS matching;
- matching ambient rules appear as non-removable `TagPill` chips because they affect the selected element through CSS matching — but only when at least one selector-list entry targets the element **specifically** (its subject compound contains a type, class, id, or attribute simple selector). Rules that match solely through a universal subject (`*`, `body.x *`, `*::before`) style every element in their scope; they are page-wide plumbing — resets — not an identity of the selected element, so they never pill. They remain editable through the dropdown (enabled) and the Selectors panel;
- ambient rules whose selector contains a supported state pseudo-class (`:hover`, `:focus`, `:checked`, etc.) but whose stripped base selector matches the selected element appear as **inactive-pseudo** pills — they are surfaced even though `element.matches()` reports false for the full selector, so state styles stay editable without physically triggering the state;
- non-matching ambient rules (those whose stripped base selector also fails) still appear in the dropdown, disabled with a "doesn't match this element" reason;
- all pills are sorted **weakest → strongest by CSS specificity** (then by `rule.order`) so the chip that actually wins the cascade reads last;
Expand All @@ -102,6 +102,8 @@ The right Properties Panel exposes this through a unified selector picker:

The picker decides ambient matches via `element.matches(selector)` on the selected live canvas element. A selector such as `.hero .title` appears when the selected element is `.title`, not when the selected element is the `.hero` ancestor.

The live element is resolved by `findRenderedCanvasNodeElement` (`src/admin/pages/site/canvas/canvasNodeLookup.ts`), which searches ONLY the per-breakpoint canvas iframe documents (identified by `data-breakpoint-id` on their `<body>`). It must never query the admin document: the DOM panel's tree rows, the Import-HTML preview rows, and the selection/hover overlay rings all carry `data-node-id` there, and whether they exist depends on transient UI state (the layers tree auto-expands the selected node's ancestors *after* selection) — matching against them made ambient pills appear and disappear between clicks of the same node.

**Pseudo-state rules.** Ambient selectors that carry a supported state pseudo-class are recognized as state rules. The supported set (`SUPPORTED_PSEUDO_STATES`) covers transient interaction, navigation, and form-state pseudo-classes: `:hover`, `:active`, `:focus`, `:focus-visible`, `:focus-within`, `:target`, `:visited`, `:checked`, `:indeterminate`, `:placeholder-shown`, `:autofill`, `:disabled`, `:valid`, `:invalid`, `:in-range`, `:out-of-range`, `:user-valid`, `:user-invalid`. Structural and attribute-condition pseudos (`:first-child`, `:required`, `:not(...)`, etc.) are intentionally absent — `element.matches()` evaluates those correctly against the static DOM, so they produce direct matches, not inactive-pseudo matches. All shared helpers live in `src/admin/pages/site/cssStatePseudo.ts` (`SUPPORTED_PSEUDO_STATES`, `selectorStatePseudo`, `stripStatePseudos`, `splitSelectorList`).

State pseudo matching is tested per selector list entry: `.btn:hover, .x .btn` is split on the top-level comma and each entry is stripped independently. Pseudo-elements (e.g. `::after` in `.card:hover::after`) are stripped because `element.matches` never matches a pseudo-element.
Expand Down
20 changes: 18 additions & 2 deletions src/__tests__/admin/siteImport/SiteImportModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ function makeMinimalPlan(overrides: Partial<ImportPlan> = {}): ImportPlan {
assets: overrides.assets ?? [],
colors: overrides.colors ?? [],
scripts: overrides.scripts ?? [],
conflicts: overrides.conflicts ?? { pages: [], rules: [], tokens: [] },
linkedStylesheets: overrides.linkedStylesheets ?? [],
stylesheets: overrides.stylesheets ?? [],
conflicts: overrides.conflicts ?? { pages: [], rules: [], tokens: [], crossSheetClasses: [] },
warnings: overrides.warnings ?? [],
droppedAtRules: overrides.droppedAtRules ?? [],
unusedCss: overrides.unusedCss ?? [],
Expand All @@ -113,7 +115,8 @@ function makeMinimalResult(overrides: Partial<ImportResult> = {}): ImportResult
assets: overrides.assets ?? [],
colors: overrides.colors ?? [],
scripts: overrides.scripts ?? [],
conflicts: overrides.conflicts ?? { pages: [], rules: [], tokens: [] },
stylesheets: overrides.stylesheets ?? [],
conflicts: overrides.conflicts ?? { pages: [], rules: [], tokens: [], crossSheetClasses: [] },
warnings: overrides.warnings ?? [],
}
}
Expand Down Expand Up @@ -993,6 +996,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'about.html',
Expand Down Expand Up @@ -1020,6 +1024,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [],
rules: [
{
Expand Down Expand Up @@ -1047,6 +1052,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'about.html',
Expand Down Expand Up @@ -1082,6 +1088,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'home.html',
Expand Down Expand Up @@ -1112,6 +1119,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'about.html',
Expand Down Expand Up @@ -1147,6 +1155,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'about.html',
Expand Down Expand Up @@ -1178,6 +1187,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [],
rules: [
{
Expand Down Expand Up @@ -1212,6 +1222,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [],
rules: [
{
Expand Down Expand Up @@ -1260,6 +1271,7 @@ describe('ConflictsStep — conflict rendering', () => {
const plan = makeMinimalPlan({
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'about.html',
Expand Down Expand Up @@ -1378,6 +1390,7 @@ describe('AnalyzeStep — MEDIA group renders from plan.assets only', () => {
assetsIncluded: new Set(['assets/logo.png']),
fontsIncluded: new Set(),
scriptsIncluded: new Set(),
stylesheetsIncluded: new Set(),
}

// The navigator no longer needs the FileMap (it binds to the plan), but the
Expand All @@ -1394,6 +1407,7 @@ describe('AnalyzeStep — MEDIA group renders from plan.assets only', () => {
pageSlugOverrides={new Map()}
busy={false}
onSelectionChange={() => {}}
onStylesheetModeChange={() => {}}
onAddFiles={() => {}}
onSlugOverride={() => {}}
/>,
Expand Down Expand Up @@ -1611,6 +1625,7 @@ describe('commitImportPlan — overwrite with no existing target falls back to a
],
conflicts: {
tokens: [],
crossSheetClasses: [],
// Intra-batch collision → empty existingPageId, but user chose overwrite.
pages: [
{
Expand Down Expand Up @@ -1648,6 +1663,7 @@ describe('commitImportPlan — overwrite with no existing target falls back to a
],
conflicts: {
tokens: [],
crossSheetClasses: [],
pages: [
{
source: 'home.html',
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/canvas/canvasNodeLookup.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from 'bun:test'
import {
escapeCssAttributeValue,
findRenderedCanvasNodeElement,
} from '@site/canvas/canvasNodeLookup'

afterEach(() => {
document.body.innerHTML = ''
})

/** Append an iframe whose body is tagged as a canvas breakpoint frame. */
function addCanvasFrame(html: string, breakpointId = 'bp-desktop'): HTMLIFrameElement {
const frame = document.createElement('iframe')
document.body.appendChild(frame)
const frameDoc = frame.contentDocument
if (!frameDoc) throw new Error('Test iframe did not create a contentDocument')
frameDoc.body.setAttribute('data-breakpoint-id', breakpointId)
frameDoc.body.innerHTML = html
return frame
}

describe('findRenderedCanvasNodeElement', () => {
it('resolves the node inside a canvas breakpoint frame', () => {
addCanvasFrame('<h1 data-node-id="title" class="title"></h1>')

const el = findRenderedCanvasNodeElement('title')

expect(el).not.toBeNull()
expect(el?.tagName).toBe('H1')
expect(el?.ownerDocument.body.getAttribute('data-breakpoint-id')).toBe('bp-desktop')
})

it('never resolves admin-document elements carrying the same data-node-id', () => {
// The DOM panel's tree rows, the Import-HTML preview rows, and the
// selection/hover overlay rings all render `data-node-id` into the ADMIN
// document. None of them are the rendered node.
const treeRow = document.createElement('div')
treeRow.setAttribute('data-node-id', 'title')
document.body.appendChild(treeRow)

expect(findRenderedCanvasNodeElement('title')).toBeNull()

addCanvasFrame('<h1 data-node-id="title"></h1>')
const el = findRenderedCanvasNodeElement('title')
expect(el?.tagName).toBe('H1')
expect(el).not.toBe(treeRow)
})

it('ignores iframes that are not canvas breakpoint frames', () => {
const frame = document.createElement('iframe')
document.body.appendChild(frame)
const frameDoc = frame.contentDocument
if (!frameDoc) throw new Error('Test iframe did not create a contentDocument')
// No data-breakpoint-id on the body — e.g. a plugin or preview iframe.
frameDoc.body.innerHTML = '<div data-node-id="title"></div>'

expect(findRenderedCanvasNodeElement('title')).toBeNull()
})

it('returns null when the node is rendered nowhere', () => {
addCanvasFrame('<h1 data-node-id="other"></h1>')
expect(findRenderedCanvasNodeElement('title')).toBeNull()
})

it('escapes quotes and backslashes in the node id', () => {
expect(escapeCssAttributeValue('a"b\\c')).toBe('a\\"b\\\\c')
// Must not throw on a hostile id.
expect(findRenderedCanvasNodeElement('a"b\\c')).toBeNull()
})
})
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
Loading