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/editor.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,7 @@ See [docs/features/plugin-system.md](features/plugin-system.md) for the plugin S
2. Bind it to a node prop via the module's schema (`src/core/module-engine/`).
3. Use existing UI primitives (`Input`, `Select`, `Switch`, `ColorInput`, etc.).
4. If the control needs token-aware autocomplete (resolving framework variables like `var(--space-md)` from a typed step label), use `TokenAwareInput` from `@site/property-controls/TokenAwareInput` — pass a `tokens` array from `useSpacingTokens()` or `useTypographyTokens()` in `tokenUtils.ts`. The component handles suggestion filtering, commit-on-Enter/Tab/blur, live-preview-on-hover (gated by the `hoverPreview` editor preference), and the Suggested/All dropdown sections. For narrow overlaid inputs (like spacing box sides), use `fieldSize="xs"`, `overlay`, and `tooltipOnOverflow`.
5. Number-backed CSS controls rendered through `ClassPropertyRow` keep the user's focused text in a lexical draft. `src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx` persists only finite numbers, so transient input such as `0.` or `-` remains typeable without entering `CSSPropertyBag`; blur restores the canonical stored value.

## Adding a new spotlight command

Expand Down
2 changes: 2 additions & 0 deletions docs/reference/css-class-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ interface StyleRule {

`styles` and `contextStyles` are typed `Record<string, unknown>` at the persistence boundary — narrowing happens at the publisher's `bagToCSS` (`classCss.ts`). The WRITE API (class slice, framework generators) uses the typed `CSSPropertyBag` shape from `src/core/page-tree/cssPropertyBag.ts`.

Number-backed fields such as `opacity` and `zIndex` stay numeric at that write boundary. `src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx` holds incomplete focused text such as `0.` or `-` in a local lexical draft and commits only finite numbers; `src/admin/pages/site/property-controls/TextControl.tsx` exposes the focus and blur boundary that releases that draft.

Declaration priority is stored structurally, beside the scalar property value. `stylePriorities` is a sparse property-to-`'important'` map for `styles`; `contextStylePriorities` is the equivalent sparse map keyed first by context id. The CSS importer reads priority through `CSSStyleDeclaration.getPropertyPriority()`, and the publisher appends ` !important` from this metadata. Removing a value also removes its priority entry, and tolerant persistence parsing drops orphaned or invalid priority metadata. Node inline styles deliberately keep their existing value-only shape.

`rawCss` is intentionally narrow. The importer uses it for sanitised `@keyframes` blocks that cannot be represented as selector declarations; the publisher emits only supported raw keyframes after its own safety gate. General arbitrary CSS strings still belong in structured `styles` / `contextStyles` entries.
Expand Down
51 changes: 51 additions & 0 deletions src/__tests__/panels/propertiesPanel-redesign.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1044,6 +1044,57 @@ describe('ClassPropertyRow — token-aware properties', () => {
})
})

describe('ClassPropertyRow — number-typed CSS properties', () => {
it('keeps a typed opacity decimal intact while persisting a number', async () => {
const { nodeId, classIds } = loadSiteWithClasses(1)
const classId = classIds[0]
selectNode(nodeId)
render(<PropertiesPanel />)
fireEvent.click(screen.getByRole('button', { name: /edit class \.class-1/i }))

const opacityRow = document.querySelector('[data-testid="css-property-row-opacity"]')
const opacityInput = opacityRow?.querySelector('input') as HTMLInputElement
const user = userEvent.setup()

await user.click(opacityInput)
await user.type(opacityInput, '0')
await user.type(opacityInput, '.')
expect(opacityInput.value).toBe('0.')

await user.type(opacityInput, '8')
expect(opacityInput.value).toBe('0.8')
expect(useEditorStore.getState().site!.styleRules[classId].styles.opacity).toBe(0.8)
expect(typeof useEditorStore.getState().site!.styleRules[classId].styles.opacity).toBe('number')

await user.tab()
expect(opacityInput.value).toBe('0.8')
})

it('keeps a leading minus while typing a negative z-index', async () => {
const { nodeId, classIds } = loadSiteWithClasses(1)
const classId = classIds[0]
selectNode(nodeId)
render(<PropertiesPanel />)
fireEvent.click(screen.getByRole('button', { name: /edit class \.class-1/i }))

const zIndexRow = document.querySelector('[data-testid="css-property-row-zIndex"]')
const zIndexInput = zIndexRow?.querySelector('input') as HTMLInputElement
const user = userEvent.setup()

await user.click(zIndexInput)
await user.type(zIndexInput, '-')
expect(zIndexInput.value).toBe('-')
expect(useEditorStore.getState().site!.styleRules[classId].styles.zIndex).toBeUndefined()

await user.type(zIndexInput, '2')
expect(zIndexInput.value).toBe('-2')
expect(useEditorStore.getState().site!.styleRules[classId].styles.zIndex).toBe(-2)

await user.tab()
expect(zIndexInput.value).toBe('-2')
})
})

