From fb0cb9ee5ff52ecfc69ae04db3b1b861262d5b9e Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 5 Oct 2026 09:15:44 +0000 Subject: [PATCH] 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. --- CHANGELOG.md | 2 + packages/docs/programmable/cli/exporting.md | 6 + packages/dom-css/src/export/bundle.ts | 26 +- packages/dom-css/src/export/css.ts | 15 +- packages/dom-css/src/export/grid.ts | 10 +- packages/dom-css/src/export/html.ts | 20 +- packages/dom-css/src/export/projection.ts | 226 ++++++++++-------- .../dom-css/src/export/storybook/export.ts | 4 +- packages/dom-css/src/export/tailwind-jsx.ts | 25 +- packages/dom-css/src/tokens/names.ts | 13 +- packages/dom-css/src/tokens/references.ts | 205 ++++++++++++++++ packages/dom-css/src/tokens/stylesheet.ts | 20 +- packages/dom-css/src/types.ts | 4 + .../tests/export/bundle/tokens.test.ts | 38 +++ .../tests/export/projection/tokens.test.ts | 97 ++++++++ .../tests/export/tailwind-jsx/tokens.test.ts | 59 +++++ .../dom-css/tests/export/token-fixtures.ts | 91 +++++++ 17 files changed, 723 insertions(+), 138 deletions(-) create mode 100644 packages/dom-css/src/tokens/references.ts create mode 100644 packages/dom-css/tests/export/bundle/tokens.test.ts create mode 100644 packages/dom-css/tests/export/projection/tokens.test.ts create mode 100644 packages/dom-css/tests/export/tailwind-jsx/tokens.test.ts create mode 100644 packages/dom-css/tests/export/token-fixtures.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c98d4c46c..ab01be610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/packages/docs/programmable/cli/exporting.md b/packages/docs/programmable/cli/exporting.md index cdc0fd6b4..7d9784d2c 100644 --- a/packages/docs/programmable/cli/exporting.md +++ b/packages/docs/programmable/cli/exporting.md @@ -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: diff --git a/packages/dom-css/src/export/bundle.ts b/packages/dom-css/src/export/bundle.ts index b877abf12..066b27512 100644 --- a/packages/dom-css/src/export/bundle.ts +++ b/packages/dom-css/src/export/bundle.ts @@ -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 { +/** + * 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 { + 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 { if (classNames.length === 0) return '' const [{ compile }, { readFile }] = await Promise.all([ import('tailwindcss'), @@ -198,7 +214,7 @@ async function compileTailwindClasses(classNames: string[]): Promise { 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 = { diff --git a/packages/dom-css/src/export/css.ts b/packages/dom-css/src/export/css.ts index bc4f24b71..1b2c56713 100644 --- a/packages/dom-css/src/export/css.ts +++ b/packages/dom-css/src/export/css.ts @@ -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 -} diff --git a/packages/dom-css/src/export/grid.ts b/packages/dom-css/src/export/grid.ts index 7ad00d6c0..8ce79a4d6 100644 --- a/packages/dom-css/src/export/grid.ts +++ b/packages/dom-css/src/export/grid.ts @@ -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. */ diff --git a/packages/dom-css/src/export/html.ts b/packages/dom-css/src/export/html.ts index 69d3c7d86..ecb05cfe5 100644 --- a/packages/dom-css/src/export/html.ts +++ b/packages/dom-css/src/export/html.ts @@ -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 | 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('') } diff --git a/packages/dom-css/src/export/projection.ts b/packages/dom-css/src/export/projection.ts index 503d20b2c..fb9b40fd8 100644 --- a/packages/dom-css/src/export/projection.ts +++ b/packages/dom-css/src/export/projection.ts @@ -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 + 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 = { - rootId: options.rootId ?? graph.rootId, - includeSourceIds: options.includeSourceIds ?? true - } - + const context = projectionContext(graph, options) + const modes = context.tokens?.defaultModes() ?? new Map() 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 = {} ): 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() + 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 } diff --git a/packages/dom-css/src/export/storybook/export.ts b/packages/dom-css/src/export/storybook/export.ts index b99ecdf1e..e6442d8e5 100644 --- a/packages/dom-css/src/export/storybook/export.ts +++ b/packages/dom-css/src/export/storybook/export.ts @@ -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, diff --git a/packages/dom-css/src/export/tailwind-jsx.ts b/packages/dom-css/src/export/tailwind-jsx.ts index 0fe343f0c..ffa2b8856 100644 --- a/packages/dom-css/src/export/tailwind-jsx.ts +++ b/packages/dom-css/src/export/tailwind-jsx.ts @@ -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 = [] 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) diff --git a/packages/dom-css/src/tokens/names.ts b/packages/dom-css/src/tokens/names.ts index 8f04a1f33..f464c8c45 100644 --- a/packages/dom-css/src/tokens/names.ts +++ b/packages/dom-css/src/tokens/names.ts @@ -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 +): Variable[] { + return compact( + [...source.variableCollections.values()].flatMap((collection) => + collection.variableIds.map((id) => source.variables.get(id)) + ) + ) +} const SCOPE_NAMESPACES: Partial> = { CORNER_RADIUS: 'radius', diff --git a/packages/dom-css/src/tokens/references.ts b/packages/dom-css/src/tokens/references.ts new file mode 100644 index 000000000..649d92728 --- /dev/null +++ b/packages/dom-css/src/tokens/references.ts @@ -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 + +/** 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 + /** Tokens the output references, with every variable their aliases reach. */ + readonly used = new Set() + 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; modes: CSSModes } { + const attrs: Record = {} + 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 { + const ids = new Set() + 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}`] : [] + }) + } +} diff --git a/packages/dom-css/src/tokens/stylesheet.ts b/packages/dom-css/src/tokens/stylesheet.ts index 43bd88d14..d7eba0318 100644 --- a/packages/dom-css/src/tokens/stylesheet.ts +++ b/packages/dom-css/src/tokens/stylesheet.ts @@ -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) => diff --git a/packages/dom-css/src/types.ts b/packages/dom-css/src/types.ts index e2a056499..9ca3cb77e 100644 --- a/packages/dom-css/src/types.ts +++ b/packages/dom-css/src/types.ts @@ -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 { diff --git a/packages/dom-css/tests/export/bundle/tokens.test.ts b/packages/dom-css/tests/export/bundle/tokens.test.ts new file mode 100644 index 000000000..9694e7bcb --- /dev/null +++ b/packages/dom-css/tests/export/bundle/tokens.test.ts @@ -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);') + }) +}) diff --git a/packages/dom-css/tests/export/projection/tokens.test.ts b/packages/dom-css/tests/export/projection/tokens.test.ts new file mode 100644 index 000000000..5eff8cca2 --- /dev/null +++ b/packages/dom-css/tests/export/projection/tokens.test.ts @@ -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' + }) + }) +}) diff --git a/packages/dom-css/tests/export/tailwind-jsx/tokens.test.ts b/packages/dom-css/tests/export/tailwind-jsx/tokens.test.ts new file mode 100644 index 000000000..cb5e65fe9 --- /dev/null +++ b/packages/dom-css/tests/export/tailwind-jsx/tokens.test.ts @@ -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)') + }) +}) diff --git a/packages/dom-css/tests/export/token-fixtures.ts b/packages/dom-css/tests/export/token-fixtures.ts new file mode 100644 index 000000000..cd7853133 --- /dev/null +++ b/packages/dom-css/tests/export/token-fixtures.ts @@ -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, + extra: Partial = {} +) { + 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 + }) +}