From 41c00a437f65ac72ab28c5a83790be69b357c534 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Wed, 3 Jun 2026 16:01:37 +0300 Subject: [PATCH] feat(dom-css): improve CSS export parity --- packages/dom-css/README.md | 9 +- packages/dom-css/src/css-values.ts | 10 +- packages/dom-css/src/from-scene-graph.ts | 59 +++++++--- packages/dom-css/src/to-scene-graph.ts | 38 +++++-- packages/dom-css/tests/conversion.test.ts | 133 +++++++++++++++++++++- tests/engine/dom-css/conversion.test.ts | 120 ++++++++++++++++++- 6 files changed, 337 insertions(+), 32 deletions(-) diff --git a/packages/dom-css/README.md b/packages/dom-css/README.md index 379f784b4..00a8de9a7 100644 --- a/packages/dom-css/README.md +++ b/packages/dom-css/README.md @@ -72,15 +72,16 @@ For DesignDOM output without creating a scene graph, use `htmlToDesignDocument() ## Tailwind pipeline -Tailwind classes flow through Tailwind's own compiler, then through the CSS runtime: +Tailwind classes flow through Tailwind's own compiler, then through the CSS runtime. Prefer the browser runtime when a document is available so custom properties, `calc()`, modern colors, and browser-default behavior come from native `getComputedStyle()`: ```ts -import { tailwindHTMLToSceneGraph } from '@open-pencil/dom-css' +import { createBrowserCSSRuntime, tailwindHTMLToSceneGraph } from '@open-pencil/dom-css' const classes = ['flex', 'w-80', 'p-6', 'rounded-xl', 'bg-white'] const graph = await tailwindHTMLToSceneGraph( `
OpenPencil
`, - classes + classes, + { runtime: createBrowserCSSRuntime({ sandbox: 'iframe' }) } ) ``` @@ -91,7 +92,7 @@ const graph = await tailwindHTMLToSceneGraph( - DOM-shaped `DesignDocument` / `DesignElement` types - Browser-backed runtime adapter for native HTML parsing, serialization, and computed-style extraction - Headless runtime adapter with `parse5` HTML parsing and CSSOM-backed style computation for basic selectors, nested CSSOM rules, cascade order, inheritance, common shorthands, and simple custom-property/calc values -- SceneGraph ⇄ DesignDOM conversion for simple HTML/CSS-shaped layouts +- SceneGraph ⇄ DesignDOM conversion for simple HTML/CSS-shaped layouts, including flex alignment, logical padding, independent side borders, constraints, clipping, opacity, typography, and shadows - Tailwind v4 compiler adapter - Browser oracle fixtures for CSS variables, `calc()`, modern color output, and Tailwind utility output diff --git a/packages/dom-css/src/css-values.ts b/packages/dom-css/src/css-values.ts index fe52d1352..2d691e322 100644 --- a/packages/dom-css/src/css-values.ts +++ b/packages/dom-css/src/css-values.ts @@ -33,9 +33,15 @@ export function colorToFillFromCSS(value: string | undefined): Fill[] { return [{ type: 'SOLID', color, opacity: color.a, visible: true }] } -export function strokeToCSS(stroke: Stroke | undefined): string | undefined { +export function strokeColorToCSS(stroke: Stroke | undefined): string | undefined { if (!stroke?.visible) return undefined - return `${stroke.weight}px solid ${colorToCSS({ ...stroke.color, a: stroke.opacity })}` + return colorToCSS({ ...stroke.color, a: stroke.opacity }) +} + +export function strokeToCSS(stroke: Stroke | undefined): string | undefined { + const color = strokeColorToCSS(stroke) + if (!color || !stroke) return undefined + return `${stroke.weight}px solid ${color}` } export function colorToStrokeFromCSS( diff --git a/packages/dom-css/src/from-scene-graph.ts b/packages/dom-css/src/from-scene-graph.ts index d39a24644..9d2c51fff 100644 --- a/packages/dom-css/src/from-scene-graph.ts +++ b/packages/dom-css/src/from-scene-graph.ts @@ -2,7 +2,13 @@ import { colorToCSS } from '@open-pencil/core/color' import { BLACK } from '@open-pencil/core/constants' import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' -import { dropShadowToCSS, fillToCSS, sceneNodeSizeStyle, strokeToCSS } from './css-values' +import { + dropShadowToCSS, + fillToCSS, + sceneNodeSizeStyle, + strokeColorToCSS, + strokeToCSS +} from './css-values' import type { DesignDocument, DesignNode, DesignStyleDeclaration } from './types' export interface SceneGraphToDesignOptions { @@ -52,19 +58,46 @@ function addCornerRadii(style: DesignStyleDeclaration, node: SceneNode): void { } function addStroke(style: DesignStyleDeclaration, node: SceneNode): void { - const stroke = strokeToCSS(node.strokes[0]) - if (!stroke) return + const stroke = node.strokes[0] + const border = strokeToCSS(stroke) + if (!border) return if (!node.independentStrokeWeights) { - style.border = stroke + style.border = border return } + const color = strokeColorToCSS(stroke) ?? 'currentColor' style['border-style'] = 'solid' - style['border-color'] = stroke.split(' solid ').at(1) ?? 'currentColor' - if (node.borderTopWeight > 0) style['border-top-width'] = `${node.borderTopWeight}px` - if (node.borderRightWeight > 0) style['border-right-width'] = `${node.borderRightWeight}px` - if (node.borderBottomWeight > 0) style['border-bottom-width'] = `${node.borderBottomWeight}px` - if (node.borderLeftWeight > 0) style['border-left-width'] = `${node.borderLeftWeight}px` + style['border-color'] = color + style['border-top-width'] = `${node.borderTopWeight}px` + style['border-right-width'] = `${node.borderRightWeight}px` + style['border-bottom-width'] = `${node.borderBottomWeight}px` + style['border-left-width'] = `${node.borderLeftWeight}px` +} + +function addPadding(style: DesignStyleDeclaration, node: SceneNode): void { + const { paddingTop, paddingRight, paddingBottom, paddingLeft } = node + if (paddingTop === 0 && paddingRight === 0 && paddingBottom === 0 && paddingLeft === 0) return + + if ( + paddingTop === paddingRight && + paddingRight === paddingBottom && + paddingBottom === paddingLeft + ) { + style.padding = `${paddingTop}px` + return + } + + if (paddingTop === paddingBottom && paddingRight === paddingLeft) { + if (paddingTop > 0) style['padding-block'] = `${paddingTop}px` + if (paddingRight > 0) style['padding-inline'] = `${paddingRight}px` + return + } + + if (paddingTop > 0) style['padding-top'] = `${paddingTop}px` + if (paddingRight > 0) style['padding-right'] = `${paddingRight}px` + if (paddingBottom > 0) style['padding-bottom'] = `${paddingBottom}px` + if (paddingLeft > 0) style['padding-left'] = `${paddingLeft}px` } function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration { @@ -87,10 +120,7 @@ function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration { if (justifyContent) style['justify-content'] = justifyContent if (alignItems) style['align-items'] = alignItems if (node.itemSpacing > 0) style.gap = `${node.itemSpacing}px` - if (node.paddingTop > 0) style['padding-top'] = `${node.paddingTop}px` - if (node.paddingRight > 0) style['padding-right'] = `${node.paddingRight}px` - if (node.paddingBottom > 0) style['padding-bottom'] = `${node.paddingBottom}px` - if (node.paddingLeft > 0) style['padding-left'] = `${node.paddingLeft}px` + addPadding(style, node) } return style @@ -107,6 +137,9 @@ function styleFromTextNode(node: SceneNode): DesignStyleDeclaration { if (node.letterSpacing !== 0) style['letter-spacing'] = `${node.letterSpacing}px` if (node.textAlignHorizontal !== 'LEFT') style['text-align'] = node.textAlignHorizontal.toLowerCase() + if (node.opacity < 1) style.opacity = String(node.opacity) + const shadow = dropShadowToCSS(node.effects[0]) + if (shadow) style['text-shadow'] = shadow if (node.textDecoration !== 'NONE') { style['text-decoration-line'] = node.textDecoration === 'UNDERLINE' ? 'underline' : 'line-through' diff --git a/packages/dom-css/src/to-scene-graph.ts b/packages/dom-css/src/to-scene-graph.ts index 302bb6d02..3d40b599c 100644 --- a/packages/dom-css/src/to-scene-graph.ts +++ b/packages/dom-css/src/to-scene-graph.ts @@ -74,15 +74,18 @@ function firstStrokeColor(style: DesignStyleDeclaration) { ) } -function firstStrokeWeight(style: DesignStyleDeclaration) { - return firstCSSNumber( - style, - 'border-width', - 'border-top-width', - 'border-right-width', - 'border-bottom-width', - 'border-left-width' - ) +function strokeWeightFromStyle(style: DesignStyleDeclaration) { + const borderWidth = firstCSSNumber(style, 'border-width') + if (borderWidth !== null) return borderWidth + + const sideWeights = [ + firstCSSNumber(style, 'border-top-width'), + firstCSSNumber(style, 'border-right-width'), + firstCSSNumber(style, 'border-bottom-width'), + firstCSSNumber(style, 'border-left-width') + ].filter((weight): weight is number => weight !== null) + if (sideWeights.length === 0) return null + return Math.max(...sideWeights) } function setBorderWeights(node: SceneNode, style: DesignStyleDeclaration, stroke: Stroke): void { @@ -153,7 +156,7 @@ function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void const strokes = colorToStrokeFromCSS( firstStrokeColor(style), - firstStrokeWeight(style)?.toString() + strokeWeightFromStyle(style)?.toString() ) if (strokes.length > 0) { node.strokes = strokes @@ -197,6 +200,12 @@ function applyTextStyle(node: SceneNode, style: DesignStyleDeclaration): void { const letterSpacing = parseCSSNumber(pickStyle(style, 'letter-spacing')) if (letterSpacing !== null) node.letterSpacing = letterSpacing + const opacity = parseCSSNumber(pickStyle(style, 'opacity')) + if (opacity !== null) node.opacity = opacity + + const effects = dropShadowFromCSS(pickStyle(style, 'text-shadow')) + if (effects.length > 0) node.effects = effects + const fontFamily = pickStyle(style, 'font-family') if (fontFamily) node.fontFamily = fontFamily.split(',')[0]?.replaceAll('"', '').trim() || node.fontFamily @@ -247,7 +256,14 @@ function hasBoxStyle(style: DesignStyleDeclaration): boolean { 'padding-right', 'padding-bottom', 'padding-left', - 'width' + 'padding-block', + 'padding-inline', + 'width', + 'min-width', + 'max-width', + 'min-height', + 'max-height', + 'overflow' ].some((property) => pickStyle(style, property) !== undefined) } diff --git a/packages/dom-css/tests/conversion.test.ts b/packages/dom-css/tests/conversion.test.ts index 67d95b7c9..dfd3b5609 100644 --- a/packages/dom-css/tests/conversion.test.ts +++ b/packages/dom-css/tests/conversion.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'bun:test' +import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' + +import type { DesignElement } from '../src/index' import { createHeadlessCSSRuntime, designDocumentToSceneGraph, @@ -8,7 +11,128 @@ import { sceneGraphToDesignDocument, serializeHTML } from '../src/index' -import { cardCSS, cardHTML, fixtureCSS, fixtureHTML } from './helpers' +import { TEST_COLORS, cardCSS, cardHTML, fixtureCSS, fixtureHTML } from './helpers' + +function expectFrame(node: SceneNode | undefined) { + expect(node?.type).toBe('FRAME') + if (node?.type !== 'FRAME') throw new Error('Expected frame node') + return node +} + +function expectText(node: SceneNode | undefined) { + expect(node?.type).toBe('TEXT') + if (node?.type !== 'TEXT') throw new Error('Expected text node') + return node +} + +function createStyleRoundTripGraph() { + return designDocumentToSceneGraph({ + type: 'document', + children: [ + { + type: 'element', + tagName: 'section', + attrs: { class: 'panel' }, + computedStyle: { + display: 'flex', + 'flex-direction': 'column', + width: '240px', + height: '120px', + 'padding-block': '8px', + 'padding-inline': '12px', + 'border-color': TEST_COLORS.slate200, + 'border-top-width': '0px', + 'border-right-width': '2px', + 'border-bottom-width': '0px', + 'border-left-width': '4px', + opacity: '0.75', + 'box-shadow': `0px 8px 24px 0px ${TEST_COLORS.slateShadow}` + }, + children: [ + { + type: 'element', + tagName: 'h1', + attrs: {}, + computedStyle: { + color: TEST_COLORS.slate950, + 'font-family': 'Inter, sans-serif', + 'font-size': '18px', + 'font-weight': '700', + 'line-height': '24px', + 'letter-spacing': '0.2px', + 'text-align': 'center', + opacity: '0.5', + 'text-shadow': `0px 1px 2px 0px ${TEST_COLORS.slateShadow}` + }, + children: [{ type: 'text', text: 'Round trip' }] + } + ] + } + ] + }) +} + +function expectStyleRoundTripPanel(graph: SceneGraph) { + const page = graph.getPages()[0] + const panel = expectFrame(page ? graph.getChildren(page.id)[0] : undefined) + expect(panel.paddingTop).toBe(8) + expect(panel.paddingRight).toBe(12) + expect(panel.paddingBottom).toBe(8) + expect(panel.paddingLeft).toBe(12) + expect(panel.borderTopWeight).toBe(0) + expect(panel.borderRightWeight).toBe(2) + expect(panel.borderBottomWeight).toBe(0) + expect(panel.borderLeftWeight).toBe(4) + expect(panel.opacity).toBe(0.75) + expect(panel.effects[0]?.type).toBe('DROP_SHADOW') + return panel +} + +function expectStyleRoundTripText(graph: SceneGraph, panel: SceneNode) { + const heading = expectText(graph.getChildren(panel.id)[0]) + expect(heading.fontSize).toBe(18) + expect(heading.fontWeight).toBe(700) + expect(heading.lineHeight).toBe(24) + expect(heading.letterSpacing).toBe(0.2) + expect(heading.textAlignHorizontal).toBe('CENTER') + expect(heading.opacity).toBe(0.5) + expect(heading.effects[0]?.type).toBe('DROP_SHADOW') +} + +function expectRoundTripPanelStyle(element: DesignElement) { + expect(element.inlineStyle?.['padding-block']).toBe('8px') + expect(element.inlineStyle?.['padding-inline']).toBe('12px') + expect(element.inlineStyle?.['border-top-width']).toBe('0px') + expect(element.inlineStyle?.['border-left-width']).toBe('4px') + expect(element.inlineStyle?.opacity).toBe('0.75') + expect(element.inlineStyle?.['box-shadow']).toContain('24px') +} + +function expectRoundTripHeadingStyle(element: DesignElement) { + expect(element.inlineStyle?.['font-size']).toBe('18px') + expect(element.inlineStyle?.['font-weight']).toBe('700') + expect(element.inlineStyle?.['line-height']).toBe('24px') + expect(element.inlineStyle?.['letter-spacing']).toBe('0.2px') + expect(element.inlineStyle?.['text-align']).toBe('center') + expect(element.inlineStyle?.opacity).toBe('0.5') + expect(element.inlineStyle?.['text-shadow']).toContain('2px') +} + +function expectStyleRoundTripHTML(graph: SceneGraph) { + const roundTrip = sceneGraphToDesignDocument(graph) + const root = roundTrip.children[0] + expect(root?.type).toBe('element') + if (root?.type !== 'element') throw new Error('Expected root element') + const roundTripPanel = root.children[0] + expect(roundTripPanel?.type).toBe('element') + if (roundTripPanel?.type !== 'element') throw new Error('Expected panel element') + expectRoundTripPanelStyle(roundTripPanel) + + const roundTripHeading = roundTripPanel.children[0] + expect(roundTripHeading?.type).toBe('element') + if (roundTripHeading?.type !== 'element') throw new Error('Expected heading element') + expectRoundTripHeadingStyle(roundTripHeading) +} describe('@open-pencil/dom-css conversion', () => { it('converts HTML and CSS to DesignDOM with one API call', async () => { @@ -92,4 +216,11 @@ describe('@open-pencil/dom-css conversion', () => { expect(card.fills[0]?.type).toBe('SOLID') expect(card.strokes[0]?.weight).toBe(1) }) + + it('round-trips logical padding, side borders, opacity, and text style fields', () => { + const graph = createStyleRoundTripGraph() + const panel = expectStyleRoundTripPanel(graph) + expectStyleRoundTripText(graph, panel) + expectStyleRoundTripHTML(graph) + }) }) diff --git a/tests/engine/dom-css/conversion.test.ts b/tests/engine/dom-css/conversion.test.ts index 89653985a..396d6d85f 100644 --- a/tests/engine/dom-css/conversion.test.ts +++ b/tests/engine/dom-css/conversion.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'bun:test' -import type { SceneNode } from '@open-pencil/core/scene-graph' +import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph' +import type { DesignElement } from '@open-pencil/dom-css' import { compileTailwindCSS, createHeadlessCSSRuntime, @@ -13,6 +14,7 @@ import { } from '@open-pencil/dom-css' import { + DOM_CSS_COLORS, computedCardDocument, cssCardCSS, cssCardHTML, @@ -30,6 +32,114 @@ function expectFrame(node: SceneNode | undefined) { return node } +function createStyleRoundTripGraph() { + return designDocumentToSceneGraph({ + type: 'document', + children: [ + { + type: 'element', + tagName: 'section', + attrs: { class: 'panel' }, + computedStyle: { + display: 'flex', + 'flex-direction': 'column', + width: '240px', + height: '120px', + 'padding-block': '8px', + 'padding-inline': '12px', + 'border-color': DOM_CSS_COLORS.slate200, + 'border-top-width': '0px', + 'border-right-width': '2px', + 'border-bottom-width': '0px', + 'border-left-width': '4px', + opacity: '0.75', + 'box-shadow': `0px 8px 24px 0px ${DOM_CSS_COLORS.slateShadow}` + }, + children: [ + { + type: 'element', + tagName: 'h1', + attrs: {}, + computedStyle: { + color: DOM_CSS_COLORS.slate950, + 'font-family': 'Inter, sans-serif', + 'font-size': '18px', + 'font-weight': '700', + 'line-height': '24px', + 'letter-spacing': '0.2px', + 'text-align': 'center', + opacity: '0.5', + 'text-shadow': `0px 1px 2px 0px ${DOM_CSS_COLORS.slateShadow}` + }, + children: [{ type: 'text', text: 'Round trip' }] + } + ] + } + ] + }) +} + +function expectStyleRoundTripPanel(graph: SceneGraph) { + const page = graph.getPages()[0] + const panel = expectFrame(page ? graph.getChildren(page.id)[0] : undefined) + expect(panel.paddingTop).toBe(8) + expect(panel.paddingRight).toBe(12) + expect(panel.borderTopWeight).toBe(0) + expect(panel.borderRightWeight).toBe(2) + expect(panel.borderLeftWeight).toBe(4) + expect(panel.opacity).toBe(0.75) + expect(panel.effects[0]?.type).toBe('DROP_SHADOW') + return panel +} + +function expectStyleRoundTripText(graph: SceneGraph, panel: SceneNode) { + const heading = graph.getChildren(panel.id)[0] + expect(heading?.type).toBe('TEXT') + if (heading?.type !== 'TEXT') throw new Error('Expected text node') + expect(heading.fontSize).toBe(18) + expect(heading.fontWeight).toBe(700) + expect(heading.lineHeight).toBe(24) + expect(heading.letterSpacing).toBe(0.2) + expect(heading.textAlignHorizontal).toBe('CENTER') + expect(heading.opacity).toBe(0.5) + expect(heading.effects[0]?.type).toBe('DROP_SHADOW') +} + +function expectRoundTripPanelStyle(element: DesignElement) { + expect(element.inlineStyle?.['padding-block']).toBe('8px') + expect(element.inlineStyle?.['padding-inline']).toBe('12px') + expect(element.inlineStyle?.['border-top-width']).toBe('0px') + expect(element.inlineStyle?.['border-left-width']).toBe('4px') + expect(element.inlineStyle?.opacity).toBe('0.75') + expect(element.inlineStyle?.['box-shadow']).toContain('24px') +} + +function expectRoundTripHeadingStyle(element: DesignElement) { + expect(element.inlineStyle?.['font-size']).toBe('18px') + expect(element.inlineStyle?.['font-weight']).toBe('700') + expect(element.inlineStyle?.['line-height']).toBe('24px') + expect(element.inlineStyle?.['letter-spacing']).toBe('0.2px') + expect(element.inlineStyle?.['text-align']).toBe('center') + expect(element.inlineStyle?.opacity).toBe('0.5') + expect(element.inlineStyle?.['text-shadow']).toContain('2px') +} + +function expectStyleRoundTripHTML(graph: SceneGraph) { + const roundTrip = sceneGraphToDesignDocument(graph) + const root = roundTrip.children[0] + expect(root?.type).toBe('element') + if (root?.type !== 'element') throw new Error('Expected root element') + const roundTripPanel = root.children[0] + expect(roundTripPanel?.type).toBe('element') + if (roundTripPanel?.type !== 'element') throw new Error('Expected panel element') + expectRoundTripPanelStyle(roundTripPanel) + + const roundTripHeading = roundTripPanel.children[0] + expect(roundTripHeading?.type).toBe('element') + if (roundTripHeading?.type !== 'element') throw new Error('Expected heading element') + expectRoundTripHeadingStyle(roundTripHeading) +} + describe('@open-pencil/dom-css conversion', () => { it('converts HTML and CSS to DesignDOM with one API call', async () => { const document = await htmlToDesignDocument(cssCardHTML, { @@ -188,10 +298,18 @@ describe('@open-pencil/dom-css conversion', () => { if (roundTripPanel?.type !== 'element') return expect(roundTripPanel.inlineStyle?.overflow).toBe('hidden') expect(roundTripPanel.inlineStyle?.['min-width']).toBe('240px') + expect(roundTripPanel.inlineStyle?.['border-top-width']).toBe('1px') expect(roundTripPanel.inlineStyle?.['border-left-width']).toBe('4px') expect(roundTripPanel.inlineStyle?.['border-bottom-left-radius']).toBe('16px') }) + it('round-trips logical padding, opacity, and text shadows', () => { + const graph = createStyleRoundTripGraph() + const panel = expectStyleRoundTripPanel(graph) + expectStyleRoundTripText(graph, panel) + expectStyleRoundTripHTML(graph) + }) + it('maps CSS flex alignment into scene graph auto-layout alignment', () => { const graph = designDocumentToSceneGraph({ type: 'document',