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:
parent
83cf621a91
commit
fb0cb9ee5f
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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('')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
205
packages/dom-css/src/tokens/references.ts
Normal file
205
packages/dom-css/src/tokens/references.ts
Normal 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}`] : []
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
38
packages/dom-css/tests/export/bundle/tokens.test.ts
Normal file
38
packages/dom-css/tests/export/bundle/tokens.test.ts
Normal 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);')
|
||||
})
|
||||
})
|
||||
97
packages/dom-css/tests/export/projection/tokens.test.ts
Normal file
97
packages/dom-css/tests/export/projection/tokens.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
59
packages/dom-css/tests/export/tailwind-jsx/tokens.test.ts
Normal file
59
packages/dom-css/tests/export/tailwind-jsx/tokens.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
91
packages/dom-css/tests/export/token-fixtures.ts
Normal file
91
packages/dom-css/tests/export/token-fixtures.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Loading…
Reference in a new issue