feat(dom-css): improve CSS export parity

This commit is contained in:
Danila Poyarkov 2026-06-03 16:01:37 +03:00
parent c1499b1745
commit 41c00a437f
6 changed files with 337 additions and 32 deletions

View file

@ -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(
`<article class="${classes.join(' ')}">OpenPencil</article>`,
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

View file

@ -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(

View file

@ -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'

View file

@ -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)
}

View file

@ -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)
})
})

View file

@ -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',