feat(dom-css): add browser JSX pipeline
This commit is contained in:
parent
50c9961d75
commit
e77a82fb3b
79
packages/docs/development/package-split.md
Normal file
79
packages/docs/development/package-split.md
Normal 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.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
63
packages/dom-css/src/browser.ts
Normal file
63
packages/dom-css/src/browser.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue