feat(dom-css): add DOM/CSS package skeleton

This commit is contained in:
Danila Poyarkov 2026-06-02 14:55:14 +03:00
parent 8983c11f45
commit 4d61c002fa
19 changed files with 544 additions and 8 deletions

View file

@ -12,6 +12,7 @@ Bun workspace with three packages:
- `packages/cli` — `@open-pencil/cli`: headless CLI for .fig inspection, export, linting. Uses `citty` + `agentfmt`.
- `packages/docs` — `@open-pencil/docs`: VitePress documentation site. Run with `cd packages/docs && bun run dev`.
- `packages/mcp` — `@open-pencil/mcp`: MCP server for AI coding tools. Stdio + HTTP (Hono). Reuses `createServer()` factory with all core tools.
- `packages/dom-css` — `@open-pencil/dom-css`: DOM/CSS projection layer for HTML/CSS/JSX/Tailwind compatibility. Owns DesignDOM types and browser/headless CSS runtime adapters; depends on core scene-graph types but keeps DOM/CSS parser dependencies out of core.
- `packages/vue` — `@open-pencil/vue`: headless Vue 3 SDK (Reka UI-style) for building custom OpenPencil-powered editor shells and embedded editing surfaces. Renderless components and composables. The app is one consumer of the SDK.

View file

@ -8,6 +8,7 @@
- Add design JSX variable helpers so color props can use `designVar()` / `defineVars()` references and emit graph variable bindings.
- Add structured design JSX paint helpers for solid fills, multiple fills, and gradients.
- Add structured design JSX effect helpers for shadows and blur effects.
- Add the `@open-pencil/dom-css` package skeleton for DOM/CSS projection and browser/headless CSS runtime adapters.
- Add type-validated `bindVariable`/`unbindVariable` with event emission and indexed binding format (`fills/N/color` instead of `fills[N]`).
- Add `unbind_variable` MCP tool for removing variable bindings.
- Add `openpencil analyze overlaps`, the `analyze_overlaps` RPC command, and the `analyze_overlaps` ToolDef for heuristic overlap detection. The command reports sibling overlaps, children overflowing non-clipping parents, and overlay/backdrop patterns, with filters for page/page ID, scope, category, severity, min area/ratio, node type, hidden/locked/absolute nodes, result limit, and `--json` output.

View file

