openpencil/packages/dom-css
Danila Poyarkov 6a3960a5d4
feat(code): link the Code tab to canvas layers and sync both ways (#805)
* feat(code): link code to canvas layers and underline design issues

Code in the Code tab and layers on the canvas were unrelated: finding the
element behind a layer, or the layer behind an element, meant reading names.

Generated Design JSX and Tailwind JSX report the layer behind each element
in the order elements open, and edited Design JSX keeps the source line of
every element through the sandbox and renderer, so hovering an element
highlights its layer, Cmd/Ctrl-click brings it into view without changing
the selection the code shows, and errors and warnings from the design check
are underlined on the property that causes them.

* feat(code): explain the Code tab when nothing is selected

With no selection the editor showed a starter frame that read like a real
layer. The tab now says it shows the selected layers' code and offers Write
JSX, which opens the editor focused on the starter template.

* refactor(code): group code-to-layer linking into its own domain

Layer link types, issue mapping, and the hover and reveal behavior move
from the Code panel and a component file into src/app/code/layers, with
useCodeLayers as the panel's entry point, so app code no longer imports
types from components.

* fix(code): underline off-scale gaps after the spacing rule renamed its property

* feat(code): mark the layer of the element around the cursor

Hover highlighting and ⌘-click reveal replaced by one model: the element
around the cursor marks its opening and closing tag names and outlines its
layer on the canvas while the editor has focus. ⌘-click also collided
with CodeMirror's add-a-cursor gesture. Read-only Tailwind JSX now takes a
cursor so it links the same way.

Leaving the editor now ends a live Design JSX edit as one undo step.
Before, canvas edits made after typing never reached the code until the
tab was reopened, and their undo entries landed before the edit's.

* feat(code): sync the Code tab and the canvas both ways by patching

Canvas edits now patch the Design JSX a person wrote instead of waiting
for them to leave the editor: each linked element remembers the layer as
Design JSX last wrote it, and a canvas change rewrites only the attributes,
text and child elements that differ from that base, as CodeMirror changes
that keep the cursor, comments, formatting and history. Attributes written
as expressions are never overwritten; the code marks them when the canvas
now differs. Untouched code is regenerated with a minimal text change.

Code edits update layers in place: the new render is reconciled into the
existing layers (reconcileRenderedLayers), which keep their ids, so links,
selection and canvas edits survive typing. Each edit is one coalesced undo
step, replacing the restore-and-rerender preview and the commit on blur.

* feat(code): patch reordered layers and aliased properties in edited code

Reordering layers on the canvas now moves their elements in code a person
wrote: each child element and the blank lines and comments above it form a
block kept as written, and the children are written again in the new
order, staying linked. Children that cannot move safely, such as a loop
between them, keep their order and are marked.

Properties accepted under several names now come from one alias table in
the Design JSX schema, which the renderer resolves through and the patcher
and issue underlines use, so a canvas change to `w` patches `width` where
the person wrote that, instead of adding a second attribute.

* feat(code): keep the cursor in moved code and patch values written in style

A reorder rewrites the children span in one change, which collapsed a
cursor or out-of-sync marker inside a moved element to the span's edge.
The patch now carries where each block moved and places selections and
markers inside it at their new position.

Properties the renderer also reads from style={{ … }} come from a table in
the Design JSX schema instead of a hand-written list, keeping the rule
that an attribute under any of its names wins. The patcher uses it to
update a value written in style where it is, as a number or a px string
as written; values the renderer cannot read, such as '50%', are marked.

The layer patcher is split by concern: syntax helpers, attribute and
style patches, child patches, out-of-sync state and transaction assembly.

* feat(code): show the code's layer on the canvas as a tinted box

The layer of the element around the cursor used the canvas hover slot, so
moving the pointer over the canvas replaced it and the two read the same.
It now has its own shared editor state, codeFocusNodeId, drawn as the hover
outline over a light tint in every pane: hover stays an outline and the
selection keeps its handles, without borrowing the dashed outlines that
already mean component sets, drag parents and ghosts.

* fix(code): write added and removed layers when a reorder cannot move the code

When children could not be moved, such as two written on one line, the
patch marked the order and returned before adding or removing elements,
so a layer created in the same change never reached the code. It now
marks the order and still writes additions and removals.

* refactor(design-jsx): format the rebased layer description and stroke aliases

* feat(code): mount the layer-linked code editor through useCodeMirror

Master moved the code editor onto the shared useCodeMirror composable.
Its layer links, issue underlines, canvas patches, minimal text updates,
autofocus and read-only cursor now sit on that composable instead of a
hand-mounted view.
2026-10-04 10:08:40 +00:00
..
scripts feat(app): open DOM/CSS documents 2026-06-30 10:48:14 +03:00
src feat(code): link the Code tab to canvas layers and sync both ways (#805) 2026-10-04 10:08:40 +00:00
tests feat(code): link the Code tab to canvas layers and sync both ways (#805) 2026-10-04 10:08:40 +00:00
AGENTS.md refactor: print OpenPencil JSX export as syntax trees (#799) 2026-10-01 21:22:43 +04:00
package.json build: update twirlwind to 0.4.0 (#851) 2026-10-04 12:36:14 +04:00
README.md docs(dom-css): audit CSS parser boundaries 2026-06-30 10:51:05 +03:00
tsconfig.json refactor(dom-css): group sources into import, export, and runtime (#789) 2026-09-30 09:44:52 +04:00
tsconfig.test.json feat(dom-css): add JSX authoring bridge 2026-06-30 10:48:13 +03:00
tsdown.config.ts refactor(dom-css): group sources into import, export, and runtime (#789) 2026-09-30 09:44:52 +04:00

@open-pencil/dom-css

DOM and CSS projection utilities for OpenPencil.

This package is the compatibility layer between OpenPencil's scene graph and DOM-shaped design documents. It is intentionally separate from @open-pencil/core so browser/CSS parser integrations can evolve without adding DOM dependencies to the renderer and editor core.

Installation

bun add @open-pencil/dom-css @open-pencil/core

@open-pencil/core is a peer dependency because @open-pencil/dom-css projects to and from OpenPencil scene graphs. Consumers that only parse/serialize DesignDOM still need the peer installed for the package entrypoint.

Package-local checks

This package can be validated independently from the app shell:

cd packages/dom-css
bun run test
bun run typecheck
bun run check

Package scripts:

  • bun run test — package-local Bun tests for runtime, conversion, and Tailwind APIs
  • bun run typecheck — type-checks src, tests, and package scripts
  • bun run build — builds the distributable dist entrypoint
  • bun run smoke:dist — imports the built dist bundle and exercises the public API
  • bun run check — runs typecheck, tests, build, and dist smoke in sequence

The repository also keeps integration/oracle coverage under tests/engine/dom-css and tests/e2e/dom-css. The package-local suite focuses on the library's public API, while the repo-level E2E suite verifies browser getComputedStyle() parity through Playwright.

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. Prefer sandbox: 'iframe' for production-style conversion because it isolates authored CSS from host-page styles:

import { createBrowserCSSRuntime } from '@open-pencil/dom-css'

const runtime = createBrowserCSSRuntime({ sandbox: 'iframe' })
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. 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. Do not expand headless CSS parsing with ad hoc regex/string parsers; add a maintained parser/runtime dependency or use the browser runtime instead.

import { createHeadlessCSSRuntime } from '@open-pencil/dom-css'

const runtime = createHeadlessCSSRuntime()

HTML/CSS to scene graph

The convenience helpers run the full pipeline:

import { htmlToSceneGraph } from '@open-pencil/dom-css'

const graph = await htmlToSceneGraph(
  '<article class="card"><h1>OpenPencil</h1></article>',
  {
    cssText: '.card { display: flex; gap: 12px; width: 320px; padding: 24px; }'
  }
)

For DesignDOM output without creating a scene graph, use htmlToDesignDocument().

JSX/DOM authoring

Use the package as a JSX import source when you want DOM-shaped authoring that still flows through DesignDOM, CSSOM, and SceneGraph conversion:

/** @jsxImportSource @open-pencil/dom-css */
import { createBrowserCSSRuntime, jsxToSceneGraph } from '@open-pencil/dom-css'

const graph = await jsxToSceneGraph(
  <article class="card">
    <h1>OpenPencil</h1>
  </article>,
  {
    cssText: '.card { display: flex; width: 320px; padding: 24px; }',
    runtime: createBrowserCSSRuntime({ sandbox: 'iframe' })
  }
)

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. Import them from @open-pencil/dom-css/browser so browser bundles do not load headless-only CSSOM dependencies:

/** @jsxImportSource @open-pencil/dom-css */
import { browserJSXToSceneGraph } from '@open-pencil/dom-css/browser'

const graph = await browserJSXToSceneGraph(
  <article class="card">
    <h1>OpenPencil</h1>
  </article>,
  {
    cssText: '.card { display: flex; width: 320px; padding: 24px; }',
    sandbox: 'iframe'
  }
)

Use browserJSXToDesignDocument() / browserJSXToSceneGraph() for authored CSS. Use browserTailwindJSXToDesignDocument() / browserTailwindJSXToSceneGraph() only when Tailwind utilities should be compiled at runtime by the host app. Browser Tailwind compilation needs the host bundler to provide Tailwind source CSS or stylesheet loading; see the Tailwind recipes below.

Tailwind pipeline

Tailwind classes flow through Tailwind's own compiler, then through the CSS runtime. Prefer the browser helpers when a document is available so custom properties, calc(), modern colors, and browser-default behavior come from native getComputedStyle().

Browser recipe: precompiled Tailwind CSS

The most portable browser path is to compile Tailwind CSS in the host app, then pass the resulting CSS as normal cssText:

import { browserHTMLToSceneGraph } from '@open-pencil/dom-css/browser'

import tailwindCSS from './generated-tailwind.css?raw'

const graph = await browserHTMLToSceneGraph(
  '<article class="flex w-80 rounded-xl bg-white p-6">OpenPencil</article>',
  {
    cssText: tailwindCSS,
    sandbox: 'iframe'
  }
)

This also works with JSX:

/** @jsxImportSource @open-pencil/dom-css */
import { browserJSXToSceneGraph } from '@open-pencil/dom-css/browser'

import tailwindCSS from './generated-tailwind.css?raw'

const graph = await browserJSXToSceneGraph(
  <article class="flex w-80 rounded-xl bg-white p-6">OpenPencil</article>,
  {
    cssText: tailwindCSS,
    sandbox: 'iframe'
  }
)

Browser recipe: runtime Tailwind compilation with supplied CSS

If the app wants to compile utility candidates at runtime, provide Tailwind source CSS yourself. Do not rely on the default Node-oriented stylesheet loader in browser bundles:

import { browserTailwindHTMLToSceneGraph } from '@open-pencil/dom-css/browser'

const classes = ['flex', 'w-80', 'rounded-xl', 'bg-white', 'p-6']
const graph = await browserTailwindHTMLToSceneGraph(
  `<article class="${classes.join(' ')}">OpenPencil</article>`,
  classes,
  {
    css: await fetch('/tailwind-source.css').then((response) => response.text()),
    sandbox: 'iframe'
  }
)

Use loadStylesheet when the supplied Tailwind CSS contains imports and the host app owns import resolution:

import { browserTailwindHTMLToSceneGraph } from '@open-pencil/dom-css/browser'

const classes = ['flex', 'w-80', 'rounded-xl', 'bg-white', 'p-6']
const graph = await browserTailwindHTMLToSceneGraph(
  `<article class="${classes.join(' ')}">OpenPencil</article>`,
  classes,
  {
    css: '@import "tailwindcss";',
    loadStylesheet: async (id) => {
      const url = id === 'tailwindcss' ? '/tailwindcss/index.css' : `/tailwindcss/${id}`
      return fetch(url).then((response) => response.text())
    },
    sandbox: 'iframe'
  }
)

Bun/Node recipe

In Bun or Node, the package can load Tailwind's default stylesheet through filesystem-backed module resolution. Without a DOM, this uses the headless CSS runtime; pass a browser runtime only when the process has a real document available, such as Playwright or a browser extension page:

import { tailwindHTMLToSceneGraph } from '@open-pencil/dom-css'

const classes = ['flex', 'w-80', 'p-6', 'rounded-xl', 'bg-white']
const graph = await tailwindHTMLToSceneGraph(
  `<article class="${classes.join(' ')}">OpenPencil</article>`,
  classes
)

compileTailwindCSS() remains available when callers want to manage CSS compilation and runtime selection themselves.

Current scope

  • 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/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(), 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 through browser-native computed style or dependency-backed parsers: richer shadows, typography details, position constraints, borders, gradients, 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