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