openpencil/tests/helpers/dom-css.ts
Danila Poyarkov e532f616ba
refactor!: move shared primitives below dom-css and core (#771)
* refactor!: move shared primitives below dom-css and core

dom-css depended on core for color conversion, base64 helpers, text
direction, and web-font assets, so core could not use dom-css and every
caller special-cased HTML and Tailwind output.

Color conversion and management, base64 helpers, and text/layout
direction now live in scene-graph under `color`, `bytes`, and
`text-direction`. dom-css takes web-font resolution as an injected
`fonts` option and owns the font face types, so it depends only on
scene-graph and core can depend on it.

BREAKING CHANGE: `@open-pencil/core/color` and `@open-pencil/core/bytes`
are removed, and the direction helpers are no longer exported from
`@open-pencil/core/text`; import them from `@open-pencil/scene-graph`
subpaths. `exportHTMLBundle` takes a font resolver in `fonts` instead of
`'assets'`.

* fix(tools): import color parsing from scene-graph in visual bisect

* fix(mcp): declare the scene-graph dependency

MCP imports `@open-pencil/scene-graph/bytes` since base64 helpers moved
there, but only reached scene-graph through core, so isolated installs
and package checks depended on transitive resolution.

* refactor!: use js-base64 directly instead of a base64 wrapper

Base64 helpers had moved into scene-graph only to sit below dom-css,
but they are a thin wrapper over js-base64 and unrelated to the graph;
fig already called js-base64 directly.

Callers use js-base64 and check `isValid` where input comes from outside
(clipboard, imported HTML, tool arguments, the plugin API). A new
`open-pencil/no-hand-rolled-base64` lint rule rejects atob, btoa, and
Buffer Base64 conversions, and AGENTS.md records the convention.

BREAKING CHANGE: `@open-pencil/core/bytes` is removed; use `js-base64`.

* fix(dom-css): keep images with invalid Base64 inline in HTML export

`exportHTMLBundle` accepts documents parsed from outside HTML, and
js-base64 drops characters it cannot decode, so extracting an invalid
image data URL wrote different bytes. Such images now stay inline.
2026-09-26 11:39:11 +04:00

287 lines
6.7 KiB
TypeScript

import type { DesignDocument, DesignElement, DesignNode } from '@open-pencil/dom-css'
import { colorToCSS } from '@open-pencil/scene-graph/color'
export function findTextElement(nodes: DesignNode[]): DesignElement | undefined {
for (const node of nodes) {
if (node.type !== 'element') continue
if (node.children.some((child) => child.type === 'text')) return node
const child = findTextElement(node.children)
if (child) return child
}
return undefined
}
export const DOM_CSS_COLORS = {
white: colorToCSS({ r: 1, g: 1, b: 1, a: 1 }),
slate950: colorToCSS({ r: 2 / 255, g: 6 / 255, b: 23 / 255, a: 1 }),
slate900: colorToCSS({ r: 17 / 255, g: 24 / 255, b: 39 / 255, a: 1 }),
slate700: colorToCSS({ r: 31 / 255, g: 41 / 255, b: 55 / 255, a: 1 }),
slate600: colorToCSS({ r: 71 / 255, g: 85 / 255, b: 105 / 255, a: 1 }),
slate200: colorToCSS({ r: 226 / 255, g: 232 / 255, b: 240 / 255, a: 1 }),
slate300: colorToCSS({ r: 203 / 255, g: 213 / 255, b: 225 / 255, a: 1 }),
slate50: colorToCSS({ r: 248 / 255, g: 250 / 255, b: 252 / 255, a: 1 }),
sky100: colorToCSS({ r: 224 / 255, g: 242 / 255, b: 254 / 255, a: 1 }),
sky700: colorToCSS({ r: 3 / 255, g: 105 / 255, b: 161 / 255, a: 1 }),
slateShadow: colorToCSS({ r: 15 / 255, g: 23 / 255, b: 42 / 255, a: 0.12 }),
dialogShadow: colorToCSS({ r: 15 / 255, g: 23 / 255, b: 42 / 255, a: 0.16 })
} as const
export const simpleCardDocument: DesignDocument = {
type: 'document',
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'card', 'data-id': 'node-1' },
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
}
export const cssCardHTML = `
<article class="card">
<h1 class="title">OpenPencil</h1>
<p class="description">Design with code-shaped CSS.</p>
</article>
`
export const cssCardCSS = `
.card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 24px;
width: 320px;
height: 180px;
border: 1px solid ${DOM_CSS_COLORS.slate200};
border-radius: 16px;
background: white;
box-shadow: 0px 8px 24px 0px ${DOM_CSS_COLORS.slateShadow};
color: ${DOM_CSS_COLORS.slate900};
}
.card .title {
font-size: 24px;
font-weight: 700;
line-height: 32px;
}
.description {
font-size: 14px;
line-height: 20px;
}
`
export const computedCardDocument: DesignDocument = {
type: 'document',
children: [
{
type: 'element',
tagName: 'div',
attrs: { class: 'card' },
computedStyle: {
width: '320px',
height: '160px',
display: 'flex',
'flex-direction': 'column',
gap: '12px',
padding: '24px',
'border-radius': '16px',
'background-color': 'rgb(255, 255, 255)'
},
children: [
{
type: 'element',
tagName: 'h1',
attrs: {},
computedStyle: {
color: 'rgb(17, 24, 39)',
'font-size': '24px',
'font-weight': '700',
'line-height': '32px'
},
children: [{ type: 'text', text: 'OpenPencil' }]
}
]
}
]
}
export const tailwindCardClasses = [
'flex',
'flex-col',
'gap-3',
'w-80',
'h-44',
'p-6',
'rounded-xl',
'bg-white',
'text-slate-900'
] as const
export const tailwindButtonClasses = [
'inline-flex',
'items-center',
'justify-center',
'gap-2',
'rounded-md',
'bg-slate-900',
'px-4',
'py-2',
'text-sm',
'font-medium',
'text-white'
] as const
export const fixtureMatrixHTML = `
<section class="fixture-shell">
<nav class="navbar">
<span class="brand">OpenPencil</span>
<div class="nav-actions">
<span class="nav-item">Docs</span>
<span class="badge">Beta</span>
</div>
</nav>
<dialog class="dialog" open>
<h2 class="dialog-title">Import from web</h2>
<p class="dialog-description">Convert HTML and CSS into editable design layers.</p>
<input class="input" value="https://openpencil.dev" />
<button class="primary-button">Create design</button>
</dialog>
</section>
`
export const fixtureMatrixCSS = `
.fixture-shell {
display: flex;
flex-direction: column;
gap: 24px;
width: 480px;
padding: 32px;
background: ${DOM_CSS_COLORS.slate50};
color: ${DOM_CSS_COLORS.slate950};
}
.navbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 416px;
height: 48px;
padding: 0 16px;
border: 1px solid ${DOM_CSS_COLORS.slate200};
border-radius: 12px;
background: ${DOM_CSS_COLORS.white};
}
.brand {
font-size: 16px;
font-weight: 700;
line-height: 24px;
}
.nav-actions {
display: flex;
align-items: center;
gap: 12px;
}
.nav-item {
font-size: 14px;
line-height: 20px;
color: ${DOM_CSS_COLORS.slate700};
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
height: 24px;
padding: 0 10px;
border-radius: 9999px;
background: ${DOM_CSS_COLORS.sky100};
color: ${DOM_CSS_COLORS.sky700};
font-size: 12px;
font-weight: 600;
line-height: 16px;
}
.dialog {
display: flex;
flex-direction: column;
gap: 16px;
width: 360px;
min-width: 320px;
max-width: 420px;
padding: 24px;
border: 1px solid ${DOM_CSS_COLORS.slate200};
border-radius: 18px;
background: ${DOM_CSS_COLORS.white};
box-shadow: 0px 16px 40px 0px ${DOM_CSS_COLORS.dialogShadow};
}
.dialog-title {
font-size: 20px;
font-weight: 700;
line-height: 28px;
}
.dialog-description {
font-size: 14px;
line-height: 20px;
color: ${DOM_CSS_COLORS.slate600};
}
.input {
width: 312px;
height: 40px;
padding: 0 12px;
border: 1px solid ${DOM_CSS_COLORS.slate300};
border-radius: 8px;
background: ${DOM_CSS_COLORS.white};
color: ${DOM_CSS_COLORS.slate950};
font-size: 14px;
line-height: 20px;
}
.primary-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 312px;
height: 40px;
border-radius: 8px;
background: ${DOM_CSS_COLORS.slate950};
color: ${DOM_CSS_COLORS.white};
font-size: 14px;
font-weight: 600;
line-height: 20px;
}
`
export const tailwindInputClasses = [
'h-10',
'w-80',
'rounded-md',
'border',
'border-slate-300',
'bg-white',
'px-3',
'text-sm',
'text-slate-900'
] as const
export const tailwindBadgeClasses = [
'inline-flex',
'h-6',
'items-center',
'justify-center',
'rounded-full',
'bg-sky-100',
'px-2.5',
'text-xs',
'font-semibold',
'text-sky-700'
] as const
export const tailwindNavClasses = [
'flex',
'h-12',
'w-96',
'items-center',
'justify-between',
'rounded-xl',
'border',
'border-slate-200',
'bg-white',
'px-4'
] as const