test(dom-css): expand fixture matrix

This commit is contained in:
Danila Poyarkov 2026-06-03 14:32:35 +03:00
parent a18a0f6403
commit 5b61c1bf46
4 changed files with 460 additions and 9 deletions

View file

@ -137,8 +137,16 @@ function counterAxisAlignFromCSS(value: string | undefined): SceneNode['counterA
return 'MIN'
}
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
node.paddingBottom = firstCSSNumber(style, 'padding-bottom', 'padding-block', 'padding') ?? 0
node.paddingLeft = firstCSSNumber(style, 'padding-left', 'padding-inline', 'padding') ?? 0
}
function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void {
setNodeBox(node, style)
applyPadding(node, style)
const fills = fillsFromStyle(style, 'background-color')
if (fills.length > 0) node.fills = fills
@ -168,10 +176,6 @@ function applyElementStyle(node: SceneNode, style: DesignStyleDeclaration): void
node.primaryAxisAlign = primaryAxisAlignFromCSS(pickStyle(style, 'justify-content'))
node.counterAxisAlign = counterAxisAlignFromCSS(pickStyle(style, 'align-items'))
node.itemSpacing = firstCSSNumber(style, 'gap', 'column-gap', 'row-gap') ?? 0
node.paddingTop = firstCSSNumber(style, 'padding-top', 'padding') ?? 0
node.paddingRight = firstCSSNumber(style, 'padding-right', 'padding') ?? 0
node.paddingBottom = firstCSSNumber(style, 'padding-bottom', 'padding') ?? 0
node.paddingLeft = firstCSSNumber(style, 'padding-left', 'padding') ?? 0
}
}
@ -225,9 +229,35 @@ function createTextNode(
return node
}
function hasBoxStyle(style: DesignStyleDeclaration): boolean {
return [
'background-color',
'border-color',
'border-width',
'border-top-width',
'border-right-width',
'border-bottom-width',
'border-left-width',
'border-radius',
'box-shadow',
'display',
'height',
'padding',
'padding-top',
'padding-right',
'padding-bottom',
'padding-left',
'width'
].some((property) => pickStyle(style, property) !== undefined)
}
function createElementNode(graph: SceneGraph, parentId: string, element: DesignElement): SceneNode {
const style = mergedStyle(element)
if (isTextLikeElement(element) && element.children.every((child) => child.type === 'text')) {
if (
isTextLikeElement(element) &&
!hasBoxStyle(style) &&
element.children.every((child) => child.type === 'text')
) {
return createTextNode(graph, parentId, textContent(element), style)
}

View file

@ -2,7 +2,15 @@ import type { Page } from '@playwright/test'
import { compileTailwindCSS } from '@open-pencil/dom-css'
import { tailwindButtonClasses, tailwindCardClasses } from '#tests/helpers/dom-css'
import {
fixtureMatrixCSS,
fixtureMatrixHTML,
tailwindBadgeClasses,
tailwindButtonClasses,
tailwindCardClasses,
tailwindInputClasses,
tailwindNavClasses
} from '#tests/helpers/dom-css'
import { expect, test } from '../fixtures'
@ -91,6 +99,153 @@ test.describe('@open-pencil/dom-css browser CSS runtime oracle', () => {
expect(styles.color).toBe('rgb(255, 255, 255)')
})
test('resolves fixture matrix layout, form, badge, and dialog styles', async ({ page }) => {
await setStyledContent(page, fixtureMatrixCSS, fixtureMatrixHTML)
const navbar = await computedStyleProperties(page, '.navbar', [
'align-items',
'border-radius',
'display',
'height',
'justify-content',
'padding-left',
'width'
])
expect(navbar.display).toBe('flex')
expect(navbar['align-items']).toBe('center')
expect(navbar['justify-content']).toBe('space-between')
expect(navbar.width).toBe('416px')
expect(navbar.height).toBe('48px')
expect(navbar['padding-left']).toBe('16px')
expect(navbar['border-radius']).toBe('12px')
const badge = await computedStyleProperties(page, '.badge', [
'align-items',
'background-color',
'border-radius',
'color',
'display',
'font-size',
'font-weight',
'height',
'justify-content',
'padding-left'
])
expect(badge.display).toBe('flex')
expect(badge['align-items']).toBe('center')
expect(badge['justify-content']).toBe('center')
expect(badge.height).toBe('24px')
expect(badge['padding-left']).toBe('10px')
expect(badge['border-radius']).toBe('9999px')
expect(badge['font-size']).toBe('12px')
expect(badge['font-weight']).toBe('600')
expect(badge['background-color']).toBe('rgb(224, 242, 254)')
expect(badge.color).toBe('rgb(3, 105, 161)')
const input = await computedStyleProperties(page, '.input', [
'border-bottom-width',
'border-radius',
'font-size',
'height',
'padding-left',
'width'
])
expect(input.width).toBe('312px')
expect(input.height).toBe('40px')
expect(input['padding-left']).toBe('12px')
expect(input['border-radius']).toBe('8px')
expect(input['border-bottom-width']).toBe('1px')
expect(input['font-size']).toBe('14px')
const dialog = await computedStyleProperties(page, '.dialog', [
'box-shadow',
'display',
'flex-direction',
'gap',
'max-width',
'min-width',
'padding-top',
'width'
])
expect(dialog.display).toBe('flex')
expect(dialog['flex-direction']).toBe('column')
expect(dialog.gap).toBe('16px')
expect(dialog.width).toBe('360px')
expect(dialog['min-width']).toBe('320px')
expect(dialog['max-width']).toBe('420px')
expect(dialog['padding-top']).toBe('24px')
expect(dialog['box-shadow']).toContain('rgba(15, 23, 42, 0.16)')
})
test('resolves Tailwind input, badge, and nav utilities in a real browser', async ({ page }) => {
const inputClasses = [...tailwindInputClasses]
const badgeClasses = [...tailwindBadgeClasses]
const navClasses = [...tailwindNavClasses]
const css = await compileTailwindCSS([...inputClasses, ...badgeClasses, ...navClasses])
await setStyledContent(
page,
css,
`
<nav class="${navClasses.join(' ')}">
<span>OpenPencil</span>
<span class="${badgeClasses.join(' ')}">Beta</span>
</nav>
<input class="${inputClasses.join(' ')}" value="https://openpencil.dev" />
`
)
const nav = await computedStyleProperties(page, 'nav', [
'align-items',
'border-radius',
'display',
'height',
'justify-content',
'padding-left',
'width'
])
expect(nav.display).toBe('flex')
expect(nav['align-items']).toBe('center')
expect(nav['justify-content']).toBe('space-between')
expect(nav.width).toBe('384px')
expect(nav.height).toBe('48px')
expect(nav['padding-left']).toBe('16px')
expect(nav['border-radius']).toBe('12px')
const badge = await computedStyleProperties(page, 'nav span:last-child', [
'align-items',
'border-radius',
'display',
'font-size',
'font-weight',
'height',
'justify-content',
'padding-left'
])
expect(badge.display).toBe('flex')
expect(badge['align-items']).toBe('center')
expect(badge['justify-content']).toBe('center')
expect(badge.height).toBe('24px')
expect(badge['padding-left']).toBe('10px')
expect(badge['border-radius']).toMatch(/^3\.\d+e\+\d+px$/)
expect(badge['font-size']).toBe('12px')
expect(badge['font-weight']).toBe('600')
const input = await computedStyleProperties(page, 'input', [
'border-bottom-width',
'border-radius',
'font-size',
'height',
'padding-left',
'width'
])
expect(input.width).toBe('320px')
expect(input.height).toBe('40px')
expect(input['padding-left']).toBe('12px')
expect(input['border-radius']).toBe('6px')
expect(input['border-bottom-width']).toBe('1px')
expect(input['font-size']).toBe('14px')
})
test('resolves custom properties when assigned to real CSS properties', async ({ page }) => {
await page.setContent(`
<style>

View file

@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test'
import type { SceneNode } from '@open-pencil/core/scene-graph'
import {
compileTailwindCSS,
createHeadlessCSSRuntime,
@ -15,9 +16,20 @@ import {
computedCardDocument,
cssCardCSS,
cssCardHTML,
tailwindCardClasses
fixtureMatrixCSS,
fixtureMatrixHTML,
tailwindBadgeClasses,
tailwindCardClasses,
tailwindInputClasses,
tailwindNavClasses
} from '#tests/helpers/dom-css'
function expectFrame(node: SceneNode | undefined) {
expect(node?.type).toBe('FRAME')
if (node?.type !== 'FRAME') throw new Error('Expected frame node')
return node
}
describe('@open-pencil/dom-css conversion', () => {
it('converts HTML and CSS to DesignDOM with one API call', async () => {
const document = await htmlToDesignDocument(cssCardHTML, {
@ -233,6 +245,98 @@ describe('@open-pencil/dom-css conversion', () => {
expect(roundTripToolbar.inlineStyle?.['align-items']).toBe('center')
})
it('projects the shared fixture matrix into editable scene graph layers', async () => {
const graph = await htmlToSceneGraph(fixtureMatrixHTML, {
cssText: fixtureMatrixCSS,
runtime: createHeadlessCSSRuntime()
})
const page = graph.getPages()[0]
const shell = expectFrame(page ? graph.getChildren(page.id)[0] : undefined)
expect(shell.width).toBe(480)
expect(shell.layoutMode).toBe('VERTICAL')
expect(shell.itemSpacing).toBe(24)
expect(shell.paddingLeft).toBe(32)
const [navbarNode, dialogNode] = graph.getChildren(shell.id)
const navbar = expectFrame(navbarNode)
const dialog = expectFrame(dialogNode)
expect(navbar.layoutMode).toBe('HORIZONTAL')
expect(navbar.primaryAxisAlign).toBe('SPACE_BETWEEN')
expect(navbar.counterAxisAlign).toBe('CENTER')
expect(navbar.cornerRadius).toBe(12)
const navActions = expectFrame(graph.getChildren(navbar.id)[1])
const badge = expectFrame(graph.getChildren(navActions.id)[1])
expect(badge.layoutMode).toBe('HORIZONTAL')
expect(badge.counterAxisAlign).toBe('CENTER')
expect(badge.cornerRadius).toBe(9999)
expect(graph.getChildren(badge.id)[0]?.type).toBe('TEXT')
expect(dialog.width).toBe(360)
expect(dialog.minWidth).toBe(320)
expect(dialog.maxWidth).toBe(420)
expect(dialog.itemSpacing).toBe(16)
expect(dialog.effects[0]?.type).toBe('DROP_SHADOW')
expect(dialog.effects[0]?.radius).toBe(40)
const [, , inputNode, buttonNode] = graph.getChildren(dialog.id)
const input = expectFrame(inputNode)
const button = expectFrame(buttonNode)
expect(input.width).toBe(312)
expect(input.height).toBe(40)
expect(input.cornerRadius).toBe(8)
expect(button.layoutMode).toBe('HORIZONTAL')
expect(button.primaryAxisAlign).toBe('CENTER')
expect(button.counterAxisAlign).toBe('CENTER')
expect(graph.getChildren(button.id)[0]?.type).toBe('TEXT')
})
it('projects Tailwind input, badge, and nav fixtures through generated CSS', async () => {
const runtime = createHeadlessCSSRuntime()
const inputClasses = [...tailwindInputClasses]
const badgeClasses = [...tailwindBadgeClasses]
const navClasses = [...tailwindNavClasses]
const classes = [...inputClasses, ...badgeClasses, ...navClasses]
const document = await runtime.computeStyles(
runtime.parseHTML(`
<nav class="${navClasses.join(' ')}">
<span>OpenPencil</span>
<span class="${badgeClasses.join(' ')}">Beta</span>
</nav>
<input class="${inputClasses.join(' ')}" value="https://openpencil.dev" />
`),
await compileTailwindCSS(classes)
)
const graph = designDocumentToSceneGraph(document)
const page = graph.getPages()[0]
const [nav, input] = page ? graph.getChildren(page.id) : []
expect(nav?.type).toBe('FRAME')
expect(input?.type).toBe('FRAME')
if (nav?.type !== 'FRAME' || input?.type !== 'FRAME') return
expect(nav.width).toBe(384)
expect(nav.height).toBe(48)
expect(nav.layoutMode).toBe('HORIZONTAL')
expect(nav.primaryAxisAlign).toBe('SPACE_BETWEEN')
expect(nav.counterAxisAlign).toBe('CENTER')
expect(nav.cornerRadius).toBe(12)
const badge = graph.getChildren(nav.id)[1]
expect(badge?.type).toBe('FRAME')
if (badge?.type !== 'FRAME') return
expect(badge.height).toBe(24)
expect(badge.layoutMode).toBe('HORIZONTAL')
expect(badge.primaryAxisAlign).toBe('CENTER')
expect(badge.counterAxisAlign).toBe('CENTER')
expect(input.width).toBe(320)
expect(input.height).toBe(40)
expect(input.paddingLeft).toBe(12)
expect(input.cornerRadius).toBe(6)
expect(input.strokes[0]?.weight).toBe(1)
})
it('projects a Tailwind card through generated CSS into a scene graph', async () => {
const runtime = createHeadlessCSSRuntime()
const classes = [...tailwindCardClasses]

View file

@ -2,10 +2,18 @@ import { colorToCSS } from '@open-pencil/core/color'
import type { DesignDocument } from '@open-pencil/dom-css'
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 }),
slate200: colorToCSS({ r: 229 / 255, g: 231 / 255, b: 235 / 255, a: 1 }),
slateShadow: colorToCSS({ r: 15 / 255, g: 23 / 255, b: 42 / 255, a: 0.12 })
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 = {
@ -112,3 +120,157 @@ export const tailwindButtonClasses = [
'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