openpencil/tests/engine/cli/export/index.test.ts

289 lines
7.5 KiB
TypeScript
Raw Normal View History

import { expect, setDefaultTimeout, test } from 'bun:test'
import { mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { unzipSync } from 'fflate'
import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core/io'
import { runOpenPencilCLI } from '#tests/helpers/cli'
import { createRect, firstPageId, makeSceneGraph } from '#tests/helpers/scene'
setDefaultTimeout(30_000)
const io = new IORegistry(BUILTIN_IO_FORMATS)
async function createFigFixture() {
const dir = await mkdtemp(join(tmpdir(), 'open-pencil-export-cli-'))
const figPath = join(dir, 'card.fig')
const graph = makeSceneGraph('Export Page')
const firstFrame = graph.createNode('FRAME', firstPageId(graph), {
name: 'First slide',
width: 1280,
height: 720
})
const rect = createRect(graph, firstFrame.id, {
name: 'Export Card',
x: 0,
y: 0,
width: 160,
height: 80
})
rect.layoutMode = 'HORIZONTAL'
rect.itemSpacing = 8
rect.paddingLeft = 16
rect.paddingRight = 16
rect.paddingTop = 16
rect.paddingBottom = 16
test: typecheck the test suites and fix what that found (#896) * build: typecheck the test suites Tests were in no TypeScript program: no tsconfig included tests/** or packages/*/tests/**, and bun strips types without checking them, so a fixture could drop a required field and keep passing until something read it. @types/bun moves to the root because it was installed per package only, and #cli-tests/* joins the paths the root config already carries. * test: fix the type errors the test suites were hiding Typechecking the tests turned up 1123 errors. Most were ordinary strictness, but some were real: `NodeChange` bound to Figma's plugin typings rather than the Kiwi codec in thirteen .fig tests, materializeInstance was called with six arguments against five so the blobs and source children were dropped, CanvasKit pixels were written to a plain object that never reached WASM, and assertions were made through accessors that do not exist, so they asserted nothing. Fixtures that had quietly lost a required field now carry it, nullable results are narrowed through the existing expectDefined helper rather than assumed, and stand-ins for CanvasKit and the editor go through one named helper instead of an unexplained cast at each site. No test was deleted, skipped, or weakened, and no `any`, non-null assertion, or ts-expect-error was introduced. * docs: record what typechecking the tests established Pins the app program's global types with an assertion rather than a note, since an unpinned types list lets any root @types package decide which platform src/** is judged against. The two environment faults that look like code regressions — Vite's dependency pre-bundle outliving a package rebuild, and heavy .fig suites failing under load — go to the development docs, where an explanation belongs. * fix: align @types/bun and keep node types resolvable when extended The root manifest declared a newer @types/bun than every package, which check:monorepo rejects, and pinning the app program's types left them unresolvable from a config that extends this one out of tree. * fix: fail the test typecheck when the compiler itself fails The gate matched diagnostics by substring, so a compiler or config failure that named no test file printed a pass while having checked nothing. Diagnostics are now split by whether they name a file: an unscoped one is the run failing and stops the gate, a test file's is a finding, and a source file's stays out by design. Also drops the parameter planComponentConstruction never read, and makes the inner-shadow verification script exit non-zero when it renders no image instead of logging and succeeding. * chore: merge master into tests-typecheck
2026-10-05 12:42:38 +00:00
rect.fills = [{ type: 'SOLID', color: { r: 1, g: 1, b: 1, a: 1 }, opacity: 1, visible: true }]
const secondPage = graph.addPage('Second Page')
const secondFrame = graph.createNode('FRAME', secondPage.id, {
name: 'Second slide',
width: 1280,
height: 720
})
createRect(graph, secondFrame.id, {
name: 'Second Card',
x: 0,
y: 0,
width: 120,
height: 60
})
const result = await io.writeDocument('fig', graph)
await Bun.write(figPath, result.data as Uint8Array)
return { dir, figPath }
}
test('FIG export preserves the whole document by default', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'whole.fig')
const { stdout, stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'fig',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
expect(stdout).toContain('Target: whole document')
const { graph } = await io.readDocument({
name: output,
data: new Uint8Array(await Bun.file(output).arrayBuffer())
})
expect(graph.getPages()).toHaveLength(3)
expect(graph.getPages().map((page) => page.name)).toContain('Second Page')
})
test('PPTX export includes slides from every page by default', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'whole.pptx')
const { stdout, stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'pptx',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
expect(stdout).toContain('Target: whole document')
const files = unzipSync(new Uint8Array(await Bun.file(output).arrayBuffer()))
expect(files['ppt/slides/slide1.xml']).toBeDefined()
expect(files['ppt/slides/slide2.xml']).toBeDefined()
expect(files['ppt/slides/slide3.xml']).toBeUndefined()
})
test('FIG export requires an explicit page for a partial archive', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'page.fig')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'fig',
'--page',
'Second Page',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const { graph } = await io.readDocument({
name: output,
data: new Uint8Array(await Bun.file(output).arrayBuffer())
})
expect(graph.getPages().map((page) => page.name)).toEqual(['Second Page'])
})
test('export CLI writes HTML with inline styles by default', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
expect(html).toContain('data-open-pencil-node-id')
expect(html).toContain('style=')
expect(html).toContain('display: flex')
})
test('export CLI can write HTML styles as Tailwind classes', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-tailwind.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--css',
'tailwind',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
expect(html).toContain('data-open-pencil-node-id')
expect(html).toContain('class="')
expect(html).toContain('flex')
expect(html).not.toContain('style=')
})
test('export CLI writes Tailwind JSX for a page', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card.jsx')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'jsx',
'--style',
'tailwind',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const jsx = await Bun.file(output).text()
expect(jsx).toStartWith('<div data-name="First slide" className="')
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
expect(jsx).toContain(
feat: show controls in a prebuilt demo and fix what saving it to .fig lost (#938) * feat: show controls in the demo and tidy the component panels The demo gains a Controls page: a switch, checkbox, slider, tabs, text field, and a button with interaction states, written as Reka-named design JSX, and a settings card of their instances to try in preview. Go to main component and Detach instance move into the instance's panel header as icon buttons. The Behaviour section's "Still needed" line is a status rather than a button that only moved focus, and the rows it names are marked instead. The DOM projection writes flex-start for auto layout's start alignment: CSS stretches children by default, so a hugging button in a column filled its card in preview and HTML export. The paint page snapshot catches up with the frame names #930 draws. * perf: open the demo as a prebuilt .fig instead of generating it /demo built every page in the browser and laid them all out in one task, which froze the page for about six seconds. The demo's content moves to tools/generate/demo, which builds public/demo.fig headlessly, before Vite serves or bundles the app and only when its inputs changed; /demo opens that file like any other, off the main thread and behind the loader. The document is now lint-clean: small captions are 12 px, and the badge and disabled button text meet contrast. Opening the demo from .fig showed three bugs in saving and reading files, fixed here: - A layer's blend mode was never written, so Multiply and Screen reopened as pass-through. - Underlines drawn from saved glyphs ran to the text box edge; glyphs now keep their advance, read from and written to .fig, and the underline ends where the text does. - A frame laid out on its own because of a saved hug size ignored the width its parent stretched it to, so centred content moved to the start. * feat(design-jsx): write Reka elements in TSX The Reka element names were only part of the JSX string grammar, so code that builds trees in TypeScript had to assemble strings by hand, unchecked. @open-pencil/design-jsx now exports each Reka namespace as typed element functions (Switch.Root, Slider.Thumb, Tabs.Trigger, …), derived from the same table the string renderer reads, with props typed as the behaviour props a root binds. The demo's controls are TSX built with them. * fix(canvas): draw a component set's editing border at the live zoom A component set without a stroke got its dashed border in the scene, sized by dividing by the zoom. The scene is cached as retained pictures and scaled while navigating, so the border was recorded at one zoom and grew into thick dashes when zooming in, until the page was redrawn. It is editor chrome, so it now comes from the overlay pass, which draws every frame at the live zoom, as slot outlines do. The label cache tracks every component set on the page for it, and image export no longer includes the border. * test: fit the P3 paint page to the canvas size it is captured at The P3 test zoomed to the paint page while the wide-gamut notice still took space, then dismissed it, so the first capture fit a shorter canvas than the second and the two only matched by timing. focusPaintEffects waits for the canvas to take a window resize, the test focuses again after the notice goes, and the snapshot shows the demo as it now opens. * refactor(canvas): fill sections and sets through one helper Without its own fallback border, a component set fills and strokes its rounded bounds the way a section does; fillRoundedBounds fills them for both. * test: check the demo controls on their own page and type pixel reads The demo test built and round-tripped the whole four-page document to check the controls, which took six seconds on CI and timed out; it now builds the Controls page alone through the same measured-text setup the build uses. The underline test reads CanvasKit pixels as any number array, since readPixels may return floats. * fix(canvas): measure long text extents without spreading glyphs Spreading every glyph into Math.min/max overflows the argument limit on long texts. * fix(demo): keep a tab touched while the demo loads and report a failed load A file opened or a layer drawn during the fetch keeps the tab; a failed fetch shows the open-file error instead of an unhandled rejection. * test: check a component set border stays one pixel wide when zoomed
2026-10-07 18:37:05 +00:00
'<div data-name="Export Card" className="flex absolute top-0 left-0 p-4 items-start gap-2'
feat: behaviours and preview mode (#893) * feat: author behaviours on main components A main component or component set can behave as a Switch, Checkbox, Slider, or Tabs, after Reka UI's primitives. The behaviour lives in OpenPencil plugin data: boolean values bind to variant or boolean properties with the values meaning on and off, a number keeps its own range since Figma has no number property, and the control's subcomponents bind to the component's slots. A Behaviour section in the properties panel adds, binds, and removes it, each as one undo step, and flags required bindings that are missing. The canvas-only layout's pill becomes a component that preview will reuse. * feat: preview instances with behaviours on the canvas View > Preview (Cmd+Alt+Enter) puts the canvas in preview: a lone canvas switches to the canvas-only layout with a Previewing pill, and a split canvas previews on its own side. Clicking a Switch or Checkbox flips it, dragging a Slider moves its thumb and range, and clicking a Tabs trigger shows its panel. Preview keeps its state on copies of the instances it touched, in a private graph with the document's ids, and the canvas draws those copies in place of the originals, so the document, undo, autosave, and collaborators never see it. Escape or the pill leaves preview, Reset restores every control, and editing shortcuts, labels, and outlines stay off while previewing. * feat: translate behaviour and preview strings; cover preview with an e2e flow * refactor(vue): reuse VariantDefinitionControl for behaviour property options * refactor: split variant actions and preview interactions by domain Variant authoring was one 706-line closure; it is now graph queries (model), undo snapshots (history), property definition edits (definitions), and the editor facade (index). Preview interactions move into play/kinds, one module per control, registered by behaviour kind so a new kind cannot ship without its contract and interaction. Behaviour contracts are keyed by kind. In the Vue SDK, slot and variant authoring controls get their own folders beside component-props and behaviour, and the app's variant section joins slot/ and behaviour/. * refactor: keep the behaviour model in scene-graph's plugin-data registry Master now defines every OpenPencil plugin-data key in one typed registry in scene-graph. The behaviour schema registers there as a field, and the model and contracts move beside slots, exported from the package root; the @open-pencil/core/behaviours subpath is gone. * feat: interaction states and keyboard focus in preview A behaviour can bind a variant property to the default, hover, pressed, focus, and disabled states; binding it maps values named like those states. Preview switches the instance's copy to the matching variant as the pointer hovers, presses, and releases, keeps other values when the set draws the combination and falls back to rest otherwise, and skips disabled instances. Tab moves visible keyboard focus between controls, Space, Enter, arrows, Home, and End use the focused one, and Escape takes visible focus off before leaving preview. A Button kind covers controls that only have states. * feat: toggle, radio, group, progress, collapsible, and accordion behaviours Radio group, toggle group, and accordion hold their items in a slot; each item is an instance with its own behaviour, so a press inside the slot goes to the group, which turns the pressed item on and the others off through the item's own interaction. Progress shares the slider's number handling through rangeControl, and a collapsible shows and hides its content slot from its trigger, remembering its open state even when no property draws it. Tabs and groups share arrow-key navigation. * feat: text field, textarea, and number field behaviours A behaviour value can now be text, bound to a text property, so preview types into a copy of the field through the same property path the editor uses. A bound Filled value switches to the placeholder variant when the field empties. A number field keeps its own range, shows its value through a text property, and steps from its increment and decrement slots and the arrow keys. Text fields show focus from a click, and the focused control receives every key; Option still types, and only Cmd or Ctrl combinations stay shortcuts. * fix: keep behaviour bindings when saving as .fig Saving as .fig gives component properties new GUIDs, but behaviours kept the old ids in their plugin data, so every binding read as missing after reopening. The export now renames the ids behaviours bind with the same GUIDs, on its own copy of the document. * fix: let previewed controls resize layout imported from .fig Layers from a .fig keep the sizes Figma computed, and auto layout prefers them, so an opened collapsible or accordion item kept its closed height in preview. When preview shows, hides, or retypes a layer in a copy, it drops those sizes from the layer's copied ancestors so auto layout sizes them again; untouched layers keep Figma's sizes. * fix: publish behaviours and other plugin content with library assets Every OpenPencil plugin-data field now declares its role: content that exists only as plugin data (behaviours, OkHCL picks), format copies of node fields written for files, or bookkeeping about where a document or node came from. Library snapshots keep a node's content plugin data, including other plugins' entries, and drop the rest; the asset hash counts the same entries, so a behaviour-only change is offered as an update while a .fig round trip still changes nothing. * feat: name behaviour rows by meaning and create what they need The Behaviour section named every main value "Value" under a "Values" heading, and a component without matching properties left an empty picker with no way forward. Rows are now named for the control (On, Checked, Pressed, Text), rows the control needs or already uses come first, and the optional rest folds under More options; a button keeps its states in view. An empty row creates what it needs in one undo step: a text layer and text property, Off and On variants on a set, or a slot frame for a part. The missing chip names the row it means and takes you there. * fix(dom-css): position free layers, hug content, and round ellipses HTML and Tailwind export stacked the layers of frames without auto layout in block flow, wrote fixed pixel sizes for auto layout frames set to Hug and for auto-sizing text, and drew ellipses as boxes. Layers a parent does not lay out are now absolutely positioned at their coordinates inside a relative frame, hugging axes are left to the content, and ellipses get a 50% radius. * feat: run preview as live Reka UI islands over the canvas Preview simulated controls on the canvas: copies of instances, a handler per kind, its own key routing, and append-only text. It now runs them as real components. Each top-level layer that holds an instance with a behaviour becomes an island: its layers are projected to DOM through dom-css into a shadow root laid over the pane at its pan and zoom, and each behaviour mounts its Reka UI primitives on its layers, so text fields are real inputs and focus, keys, and layout are the browser's. Core's resolvePlayState shows instances in a state on a private graph, so the component's variants draw it, and controls are keyed by layer path so a variant switch keeps their DOM. The canvas leaves island layers to the islands, and the canvas play runtime and its key routing are gone. * fix: derive variant properties from Property=Value component names figma.combineAsVariants and Combine as variants only derived variant properties from slash-separated names, so components named as Figma names variants, such as State=On, Size=Large, became a set with no properties. Both now derive each named property and its values, after the slash form. * feat: script and tool access to behaviours by name Behaviour contracts follow Reka UI's anatomy: tabs keep their triggers in the list slot and their content panels in a panels slot, and a slot of repeated parts names the Reka part of its children. A behaviour spec names component properties and slots instead of ids and resolves to the stored behaviour and back, with errors that list what the component has. Scripts get an `openpencil` global next to `figma`, in the Figma API's style: setBehaviour, getBehaviour with bindValue, bindPart, states, and missing, behaviourKinds, and createSlot. The eval tool, the CLI, and app automation compile scripts through one compileScript, so the CLI now returns the last expression as the others do. MCP and AI chat get set_behaviour, get_behaviour, and create_slot. * feat: write controls in design JSX with Reka UI's element names `<Switch.Root modelValue="State">` renders a main component, or a set when its children are variants, that behaves as a switch, and `<Switch.Thumb>` the slot that draws its thumb, one slot across the set's variants. Inputs become the text property of a field, tab triggers and panels go in their List and Panels slots, and a group's items are `<RadioGroup.Item of={…} />` instances in its Items slot. JSX export writes components with behaviours the same way, so they render back unchanged. The authoring reference documents controls, and the codegen and chat prompts now include it verbatim instead of dedenting its code examples. * chore: format the CLI export test * docs: document slots, behaviours, preview, and the openpencil API The components guide covers slots, behaviours, and preview with its shortcut; scripting covers the openpencil global and eval's last- expression result; the MCP and AI chat pages list the new tools; the features overview, README, and roadmap mention working controls. The chat prompt says how to build a control, and the codegen prompt builds components with behaviours on their Reka UI primitives. * chore: format the eval CLI test * docs: explain behaviours and preview islands, and guide the openpencil API A development page explains the behaviour model, the four authoring surfaces, how preview islands turn a control's state into live Reka UI components, and how to add a kind; the architecture page links it. The Core guide sets the rules for OpenPencilAPI: Figma-only `figma`, OpenPencil features on `openpencil` in the same style, one compileScript, names over ids, and docs with every member. Package READMEs mention the openpencil global, PlayIslands, Reka-named JSX, and the behaviour model. Design JSX's behaviour modules move into a behaviours folder instead of a suffixed sibling. * refactor: center pasted layers through translate centerNodesAt repeated translate's loop, which test:dupes reports on master too. * fix: validate behaviour ranges and guess on and off by name A number value now needs max above min and a positive step: the schema, specs, and the panel reject a range a slider cannot step through. Binding a variant property guesses on and off by value name, as specs do, and a boolean property gets no on/off pair. Part bindings are read through partBinding, a replaced document restarts preview from its designed state, and the e2e preview shortcut uses ControlOrMeta. * feat: make the Behaviour section say what to do next A slider's range fields now carry inline Min, Max, Step, and Start labels. States offers Add state variants, which adds a Default, Hover, Pressed, Focus, and Disabled variant and binds them; Add Off and On variants and Add state variants turn a lone main component into a component set first, and a part's slot can be added to a set, in every variant under one slot id. Rows that could do nothing are gone: no empty pickers and no hints to combine variants by hand, and an unbound Disabled is left to the states. A warning line names what is still needed and replaces the missing chip, and the Switch's main value is called Checked. * fix: keep each slot to one part and keep creating slots at hand A slot draws one part, so the Behaviour section no longer offers a slot another part uses, and specs (the openpencil API, tools, and JSX) reject binding one slot to two parts. A part's picker keeps an action to add a new slot in its footer, so adding the first slot no longer hides it for the other parts.
2026-10-06 13:23:05 +00:00
)
expect(jsx).not.toContain('Second Card')
})
test('export CLI can write standalone HTML', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-standalone.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--html',
'standalone',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
expect(html).toContain('<!doctype html>')
expect(html).toContain('data-open-pencil-html="standalone"')
expect(html).toContain('position:relative')
expect(html).toContain('position: absolute')
expect(html).not.toContain('@tailwindcss/browser@4')
})
test('export CLI precompiles Tailwind CSS for standalone Tailwind HTML', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-standalone-tailwind.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--html',
'standalone',
'--css',
'tailwind',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
expect(html).toContain('<!doctype html>')
expect(html).toContain('<style>')
expect(html).toContain('class="')
expect(html).toContain('.flex')
expect(html).not.toContain('@tailwindcss/browser@4')
})
test('export CLI can write external standalone HTML assets', async () => {
const { dir, figPath } = await createFigFixture()
const output = join(dir, 'card-external.html')
const { stderr, exitCode } = await runOpenPencilCLI([
'export',
figPath,
'--format',
'html',
'--html',
'standalone',
'--css',
'tailwind',
'--assets',
'external',
'--output',
output
])
expect(stderr).toBe('')
expect(exitCode).toBe(0)
const html = await Bun.file(output).text()
const cssPath = join(dir, 'card-external.assets', 'openpencil.css')
const css = await Bun.file(cssPath).text()
expect(html).toContain('<link rel="stylesheet" href="card-external.assets/openpencil.css">')
expect(html).not.toContain('<style>')
expect(css).toContain('.flex')
expect(css).toContain('.op-stage')
})