feat: write variable-bound properties as tokens in exported code (#895)

* feat: write variable-bound properties as tokens in exported code

HTML and Tailwind JSX export reference the design tokens a layer is bound to, var(--color-primary) or bg-primary, instead of baking in their values. A binding becomes a reference only where the stylesheet resolves it as the canvas draws it: the layer still draws the value, the token's unit fits the property, and an attribute such as data-theme="dark" can put the element in the layer's mode. Standalone HTML includes the stylesheet for the tokens it uses, compiled into Tailwind's @theme for Tailwind pages.

* refactor: share the collection variable list and tighten token helpers

Read every variable in collection order through one collectionVariables helper built on es-toolkit's compact, used by the stylesheet and the token references, and map padding sides and color channels instead of repeating them.
This commit is contained in:
Danila Poyarkov 2026-10-05 09:15:44 +00:00 committed by GitHub
parent 83cf621a91
commit fb0cb9ee5f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 723 additions and 138 deletions

View file

@ -4,6 +4,7 @@
### Breaking changes
- `sceneNodeToDesignDocument` from `@open-pencil/dom-css` takes an options object, `{ includeSourceIds, tokens }`, instead of a boolean third argument; `tokens: false` writes literal values instead of variable references.
- `randomHex`, `randomInt`, and `randomIndex` moved from `@open-pencil/core/random` and the `@open-pencil/core` barrel to `@open-pencil/scene-graph/random`.
- `Stroke` from `@open-pencil/scene-graph` extends `Fill`, so every stroke states a paint `type` that code constructing one must set to `'SOLID'`, and `copyStroke` deep-copies the paint fields a fill already copied.
- `Stroke` from `@open-pencil/scene-graph` extends `Fill`, so every stroke states a paint `type` that code constructing one must set to `'SOLID'`, and `copyStroke` deep-copies the paint fields a fill already copied. Rendering, `.fig` round-trips, and the stroke panel still handle solid strokes only.
@ -56,6 +57,7 @@
### Changed
- HTML and Tailwind JSX export write variable-bound colors, spacing, radii, borders, sizes, type sizes, and opacity as the tokens they come from, such as `var(--color-primary)` or `bg-primary`, and put layers set to another mode in it with an attribute such as `data-theme="dark"`. Values CSS would not resolve as the canvas draws them stay literal, and standalone HTML includes the stylesheet for the tokens it uses.
- Give popovers, menus, dropdowns, and pickers one look and motion: the same rounded panel with a thin outline that reads in light and dark themes, a short fade and grow from the side they open on, and an immediate close; with reduced motion they appear at once.
- Add and remove items the same way across the properties panel: a section's + adds an item and a row's − removes it, now including grid columns and rows and variant properties. The + of a component set adds Property 1 ready to rename instead of showing a form, and a variant is removed with Delete like any layer.
- Pick variables, shared styles, and swap components from one searchable list that groups preferred components first and works with the keyboard.

View file

@ -88,6 +88,12 @@ Standalone Tailwind output is compiled during export; it does not depend on the
HTML export is available in file mode.
### Tokens in exported code
HTML and Tailwind JSX write variable-bound properties as the design tokens they come from: a fill bound to `Primary` is `background-color: var(--color-primary)`, and in Tailwind `bg-primary`, or `bg-(--name)` for a token outside Tailwind's namespaces. A layer set to another mode gets that mode's attribute, such as `data-theme="dark"`, so the tokens resolve as the canvas draws them.
A value stays literal where CSS would not reproduce it: the layer no longer draws the variable's value, the token is unitless where a length is needed, or the layer sits in a mode that only its own condition, such as a `@media` query, can select. Standalone HTML includes the stylesheet for the tokens it uses; for fragments and JSX, generate it with `openpencil tokens` (below).
## Design Tokens
Write the document's variables as CSS custom properties:

View file

@ -4,6 +4,7 @@ import { parseFragment, serialize, type DefaultTreeAdapterTypes } from 'parse5'
import { normalizeFontFamily } from '@open-pencil/scene-graph'
import { tokenStylesheet, type TokenStylesheetFormat } from '../tokens/stylesheet'
import type { DesignDocument, DesignElement, DesignNode, DesignStyleDeclaration } from '../types'
import { mergeClassNames, serializeHTML, splitWhitespace } from './html'
@ -188,7 +189,22 @@ function classNamesFromHTML(html: string): string[] {
return [...classes]
}
async function compileTailwindClasses(classNames: string[]): Promise<string> {
/**
* The stylesheet for the tokens the document references, in the format the page needs: plain
* custom properties for inline styles, `@theme` for Tailwind, so token utilities compile.
*/
async function tokenCSS(document: DesignDocument, format: TokenStylesheetFormat): Promise<string> {
const { tokens, sourceGraph } = document
if (!tokens || !sourceGraph || tokens.used.size === 0) return ''
const variables = tokens.stylesheetVariables()
const { css } = await tokenStylesheet(sourceGraph, {
format,
include: (variable) => variables.has(variable.id)
})
return css
}
async function compileTailwindClasses(classNames: string[], tokens: string): Promise<string> {
if (classNames.length === 0) return ''
const [{ compile }, { readFile }] = await Promise.all([
import('tailwindcss'),
@ -198,7 +214,7 @@ async function compileTailwindClasses(classNames: string[]): Promise<string> {
readFile(new URL(import.meta.resolve('tailwindcss/theme.css')), 'utf8'),
readFile(new URL(import.meta.resolve('tailwindcss/utilities.css')), 'utf8')
])
const compiler = await compile(`${themeCSS}\n${utilitiesCSS}`)
const compiler = await compile(`${themeCSS}\n${tokens}\n${utilitiesCSS}`)
return compiler.build(classNames)
}
@ -336,8 +352,12 @@ async function exportStandaloneHTML(
if (options.style === 'tailwind') {
body = serializeHTML(doc, { style: 'tailwind' })
css += await compileTailwindClasses(classNamesFromHTML(body))
css += await compileTailwindClasses(
classNamesFromHTML(body),
await tokenCSS(document, 'tailwind')
)
} else {
css += await tokenCSS(document, 'css')
const rules: string[] = []
const index = { value: 0 }
const styledDocument: DesignDocument = {

View file

@ -1,4 +1,4 @@
import type { Effect, Fill, SceneNode, Stroke } from '@open-pencil/scene-graph'
import type { Effect, Fill, Stroke } from '@open-pencil/scene-graph'
import { colorToCSS, colorToHex } from '@open-pencil/scene-graph/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
@ -19,12 +19,6 @@ export function strokeColorToCSS(stroke: Stroke | undefined): string | undefined
return cssColor({ ...stroke.color, a: stroke.opacity })
}
export function strokeToCSS(stroke: Stroke | undefined): string | undefined {
const color = strokeColorToCSS(stroke)
if (!color || !stroke) return undefined
return `${stroke.weight}px solid ${color}`
}
function shadowToCSS(effect: Effect): string {
const inset = effect.type === 'INNER_SHADOW' ? 'inset ' : ''
return `${inset}${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread}px ${cssColor(effect.color)}`
@ -50,10 +44,3 @@ export function effectsToCSS(effects: Effect[]): DesignStyleDeclaration {
}
return style
}
export function sceneNodeSizeStyle(node: SceneNode): DesignStyleDeclaration {
const style: DesignStyleDeclaration = {}
if (node.width > 0) style.width = `${node.width}px`
if (node.height > 0) style.height = `${node.height}px`
return style
}

View file

@ -15,14 +15,18 @@ function tracksToCSS(tracks: GridTrack[]): string {
return tracks.map(trackToCSS).join(' ')
}
export function addGridContainer(style: DesignStyleDeclaration, node: SceneNode): void {
export function addGridContainer(
style: DesignStyleDeclaration,
node: SceneNode,
css: (field: string, literal: string) => string
): void {
style.display = 'grid'
if (node.gridTemplateColumns.length > 0)
style['grid-template-columns'] = tracksToCSS(node.gridTemplateColumns)
if (node.gridTemplateRows.length > 0)
style['grid-template-rows'] = tracksToCSS(node.gridTemplateRows)
if (node.gridColumnGap > 0) style['column-gap'] = `${node.gridColumnGap}px`
if (node.gridRowGap > 0) style['row-gap'] = `${node.gridRowGap}px`
if (node.gridColumnGap > 0) style['column-gap'] = css('gridColumnGap', `${node.gridColumnGap}px`)
if (node.gridRowGap > 0) style['row-gap'] = css('gridRowGap', `${node.gridRowGap}px`)
}
/** A span sets the whole placement, so the start follows it. */

View file

@ -4,6 +4,8 @@ import type { DesignDocument, DesignElement, DesignNode, DesignText } from '../t
export interface SerializeHTMLOptions {
style?: 'inline' | 'tailwind'
/** Custom properties declared in `@theme`; a `var()` naming one becomes its utility. */
themeVariables?: readonly string[]
}
const VOID_ELEMENTS = new Set([
@ -58,10 +60,13 @@ function serializeStyle(node: DesignElement): string | undefined {
.join('; ')
}
export function serializeTailwindClasses(node: DesignElement): string | undefined {
export function serializeTailwindClasses(
node: DesignElement,
themeVariables: readonly string[] = []
): string | undefined {
const style = serializeStyle(node)
if (!style) return undefined
const className = twirl(style)
const className = twirl(style, { theme: { variables: themeVariables } })
return className.length > 0 ? className : undefined
}
@ -76,7 +81,10 @@ export function mergeClassNames(...values: Array<string | undefined>): string |
function serializeAttrs(node: DesignElement, options: SerializeHTMLOptions): string {
const style = serializeStyle(node)
const tailwindClass = options.style === 'tailwind' ? serializeTailwindClasses(node) : undefined
const tailwindClass =
options.style === 'tailwind'
? serializeTailwindClasses(node, options.themeVariables)
: undefined
const attrsWithoutStyle = { ...node.attrs }
delete attrsWithoutStyle.style
const sourceAttrs = options.style === 'tailwind' && tailwindClass ? attrsWithoutStyle : node.attrs
@ -106,5 +114,9 @@ export function serializeHTML(
document: DesignDocument,
options: SerializeHTMLOptions = {}
): string {
return document.children.map((node) => serializeNode(node, options)).join('')
const resolved: SerializeHTMLOptions = {
...options,
themeVariables: options.themeVariables ?? document.tokens?.themeVariables()
}
return document.children.map((node) => serializeNode(node, resolved)).join('')
}

View file

@ -4,16 +4,9 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { BLACK } from '@open-pencil/scene-graph/constants'
import { resolveNodeTextDirection } from '@open-pencil/scene-graph/text-direction'
import { DesignTokens, type CSSModes } from '../tokens/references'
import type { DesignDocument, DesignNode, DesignStyleDeclaration } from '../types'
import {
cssColor,
dropShadowToCSS,
effectsToCSS,
fillToCSS,
sceneNodeSizeStyle,
strokeColorToCSS,
strokeToCSS
} from './css'
import { cssColor, dropShadowToCSS, effectsToCSS, fillToCSS, strokeColorToCSS } from './css'
import { addGridContainer, addGridPlacement } from './grid'
const DOM_CSS_PLUGIN_ID = 'open-pencil-dom-css'
@ -22,8 +15,15 @@ const IMAGE_SOURCE_URL_KEY = 'image-source-url'
export interface SceneGraphToDesignOptions {
rootId?: string
includeSourceIds?: boolean
/** Write variable-bound values as `var(--name)` where CSS resolves them as drawn. Default true. */
tokens?: boolean
}
/** A field's CSS: its token reference where one is faithful, otherwise the literal value. */
type FieldCSS = (field: string, literal: string) => string
const px = (value: number) => `${value}px`
function nodeChildren(graph: SceneGraph, node: SceneNode): SceneNode[] {
return node.childIds
.map((id) => graph.getNode(id))
@ -61,6 +61,11 @@ function textCaseToCSS(value: SceneNode['textCase']): string | undefined {
return undefined
}
function addSize(style: DesignStyleDeclaration, node: SceneNode, css: FieldCSS): void {
if (node.width > 0) style.width = css('width', px(node.width))
if (node.height > 0) style.height = css('height', px(node.height))
}
function addPositioning(style: DesignStyleDeclaration, node: SceneNode): void {
if (node.layoutPositioning !== 'ABSOLUTE') return
style.position = 'absolute'
@ -68,86 +73,91 @@ function addPositioning(style: DesignStyleDeclaration, node: SceneNode): void {
style.top = `${node.y}px`
}
function addSizeConstraints(style: DesignStyleDeclaration, node: SceneNode): void {
if (node.minWidth !== null) style['min-width'] = `${node.minWidth}px`
if (node.maxWidth !== null) style['max-width'] = `${node.maxWidth}px`
if (node.minHeight !== null) style['min-height'] = `${node.minHeight}px`
if (node.maxHeight !== null) style['max-height'] = `${node.maxHeight}px`
function addSizeConstraints(style: DesignStyleDeclaration, node: SceneNode, css: FieldCSS): void {
if (node.minWidth !== null) style['min-width'] = css('minWidth', px(node.minWidth))
if (node.maxWidth !== null) style['max-width'] = css('maxWidth', px(node.maxWidth))
if (node.minHeight !== null) style['min-height'] = css('minHeight', px(node.minHeight))
if (node.maxHeight !== null) style['max-height'] = css('maxHeight', px(node.maxHeight))
}
function addCornerRadii(style: DesignStyleDeclaration, node: SceneNode): void {
function addCornerRadii(style: DesignStyleDeclaration, node: SceneNode, css: FieldCSS): void {
if (node.independentCorners) {
if (node.topLeftRadius > 0) style['border-top-left-radius'] = `${node.topLeftRadius}px`
if (node.topRightRadius > 0) style['border-top-right-radius'] = `${node.topRightRadius}px`
if (node.bottomRightRadius > 0)
style['border-bottom-right-radius'] = `${node.bottomRightRadius}px`
if (node.bottomLeftRadius > 0) style['border-bottom-left-radius'] = `${node.bottomLeftRadius}px`
const corners = [
['border-top-left-radius', 'topLeftRadius', node.topLeftRadius],
['border-top-right-radius', 'topRightRadius', node.topRightRadius],
['border-bottom-right-radius', 'bottomRightRadius', node.bottomRightRadius],
['border-bottom-left-radius', 'bottomLeftRadius', node.bottomLeftRadius]
] as const
for (const [property, field, radius] of corners)
if (radius > 0) style[property] = css(field, px(radius))
return
}
if (node.cornerRadius > 0) style['border-radius'] = `${node.cornerRadius}px`
if (node.cornerRadius > 0) style['border-radius'] = css('cornerRadius', px(node.cornerRadius))
}
function addStroke(style: DesignStyleDeclaration, node: SceneNode): void {
const stroke = node.strokes[0]
const border = strokeToCSS(stroke)
if (!border) return
function addStroke(style: DesignStyleDeclaration, node: SceneNode, css: FieldCSS): void {
const stroke = node.strokes.at(0)
const literalColor = strokeColorToCSS(stroke)
if (!literalColor || !stroke) return
const color = css('strokes/0/color', literalColor)
const borderStyle = node.dashPattern.length > 0 ? 'dashed' : 'solid'
if (!node.independentStrokeWeights) {
style.border = border
style.border = `${css('strokeWeight', px(stroke.weight))} solid ${color}`
if (borderStyle !== 'solid') style['border-style'] = borderStyle
return
}
const color = strokeColorToCSS(stroke) ?? 'currentColor'
style['border-style'] = borderStyle
style['border-color'] = color
style['border-top-width'] = `${node.borderTopWeight}px`
style['border-right-width'] = `${node.borderRightWeight}px`
style['border-bottom-width'] = `${node.borderBottomWeight}px`
style['border-left-width'] = `${node.borderLeftWeight}px`
style['border-top-width'] = css('borderTopWeight', px(node.borderTopWeight))
style['border-right-width'] = css('borderRightWeight', px(node.borderRightWeight))
style['border-bottom-width'] = css('borderBottomWeight', px(node.borderBottomWeight))
style['border-left-width'] = css('borderLeftWeight', px(node.borderLeftWeight))
}
function addPadding(style: DesignStyleDeclaration, node: SceneNode): void {
const { paddingTop, paddingRight, paddingBottom, paddingLeft } = node
if (paddingTop === 0 && paddingRight === 0 && paddingBottom === 0 && paddingLeft === 0) return
const PADDING_FIELDS = ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'] as const
if (
paddingTop === paddingRight &&
paddingRight === paddingBottom &&
paddingBottom === paddingLeft
) {
style.padding = `${paddingTop}px`
/** Sides compare by their CSS, so a token and a literal of the same size stay separate. */
function addPadding(style: DesignStyleDeclaration, node: SceneNode, css: FieldCSS): void {
const [top, right, bottom, left] = PADDING_FIELDS.map((field) => css(field, px(node[field])))
const zero = px(0)
if ([top, right, bottom, left].every((value) => value === zero)) return
if (top === right && right === bottom && bottom === left) {
style.padding = top
return
}
if (paddingTop === paddingBottom && paddingRight === paddingLeft) {
if (paddingTop > 0) style['padding-block'] = `${paddingTop}px`
if (paddingRight > 0) style['padding-inline'] = `${paddingRight}px`
if (top === bottom && right === left) {
if (top !== zero) style['padding-block'] = top
if (right !== zero) style['padding-inline'] = right
return
}
if (paddingTop > 0) style['padding-top'] = `${paddingTop}px`
if (paddingRight > 0) style['padding-right'] = `${paddingRight}px`
if (paddingBottom > 0) style['padding-bottom'] = `${paddingBottom}px`
if (paddingLeft > 0) style['padding-left'] = `${paddingLeft}px`
if (top !== zero) style['padding-top'] = top
if (right !== zero) style['padding-right'] = right
if (bottom !== zero) style['padding-bottom'] = bottom
if (left !== zero) style['padding-left'] = left
}
function addFlexGap(style: DesignStyleDeclaration, node: SceneNode): void {
function addFlexGap(style: DesignStyleDeclaration, node: SceneNode, css: FieldCSS): void {
if (node.itemSpacing <= 0 && node.counterAxisSpacing <= 0) return
const item = css('itemSpacing', px(node.itemSpacing))
if (node.counterAxisSpacing <= 0) {
style.gap = `${node.itemSpacing}px`
style.gap = item
return
}
const counter = css('counterAxisSpacing', px(node.counterAxisSpacing))
if (node.layoutMode === 'HORIZONTAL') {
if (node.itemSpacing > 0) style['column-gap'] = `${node.itemSpacing}px`
style['row-gap'] = `${node.counterAxisSpacing}px`
if (node.itemSpacing > 0) style['column-gap'] = item
style['row-gap'] = counter
return
}
if (node.itemSpacing > 0) style['row-gap'] = `${node.itemSpacing}px`
style['column-gap'] = `${node.counterAxisSpacing}px`
if (node.itemSpacing > 0) style['row-gap'] = item
style['column-gap'] = counter
}
function addImageStyle(style: DesignStyleDeclaration, node: SceneNode): void {
@ -171,18 +181,20 @@ function addLayoutChild(
function styleFromSceneNode(
node: SceneNode,
parent: SceneNode | undefined
parent: SceneNode | undefined,
css: FieldCSS
): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
const style: DesignStyleDeclaration = {}
addSize(style, node, css)
addPositioning(style, node)
addSizeConstraints(style, node)
addSizeConstraints(style, node, css)
const fill = fillToCSS(node.fills.at(0))
if (fill) style['background-color'] = fill
if (fill) style['background-color'] = css('fills/0/color', fill)
addImageStyle(style, node)
addStroke(style, node)
addStroke(style, node, css)
Object.assign(style, effectsToCSS(node.effects))
if (node.opacity < 1) style.opacity = String(node.opacity)
addCornerRadii(style, node)
if (node.opacity < 1) style.opacity = css('opacity', String(node.opacity))
addCornerRadii(style, node, css)
if (node.clipsContent) style.overflow = 'hidden'
const alignSelf = alignSelfToCSS(node.layoutAlignSelf)
if (alignSelf) style['align-self'] = alignSelf
@ -191,8 +203,8 @@ function styleFromSceneNode(
if (node.layoutDirection === 'RTL') style.direction = 'rtl'
if (node.layoutMode === 'GRID') {
addGridContainer(style, node)
addPadding(style, node)
addGridContainer(style, node, css)
addPadding(style, node, css)
} else if (node.layoutMode !== 'NONE') {
style.display = 'flex'
// Row is the flexbox default.
@ -202,28 +214,35 @@ function styleFromSceneNode(
if (justifyContent) style['justify-content'] = justifyContent
if (alignItems) style['align-items'] = alignItems
if (node.layoutWrap === 'WRAP') style['flex-wrap'] = 'wrap'
addFlexGap(style, node)
addPadding(style, node)
addFlexGap(style, node, css)
addPadding(style, node, css)
}
return style
}
function styleFromTextNode(node: SceneNode, parent: SceneNode | undefined): DesignStyleDeclaration {
const style = sceneNodeSizeStyle(node)
function styleFromTextNode(
node: SceneNode,
parent: SceneNode | undefined,
css: FieldCSS
): DesignStyleDeclaration {
const style: DesignStyleDeclaration = {}
addSize(style, node, css)
addPositioning(style, node)
addLayoutChild(style, node, parent)
if (resolveNodeTextDirection(node) === 'RTL') style.direction = 'rtl'
style.color = fillToCSS(node.fills.at(0)) ?? cssColor(BLACK)
const color = fillToCSS(node.fills.at(0))
style.color = color ? css('fills/0/color', color) : cssColor(BLACK)
style['font-family'] = node.fontFamily
style['font-size'] = `${node.fontSize}px`
style['font-size'] = css('fontSize', px(node.fontSize))
style['font-weight'] = String(node.fontWeight)
if (node.italic) style['font-style'] = 'italic'
if (node.lineHeight !== null) style['line-height'] = `${node.lineHeight}px`
if (node.letterSpacing !== 0) style['letter-spacing'] = `${node.letterSpacing}px`
if (node.lineHeight !== null) style['line-height'] = css('lineHeight', px(node.lineHeight))
if (node.letterSpacing !== 0)
style['letter-spacing'] = css('letterSpacing', px(node.letterSpacing))
if (node.textAlignHorizontal !== 'LEFT')
style['text-align'] = node.textAlignHorizontal.toLowerCase()
if (node.opacity < 1) style.opacity = String(node.opacity)
if (node.opacity < 1) style.opacity = css('opacity', String(node.opacity))
const shadow = dropShadowToCSS(node.effects[0])
if (shadow) style['text-shadow'] = shadow
if (node.textDecoration !== 'NONE') {
@ -266,20 +285,32 @@ function tagNameForNode(node: SceneNode): string {
return 'div'
}
interface ProjectionContext {
graph: SceneGraph
includeSourceIds: boolean
tokens: DesignTokens | undefined
}
function sceneNodeToDesignNode(
graph: SceneGraph,
context: ProjectionContext,
node: SceneNode,
options: Required<SceneGraphToDesignOptions>
inherited: CSSModes,
root: boolean
): DesignNode | null {
if (!node.visible || node.internalOnly) return null
const { graph, includeSourceIds, tokens } = context
const parent = node.parentId ? graph.getNode(node.parentId) : undefined
const entered = tokens?.enter(node, inherited, root)
const modes = entered?.modes ?? inherited
const attrs = { ...attrsForNode(graph, node, includeSourceIds), ...entered?.attrs }
const css: FieldCSS = (field, literal) => tokens?.reference(node, field, modes) ?? literal
if (node.type === 'TEXT') {
return {
type: 'element',
tagName: 'span',
attrs: attrsForNode(graph, node, options.includeSourceIds),
inlineStyle: styleFromTextNode(node, parent),
attrs,
inlineStyle: styleFromTextNode(node, parent, css),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children: [{ type: 'text', text: node.text }]
@ -287,14 +318,14 @@ function sceneNodeToDesignNode(
}
const children = nodeChildren(graph, node)
.map((child) => sceneNodeToDesignNode(graph, child, options))
.map((child) => sceneNodeToDesignNode(context, child, modes, false))
.filter((child): child is DesignNode => child !== null)
if (node.type === 'CANVAS') {
return {
type: 'element',
tagName: 'main',
attrs: attrsForNode(graph, node, options.includeSourceIds),
attrs,
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children
@ -304,48 +335,53 @@ function sceneNodeToDesignNode(
return {
type: 'element',
tagName: tagNameForNode(node),
attrs: attrsForNode(graph, node, options.includeSourceIds),
inlineStyle: styleFromSceneNode(node, parent),
attrs,
inlineStyle: styleFromSceneNode(node, parent, css),
sourceSceneNodeId: node.id,
sourceSceneNode: node,
children
}
}
function projectionContext(
graph: SceneGraph,
{ includeSourceIds = true, tokens = true }: SceneGraphToDesignOptions
): ProjectionContext {
return { graph, includeSourceIds, tokens: tokens ? new DesignTokens(graph) : undefined }
}
export function sceneGraphToDesignDocument(
graph: SceneGraph,
options: SceneGraphToDesignOptions = {}
): DesignDocument {
const root = graph.getNode(options.rootId ?? graph.rootId)
const resolvedOptions: Required<SceneGraphToDesignOptions> = {
rootId: options.rootId ?? graph.rootId,
includeSourceIds: options.includeSourceIds ?? true
}
const context = projectionContext(graph, options)
const modes = context.tokens?.defaultModes() ?? new Map<string, string>()
const children = root
? nodeChildren(graph, root)
.map((child) => sceneNodeToDesignNode(graph, child, resolvedOptions))
.map((child) => sceneNodeToDesignNode(context, child, modes, true))
.filter((child): child is DesignNode => child !== null)
: []
return {
type: 'document',
sourceGraph: graph,
children
}
return { type: 'document', sourceGraph: graph, tokens: context.tokens, children }
}
/** Project one node, including its own box, instead of the children of a root. */
export function sceneNodeToDesignDocument(
graph: SceneGraph,
nodeId: string,
includeSourceIds = true
options: Omit<SceneGraphToDesignOptions, 'rootId'> = {}
): DesignDocument {
const node = graph.getNode(nodeId)
const projected = node
? sceneNodeToDesignNode(graph, node, { rootId: nodeId, includeSourceIds })
: null
return { type: 'document', sourceGraph: graph, children: projected ? [projected] : [] }
const context = projectionContext(graph, options)
const modes = context.tokens?.defaultModes() ?? new Map<string, string>()
const projected = node ? sceneNodeToDesignNode(context, node, modes, true) : null
return {
type: 'document',
sourceGraph: graph,
tokens: context.tokens,
children: projected ? [projected] : []
}
}
export type { SceneGraphToDesignOptions as ToDesignDocumentOptions }

View file

@ -72,7 +72,9 @@ function storyModule(group: StoryGroup, context: ModuleContext): string {
props: group.props,
variants: group.variants.map((variant) => ({
values: variant.values,
html: serializeHTML(sceneNodeToDesignDocument(context.graph, variant.node.id, false))
html: serializeHTML(
sceneNodeToDesignDocument(context.graph, variant.node.id, { includeSourceIds: false })
)
})),
metaDesign: designLink(context, group.linkNode),
images: context.images,

View file

@ -13,13 +13,13 @@ function tagName(node: DesignElement): string {
return node.sourceSceneNode?.type === 'TEXT' ? 'p' : node.tagName
}
function attributes(node: DesignElement): SyntaxNode[] {
function attributes(node: DesignElement, themeVariables: readonly string[]): SyntaxNode[] {
const { class: className, ...attrs } = node.attrs
const name = node.sourceSceneNode?.name
const entries: [string, string | undefined][] = [
['data-name', name && name !== node.sourceSceneNode?.type ? name : undefined],
...Object.entries(attrs),
['className', mergeClassNames(className, serializeTailwindClasses(node))]
['className', mergeClassNames(className, serializeTailwindClasses(node, themeVariables))]
]
return entries.flatMap(([key, value]) =>
value === undefined
@ -28,20 +28,27 @@ function attributes(node: DesignElement): SyntaxNode[] {
)
}
function element(node: DesignElement, depth: number): SyntaxNode {
const children = node.children.map((child) => jsxNode(child, depth + 1))
function element(
node: DesignElement,
depth: number,
themeVariables: readonly string[]
): SyntaxNode {
const children = node.children.map((child) => jsxNode(child, depth + 1, themeVariables))
// A lone text child stays on the element's line.
const inline = children.length === 1 && node.children[0]?.type === 'text'
return jsx.element(tagName(node), attributes(node), children, depth, inline)
return jsx.element(tagName(node), attributes(node, themeVariables), children, depth, inline)
}
function jsxNode(node: DesignNode, depth: number): SyntaxNode {
return node.type === 'text' ? jsx.text(node.text) : element(node, depth)
function jsxNode(node: DesignNode, depth: number, themeVariables: readonly string[]): SyntaxNode {
return node.type === 'text' ? jsx.text(node.text) : element(node, depth, themeVariables)
}
/** Print a design document as Tailwind JSX, one top-level element per block. */
export function designDocumentToTailwindJSX(document: DesignDocument): string {
return document.children.map((node) => jsx.printJSX(jsxNode(node, 0))).join('\n\n')
const themeVariables = document.tokens?.themeVariables() ?? []
return document.children
.map((node) => jsx.printJSX(jsxNode(node, 0, themeVariables)))
.join('\n\n')
}
/** Tailwind JSX for scene nodes, separated by blank lines. */
@ -69,7 +76,7 @@ export function sceneNodesToTailwindJSXWithLayers(
const layerIds: Array<string | null> = []
const blocks: string[] = []
for (const id of nodeIds) {
const document = sceneNodeToDesignDocument(graph, id, false)
const document = sceneNodeToDesignDocument(graph, id, { includeSourceIds: false })
const code = designDocumentToTailwindJSX(document)
if (!code) continue
blocks.push(code)

View file

@ -2,7 +2,18 @@ import { compact } from 'es-toolkit/array'
import valueParser from 'postcss-value-parser'
import { themeNamespaces, type ThemeNamespace } from 'twirlwind'
import type { Variable, VariableScope } from '@open-pencil/scene-graph'
import type { SceneGraph, Variable, VariableScope } from '@open-pencil/scene-graph'
/** Every variable, collection by collection, in each collection's own order. */
export function collectionVariables(
source: Pick<SceneGraph, 'variables' | 'variableCollections'>
): Variable[] {
return compact(
[...source.variableCollections.values()].flatMap((collection) =>
collection.variableIds.map((id) => source.variables.get(id))
)
)
}
const SCOPE_NAMESPACES: Partial<Record<VariableScope, ThemeNamespace>> = {
CORNER_RADIUS: 'radius',

View file

@ -0,0 +1,205 @@
import {
type Color,
type SceneGraph,
type SceneNode,
type Variable
} from '@open-pencil/scene-graph'
import { collectionVariables, variableCSSNames, variableNamespace } from './names'
import { modeAttribute } from './stylesheet'
import { variableUnit } from './values'
/** The mode CSS applies for each collection, by collection id. */
export type CSSModes = ReadonlyMap<string, string>
/** Bound fields drawn as a length; any other numeric binding is unitless or not exported. */
const LENGTH_FIELDS = new Set([
'width',
'height',
'minWidth',
'maxWidth',
'minHeight',
'maxHeight',
'cornerRadius',
'topLeftRadius',
'topRightRadius',
'bottomLeftRadius',
'bottomRightRadius',
'strokeWeight',
'borderTopWeight',
'borderBottomWeight',
'borderLeftWeight',
'borderRightWeight',
'fontSize',
'letterSpacing',
'lineHeight',
'paddingLeft',
'paddingRight',
'paddingTop',
'paddingBottom',
'itemSpacing',
'counterAxisSpacing',
'gridRowGap',
'gridColumnGap'
])
const LENGTH_UNITS = new Set(['px', 'rem'])
/** Color channels are stored as floats; a bound paint copies them, so this only absorbs rounding. */
const CHANNEL_TOLERANCE = 1 / 512
const CHANNELS = ['r', 'g', 'b', 'a'] as const
function sameColor(a: Color, b: Color): boolean {
return CHANNELS.every((channel) => Math.abs(a[channel] - b[channel]) <= CHANNEL_TOLERANCE)
}
function isColor(value: unknown): value is Color {
return typeof value === 'object' && value !== null && 'r' in value && 'a' in value
}
/** Where an alias chain ends in the modes the canvas draws: a value, or a CSS expression. */
type TokenChain = {
ids: string[]
value: Variable['valuesByMode'][string] | undefined
end: Variable
expression: boolean
}
/** The paint a `fills/0/color` or `strokes/1/color` binding names. */
function boundPaint(node: SceneNode, field: string) {
const [list, index] = field.split('/')
if (list === 'fills') return node.fills[Number(index)]
if (list === 'strokes') return node.strokes[Number(index)]
return undefined
}
/**
* Variable references for design-to-code. A bound field becomes `var(--name)` only when the
* stylesheet resolves it to what the canvas draws: the element sits in the same modes, and the
* token's CSS value is a length for a length, a bare number for opacity, or the paint's color.
* Anything else keeps its literal value.
*/
export class DesignTokens {
readonly names: ReadonlyMap<string, string>
/** Tokens the output references, with every variable their aliases reach. */
readonly used = new Set<string>()
readonly #graph: SceneGraph
constructor(graph: SceneGraph) {
this.#graph = graph
this.names = variableCSSNames(collectionVariables(graph))
}
/** The modes CSS applies outside the exported nodes: every collection's default. */
defaultModes(): CSSModes {
return new Map(
[...this.#graph.variableCollections.values()].map((collection) => [
collection.id,
collection.defaultModeId
])
)
}
/**
* Attributes that put an element in the modes the canvas draws it in, and the modes CSS then
* applies to it. An exported root takes the editor's active modes; below it, a node changes
* modes only where it sets one. Only a mode without its own condition can be set this way.
*/
enter(
node: SceneNode,
inherited: CSSModes,
root: boolean
): { attrs: Record<string, string>; modes: CSSModes } {
const attrs: Record<string, string> = {}
const modes = new Map(inherited)
for (const collection of this.#graph.variableCollections.values()) {
if (!root && !node.variableModes[collection.id]) continue
const modeId = this.#graph.getNodeVariableModeId(node.id, collection.id)
const mode = collection.modes.find((candidate) => candidate.modeId === modeId)
if (!mode || modeId === inherited.get(collection.id)) continue
if (modeId === collection.defaultModeId || mode.condition) continue
const { name, value } = modeAttribute(collection, mode)
attrs[name] = value
modes.set(collection.id, modeId)
}
return { attrs, modes }
}
/** `var(--name)` for a bound field, or `undefined` where the literal value must stay. */
reference(node: SceneNode, field: string, modes: CSSModes): string | undefined {
const variableId = node.boundVariables[field]
const variable = variableId ? this.#graph.variables.get(variableId) : undefined
const name = variable && this.names.get(variable.id)
if (!variable || !name) return undefined
const chain = this.#chain(node, variable, modes)
if (!chain || !this.#drawsResolved(node, field, chain)) return undefined
for (const id of chain.ids) this.used.add(id)
return `var(--${name})`
}
/**
* Follow aliases in the modes the canvas draws the node in, stopping at a value or at a CSS
* expression, which is what the stylesheet writes for that mode. Fails where CSS would resolve
* any link in another mode.
*/
#chain(node: SceneNode, start: Variable, modes: CSSModes): TokenChain | undefined {
const ids: string[] = []
let variable: Variable | undefined = start
while (variable && !ids.includes(variable.id)) {
ids.push(variable.id)
const modeId = this.#graph.getNodeVariableModeId(node.id, variable.collectionId)
const collection = this.#graph.variableCollections.get(variable.collectionId)
const cssModeId = modes.get(variable.collectionId) ?? collection?.defaultModeId
if (modeId !== cssModeId) return undefined
const value =
variable.valuesByMode[modeId] ??
(collection ? variable.valuesByMode[collection.defaultModeId] : undefined)
if (variable.expressions?.[modeId]) return { ids, value, end: variable, expression: true }
if (typeof value === 'object' && 'aliasId' in value) {
variable = this.#graph.variables.get(value.aliasId)
continue
}
return { ids, value, end: variable, expression: false }
}
return undefined
}
#drawsResolved(node: SceneNode, field: string, { value, end, expression }: TokenChain): boolean {
if (field.endsWith('/color')) {
const paint = boundPaint(node, field)
if (!paint?.visible || paint.type !== 'SOLID' || !isColor(value)) return false
return sameColor({ ...paint.color, a: paint.opacity }, value)
}
const drawn: unknown = Reflect.get(node, field)
if (typeof drawn !== 'number' || typeof value !== 'number') return false
if (Math.fround(drawn) !== Math.fround(value)) return false
// An expression is the author's CSS for the value, whatever unit the token stores.
if (field === 'opacity') return expression || variableUnit(end) === 'none'
return LENGTH_FIELDS.has(field) && (expression || LENGTH_UNITS.has(variableUnit(end)))
}
/**
* The variables a stylesheet for this output declares: the referenced ones and every variable
* their aliases reach in any mode, since each mode's scope declares its own alias targets.
*/
stylesheetVariables(): Set<string> {
const ids = new Set<string>()
const pending = [...this.used]
for (let id = pending.pop(); id !== undefined; id = pending.pop()) {
if (ids.has(id)) continue
ids.add(id)
for (const value of Object.values(this.#graph.variables.get(id)?.valuesByMode ?? {}))
if (typeof value === 'object' && 'aliasId' in value) pending.push(value.aliasId)
}
return ids
}
/** Custom properties the Tailwind stylesheet declares in `@theme`, which name utilities. */
themeVariables(): string[] {
return [...this.names].flatMap(([id, name]) => {
const variable = this.#graph.variables.get(id)
return variable && variableNamespace(variable) !== undefined ? [`--${name}`] : []
})
}
}

View file

@ -7,7 +7,7 @@ import type {
} from '@open-pencil/scene-graph'
import { cssColor } from '../export/css'
import { tokenSlug, variableCSSNames, variableNamespace } from './names'
import { collectionVariables, tokenSlug, variableCSSNames, variableNamespace } from './names'
import { createTokenValidator, parseWithBrowser, type TokenValidator } from './validate'
import { tokenNumberToCSS, variableUnit } from './values'
@ -66,12 +66,21 @@ function modeSlug(collection: VariableCollection, mode: VariableCollectionMode):
return plainSlug(mode)
}
/** The attribute that puts an element in a mode with no condition of its own: `data-theme`. */
export function modeAttribute(
collection: VariableCollection,
mode: VariableCollectionMode
): { name: string; value: string } {
return { name: `data-${tokenSlug(collection.name) || 'mode'}`, value: modeSlug(collection, mode) }
}
/** The scope a mode applies in when it names none: `[data-theme="dark"]` for Theme / Dark. */
export function defaultModeCondition(
collection: VariableCollection,
mode: VariableCollectionMode
): string {
return `[data-${tokenSlug(collection.name) || 'mode'}="${modeSlug(collection, mode)}"]`
const { name, value } = modeAttribute(collection, mode)
return `[${name}="${value}"]`
}
function quoteString(value: string): string {
@ -146,12 +155,7 @@ export function buildTokenStylesheet(
): TokenStylesheet {
const issues: TokenStylesheetIssue[] = []
const collections = [...source.variableCollections.values()]
const all = collections.flatMap((collection) =>
collection.variableIds.flatMap((id) => {
const variable = source.variables.get(id)
return variable ? [variable] : []
})
)
const all = collectionVariables(source)
const names = variableCSSNames(all)
const variables = all.filter(include)
const defaultModeOf = (variable: Variable) =>

View file

@ -1,5 +1,7 @@
import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import type { DesignTokens } from './tokens/references'
export type DesignNode = DesignElement | DesignText
export interface DesignDocument {
@ -7,6 +9,8 @@ export interface DesignDocument {
children: DesignNode[]
stylesheets?: DesignStyleSheet[]
sourceGraph?: SceneGraph
/** Variable references the projection wrote, for Tailwind names and the token stylesheet. */
tokens?: DesignTokens
}
export interface DesignElement {

View file

@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test'
import { exportHTMLBundle, sceneNodeToDesignDocument } from '#dom-css/export/index'
import { BLUE_500, card, designSystem } from '../token-fixtures'
async function standalone(style: 'inline' | 'tailwind') {
const graph = designSystem()
const frame = card(graph, graph.getPages()[0].id, BLUE_500)
const bundle = await exportHTMLBundle(sceneNodeToDesignDocument(graph, frame.id), {
html: 'standalone',
style
})
const page = bundle.files.find((file) => file.path === bundle.entrypoint)
if (typeof page?.content !== 'string') throw new Error('Expected an HTML page')
return page.content
}
describe('token stylesheet in standalone HTML', () => {
test('declares the tokens the page uses and the variables their aliases reach', async () => {
const html = await standalone('inline')
expect(html).toContain('--color-primary: var(--color-blue-500);')
expect(html).toContain('--spacing-gutter: 24px;')
// Dark points Primary at Blue/300, so its scope needs Blue/300 even though no layer is dark.
expect(html).toContain('--color-blue-300: #94C4FC;')
expect(html).toContain('[data-theme="dark"]')
expect(html).not.toContain('--count')
})
test('compiles token utilities into Tailwind pages', async () => {
const html = await standalone('tailwind')
expect(html).toMatch(/class="[^"]*\bbg-primary\b/)
expect(html).toMatch(/\.bg-primary\s*\{\s*background-color: var\(--color-primary\)/)
expect(html).toContain('--color-primary: var(--color-blue-500);')
})
})

View file

@ -0,0 +1,97 @@
import { describe, expect, test } from 'bun:test'
import { sceneNodeToDesignDocument, type DesignElement } from '#dom-css/export/index'
import type { SceneGraph } from '@open-pencil/scene-graph'
import { BLUE_300, BLUE_500, card, designSystem } from '../token-fixtures'
function element(graph: SceneGraph, nodeId: string, tokens?: boolean): DesignElement {
const [node] = sceneNodeToDesignDocument(graph, nodeId, { tokens }).children
if (node?.type !== 'element') throw new Error('Expected an element')
return node
}
function child(node: DesignElement, index = 0): DesignElement {
const found = node.children[index]
if (found?.type !== 'element') throw new Error('Expected a child element')
return found
}
describe('token references in design-to-code', () => {
test('writes bound colors and lengths as the tokens the stylesheet declares', () => {
const graph = designSystem()
const frame = card(graph, graph.getPages()[0].id, BLUE_500)
const { inlineStyle, attrs } = element(graph, frame.id)
expect(inlineStyle).toMatchObject({
'background-color': 'var(--color-primary)',
padding: 'var(--spacing-gutter)'
})
expect(attrs).not.toHaveProperty('data-theme')
})
test('keeps the literal value where the node no longer draws the token', () => {
const graph = designSystem()
const frame = card(graph, graph.getPages()[0].id, BLUE_300)
expect(element(graph, frame.id).inlineStyle?.['background-color']).toBe('#94C4FC')
})
test('keeps a length literal when the token is unitless', () => {
const graph = designSystem()
const frame = card(graph, graph.getPages()[0].id, BLUE_500, {
boundVariables: { 'fills/0/color': 'primary', paddingTop: 'count' }
})
expect(element(graph, frame.id).inlineStyle?.padding).toBe('24px')
})
test('puts a node in its mode, so the token resolves as the canvas draws it', () => {
const graph = designSystem()
const outer = card(graph, graph.getPages()[0].id, BLUE_500)
card(graph, outer.id, BLUE_300, { variableModes: { theme: 'dark' } })
const inner = child(element(graph, outer.id))
expect(inner.attrs['data-theme']).toBe('dark')
expect(inner.inlineStyle?.['background-color']).toBe('var(--color-primary)')
})
test('keeps literals where no attribute can bring back the mode the node is in', () => {
const graph = designSystem()
const outer = card(graph, graph.getPages()[0].id, BLUE_300, {
variableModes: { theme: 'dark' }
})
card(graph, outer.id, BLUE_500, { variableModes: { theme: 'light' } })
card(graph, outer.id, BLUE_300, { variableModes: { theme: 'contrast' } })
const projected = element(graph, outer.id)
expect(projected.attrs['data-theme']).toBe('dark')
expect(child(projected, 0).inlineStyle?.['background-color']).toBe('#3B82F5')
expect(child(projected, 1).inlineStyle?.['background-color']).toBe('#94C4FC')
})
test('starts an exported node in the modes the editor is showing', () => {
const graph = designSystem()
graph.setActiveMode('theme', 'dark')
const frame = card(graph, graph.getPages()[0].id, BLUE_300)
const projected = element(graph, frame.id)
expect(projected.attrs['data-theme']).toBe('dark')
expect(projected.inlineStyle?.['background-color']).toBe('var(--color-primary)')
})
test('writes literal values when tokens are turned off', () => {
const graph = designSystem()
const frame = card(graph, graph.getPages()[0].id, BLUE_500)
expect(element(graph, frame.id, false).inlineStyle).toMatchObject({
'background-color': '#3B82F5',
padding: '24px'
})
})
})

View file

@ -0,0 +1,59 @@
import { describe, expect, test } from 'bun:test'
import { sceneNodesToTailwindJSX } from '#dom-css/index'
import { makeGraph, pageId } from './helpers'
const INK = { r: 0.07, g: 0.09, b: 0.15, a: 1 }
describe('Tailwind JSX with tokens', () => {
test('names theme tokens as utilities and other tokens as variables', () => {
const graph = makeGraph()
graph.addCollection({
id: 'brand',
name: 'Brand',
modes: [{ modeId: 'one', name: 'One' }],
defaultModeId: 'one',
variableIds: []
})
for (const [id, name, value, scopes] of [
['gutter', 'Gutter', 16, ['GAP'] as const],
['ink', 'Ink', INK, ['FRAME_FILL'] as const],
['outline', 'Outline', INK, [] as const]
] as const) {
graph.addVariable({
id,
name,
type: typeof value === 'number' ? 'FLOAT' : 'COLOR',
collectionId: 'brand',
valuesByMode: { one: value },
scopes: [...scopes],
// A name outside Tailwind's namespaces declares no utility.
codeSyntax: id === 'outline' ? { WEB: 'var(--brand-outline)' } : undefined,
description: '',
hiddenFromPublishing: false
})
}
const card = graph.createNode('FRAME', pageId(graph), {
width: 80,
height: 40,
layoutMode: 'HORIZONTAL',
itemSpacing: 16,
fills: [{ type: 'SOLID', visible: true, opacity: 1, color: INK }],
strokes: [
{ type: 'SOLID', visible: true, opacity: 1, color: INK, weight: 1, align: 'INSIDE' }
],
boundVariables: {
itemSpacing: 'gutter',
'fills/0/color': 'ink',
'strokes/0/color': 'outline'
}
})
const code = sceneNodesToTailwindJSX(graph, [card.id])
expect(code).toContain('gap-gutter')
expect(code).toContain('bg-ink')
expect(code).toContain('border-(--brand-outline)')
})
})

View file

@ -0,0 +1,91 @@
import { SceneGraph, type Fill, type Variable, type VariableValue } from '@open-pencil/scene-graph'
export const BLUE_500 = { r: 0.23, g: 0.51, b: 0.96, a: 1 }
export const BLUE_300 = { r: 0.58, g: 0.77, b: 0.99, a: 1 }
function addVariable(
graph: SceneGraph,
collectionId: string,
id: string,
name: string,
type: Variable['type'],
valuesByMode: Record<string, VariableValue>,
extra: Partial<Variable> = {}
) {
graph.addVariable({
id,
name,
type,
collectionId,
valuesByMode,
description: '',
hiddenFromPublishing: false,
...extra
})
}
/** Primitives, a Theme with Light, Dark and a media-query Contrast mode, and spacing tokens. */
export function designSystem(): SceneGraph {
const graph = new SceneGraph()
graph.addCollection({
id: 'primitives',
name: 'Primitives',
modes: [{ modeId: 'base', name: 'Base' }],
defaultModeId: 'base',
variableIds: []
})
addVariable(graph, 'primitives', 'blue-500', 'Blue/500', 'COLOR', { base: BLUE_500 })
addVariable(graph, 'primitives', 'blue-300', 'Blue/300', 'COLOR', { base: BLUE_300 })
graph.addCollection({
id: 'theme',
name: 'Theme',
modes: [
{ modeId: 'light', name: 'Light' },
{ modeId: 'dark', name: 'Dark' },
{ modeId: 'contrast', name: 'Contrast', condition: '@media (prefers-contrast: more)' }
],
defaultModeId: 'light',
variableIds: []
})
addVariable(graph, 'theme', 'primary', 'Primary', 'COLOR', {
light: { aliasId: 'blue-500' },
dark: { aliasId: 'blue-300' },
contrast: { aliasId: 'blue-300' }
})
graph.addCollection({
id: 'space',
name: 'Space',
modes: [{ modeId: 'one', name: 'One' }],
defaultModeId: 'one',
variableIds: []
})
addVariable(graph, 'space', 'gutter', 'Gutter', 'FLOAT', { one: 24 }, { scopes: ['GAP'] })
addVariable(graph, 'space', 'count', 'Count', 'FLOAT', { one: 24 }, { unit: 'none' })
return graph
}
function solid(color: typeof BLUE_500): Fill {
return { type: 'SOLID', visible: true, opacity: color.a, color }
}
/** A frame filled with Primary and padded with Gutter, as the canvas resolves them. */
export function card(graph: SceneGraph, parentId: string, fill: typeof BLUE_500, extra = {}) {
return graph.createNode('FRAME', parentId, {
width: 100,
height: 50,
fills: [solid(fill)],
layoutMode: 'VERTICAL',
paddingTop: 24,
paddingRight: 24,
paddingBottom: 24,
paddingLeft: 24,
boundVariables: {
'fills/0/color': 'primary',
paddingTop: 'gutter',
paddingRight: 'gutter',
paddingBottom: 'gutter',
paddingLeft: 'gutter'
},
...extra
})
}