diff --git a/packages/docs/development/package-split.md b/packages/docs/development/package-split.md
new file mode 100644
index 000000000..7fc98fe6f
--- /dev/null
+++ b/packages/docs/development/package-split.md
@@ -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.
diff --git a/packages/dom-css/README.md b/packages/dom-css/README.md
index d2251983f..350cc078a 100644
--- a/packages/dom-css/README.md
+++ b/packages/dom-css/README.md
@@ -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('OpenPencil')
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(
+
+ OpenPencil
+ ,
+ 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`
diff --git a/packages/dom-css/package.json b/packages/dom-css/package.json
index a4b09832a..2be96250a 100644
--- a/packages/dom-css/package.json
+++ b/packages/dom-css/package.json
@@ -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",
diff --git a/packages/dom-css/scripts/smoke-dist.ts b/packages/dom-css/scripts/smoke-dist.ts
index fd8022518..54d7836a9 100644
--- a/packages/dom-css/scripts/smoke-dist.ts
+++ b/packages/dom-css/scripts/smoke-dist.ts
@@ -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')
+}
diff --git a/packages/dom-css/src/browser.ts b/packages/dom-css/src/browser.ts
new file mode 100644
index 000000000..d9ba262e1
--- /dev/null
+++ b/packages/dom-css/src/browser.ts
@@ -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, 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 {
+ 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 {
+ const document = await browserJSXToDesignDocument(input, options)
+ return designDocumentToSceneGraph(document, options)
+}
+
+export async function browserTailwindJSXToDesignDocument(
+ input: JSXChild,
+ candidates: string | Iterable,
+ options: BrowserTailwindToDesignDocumentOptions = {}
+): Promise {
+ const cssText = await compileTailwindCSS(candidates, options)
+ return browserJSXToDesignDocument(input, { ...options, cssText })
+}
+
+export async function browserTailwindJSXToSceneGraph(
+ input: JSXChild,
+ candidates: string | Iterable,
+ options: BrowserTailwindToSceneGraphOptions = {}
+): Promise {
+ const document = await browserTailwindJSXToDesignDocument(input, candidates, options)
+ return designDocumentToSceneGraph(document, options)
+}
diff --git a/packages/dom-css/src/from-scene-graph.ts b/packages/dom-css/src/from-scene-graph.ts
index 9d2c51fff..05253eff9 100644
--- a/packages/dom-css/src/from-scene-graph.ts
+++ b/packages/dom-css/src/from-scene-graph.ts
@@ -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`
diff --git a/packages/dom-css/src/index.ts b/packages/dom-css/src/index.ts
index 878d7abcf..074e0ca9f 100644
--- a/packages/dom-css/src/index.ts
+++ b/packages/dom-css/src/index.ts
@@ -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 {
diff --git a/packages/dom-css/src/jsx/runtime.ts b/packages/dom-css/src/jsx/runtime.ts
index f49ac6907..717be781f 100644
--- a/packages/dom-css/src/jsx/runtime.ts
+++ b/packages/dom-css/src/jsx/runtime.ts
@@ -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)
}
diff --git a/packages/dom-css/src/runtime/browser.ts b/packages/dom-css/src/runtime/browser.ts
index 939dcefdd..49668b721 100644
--- a/packages/dom-css/src/runtime/browser.ts
+++ b/packages/dom-css/src/runtime/browser.ts
@@ -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
diff --git a/packages/dom-css/src/to-scene-graph.ts b/packages/dom-css/src/to-scene-graph.ts
index 3d40b599c..4a7bad8ec 100644
--- a/packages/dom-css/src/to-scene-graph.ts
+++ b/packages/dom-css/src/to-scene-graph.ts
@@ -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)
}
diff --git a/packages/dom-css/tests/conversion.test.ts b/packages/dom-css/tests/conversion.test.ts
index dfd3b5609..0ca85206e 100644
--- a/packages/dom-css/tests/conversion.test.ts
+++ b/packages/dom-css/tests/conversion.test.ts
@@ -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')
+ })
})
diff --git a/packages/dom-css/tsconfig.json b/packages/dom-css/tsconfig.json
index 96dbc2e1d..337e5295d 100644
--- a/packages/dom-css/tsconfig.json
+++ b/packages/dom-css/tsconfig.json
@@ -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"]
}
diff --git a/packages/dom-css/tsdown.config.ts b/packages/dom-css/tsdown.config.ts
index e32349c40..606eff70c 100644
--- a/packages/dom-css/tsdown.config.ts
+++ b/packages/dom-css/tsdown.config.ts
@@ -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'
},
diff --git a/tests/e2e/dom-css/browser-runtime.spec.ts b/tests/e2e/dom-css/browser-runtime.spec.ts
index 6b7e49c57..6cc57801c 100644
--- a/tests/e2e/dom-css/browser-runtime.spec.ts
+++ b/tests/e2e/dom-css/browser-runtime.spec.ts
@@ -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('')
+ }
+
+ 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
}) => {
diff --git a/tests/engine/dom-css/conversion.test.ts b/tests/engine/dom-css/conversion.test.ts
index 396d6d85f..8e457ace0 100644
--- a/tests/engine/dom-css/conversion.test.ts
+++ b/tests/engine/dom-css/conversion.test.ts
@@ -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',