From 72d993ee76c98b16f3afef2c22cff32742776363 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 22 Jul 2026 22:20:14 +0200 Subject: [PATCH 1/2] fix(editor): preserve decimal number input drafts --- docs/editor.md | 1 + docs/reference/css-class-registry.md | 2 + .../panels/propertiesPanel-redesign.test.tsx | 51 +++++++++++++++++++ .../PropertiesPanel/ClassPropertyRow.tsx | 50 +++++++++++++++--- .../site/property-controls/TextControl.tsx | 13 +++-- 5 files changed, 106 insertions(+), 11 deletions(-) diff --git a/docs/editor.md b/docs/editor.md index 57127df01..56e29380d 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -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 diff --git a/docs/reference/css-class-registry.md b/docs/reference/css-class-registry.md index 1089fb013..3905931bb 100644 --- a/docs/reference/css-class-registry.md +++ b/docs/reference/css-class-registry.md @@ -59,6 +59,8 @@ interface StyleRule { `styles` and `contextStyles` are typed `Record` 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. diff --git a/src/__tests__/panels/propertiesPanel-redesign.test.tsx b/src/__tests__/panels/propertiesPanel-redesign.test.tsx index f5b4e4683..a8fcc43c2 100644 --- a/src/__tests__/panels/propertiesPanel-redesign.test.tsx +++ b/src/__tests__/panels/propertiesPanel-redesign.test.tsx @@ -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() + 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() + 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) diff --git a/src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx b/src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx index d9db61aa9..0e4dd4d69 100644 --- a/src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx +++ b/src/admin/pages/site/panels/PropertiesPanel/ClassPropertyRow.tsx @@ -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' @@ -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(null) // Always read both token catalogs — hooks must run unconditionally on // every render. The selected catalog is forwarded to TokenAwareInput @@ -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 @@ -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 @@ -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 @@ -240,10 +272,12 @@ export function ClassPropertyRow({ control = ( ) break diff --git a/src/admin/pages/site/property-controls/TextControl.tsx b/src/admin/pages/site/property-controls/TextControl.tsx index b8c1165fb..113aaffb3 100644 --- a/src/admin/pages/site/property-controls/TextControl.tsx +++ b/src/admin/pages/site/property-controls/TextControl.tsx @@ -7,6 +7,8 @@ import { ControlRow } from '@ui/components/ControlRow' interface TextControlProps extends ControlProps { placeholder?: string normalize?: TextControlNormalize + onInputFocus?: () => void + onInputBlur?: (value: string) => void } export function TextControl({ @@ -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 ( @@ -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)} /> From 5db99219031c55ef04fef3eb3328cabcf14c90b0 Mon Sep 17 00:00:00 2001 From: DavidBabinec Date: Wed, 22 Jul 2026 22:33:32 +0200 Subject: [PATCH 2/2] test(plugins): isolate media adapter worker stub --- .../server/pluginMediaAdapterBoundary.test.ts | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src/__tests__/server/pluginMediaAdapterBoundary.test.ts b/src/__tests__/server/pluginMediaAdapterBoundary.test.ts index 4bfd7ce75..b2fd910f5 100644 --- a/src/__tests__/server/pluginMediaAdapterBoundary.test.ts +++ b/src/__tests__/server/pluginMediaAdapterBoundary.test.ts @@ -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({ @@ -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 () => {