feat(dom-css): add browser JSX pipeline

This commit is contained in:
Danila Poyarkov 2026-06-03 20:17:48 +03:00
parent 50c9961d75
commit e77a82fb3b
15 changed files with 453 additions and 11 deletions

View file

@ -0,0 +1,79 @@
# Package split plan
OpenPencil currently keeps file-format internals in `@open-pencil/core` while `@open-pencil/dom-css` is being split out as a standalone DOM/CSS compatibility layer. Future package splits should preserve the current app behavior first, then move stable surfaces into independently publishable packages.
## Goals
- Keep renderer/editor core free of DOM and browser-only dependencies.
- Let `.fig`, Kiwi, and DOM/CSS compatibility evolve without forcing consumers to install unrelated heavy dependencies.
- Preserve import/export fidelity by moving code only after oracle coverage exists.
- Keep public APIs narrow and package-local checks available for every standalone package.
## Current package boundary
- `@open-pencil/core` owns scene graph, renderer, editor actions, Figma API compatibility, Kiwi codec internals, and format import/export.
- `@open-pencil/dom-css` owns DesignDOM, CSS runtimes, HTML/JSX/Tailwind projection, and SceneGraph ⇄ DesignDOM conversion.
- App, CLI, MCP, and Vue SDK consume packages through public workspace exports only.
## Candidate packages
### `@open-pencil/kiwi`
Scope:
- Kiwi binary schema runtime.
- Schema-generated codec modules.
- Generic binary parse/serialize helpers.
- Low-level validation helpers that do not know about OpenPencil scene graph nodes.
Should not include:
- Figma `.fig` container policy.
- SceneGraph conversion.
- Renderer/editor code.
Minimum exit criteria:
- Package-local typecheck, unit tests, build, and dist smoke.
- Existing Kiwi serialize/parse tests passing through the package public API.
- No import cycles from `@open-pencil/core` back into the package.
### `@open-pencil/fig`
Scope:
- `.fig` container read/write.
- Figma node-change import/export.
- Raw metadata preservation and invalidation policy.
- Figma oracle fixtures and compatibility helpers.
Should depend on:
- `@open-pencil/core` scene graph types.
- `@open-pencil/kiwi` once the Kiwi split exists.
Should not include:
- Canvas rendering.
- Editor UI/actions.
- DOM/CSS compatibility.
Minimum exit criteria:
- Import/export round-trip tests moved or duplicated as package-local coverage.
- Heavy Figma fixture coverage still available at repo level.
- Public API supports CLI/MCP/app document I/O without private path imports.
## Migration order
1. Keep `@open-pencil/dom-css` standalone and stabilize its browser/headless runtime split.
2. Extract pure Kiwi runtime/codecs behind `@open-pencil/kiwi` without moving `.fig` policy.
3. Move `.fig` container and node-change conversion into `@open-pencil/fig`.
4. Update core/app/CLI/MCP imports to consume public package exports.
5. Keep compatibility re-exports in `@open-pencil/core` only if existing consumers need a deprecation window.
## Non-goals
- Do not guess Figma schema fields during the split.
- Do not move renderer-specific fallback behavior into file-format packages.
- Do not add browser DOM dependencies to core or file-format packages.

View file