@ -16,6 +16,7 @@
"@chenglou/pretext": "^0.0.7",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
"@open-pencil/vue": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@tailwindcss/vite": "^4.2.1",
@ -155,6 +156,17 @@
"vitepress-plugin-llms": "1.12.2",
},
},
"packages/dom-css": {
"name": "@open-pencil/dom-css",
"version": "0.13.2",
"devDependencies": {
"tsdown": "^0.21.7",
"typescript": "~5.8.3",
},
"peerDependencies": {
"@open-pencil/core": "workspace:*",
},
},
"packages/mcp": {
"name": "@open-pencil/mcp",
"version": "0.13.2",
@ -760,6 +772,8 @@
"@open-pencil/docs": ["@open-pencil/docs@workspace:packages/docs"],
"@open-pencil/dom-css": ["@open-pencil/dom-css@workspace:packages/dom-css"],
"@open-pencil/mcp": ["@open-pencil/mcp@workspace:packages/mcp"],
"@open-pencil/vue": ["@open-pencil/vue@workspace:packages/vue"],

View file

@ -12,9 +12,9 @@
"build": "bun run build:packages && bun run lint && vite build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ tests scripts/ tools/",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/",
"lint:structure": "oxlint -c oxlint.json vite.config.ts vite/ src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/dom-css/src/ tests/ scripts/ tools/",
"format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ packages/dom-css/src/ tests scripts/ tools/",
"format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)",
"check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes",
"check:i18n": "bun tools/i18n/src/check-locales.ts",
@ -29,9 +29,9 @@
"test:coverage": "bun test --coverage ./tests/engine",
"test:type-shapes": "bun tools/type-shapes/src/index.ts",
"test:tools": "bun tools/test.ts",
"test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0",
"test:dupes": "jscpd packages/core/src packages/cli/src packages/dom-css/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0",
"test:packages": "bun tools/package-quality/src/check-metadata.ts && bun tools/package-quality/src/smoke.ts",
"build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",
"build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/dom-css build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build",
"open-pencil": "bun packages/cli/src/index.ts",
"docs:dev": "bun --filter @open-pencil/docs dev",
"docs:build": "bun --filter @open-pencil/docs build",
@ -51,6 +51,7 @@
"@chenglou/pretext": "^0.0.7",
"@open-pencil/cli": "workspace:*",
"@open-pencil/core": "workspace:*",
"@open-pencil/dom-css": "workspace:*",
"@open-pencil/vue": "workspace:*",
"@openrouter/ai-sdk-provider": "^2.9.0",
"@tailwindcss/vite": "^4.2.1",

View file

@ -0,0 +1,17 @@
# @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.
Current scope:
- DOM-shaped `DesignDocument` / `DesignElement` types
- Browser-backed runtime adapter for native HTML parsing, serialization, and computed-style extraction
- Headless runtime placeholder for future `parse5` / CSSOM-backed execution
Planned scope:
- SceneGraph ⇄ DesignDOM conversion
- CSSOM and cascade support in headless contexts
- Tailwind-generated CSS ingestion

View file

@ -0,0 +1,48 @@
{
"name": "@open-pencil/dom-css",
"version": "0.13.2",
"license": "MIT",
"type": "module",
"imports": {
"#dom-css/*": {
"types": "./src/*",
"bun": "./src/*",
"default": "./dist/*"
}
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"bun": "./src/index.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"sideEffects": false,
"files": [
"dist",
"README.md"
],
"scripts": {
"build": "bunx tsdown --config tsdown.config.ts",
"prepack": "bun run build"
},
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",
"directory": "packages/dom-css"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"peerDependencies": {
"@open-pencil/core": "workspace:*"
},
"devDependencies": {
"tsdown": "^0.21.7",
"typescript": "~5.8.3"
}
}

View file

@ -0,0 +1,12 @@
export { serializeHTML, serializeNode } from './serialize'
export { createBrowserCssRuntime, createCssRuntime, createHeadlessCssRuntime } from './runtime'
export type {
CssComputeOptions,
CssRuntime,
DesignDocument,
DesignElement,
DesignNode,
DesignStyleDeclaration,
DesignStyleSheet,
DesignText
} from './types'

View file

@ -0,0 +1,201 @@
import { serializeHTML } from '../serialize'
import type {
CssComputeOptions,
CssRuntime,
DesignDocument,
DesignElement,
DesignNode
} from '../types'
const DEFAULT_COMPUTED_PROPERTIES = [
'align-items',
'background-color',
'background-image',
'border-bottom-color',
'border-bottom-left-radius',
'border-bottom-right-radius',
'border-bottom-width',
'border-left-color',
'border-left-width',
'border-radius',
'border-right-color',
'border-right-width',
'border-top-color',
'border-top-left-radius',
'border-top-right-radius',
'border-top-width',
'box-shadow',
'color',
'display',
'flex-direction',
'font-family',
'font-size',
'font-style',
'font-weight',
'gap',
'height',
'justify-content',
'letter-spacing',
'line-height',
'opacity',
'padding-bottom',
'padding-left',
'padding-right',
'padding-top',
'text-align',
'text-decoration-line',
'width'
] as const
function requireBrowserDocument(): Document {
if (typeof document === 'undefined') {
throw new TypeError('Browser CSS runtime requires a DOM document')
}
return document
}
function attributesToRecord(element: Element): Record<string, string> {
const attrs: Record<string, string> = {}
for (const attr of Array.from(element.attributes)) {
attrs[attr.name] = attr.value
}
return attrs
}
function styleToRecord(style: CSSStyleDeclaration): Record<string, string> | undefined {
const entries: Record<string, string> = {}
for (const property of Array.from(style)) {
const value = style.getPropertyValue(property)
if (value) entries[property] = value
}
return Object.keys(entries).length > 0 ? entries : undefined
}
function domNodeToDesignNode(node: Node): DesignNode | null {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ''
return text.length > 0 ? { type: 'text', text } : null
}
if (node.nodeType !== Node.ELEMENT_NODE || !(node instanceof Element)) return null
const children = Array.from(node.childNodes)
.map(domNodeToDesignNode)
.filter((child): child is DesignNode => child !== null)
return {
type: 'element',
tagName: node.tagName.toLowerCase(),
attrs: attributesToRecord(node),
children,
inlineStyle: node instanceof HTMLElement ? styleToRecord(node.style) : undefined
}
}
function parseHTML(html: string): DesignDocument {
requireBrowserDocument()
const parsed = new DOMParser().parseFromString(html, 'text/html')
return {
type: 'document',
children: Array.from(parsed.body.childNodes)
.map(domNodeToDesignNode)
.filter((node): node is DesignNode => node !== null)
}
}
function collectElementPairs(
designNode: DesignNode,
domNode: Node,
pairs: [DesignElement, Element][]
): void {
if (designNode.type === 'text') return
if (!(domNode instanceof Element)) return
pairs.push([designNode, domNode])
const elementChildren = designNode.children.filter(
(child): child is DesignElement => child.type === 'element'
)
const domChildren = Array.from(domNode.children)
for (const [index, child] of elementChildren.entries()) {
const domChild = domChildren.at(index)
if (domChild) collectElementPairs(child, domChild, pairs)
}
}
function computedStyleToRecord(
style: CSSStyleDeclaration,
options: CssComputeOptions
): Record<string, string> {
const entries: Record<string, string> = {}
const properties = options.includeBrowserDefaults
? Array.from(style)
: DEFAULT_COMPUTED_PROPERTIES
for (const property of properties) {
const value = style.getPropertyValue(property)
if (value) entries[property] = value
}
return entries
}
async function computeStyles(
designDocument: DesignDocument,
cssText = '',
options: CssComputeOptions = {}
): Promise<DesignDocument> {
const browserDocument = requireBrowserDocument()
const host = browserDocument.createElement('div')
host.style.cssText = [
'position: fixed',
'left: -100000px',
'top: 0',
'width: 1000px',
'height: auto',
'visibility: hidden',
'pointer-events: none',
'contain: layout style paint'
].join(';')
const shadow = host.attachShadow({ mode: 'open' })
const style = browserDocument.createElement('style')
style.textContent = cssText
shadow.append(style)
const content = browserDocument.createElement('div')
content.innerHTML = serializeHTML(designDocument)
shadow.append(content)
browserDocument.body.append(host)
try {
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
const nextDocument = structuredClone(designDocument)
const pairs: [DesignElement, Element][] = []
const domChildren = Array.from(content.childNodes)
for (const [index, child] of nextDocument.children.entries()) {
const domChild = domChildren.at(index)
if (domChild) collectElementPairs(child, domChild, pairs)
}
for (const [designElement, domElement] of pairs) {
designElement.computedStyle = computedStyleToRecord(getComputedStyle(domElement), options)
}
return nextDocument
} finally {
host.remove()
}
}
export function createBrowserCssRuntime(): CssRuntime {
return {
kind: 'browser',
parseHTML,
serializeHTML,
computeStyles
}
}

View file

@ -0,0 +1,23 @@
import { serializeHTML } from '../serialize'
import type { CssRuntime, DesignDocument } from '../types'
function unavailable(feature: string): never {
throw new Error(
`${feature} requires a headless DOM/CSS adapter. Planned dependencies: parse5 + CSSOM.`
)
}
export function createHeadlessCssRuntime(): CssRuntime {
return {
kind: 'headless',
parseHTML() {
return unavailable('parseHTML')
},
serializeHTML(document: DesignDocument) {
return serializeHTML(document)
},
computeStyles() {
return unavailable('computeStyles')
}
}
}

View file

@ -0,0 +1,10 @@
import type { CssRuntime } from '../types'
import { createBrowserCssRuntime } from './browser'
import { createHeadlessCssRuntime } from './headless'
export { createBrowserCssRuntime } from './browser'
export { createHeadlessCssRuntime } from './headless'
export function createCssRuntime(): CssRuntime {
return typeof document !== 'undefined' ? createBrowserCssRuntime() : createHeadlessCssRuntime()
}

View file

@ -0,0 +1,54 @@
import type { DesignDocument, DesignElement, DesignNode, DesignText } from './types'
const VOID_ELEMENTS = new Set([
'area',
'base',
'br',
'col',
'embed',
'hr',
'img',
'input',
'link',
'meta',
'param',
'source',
'track',
'wbr'
])
function escapeText(value: string): string {
return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')
}
function escapeAttr(value: string): string {
return escapeText(value).replaceAll('"', '&quot;')
}
function serializeText(node: DesignText): string {
return escapeText(node.text)
}
function serializeAttrs(node: DesignElement): string {
const attrs = Object.entries(node.attrs)
.filter(([, value]) => value !== '')
.map(([name, value]) => `${name}="${escapeAttr(value)}"`)
if (attrs.length === 0) return ''
return ` ${attrs.join(' ')}`
}
function serializeElement(node: DesignElement): string {
const tagName = node.tagName.toLowerCase()
const attrs = serializeAttrs(node)
if (VOID_ELEMENTS.has(tagName)) return `<${tagName}${attrs}>`
return `<${tagName}${attrs}>${node.children.map(serializeNode).join('')}</${tagName}>`
}
export function serializeNode(node: DesignNode): string {
return node.type === 'text' ? serializeText(node) : serializeElement(node)
}
export function serializeHTML(document: DesignDocument): string {
return document.children.map(serializeNode).join('')
}

View file

@ -0,0 +1,49 @@
import type { SceneGraph, SceneNode } from '@open-pencil/core/scene-graph'
export type DesignNode = DesignElement | DesignText
export interface DesignDocument {
type: 'document'
children: DesignNode[]
stylesheets?: DesignStyleSheet[]
sourceGraph?: SceneGraph
}
export interface DesignElement {
type: 'element'
tagName: string
attrs: Record<string, string>
children: DesignNode[]
inlineStyle?: DesignStyleDeclaration
computedStyle?: DesignStyleDeclaration
sourceSceneNodeId?: string
sourceSceneNode?: SceneNode
}
export interface DesignText {
type: 'text'
text: string
}
export interface DesignStyleSheet {
type: 'stylesheet'
cssText: string
href?: string
}
export type DesignStyleDeclaration = Record<string, string>
export interface CssComputeOptions {
includeBrowserDefaults?: boolean
}
export interface CssRuntime {
readonly kind: 'browser' | 'headless'
parseHTML(html: string): DesignDocument
serializeHTML(document: DesignDocument): string
computeStyles(
document: DesignDocument,
cssText?: string,
options?: CssComputeOptions
): Promise<DesignDocument>
}

View file

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"declaration": true,
"declarationDir": "./dist",
"outDir": "./dist",
"rootDir": ".",
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"#dom-css/*": ["./src/*"],
"#core/*": ["../core/src/*"],
"@open-pencil/core": ["../core/src/index.ts"],
"@open-pencil/core/*": ["../core/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,21 @@
import { defineConfig } from 'tsdown'
export default defineConfig({
entry: {
index: './src/index.ts'
},
platform: 'neutral',
format: ['esm'],
dts: true,
sourcemap: true,
hash: false,
clean: true,
outDir: './dist',
treeshake: {
moduleSideEffects: false
},
deps: {
neverBundle: ['@open-pencil/core', /^@open-pencil\/core\//],
onlyBundle: false
}
})

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from 'bun:test'
import {
createCssRuntime,
createHeadlessCssRuntime,
serializeHTML,
type DesignDocument
} from '@open-pencil/dom-css'
const documentFixture: DesignDocument = {
type: 'document',
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'card', 'data-id': 'node-1' },
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
}
describe('@open-pencil/dom-css', () => {
it('serializes DesignDOM as HTML', () => {
expect(serializeHTML(documentFixture)).toBe(
'<div class="card" data-id="node-1">OpenPencil</div>'
)
})
it('uses the headless runtime outside browser contexts', () => {
const runtime = createCssRuntime()
expect(runtime.kind).toBe('headless')
expect(runtime.serializeHTML(documentFixture)).toBe(
'<div class="card" data-id="node-1">OpenPencil</div>'
)
})
it('keeps headless HTML parsing explicit until the parser adapter is added', () => {
const runtime = createHeadlessCssRuntime()
expect(() => runtime.parseHTML('<div />')).toThrow('headless DOM/CSS adapter')
})
})

View file

@ -1,7 +1,13 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
const publicPackages = ['packages/core', 'packages/cli', 'packages/mcp', 'packages/vue']
const publicPackages = [
'packages/core',
'packages/dom-css',
'packages/cli',
'packages/mcp',
'packages/vue'
]
interface PackageJson {
name: string

View file

@ -4,7 +4,13 @@ import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const rootDir = fileURLToPath(new URL('../../..', import.meta.url))
const packageDirs = ['packages/core', 'packages/vue', 'packages/mcp', 'packages/cli']
const packageDirs = [
'packages/core',
'packages/dom-css',
'packages/vue',
'packages/mcp',
'packages/cli'
]
function run(command: string[], cwd = rootDir): string {
const proc = Bun.spawnSync(command, { cwd, stdout: 'pipe', stderr: 'pipe' })
@ -57,6 +63,7 @@ try {
nodeEval("await import('@open-pencil/core')", tempDir)
nodeEval("await import('@open-pencil/core/scene-graph')", tempDir)
nodeEval("await import('@open-pencil/dom-css')", tempDir)
nodeEval("await import('@open-pencil/vue')", tempDir)
nodeEval("await import('@open-pencil/mcp')", tempDir)

View file

@ -22,11 +22,13 @@
"#core/*": ["./packages/core/src/*"],
"#cli/*": ["./packages/cli/src/*"],
"#mcp/*": ["./packages/mcp/src/*"],
"#dom-css/*": ["./packages/dom-css/src/*"],
"#tests/*": ["./tests/*"],
"tinykeys": ["./node_modules/tinykeys/dist/tinykeys.d.ts"],
"@open-pencil/vue": ["./packages/vue/src/index.ts"],
"@open-pencil/core": ["./packages/core/src/index.ts"],
"@open-pencil/core/*": ["./packages/core/src/*"]
"@open-pencil/core/*": ["./packages/core/src/*"],
"@open-pencil/dom-css": ["./packages/dom-css/src/index.ts"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"],

View file

@ -9,8 +9,10 @@ export function createOpenPencilAliases(rootDir: string) {
'@': resolve(rootDir, 'src'),
'#vue': resolve(rootDir, 'packages/vue/src'),
'#core': resolve(rootDir, 'packages/core/src'),
'#dom-css': resolve(rootDir, 'packages/dom-css/src'),
'@open-pencil/vue': resolve(rootDir, 'packages/vue/src'),
'@open-pencil/core': resolve(rootDir, 'packages/core/src'),
'@open-pencil/dom-css': resolve(rootDir, 'packages/dom-css/src'),
'opentype.js': resolve(rootDir, 'node_modules/opentype.js/dist/opentype.module.js'),
mermaid: resolve(rootDir, 'src/app/shell/markdown/index.ts'),
'beautiful-mermaid': resolve(rootDir, 'src/app/shell/markdown/index.ts')