describe('StyleRuleComposer set style indicators', () => {
it('marks category rail icons and section headers that contain stored class styles', () => {
const { nodeId, classIds } = loadSiteWithClasses(1)
Expand Down
38 changes: 32 additions & 6 deletions src/__tests__/server/pluginMediaAdapterBoundary.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
import { beforeEach, describe, expect, it, mock } from 'bun:test'
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
import { buildAdapterShim } from '../../../server/plugins/host/media'
import { pendingRequests, workers } from '../../../server/plugins/host/workerState'
import type { MainToWorkerMessage } from '../../../server/plugins/protocol/messages'

let workerValue: unknown

mock.module('../../../server/plugins/host/workerPool', () => ({
requestFromWorker: mock(async () => ({ ok: true, value: workerValue })),
}))

const { buildAdapterShim } = await import('../../../server/plugins/host/media')
function stubWorker(): Worker {
return {
postMessage(message: MainToWorkerMessage) {
if (message.kind !== 'run-media-adapter-call') {
throw new Error(`Unexpected worker message kind: ${message.kind}`)
}
const pending = pendingRequests.get(message.correlationId)
if (!pending) {
throw new Error(`Missing pending worker request: ${message.correlationId}`)
}
pendingRequests.delete(message.correlationId)
pending.resolve({
kind: 'media-adapter-call-result',
correlationId: message.correlationId,
ok: true,
value: workerValue,
})
},
terminate() { /* no-op test worker */ },
addEventListener() { /* no-op test worker */ },
} as unknown as Worker
}

function adapter() {
return buildAdapterShim({
Expand All @@ -23,6 +43,12 @@ function adapter() {
describe('plugin media adapter host boundary', () => {
beforeEach(() => {
workerValue = undefined
workers.set('acme.media', stubWorker())
})

afterEach(() => {
workers.clear()
pendingRequests.clear()
})

it('rejects plugin upload plans that try to use the host-only LOCAL transport', async () => {
Expand Down
50 changes: 42 additions & 8 deletions src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* Phase 3 / Task #464 / Spec #671.
*/

import { useState } from 'react'
import type { CSSPropertyBag } from '@core/page-tree'
import { TextControl } from '@site/property-controls/TextControl'
import { ColorControl } from '@site/property-controls/ColorControl'
Expand Down Expand Up @@ -79,6 +80,11 @@ export function ClassPropertyRow({
const label = cssPropertyLabel(String(property))
const placeholderText = placeholder !== undefined ? String(placeholder) : undefined
const fonts = useEditorStore((state) => state.site?.settings.fonts ?? null)
const isNumberTyped = NUMBER_TYPED_PROPS.has(property)
// Numeric CSS values are persisted as numbers, but their text fields need
// to retain lexical editing states such as `0.`, `-`, and `1e`. `null`
// means the field is not being edited and should reflect the stored value.
const [numberDraft, setNumberDraft] = useState<string | null>(null)

// Always read both token catalogs — hooks must run unconditionally on
// every render. The selected catalog is forwarded to TokenAwareInput
Expand All @@ -92,23 +98,49 @@ export function ClassPropertyRow({
? spacingTokens
: []

const commitNumberValue = (rawValue: string) => {
const trimmed = rawValue.trim()
if (trimmed === '') {
if (value !== undefined) onChange(property, undefined)
return
}

const parsed = Number(trimmed)
if (Number.isFinite(parsed) && !Object.is(value, parsed)) {
onChange(property, parsed)
}
}

// Translate a control's (propKey, val) onChange signature into a typed
// CSSPropertyBag value, coercing to number when the property expects one.
// CSSPropertyBag value. Number-typed properties keep the raw focused draft
// in the input while finite values continue to update the canvas live.
const handleControlChange = (_key: string, val: unknown) => {
const nextValue = String(val ?? '')
if (NUMBER_TYPED_PROPS.has(property)) {
const parsed = Number(nextValue)
onChange(property, Number.isFinite(parsed) && nextValue.trim() !== '' ? parsed : undefined)
if (isNumberTyped) {
setNumberDraft(nextValue)
commitNumberValue(nextValue)
return
}
onChange(property, nextValue)
}

const handleNumberFocus = () => {
if (isNumberTyped) setNumberDraft(String(value ?? ''))
}

const handleNumberBlur = (nextValue: string) => {
if (!isNumberTyped) return
commitNumberValue(nextValue)
// Returning to the stored value also canonicalizes incomplete drafts:
// `0.` becomes `0`, while an uncommittable `-`/`.` is discarded.
setNumberDraft(null)
}

// Token-aware properties commit on blur via TokenAwareInput's `onCommit`.
// It already returns undefined for empty input (clears the value), so
// the only translation we do here is the number-typed coercion.
const handleTokenCommit = (resolved: string | undefined) => {
if (NUMBER_TYPED_PROPS.has(property)) {
if (isNumberTyped) {
if (resolved == null || resolved === '') {
onChange(property, undefined)
return
Expand All @@ -126,7 +158,7 @@ export function ClassPropertyRow({
const handleControlPreview = (_key: string, val: unknown) => {
if (!onPreview) return
const nextValue = String(val ?? '')
if (NUMBER_TYPED_PROPS.has(property)) {
if (isNumberTyped) {
const parsed = Number(nextValue)
onPreview(property, Number.isFinite(parsed) && nextValue.trim() !== '' ? parsed : undefined)
return
Expand All @@ -136,7 +168,7 @@ export function ClassPropertyRow({

const handleTokenPreview = (resolved: string | undefined) => {
if (!onPreview) return
if (NUMBER_TYPED_PROPS.has(property)) {
if (isNumberTyped) {
if (resolved == null || resolved === '') {
onPreview(property, undefined)
return
Expand Down Expand Up @@ -240,10 +272,12 @@ export function ClassPropertyRow({
control = (
<TextControl
propKey={String(property)}
value={String(value ?? '')}
value={isNumberTyped && numberDraft !== null ? numberDraft : String(value ?? '')}
placeholder={placeholderText}
onChange={handleControlChange}
label={label}
onInputFocus={isNumberTyped ? handleNumberFocus : undefined}
onInputBlur={isNumberTyped ? handleNumberBlur : undefined}
/>
)
break
Expand Down
13 changes: 10 additions & 3 deletions src/admin/pages/site/property-controls/TextControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { ControlRow } from '@ui/components/ControlRow'
interface TextControlProps extends ControlProps<string> {
placeholder?: string
normalize?: TextControlNormalize
onInputFocus?: () => void
onInputBlur?: (value: string) => void
}

export function TextControl({
Expand All @@ -19,15 +21,19 @@ export function TextControl({
isOverride,
disabled,
layout,
onInputFocus,
onInputBlur,
}: TextControlProps) {
function handleChange(nextValue: string) {
onChange(propKey, normalize === 'identifier' ? normalizeIdentifierInput(nextValue) : nextValue)
}

function handleBlur(nextValue: string) {
if (normalize !== 'identifier') return
const normalized = normalizeIdentifierValue(nextValue)
if (normalized !== value) onChange(propKey, normalized)
if (normalize === 'identifier') {
const normalized = normalizeIdentifierValue(nextValue)
if (normalized !== value) onChange(propKey, normalized)
}
onInputBlur?.(nextValue)
}

return (
Expand All @@ -48,6 +54,7 @@ export function TextControl({
autoCapitalize={normalize === 'identifier' ? 'none' : undefined}
spellCheck={normalize === 'identifier' ? false : undefined}
onChange={(e) => handleChange(e.target.value)}
onFocus={onInputFocus}
onBlur={(e) => handleBlur(e.target.value)}
/>
</ControlRow>
Expand Down
Loading