@ -35,7 +35,7 @@ The repository also keeps integration/oracle coverage under `tests/engine/dom-cs
## Runtime model
Use the browser runtime as the high-fidelity source of truth whenever a DOM is available. It uses native parsing and `getComputedStyle()` inside an isolated sandbox:
Use the browser runtime as the high-fidelity source of truth whenever a DOM is available. It uses native parsing and `getComputedStyle()` inside an isolated sandbox. Prefer `sandbox: 'iframe'` for production-style conversion because it isolates authored CSS from host-page styles:
```ts
import { createBrowserCSSRuntime } from '@open-pencil/dom-css'
@ -45,7 +45,7 @@ const document = runtime.parseHTML('<article class="card">OpenPencil</article>')
const styled = await runtime.computeStyles(document, '.card { width: calc(10rem + 16px); }')
```
The headless runtime is useful for Bun/Node tests, CLI flows, and fast approximate conversion. It supports common selectors, inheritance, shorthands, CSSOM grouping rules, and simple variable/calc values, but it is not a browser replacement.
The headless runtime is useful for Bun/Node tests, CLI flows, and fast approximate conversion. It supports common selectors, inheritance, shorthands, CSSOM grouping rules, and simple variable/calc values, but it is not a browser replacement. Do not use it as an oracle for browser-only CSS behavior such as layout-dependent computed values, full custom-property fallback behavior, modern color serialization, or UA defaults.
```ts
import { createHeadlessCSSRuntime } from '@open-pencil/dom-css'
@ -91,6 +91,24 @@ const graph = await jsxToSceneGraph(
The JSX runtime preserves `class`, attributes, inline `style`, text, fragments, and simple function components as DesignDOM. Class semantics still come from generated or authored CSS passed to a CSS runtime; the JSX layer does not interpret Tailwind or CSS utility names directly.
When running in a browser, use the browser-first helpers so native `getComputedStyle()` is used automatically:
```tsx
/** @jsxImportSource @open-pencil/dom-css */
import { browserTailwindJSXToSceneGraph } from '@open-pencil/dom-css/browser'
const classes = ['flex', 'flex-col', 'gap-3', 'w-80', 'p-6', 'rounded-xl', 'bg-white']
const graph = await browserTailwindJSXToSceneGraph(
<article class={classes.join(' ')}>
<h1>OpenPencil</h1>
</article>,
classes,
{ sandbox: 'iframe' }
)
```
Use `browserJSXToDesignDocument()` / `browserJSXToSceneGraph()` for authored CSS, and `browserTailwindJSXToDesignDocument()` / `browserTailwindJSXToSceneGraph()` when Tailwind utilities should flow through Tailwind's compiler first. Import these from `@open-pencil/dom-css/browser` in browser bundles to avoid loading headless-only CSSOM dependencies. Browser Tailwind compilation may need an explicit `css` or `loadStylesheet` option from the host bundler; precompiling with `compileTailwindCSS()` and passing `cssText` to `browserJSXToSceneGraph()` is the most portable browser path.
## Tailwind pipeline
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()`:
@ -113,14 +131,14 @@ 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, including flex alignment, logical padding, independent side borders, constraints, clipping, opacity, typography, and shadows
- SceneGraph ⇄ DesignDOM conversion for simple HTML/CSS-shaped layouts, including flex alignment/wrapping, self alignment, absolute positioning basics, logical padding, independent side borders, constraints, clipping, opacity, typography, and shadows
- JSX runtime helpers for DOM-shaped authoring into DesignDOM and SceneGraph
- Tailwind v4 compiler adapter
- Browser oracle fixtures for CSS variables, `calc()`, modern color output, and Tailwind utility output
- Browser oracle fixtures for CSS variables, `calc()`, sandboxed browser-runtime output, modern color output, JSX/Tailwind browser helpers, and Tailwind utility output
## Roadmap
- Expand reusable fixtures: inputs, badges, nav/menu rows, dialog shells, and richer cards
- Map more computed CSS properties to scene graph fields: richer shadows, typography details, overflow, constraints, borders, and grid once OpenPencil's grid support matures
- Map more computed CSS properties to scene graph fields: richer shadows, typography details, position constraints, borders, and grid once OpenPencil's grid support matures
- Improve SceneGraph → CSS export so generated HTML/CSS is useful for JSX, Tailwind, and web export
- Keep `@open-pencil/dom-css` stable before splitting lower-level file-format packages such as future `@open-pencil/kiwi` and `@open-pencil/fig`

View file

@ -17,6 +17,12 @@
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./browser": {
"types": "./dist/browser.d.ts",
"bun": "./src/browser.ts",
"import": "./dist/browser.js",
"default": "./dist/browser.js"
},
"./jsx-runtime": {
"types": "./dist/jsx-runtime.d.ts",
"bun": "./src/jsx/runtime.ts",

View file

@ -1,8 +1,12 @@
import type * as DomCSS from '../src/index'
const distPath = '../dist/index.js'
const browserDistPath = '../dist/browser.js'
const dist = await import(distPath)
const browserDist = await import(browserDistPath)
const browserTailwindJSXToDesignDocument: typeof DomCSS.browserTailwindJSXToDesignDocument =
dist.browserTailwindJSXToDesignDocument
const compileTailwindCSS: typeof DomCSS.compileTailwindCSS = dist.compileTailwindCSS
const createHeadlessCSSRuntime: typeof DomCSS.createHeadlessCSSRuntime =
dist.createHeadlessCSSRuntime
@ -51,3 +55,11 @@ const jsxDocument = await jsxToDesignDocument(
if (jsxDocument.children[0]?.type !== 'element') {
throw new Error('Expected built JSX helpers to produce DesignDOM elements')
}
if (typeof browserTailwindJSXToDesignDocument !== 'function') {
throw new TypeError('Expected built browser JSX helper to be exported')
}
if (typeof browserDist.browserTailwindJSXToDesignDocument !== 'function') {
throw new TypeError('Expected built browser subpath helper to be exported')
}

View file

@ -0,0 +1,63 @@
import type { SceneGraph } from '@open-pencil/core/scene-graph'
import { jsxToDesignDocument, type JSXChild } from './jsx/runtime'
export { Fragment, jsx, jsxs } from './jsx/runtime'
export type { JSXChild, JSXElementProps, JSXStyleInput, JSXTag } from './jsx/runtime'
import { createBrowserCSSRuntime, type BrowserCSSRuntimeOptions } from './runtime/browser'
import { compileTailwindCSS, type CompileTailwindCSSOptions } from './tailwind'
import { designDocumentToSceneGraph, type ToSceneGraphOptions } from './to-scene-graph'
import type { CSSComputeOptions, DesignDocument } from './types'
export interface BrowserToDesignDocumentOptions extends BrowserCSSRuntimeOptions {
cssText?: string
compute?: CSSComputeOptions
}
export interface BrowserToSceneGraphOptions
extends BrowserToDesignDocumentOptions, ToSceneGraphOptions {}
export interface BrowserTailwindToDesignDocumentOptions
extends Omit<BrowserToDesignDocumentOptions, 'cssText'>, CompileTailwindCSSOptions {}
export interface BrowserTailwindToSceneGraphOptions
extends BrowserTailwindToDesignDocumentOptions, ToSceneGraphOptions {}
function createRuntime(options: BrowserCSSRuntimeOptions) {
return createBrowserCSSRuntime({ sandbox: 'iframe', ...options })
}
export async function browserJSXToDesignDocument(
input: JSXChild,
options: BrowserToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const document = await jsxToDesignDocument(input)
const runtime = createRuntime(options)
return runtime.computeStyles(document, options.cssText, options.compute)
}
export async function browserJSXToSceneGraph(
input: JSXChild,
options: BrowserToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserJSXToDesignDocument(input, options)
return designDocumentToSceneGraph(document, options)
}
export async function browserTailwindJSXToDesignDocument(
input: JSXChild,
candidates: string | Iterable<string>,
options: BrowserTailwindToDesignDocumentOptions = {}
): Promise<DesignDocument> {
const cssText = await compileTailwindCSS(candidates, options)
return browserJSXToDesignDocument(input, { ...options, cssText })
}
export async function browserTailwindJSXToSceneGraph(
input: JSXChild,
candidates: string | Iterable<string>,
options: BrowserTailwindToSceneGraphOptions = {}
): Promise<SceneGraph> {
const document = await browserTailwindJSXToDesignDocument(input, candidates, options)
return designDocumentToSceneGraph(document, options)
}

View file

@ -37,6 +37,22 @@ function alignItemsToCSS(value: SceneNode['counterAxisAlign']): string | undefin
return undefined
}
function alignSelfToCSS(value: SceneNode['layoutAlignSelf']): string | undefined {
if (value === 'MIN') return 'flex-start'
if (value === 'CENTER') return 'center'
if (value === 'MAX') return 'flex-end'
if (value === 'STRETCH') return 'stretch'
if (value === 'BASELINE') return 'baseline'
return undefined
}
function addPositioning(style: DesignStyleDeclaration, node: SceneNode): void {
if (node.layoutPositioning !== 'ABSOLUTE') return
style.position = 'absolute'
style.left = `${node.x}px`
style.top = `${node.y}px`
}
function addSizeConstraints(style: DesignStyleDeclaration, node: SceneNode): void {
if (node.minWidth !== null) style['min-width'] = `${node.minWidth}px`
if (node.maxWidth !== null) style['max-width'] = `${node.maxWidth}px`
@ -102,6 +118,7 @@ function addPadding(style: DesignStyleDeclaration, node: SceneNode): void {
function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
addPositioning(style, node)
addSizeConstraints(style, node)
const fill = fillToCSS(node.fills[0])
if (fill) style['background-color'] = fill
@ -111,6 +128,8 @@ function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
if (node.opacity < 1) style.opacity = String(node.opacity)
addCornerRadii(style, node)
if (node.clipsContent) style.overflow = 'hidden'
const alignSelf = alignSelfToCSS(node.layoutAlignSelf)
if (alignSelf) style['align-self'] = alignSelf
if (node.layoutMode !== 'NONE') {
style.display = 'flex'
@ -119,7 +138,9 @@ function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
const alignItems = alignItemsToCSS(node.counterAxisAlign)
if (justifyContent) style['justify-content'] = justifyContent
if (alignItems) style['align-items'] = alignItems
if (node.layoutWrap === 'WRAP') style['flex-wrap'] = 'wrap'
if (node.itemSpacing > 0) style.gap = `${node.itemSpacing}px`
if (node.counterAxisSpacing > 0) style['row-gap'] = `${node.counterAxisSpacing}px`
addPadding(style, node)
}
@ -128,6 +149,7 @@ function styleFromSceneNode(node: SceneNode): DesignStyleDeclaration {
function styleFromTextNode(node: SceneNode): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
addPositioning(style, node)
style.color = fillToCSS(node.fills[0]) ?? colorToCSS(BLACK)
style['font-family'] = node.fontFamily
style['font-size'] = `${node.fontSize}px`

View file

@ -9,6 +9,12 @@ export {
export { designDocumentToSceneGraph } from './to-scene-graph'
export { sceneGraphToDesignDocument } from './from-scene-graph'
export { compileTailwindCSS } from './tailwind'
export {
browserJSXToDesignDocument,
browserJSXToSceneGraph,
browserTailwindJSXToDesignDocument,
browserTailwindJSXToSceneGraph
} from './browser'
export {
Fragment,
jsx,
@ -38,6 +44,12 @@ export type {
TailwindJSXToDesignDocumentOptions,
TailwindJSXToSceneGraphOptions
} from './jsx/runtime'
export type {
BrowserTailwindToDesignDocumentOptions,
BrowserTailwindToSceneGraphOptions,
BrowserToDesignDocumentOptions,
BrowserToSceneGraphOptions
} from './browser'
export type { CompileTailwindCSSOptions } from './tailwind'
export type { ToSceneGraphOptions } from './to-scene-graph'
export type {

View file

@ -1,6 +1,5 @@
import type { SceneGraph } from '@open-pencil/core/scene-graph'
import { createCSSRuntime } from '../runtime'
import { compileTailwindCSS, type CompileTailwindCSSOptions } from '../tailwind'
import { designDocumentToSceneGraph, type ToSceneGraphOptions } from '../to-scene-graph'
import type {
@ -43,8 +42,10 @@ export interface TailwindJSXToDesignDocumentOptions
export interface TailwindJSXToSceneGraphOptions
extends TailwindJSXToDesignDocumentOptions, ToSceneGraphOptions {}
function runtimeForOptions(runtime: CSSRuntime | undefined) {
return runtime ?? createCSSRuntime()
async function runtimeForOptions(runtime: CSSRuntime | undefined) {
if (runtime) return runtime
const { createCSSRuntime } = await import('../runtime')
return createCSSRuntime()
}
function cssPropertyName(name: string) {
@ -150,7 +151,7 @@ export async function jsxToDesignDocument(
}
if (!options.cssText && !options.runtime && !options.compute) return document
const runtime = runtimeForOptions(options.runtime)
const runtime = await runtimeForOptions(options.runtime)
return runtime.computeStyles(document, options.cssText, options.compute)
}

View file

@ -31,8 +31,12 @@ const DEFAULT_COMPUTED_PROPERTIES = [
'border-top-width',
'box-shadow',
'color',
'column-gap',
'display',
'align-self',
'bottom',
'flex-direction',
'flex-wrap',
'font-family',
'font-size',
'font-style',
@ -41,6 +45,7 @@ const DEFAULT_COMPUTED_PROPERTIES = [
'height',
'justify-content',
'letter-spacing',
'left',
'line-height',
'max-height',
'max-width',
@ -52,9 +57,13 @@ const DEFAULT_COMPUTED_PROPERTIES = [
'padding-left',
'padding-right',
'padding-top',
'position',
'right',
'row-gap',
'text-align',
'text-decoration-line',
'text-shadow',
'top',
'width'
] as const

View file

@ -140,6 +140,25 @@ function counterAxisAlignFromCSS(value: string | undefined): SceneNode['counterA
return 'MIN'
}
function layoutAlignSelfFromCSS(value: string | undefined): SceneNode['layoutAlignSelf'] {
if (value === 'center') return 'CENTER'
if (value === 'end' || value === 'flex-end') return 'MAX'
if (value === 'stretch') return 'STRETCH'
if (value === 'baseline') return 'BASELINE'
if (value === 'start' || value === 'flex-start') return 'MIN'
return 'AUTO'
}
function applyPositioning(node: SceneNode, style: DesignStyleDeclaration): void {
const position = pickStyle(style, 'position')
if (position === 'absolute' || position === 'fixed') node.layoutPositioning = 'ABSOLUTE'
const left = firstCSSNumber(style, 'left')
const top = firstCSSNumber(style, 'top')
if (left !== null) node.x = left
if (top !== null) node.y = top
}
function applyPadding(node: SceneNode, style: DesignStyleDeclaration): void {
node.paddingTop = firstCSSNumber(style, 'padding-top', 'padding-block', 'padding') ?? 0
node.paddingRight = firstCSSNumber(style, 'padding-right', 'padding-inline', 'padding') ?? 0
@ -149,6 +168,7 @@ function applyPadding(node: SceneNode, style: DesignStyleDeclaration): void {
function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void {
setNodeBox(node, style)
applyPositioning(node, style)
applyPadding(node, style)
const fills = fillsFromStyle(style, 'background-color')
@ -171,19 +191,26 @@ function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void
applyCornerRadii(node, style)
if (pickStyle(style, 'overflow') === 'hidden') node.clipsContent = true
const overflow = pickStyle(style, 'overflow')
if (overflow === 'hidden' || overflow === 'clip') node.clipsContent = true
const alignSelf = layoutAlignSelfFromCSS(pickStyle(style, 'align-self'))
if (alignSelf !== 'AUTO') node.layoutAlignSelf = alignSelf
const display = pickStyle(style, 'display')
if (display === 'flex' || display === 'inline-flex') {
node.layoutMode = pickStyle(style, 'flex-direction') === 'column' ? 'VERTICAL' : 'HORIZONTAL'
node.primaryAxisAlign = primaryAxisAlignFromCSS(pickStyle(style, 'justify-content'))
node.counterAxisAlign = counterAxisAlignFromCSS(pickStyle(style, 'align-items'))
node.layoutWrap = pickStyle(style, 'flex-wrap') === 'wrap' ? 'WRAP' : 'NO_WRAP'
node.itemSpacing = firstCSSNumber(style, 'gap', 'column-gap', 'row-gap') ?? 0
node.counterAxisSpacing = firstCSSNumber(style, 'row-gap') ?? 0
}
}
function applyTextStyle(node: SceneNode, style: DesignStyleDeclaration): void {
setNodeBox(node, style)
applyPositioning(node, style)
const fills = fillsFromStyle(style, 'color')
if (fills.length > 0) node.fills = fills
@ -263,7 +290,14 @@ function hasBoxStyle(style: DesignStyleDeclaration): boolean {
'max-width',
'min-height',
'max-height',
'overflow'
'overflow',
'position',
'top',
'right',
'bottom',
'left',
'flex-wrap',
'align-self'
].some((property) => pickStyle(style, property) !== undefined)
}

View file

@ -223,4 +223,71 @@ describe('@open-pencil/dom-css conversion', () => {
expectStyleRoundTripText(graph, panel)
expectStyleRoundTripHTML(graph)
})
it('maps flex wrapping, align-self, clipping, and absolute positioning', () => {
const graph = designDocumentToSceneGraph({
type: 'document',
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'wrap' },
computedStyle: {
display: 'flex',
'flex-wrap': 'wrap',
gap: '12px',
'row-gap': '20px',
overflow: 'clip',
width: '240px',
height: '120px'
},
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'absolute' },
computedStyle: {
position: 'absolute',
left: '16px',
top: '24px',
'align-self': 'center',
width: '80px',
height: '32px'
},
children: []
}
]
}
]
})
const page = graph.getPages()[0]
const wrap = expectFrame(page ? graph.getChildren(page.id)[0] : undefined)
const absolute = expectFrame(graph.getChildren(wrap.id)[0])
expect(wrap.layoutWrap).toBe('WRAP')
expect(wrap.counterAxisSpacing).toBe(20)
expect(wrap.clipsContent).toBe(true)
expect(absolute.layoutPositioning).toBe('ABSOLUTE')
expect(absolute.layoutAlignSelf).toBe('CENTER')
expect(absolute.x).toBe(16)
expect(absolute.y).toBe(24)
const roundTrip = sceneGraphToDesignDocument(graph)
const root = roundTrip.children[0]
expect(root?.type).toBe('element')
if (root?.type !== 'element') return
const roundTripWrap = root.children[0]
expect(roundTripWrap?.type).toBe('element')
if (roundTripWrap?.type !== 'element') return
expect(roundTripWrap.inlineStyle?.['flex-wrap']).toBe('wrap')
expect(roundTripWrap.inlineStyle?.['row-gap']).toBe('20px')
const roundTripAbsolute = roundTripWrap.children[0]
expect(roundTripAbsolute?.type).toBe('element')
if (roundTripAbsolute?.type !== 'element') return
expect(roundTripAbsolute.inlineStyle?.position).toBe('absolute')
expect(roundTripAbsolute.inlineStyle?.left).toBe('16px')
expect(roundTripAbsolute.inlineStyle?.top).toBe('24px')
expect(roundTripAbsolute.inlineStyle?.['align-self']).toBe('center')
})
})

View file

@ -18,6 +18,7 @@
"paths": {
"#dom-css/*": ["./src/*"],
"@open-pencil/dom-css": ["./src/index.ts"],
"@open-pencil/dom-css/browser": ["./src/browser.ts"],
"@open-pencil/dom-css/jsx-runtime": ["./src/jsx/runtime.ts"],
"@open-pencil/dom-css/jsx-dev-runtime": ["./src/jsx/dev-runtime.ts"]
}

View file

@ -3,6 +3,7 @@ import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: './src/index.ts',
browser: './src/browser.ts',
'jsx-runtime': './src/jsx/runtime.ts',
'jsx-dev-runtime': './src/jsx/dev-runtime.ts'
},

View file

@ -31,6 +31,7 @@ async function setStyledContent(page: Page, css: string, body: string) {
}
const BROWSER_RUNTIME_MODULE = `http://localhost:1420/@fs${process.cwd()}/packages/dom-css/src/runtime/browser.ts`
const DOM_CSS_BROWSER_MODULE = 'http://localhost:1420/@id/@open-pencil/dom-css/browser'
async function browserRuntimeComputeStyles(
page: Page,
@ -57,6 +58,39 @@ async function browserRuntimeComputeStyles(
)
}
async function publicBrowserSceneGraph(page: Page, classes: string[], cssText: string) {
if (!page.url().startsWith('http://localhost:1420')) {
await page.goto('/')
await page.setContent('<main></main>')
}
return page.evaluate(
async ({ candidates, css, modulePath }) => {
const { browserJSXToSceneGraph, jsx } = await import(modulePath)
const graph = await browserJSXToSceneGraph(
jsx('article', {
class: candidates.join(' '),
children: jsx('h1', { children: 'OpenPencil' })
}),
{ cssText: css }
)
const pageNode = graph.getPages()[0]
const card = pageNode ? graph.getChildren(pageNode.id)[0] : undefined
return card
? {
height: card.height,
itemSpacing: card.itemSpacing,
layoutMode: card.layoutMode,
paddingLeft: card.paddingLeft,
type: card.type,
width: card.width
}
: null
},
{ candidates: classes, css: cssText, modulePath: DOM_CSS_BROWSER_MODULE }
)
}
async function computedStyleProperties(
page: Page,
selector: string,
@ -363,6 +397,18 @@ test.describe('@open-pencil/dom-css browser CSS runtime oracle', () => {
expect(hostWidth).toBe('20px')
})
test('projects JSX through public browser helpers into scene graph', async ({ page }) => {
const css = await compileTailwindCSS(tailwindCardClasses)
const card = await publicBrowserSceneGraph(page, [...tailwindCardClasses], css)
expect(card?.type).toBe('FRAME')
expect(card?.width).toBe(320)
expect(card?.height).toBe(176)
expect(card?.layoutMode).toBe('VERTICAL')
expect(card?.itemSpacing).toBe(12)
expect(card?.paddingLeft).toBe(24)
})
test('projects JSX and Tailwind through browser computed styles into scene graph', async ({
page
}) => {

View file

@ -310,6 +310,77 @@ describe('@open-pencil/dom-css conversion', () => {
expectStyleRoundTripHTML(graph)
})
it('maps CSS flex wrapping, self alignment, clipping, and absolute position', () => {
const graph = designDocumentToSceneGraph({
type: 'document',
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'stack' },
computedStyle: {
display: 'flex',
'flex-direction': 'row',
'flex-wrap': 'wrap',
gap: '12px',
'row-gap': '20px',
overflow: 'clip',
width: '240px',
height: '120px'
},
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'chip' },
computedStyle: {
position: 'absolute',
left: '16px',
top: '24px',
'align-self': 'center',
width: '80px',
height: '32px'
},
children: []
}
]
}
]
})
const page = graph.getPages()[0]
const stack = expectFrame(page ? graph.getChildren(page.id)[0] : undefined)
const chip = expectFrame(graph.getChildren(stack.id)[0])
expect(stack.layoutMode).toBe('HORIZONTAL')
expect(stack.layoutWrap).toBe('WRAP')
expect(stack.itemSpacing).toBe(12)
expect(stack.counterAxisSpacing).toBe(20)
expect(stack.clipsContent).toBe(true)
expect(chip.layoutPositioning).toBe('ABSOLUTE')
expect(chip.layoutAlignSelf).toBe('CENTER')
expect(chip.x).toBe(16)
expect(chip.y).toBe(24)
const roundTrip = sceneGraphToDesignDocument(graph)
const root = roundTrip.children[0]
expect(root?.type).toBe('element')
if (root?.type !== 'element') return
const roundTripStack = root.children[0]
expect(roundTripStack?.type).toBe('element')
if (roundTripStack?.type !== 'element') return
expect(roundTripStack.inlineStyle?.['flex-wrap']).toBe('wrap')
expect(roundTripStack.inlineStyle?.['row-gap']).toBe('20px')
expect(roundTripStack.inlineStyle?.overflow).toBe('hidden')
const roundTripChip = roundTripStack.children[0]
expect(roundTripChip?.type).toBe('element')
if (roundTripChip?.type !== 'element') return
expect(roundTripChip.inlineStyle?.position).toBe('absolute')
expect(roundTripChip.inlineStyle?.left).toBe('16px')
expect(roundTripChip.inlineStyle?.top).toBe('24px')
expect(roundTripChip.inlineStyle?.['align-self']).toBe('center')
})
it('maps CSS flex alignment into scene graph auto-layout alignment', () => {
const graph = designDocumentToSceneGraph({
type: 'document',