From 67dbd7156f9757efcbe07b29d02110a8b72d79c8 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 3 Oct 2026 15:40:21 +0400 Subject: [PATCH] feat(design-jsx): export every property the renderer accepts (#814) * feat(design-jsx): export every property the renderer accepts JSX export wrote only part of a layer: one solid fill, one stroke without its alignment, shadows as repeated attributes, background blurs as layer blurs, and nothing for hidden children, constraints, size limits, absolute positioning, vertical text alignment, masks, or variable bindings. Rendering an export lost those properties, and JSX diffs could not see changes to them. The export now writes them, using paint and effect helper calls when a shorthand cannot express a value exactly, and leaves out values the renderer would infer, so ordinary output stays as it was. Prop values can now hold objects, arrays, and helper calls, printed through @open-pencil/codegen's builders, which gain a call expression. The language gains `visible`, `locked`, `constraints` (Figma's constraints object with lowercase values, as `blendMode` uses), `italic`, `strokes`, `strokeWeights`, `strokeCap`, and `strokeJoin`, and now applies `strokeAlign`, `strokeDash`, and the size limits, which it accepted but ignored. Per-corner radii are written even when the uniform radius is 0. A round-trip test renders each case's export and checks the fields and that exporting again changes nothing. * docs(fig): name the saved-glyph fixture by its repository path The observation note linked the fixture with a relative path climbing four directories. Other notes name fixtures by their repository path, which reads the same from anywhere. * fix(design-jsx): export the node-level dash pattern A node's own dashPattern was not written, so a node dashed at node level came back solid and the DOM/CSS export chose a solid border. It now round-trips as a separate dashPattern prop; strokeDash stays the stroke-local dash. --- CHANGELOG.md | 3 + packages/codegen/src/estree.ts | 8 + .../design-jsx/export-round-trip.test.ts | 361 +++++++++++++++++- packages/design-jsx/src/export/helpers.ts | 34 +- packages/design-jsx/src/export/index.ts | 16 +- packages/design-jsx/src/export/paint.ts | 222 +++++++++++ packages/design-jsx/src/export/props.ts | 76 ++-- packages/design-jsx/src/export/state.ts | 65 ++++ packages/design-jsx/src/export/value.ts | 68 ++++ packages/design-jsx/src/overrides/effects.ts | 51 +++ packages/design-jsx/src/overrides/state.ts | 65 ++++ packages/design-jsx/src/overrides/strokes.ts | 129 +++++++ packages/design-jsx/src/props-overrides.ts | 108 +----- .../design-jsx/src/reference/authoring.md | 4 +- packages/design-jsx/src/schema.ts | 10 + packages/design-jsx/src/tree.ts | 1 + packages/design-jsx/tests/export.test.ts | 61 ++- packages/docs/programmable/jsx-renderer.md | 2 +- packages/docs/reference/design-authoring.md | 6 +- .../docs/observations/styled-saved-glyphs.md | 2 +- .../references/design-authoring.md | 6 +- 21 files changed, 1119 insertions(+), 179 deletions(-) create mode 100644 packages/design-jsx/src/export/paint.ts create mode 100644 packages/design-jsx/src/export/state.ts create mode 100644 packages/design-jsx/src/export/value.ts create mode 100644 packages/design-jsx/src/overrides/effects.ts create mode 100644 packages/design-jsx/src/overrides/state.ts create mode 100644 packages/design-jsx/src/overrides/strokes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 82f9af6c2..428dd7536 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ ### Added - See where the built-in AI chat is working: while it replies, a cursor whose outlined label shows a sparkle and a callsign such as *Fern* marks the layers it edits. In a shared room, collaborators see each other's agents in the color of the person running them. +- Hide, lock, and constrain layers in design JSX with `visible={false}`, `locked`, and `constraints={{ horizontal, vertical }}`, set italic text with `italic`, and describe strokes fully with `strokes`, `strokeWeights`, `strokeCap`, `strokeJoin`, and the node-level `dashPattern`. JSX export now writes these together with stacked, gradient, and image fills, every effect, absolutely positioned children, size limits, vertical text alignment, masks, and variable bindings, so rendering exported JSX reproduces them and `diff_jsx` reports changes to them. - Jump between pages from the command palette: it lists the pages you visited recently in the tab, **Go to page…** lists every page, and typing a page name finds it. - Choose how much an AI model thinks for each message from the chat composer, from Off to Extra high or the provider default. Anthropic, Google, DeepSeek, OpenAI, OpenRouter, and compatible models apply it, where reasoning effort previously reached only OpenAI and OpenRouter models. A model profile's thinking level, which replaces its free-text reasoning effort, sets the starting choice, and finished reasoning shows how long the model thought. - Preview designs progressively on the canvas as direct AI providers stream JSX, without saving partial designs or adding intermediate undo steps. A preview stays with its page: it hides while you view another page and returns when you come back. @@ -37,6 +38,8 @@ ### Fixed +- Export layers with two shadows as one `effects` prop instead of repeating the `shadow` attribute, background blurs as `backgroundBlur` instead of a layer blur, hidden children with `visible={false}` instead of leaving them out, and per-corner radii even when the uniform radius is 0. +- Apply `strokeAlign`, `strokeDash`, `minH`, and `maxH` in design JSX, which were accepted but ignored, and make `minW` and `maxW` set the layer's minimum and maximum width rather than only clamping its initial width. - Render the canvas with the Vue SDK's `CanvasRoot` and `CanvasSurface`; CanvasKit never started there and the canvas stayed blank. - Keep the view centered on what you were looking at when zooming to 100% or another fixed level, instead of jumping elsewhere whenever the zoom changes. - Draw collaborators' names on their cursors with proper letter spacing and fallback fonts, and end long names with an ellipsis. diff --git a/packages/codegen/src/estree.ts b/packages/codegen/src/estree.ts index 942f84c36..a04f2ba61 100644 --- a/packages/codegen/src/estree.ts +++ b/packages/codegen/src/estree.ts @@ -114,6 +114,14 @@ export function object(entries: (readonly [string, SyntaxNode])[]): SyntaxNode { export const array = (elements: SyntaxNode[]): SyntaxNode => ({ type: 'ArrayExpression', elements }) +/** A call of a function by name, such as a helper inside an exported prop value. */ +export const call = (callee: string, args: SyntaxNode[]): SyntaxNode => ({ + type: 'CallExpression', + callee: identifier(callee), + arguments: args, + optional: false +}) + export function stringUnionType(values: string[]): SyntaxNode { const literals = values.map((value) => ({ type: 'TSLiteralType', literal: string(value) })) return literals.length === 1 && literals[0] diff --git a/packages/core/tests/design-jsx/export-round-trip.test.ts b/packages/core/tests/design-jsx/export-round-trip.test.ts index fb0a4f9ac..2f7f41f63 100644 --- a/packages/core/tests/design-jsx/export-round-trip.test.ts +++ b/packages/core/tests/design-jsx/export-round-trip.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from 'bun:test' +import { describe, expect, it, test } from 'bun:test' +import { pick } from 'es-toolkit' import { renderJSX } from '@open-pencil/core/design-jsx' import { sceneNodeToJSX } from '@open-pencil/design-jsx' -import { SceneGraph } from '@open-pencil/scene-graph' +import { SceneGraph, type Fill, type SceneNode, type Stroke } from '@open-pencil/scene-graph' -import { getNodeOrThrow } from '../helpers/assert' +import { expectDefined, getNodeOrThrow } from '../helpers/assert' describe('attribute string round-trip', () => { it.each([ @@ -40,3 +41,357 @@ describe('text content round-trip', () => { expect(getNodeOrThrow(g, result.id).text).toBe(text) }) }) + +const RED = { r: 1, g: 0, b: 0, a: 1 } +const BLUE = { r: 0, g: 0, b: 1, a: 1 } +/** Alphas that survive the two-digit hex of exported colors exactly. */ +const SHADE = { r: 0, g: 0, b: 0, a: 0.2 } + +function solidFill(color = RED, fields: Partial = {}): Fill { + return { type: 'SOLID', color, opacity: color.a, visible: true, ...fields } +} + +function stroke(fields: Partial = {}): Stroke { + return { color: BLUE, weight: 1, opacity: 1, visible: true, align: 'INSIDE', ...fields } +} + +interface RoundTripCase { + name: string + /** Builds the exported root under `pageId` and returns the name of the node to compare. */ + build(graph: SceneGraph, pageId: string): { rootId: string; target: string } + fields: (keyof SceneNode)[] +} + +function frame(graph: SceneGraph, parentId: string, fields: Partial = {}) { + return graph.createNode('FRAME', parentId, { name: 'Root', width: 300, height: 200, ...fields }) +} + +function child( + graph: SceneGraph, + parentId: string, + type: SceneNode['type'], + fields: Partial +) { + return graph.createNode(type, parentId, { name: 'Target', width: 40, height: 30, ...fields }) +} + +/** A leaf under a plain frame, compared on `fields`. */ +function leafCase( + name: string, + type: SceneNode['type'], + fields: Partial, + compared = Object.keys(fields) as (keyof SceneNode)[] +): RoundTripCase { + return { + name, + build(graph, pageId) { + const root = frame(graph, pageId) + child(graph, root.id, type, fields) + return { rootId: root.id, target: 'Target' } + }, + fields: compared + } +} + +const CASES: RoundTripCase[] = [ + leafCase('hidden layer', 'RECTANGLE', { visible: false }), + leafCase('locked layer', 'RECTANGLE', { locked: true }), + leafCase('constraints', 'RECTANGLE', { + horizontalConstraint: 'MAX', + verticalConstraint: 'STRETCH' + }), + leafCase('scale and center constraints', 'RECTANGLE', { + horizontalConstraint: 'SCALE', + verticalConstraint: 'CENTER' + }), + leafCase('size limits', 'FRAME', { + minWidth: 20, + maxWidth: 400, + minHeight: 10, + maxHeight: 300 + }), + leafCase('position and rotation', 'RECTANGLE', { x: 12, y: 34, rotation: 15 }), + leafCase('opacity and blend mode', 'RECTANGLE', { opacity: 0.5, blendMode: 'MULTIPLY' }), + // Figma leaves the uniform radius at 0 when corners differ. + leafCase('independent corners and smoothing', 'RECTANGLE', { + cornerRadius: 0, + independentCorners: true, + topLeftRadius: 4, + topRightRadius: 8, + bottomRightRadius: 12, + bottomLeftRadius: 16, + cornerSmoothing: 0.6 + }), + leafCase('single solid fill', 'RECTANGLE', { fills: [solidFill()] }), + leafCase('stacked, hidden, and blended fills', 'RECTANGLE', { + fills: [ + solidFill(RED, { visible: false }), + solidFill(BLUE, { opacity: 0.5, blendMode: 'SCREEN' }) + ] + }), + leafCase('gradient fill with transform', 'RECTANGLE', { + fills: [ + { + type: 'GRADIENT_LINEAR', + color: { r: 0, g: 0, b: 0, a: 0 }, + opacity: 1, + visible: true, + gradientStops: [ + { color: RED, position: 0 }, + { color: BLUE, position: 1 } + ], + gradientTransform: { m00: 0, m01: 1, m02: 0, m10: -1, m11: 0, m12: 1 } + } + ] + }), + leafCase('gradient fading to transparent', 'RECTANGLE', { + fills: [ + { + type: 'GRADIENT_RADIAL', + color: { r: 0, g: 0, b: 0, a: 0 }, + opacity: 1, + visible: true, + gradientStops: [ + { color: SHADE, position: 0 }, + { color: { r: 0, g: 0, b: 0, a: 0 }, position: 1 } + ], + gradientTransform: { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } + } + ] + }), + leafCase('image fill', 'RECTANGLE', { + fills: [ + { + type: 'IMAGE', + color: { r: 0, g: 0, b: 0, a: 1 }, + opacity: 1, + visible: true, + imageHash: 'abc123', + imageScaleMode: 'FIT' + } + ] + }), + leafCase('single stroke with every option', 'RECTANGLE', { + strokes: [ + stroke({ weight: 3, align: 'OUTSIDE', dashPattern: [4, 2], cap: 'ROUND', join: 'BEVEL' }) + ] + }), + leafCase('node-level stroke cap and join under a stroke without its own', 'RECTANGLE', { + strokes: [stroke({ weight: 2 })], + strokeCap: 'ROUND', + strokeJoin: 'BEVEL' + }), + leafCase('stroke cap that differs from the node cap', 'RECTANGLE', { + strokes: [stroke({ cap: 'SQUARE' })], + strokeCap: 'ROUND' + }), + leafCase('node-level dash pattern under a stroke without its own', 'RECTANGLE', { + strokes: [stroke()], + dashPattern: [6, 3] + }), + leafCase('stroke opacity that differs from its colour alpha', 'RECTANGLE', { + strokes: [stroke({ opacity: 0.5 })] + }), + leafCase('fill opacity that differs from its colour alpha', 'RECTANGLE', { + fills: [solidFill(RED, { opacity: 0.5 })] + }), + leafCase('size limits without a fixed width', 'FRAME', { + layoutMode: 'VERTICAL', + primaryAxisSizing: 'HUG', + counterAxisSizing: 'HUG', + minWidth: 40, + maxWidth: 400 + }), + leafCase('several strokes, one hidden', 'RECTANGLE', { + strokes: [stroke({ weight: 2 }), stroke({ color: RED, align: 'CENTER', visible: false })] + }), + leafCase('per-side stroke weights', 'RECTANGLE', { + strokes: [stroke()], + independentStrokeWeights: true, + borderTopWeight: 0, + borderRightWeight: 1, + borderBottomWeight: 2, + borderLeftWeight: 0 + }), + leafCase('drop shadow and layer blur shorthands', 'RECTANGLE', { + effects: [ + { + type: 'DROP_SHADOW', + color: SHADE, + offset: { x: 0, y: 4 }, + radius: 8, + spread: 0, + visible: true + }, + { + type: 'LAYER_BLUR', + color: { r: 0, g: 0, b: 0, a: 0 }, + offset: { x: 0, y: 0 }, + radius: 4, + spread: 0, + visible: true + } + ] + }), + leafCase('inner shadow, spread, hidden shadow, background blur', 'RECTANGLE', { + effects: [ + { + type: 'INNER_SHADOW', + color: SHADE, + offset: { x: 1, y: 2 }, + radius: 3, + spread: 0, + visible: true + }, + { + type: 'DROP_SHADOW', + color: SHADE, + offset: { x: 0, y: 8 }, + radius: 16, + spread: 4, + visible: false + }, + { + type: 'BACKGROUND_BLUR', + color: { r: 0, g: 0, b: 0, a: 0 }, + offset: { x: 0, y: 0 }, + radius: 12, + spread: 0, + visible: true + } + ] + }), + leafCase('mask', 'RECTANGLE', { isMask: true, maskType: 'LUMINANCE' }), + leafCase('clipping', 'FRAME', { clipsContent: true }), + leafCase('star geometry', 'STAR', { pointCount: 7, starInnerRadius: 0.5 }), + leafCase('text style', 'TEXT', { + text: 'Hello', + fontSize: 20, + fontWeight: 700, + italic: true, + textAlignHorizontal: 'CENTER', + textAlignVertical: 'BOTTOM', + textAutoResize: 'NONE', + letterSpacing: 1, + lineHeight: 28, + textCase: 'UPPER', + textDecoration: 'UNDERLINE' + }), + leafCase('text that wraps at a fixed width', 'TEXT', { + text: 'Wrapped', + textAutoResize: 'HEIGHT' + }), + leafCase('text that hugs its content', 'TEXT', { + text: 'Hug', + textAutoResize: 'WIDTH_AND_HEIGHT' + }), + leafCase('truncated text', 'TEXT', { + text: 'Clipped', + textAutoResize: 'TRUNCATE', + textTruncation: 'ENDING' + }), + { + name: 'absolute child of an auto-layout frame', + build(graph, pageId) { + const root = frame(graph, pageId, { layoutMode: 'VERTICAL', itemSpacing: 8 }) + child(graph, root.id, 'RECTANGLE', { name: 'Flow' }) + child(graph, root.id, 'RECTANGLE', { + layoutPositioning: 'ABSOLUTE', + x: 10, + y: 20, + horizontalConstraint: 'MAX' + }) + return { rootId: root.id, target: 'Target' } + }, + fields: ['layoutPositioning', 'x', 'y', 'horizontalConstraint'] + }, + { + name: 'auto layout', + build(graph, pageId) { + const root = frame(graph, pageId, { + layoutMode: 'HORIZONTAL', + itemSpacing: 12, + paddingTop: 4, + paddingRight: 8, + paddingBottom: 4, + paddingLeft: 8, + primaryAxisAlign: 'SPACE_BETWEEN', + counterAxisAlign: 'CENTER', + primaryAxisSizing: 'FIXED', + counterAxisSizing: 'FIXED' + }) + child(graph, root.id, 'RECTANGLE', { name: 'A' }) + return { rootId: root.id, target: 'Root' } + }, + fields: [ + 'layoutMode', + 'itemSpacing', + 'paddingTop', + 'paddingRight', + 'paddingBottom', + 'paddingLeft', + 'primaryAxisAlign', + 'counterAxisAlign', + 'width', + 'height' + ] + }, + { + name: 'variable bindings', + build(graph, pageId) { + const collection = graph.createCollection('Tokens') + const brand = graph.createVariable('Brand', 'COLOR', collection.id, RED) + const space = graph.createVariable('Space', 'FLOAT', collection.id, 16) + const root = frame(graph, pageId, { + layoutMode: 'VERTICAL', + itemSpacing: 16, + fills: [solidFill()] + }) + graph.bindVariable(root.id, 'fills/0/color', brand.id) + graph.bindVariable(root.id, 'itemSpacing', space.id) + return { rootId: root.id, target: 'Root' } + }, + fields: ['boundVariables'] + } +] + +function findByName(graph: SceneGraph, rootId: string, name: string): SceneNode { + const root = expectDefined(graph.getNode(rootId), 'rendered root') + if (root.name === name) return root + for (const childId of root.childIds) { + const found = graph.getNode(childId)?.name === name ? graph.getNode(childId) : undefined + if (found) return found + } + throw new Error(`No node named ${name} under ${root.name}`) +} + +test('size limits are limits, not a width', async () => { + const graph = new SceneGraph() + const [defaultWidth] = await renderJSX(graph, '') + const [limited] = await renderJSX(graph, '') + const width = (id: string | undefined) => graph.getNode(expectDefined(id, 'frame'))?.width + expect(graph.getNode(expectDefined(limited, 'frame').id)?.maxWidth).toBe(400) + expect(width(limited?.id)).toBe(width(defaultWidth?.id)) +}) + +describe('design JSX export round trip', () => { + test.each(CASES.map((testCase) => [testCase.name, testCase] as const))( + '%s', + async (_, testCase) => { + const graph = new SceneGraph() + const source = expectDefined(graph.getPages()[0], 'source page') + const target = graph.addPage('Rendered') + const { rootId, target: targetName } = testCase.build(graph, source.id) + + const jsx = sceneNodeToJSX(rootId, graph) + const [rendered] = await renderJSX(graph, jsx, { parentId: target.id }) + const renderedRoot = expectDefined(rendered, 'render result').id + + const original = findByName(graph, rootId, targetName) + const copy = findByName(graph, renderedRoot, targetName) + expect(pick(copy, testCase.fields)).toEqual(pick(original, testCase.fields)) + // Everything the export wrote comes back, so exporting the copy changes nothing. + expect(sceneNodeToJSX(renderedRoot, graph)).toBe(jsx) + } + ) +}) diff --git a/packages/design-jsx/src/export/helpers.ts b/packages/design-jsx/src/export/helpers.ts index deb4faac7..cb967c632 100644 --- a/packages/design-jsx/src/export/helpers.ts +++ b/packages/design-jsx/src/export/helpers.ts @@ -1,37 +1,10 @@ -import type { - SceneGraph, - SceneNode, - Fill, - Stroke, - Effect, - Color, - GridTrack -} from '@open-pencil/scene-graph' +import type { SceneGraph, SceneNode, Effect, Color, GridTrack } from '@open-pencil/scene-graph' import { colorToHex8 } from '@open-pencil/scene-graph/color' export function formatColor(color: Color, opacity = 1): string { return colorToHex8(color, opacity) } -export function solidFillColor(fills: Fill[]): string | null { - const visible = fills.filter((f) => f.visible && f.type === 'SOLID') - if (visible.length !== 1) return null - return formatColor(visible[0].color, visible[0].opacity) -} - -export function solidStroke( - strokes: Stroke[] -): { color: string; weight: number; dash: number[] | null } | null { - const visible = strokes.filter((s) => s.visible) - if (visible.length !== 1) return null - const s = visible[0] - return { - color: formatColor(s.color, s.opacity), - weight: s.weight, - dash: s.dashPattern && s.dashPattern.length > 0 ? [...s.dashPattern] : null - } -} - export function formatShadow(e: Effect): string | null { if (e.type !== 'DROP_SHADOW' && e.type !== 'INNER_SHADOW') return null return `${e.offset.x} ${e.offset.y} ${e.radius} ${formatColor(e.color, e.color.a)}` @@ -76,15 +49,16 @@ export interface CornerRadii { } export function collectCornerRadii(node: SceneNode): CornerRadii | null { - if (node.cornerRadius <= 0) return null if (node.independentCorners) { - return { + const corners = { tl: node.topLeftRadius, tr: node.topRightRadius, br: node.bottomRightRadius, bl: node.bottomLeftRadius } + return Object.values(corners).some((radius) => radius > 0) ? corners : null } + if (node.cornerRadius <= 0) return null const r = node.cornerRadius return { tl: r, tr: r, br: r, bl: r } } diff --git a/packages/design-jsx/src/export/index.ts b/packages/design-jsx/src/export/index.ts index b4ea83f19..72699413d 100644 --- a/packages/design-jsx/src/export/index.ts +++ b/packages/design-jsx/src/export/index.ts @@ -2,20 +2,18 @@ import { jsx, type SyntaxNode } from '@open-pencil/codegen' import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import { collectProps, NODE_TYPE_TO_TAG, type JSXProp } from './props' +import { valueSyntax } from './value' function propValue(value: JSXProp[1]): SyntaxNode | null { if (value === true) return null if (typeof value === 'string') return jsx.stringValue(value) - if (Array.isArray(value)) { - return jsx.container({ - type: 'ArrayExpression', - elements: value.map((item) => jsx.literal(item)) - }) - } - return jsx.container(jsx.literal(value)) + return jsx.container(valueSyntax(value)) } -/** A prop as a JSX attribute: `true` prints the bare name, other values as typed literals. */ +/** + * A prop as a JSX attribute: `true` prints the bare name, strings as attribute strings, and + * other values as expressions, with paint and effect helpers as calls. + */ function propAttribute([name, value]: JSXProp): SyntaxNode { return jsx.attribute(name, propValue(value)) } @@ -27,9 +25,9 @@ function nodeToJSX(node: SceneNode, graph: SceneGraph, depth: number): SyntaxNod if (node.type === 'TEXT') { return jsx.element(tag, attributes, node.text ? [jsx.text(node.text)] : [], depth, true) } + // Hidden children export with `visible={false}` rather than disappearing. const children = graph .getChildren(node.id) - .filter((child) => child.visible) .flatMap((child) => nodeToJSX(child, graph, depth + 1) ?? []) return jsx.element(tag, attributes, children, depth) } diff --git a/packages/design-jsx/src/export/paint.ts b/packages/design-jsx/src/export/paint.ts new file mode 100644 index 000000000..f16aa6895 --- /dev/null +++ b/packages/design-jsx/src/export/paint.ts @@ -0,0 +1,222 @@ +import type { Effect, Fill, GradientTransform, SceneNode, Stroke } from '@open-pencil/scene-graph' + +import { formatColor, formatShadow } from './helpers' +import { helperCall, plainValue, type HelperCall, type JSXProp, type JSXValue } from './value' + +const GRADIENT_HELPERS: Partial> = { + GRADIENT_LINEAR: 'linearGradient', + GRADIENT_RADIAL: 'radialGradient', + GRADIENT_ANGULAR: 'angularGradient', + GRADIENT_DIAMOND: 'diamondGradient' +} + +const IDENTITY_TRANSFORM: GradientTransform = { m00: 1, m01: 0, m02: 0, m10: 0, m11: 1, m12: 0 } + +function isIdentity(transform: GradientTransform | undefined): boolean { + if (!transform) return true + return (Object.keys(IDENTITY_TRANSFORM) as (keyof GradientTransform)[]).every( + (key) => transform[key] === IDENTITY_TRANSFORM[key] + ) +} + +/** Paints and effects imported from Figma often spell out the default blend mode. */ +function blendMode(mode: Fill['blendMode']): Fill['blendMode'] { + return mode === 'NORMAL' || mode === 'PASS_THROUGH' ? undefined : mode +} + +type Options = Record + +/** Helper options with defaults left out, or `undefined` when none differ. */ +function options(entries: Options): Options | undefined { + return Object.values(entries).some((value) => value !== undefined) ? entries : undefined +} + +function fillValue(fill: Fill): JSXValue { + const visible = fill.visible ? undefined : false + if (fill.type === 'SOLID') { + const color = formatColor(fill.color, fill.color.a) + const opacity = fill.opacity === fill.color.a ? undefined : fill.opacity + return helperCall( + 'solid', + color, + options({ opacity, visible, blendMode: blendMode(fill.blendMode) }) + ) + } + const helper = GRADIENT_HELPERS[fill.type] + if (helper && fill.gradientStops) { + const stops = fill.gradientStops.map((stop) => [ + formatColor(stop.color, stop.color.a), + stop.position + ]) + return helperCall( + helper, + stops, + options({ + opacity: fill.opacity === 1 ? undefined : fill.opacity, + visible, + blendMode: blendMode(fill.blendMode), + transform: isIdentity(fill.gradientTransform) + ? undefined + : plainValue(fill.gradientTransform) + }) + ) + } + // Image, pattern, and other paints have no helper; they render back from their fields. + return plainValue(fill) +} + +/** A single visible solid paint fits the `bg` or text `color` shorthand. */ +function solidShorthand(fills: Fill[]): string | null { + if (fills.length !== 1) return null + const [fill] = fills + if (fill.type !== 'SOLID' || !fill.visible || blendMode(fill.blendMode)) return null + // The shorthand has one alpha; a fill whose opacity differs from its colour's needs `solid`. + if (fill.opacity !== fill.color.a) return null + return formatColor(fill.color, fill.color.a) +} + +export function collectFillProps(node: SceneNode, props: JSXProp[]): void { + if (node.fills.length === 0) return + const shorthand = solidShorthand(node.fills) + if (shorthand) props.push([node.type === 'TEXT' ? 'color' : 'bg', shorthand]) + else props.push(['fills', node.fills.map(fillValue)]) +} + +interface StrokeValue { + [key: string]: JSXValue | undefined + color: string + /** Only when it differs from the colour's alpha, which the colour already carries. */ + opacity?: number + weight?: number + align?: string + dash?: number[] + cap?: string + join?: string + visible?: false +} + +function strokeValue(stroke: Stroke): StrokeValue { + return { + color: formatColor(stroke.color, stroke.color.a), + opacity: stroke.opacity === stroke.color.a ? undefined : stroke.opacity, + weight: stroke.weight === 1 ? undefined : stroke.weight, + align: stroke.align === 'INSIDE' ? undefined : stroke.align.toLowerCase(), + dash: stroke.dashPattern && stroke.dashPattern.length > 0 ? [...stroke.dashPattern] : undefined, + cap: stroke.cap?.toLowerCase(), + join: stroke.join?.toLowerCase(), + visible: stroke.visible ? undefined : false + } +} + +const STROKE_SHORTHAND: Record = { + color: 'stroke', + weight: 'strokeWidth', + align: 'strokeAlign', + dash: 'strokeDash' +} + +/** Node-level cap and join apply to strokes that do not set their own. */ +const DEFAULT_STROKE_CAP = 'NONE' +const DEFAULT_STROKE_JOIN = 'MITER' + +/** + * The shorthand props describe one stroke, and `strokeCap`/`strokeJoin` also set the node's + * cap and join; they fit only when the stroke agrees with the node on both. + */ +function fitsStrokeShorthand(node: SceneNode, stroke: StrokeValue): boolean { + if (stroke.visible !== undefined || stroke.opacity !== undefined) return false + const nodeCap = node.strokeCap.toLowerCase() + const nodeJoin = node.strokeJoin.toLowerCase() + return (stroke.cap ?? nodeCap) === nodeCap && (stroke.join ?? nodeJoin) === nodeJoin +} + +export function collectStrokeProps(node: SceneNode, props: JSXProp[]): void { + const strokes = node.strokes.map(strokeValue) + const [only] = strokes + const shorthand = strokes.length === 1 && fitsStrokeShorthand(node, only) + if (shorthand) { + for (const [key, value] of Object.entries(only)) { + if (value !== undefined && key !== 'cap' && key !== 'join') { + props.push([STROKE_SHORTHAND[key], value]) + } + } + } else if (strokes.length > 0) { + props.push(['strokes', strokes]) + } + if (node.strokeCap !== DEFAULT_STROKE_CAP) props.push(['strokeCap', node.strokeCap.toLowerCase()]) + if (node.strokeJoin !== DEFAULT_STROKE_JOIN) { + props.push(['strokeJoin', node.strokeJoin.toLowerCase()]) + } + if (node.dashPattern.length > 0) props.push(['dashPattern', [...node.dashPattern]]) + if (node.independentStrokeWeights) { + props.push([ + 'strokeWeights', + { + top: node.borderTopWeight, + right: node.borderRightWeight, + bottom: node.borderBottomWeight, + left: node.borderLeftWeight + } + ]) + } +} + +const EFFECT_HELPERS: Record = { + DROP_SHADOW: 'dropShadow', + INNER_SHADOW: 'innerShadow', + LAYER_BLUR: 'layerBlur', + BACKGROUND_BLUR: 'backgroundBlur', + FOREGROUND_BLUR: 'foregroundBlur' +} + +function effectValue(effect: Effect): HelperCall { + const visible = effect.visible ? undefined : false + if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') { + return helperCall( + EFFECT_HELPERS[effect.type], + options({ + x: effect.offset.x, + y: effect.offset.y, + radius: effect.radius, + spread: effect.spread === 0 ? undefined : effect.spread, + color: formatColor(effect.color, effect.color.a), + visible, + blendMode: blendMode(effect.blendMode), + showShadowBehindNode: effect.showShadowBehindNode + }) + ) + } + return helperCall( + EFFECT_HELPERS[effect.type], + visible === undefined ? effect.radius : { radius: effect.radius, visible } + ) +} + +function plainDropShadow(effect: Effect): string | null { + const plain = + effect.type === 'DROP_SHADOW' && + effect.spread === 0 && + !blendMode(effect.blendMode) && + effect.showShadowBehindNode === undefined + return plain ? formatShadow(effect) : null +} + +/** + * `shadow` and `blur` describe a plain drop shadow followed by a layer blur, each optional, + * in the order the renderer appends them; anything else needs `effects`. + */ +function effectShorthand(effects: Effect[]): JSXProp[] | null { + if (effects.some((effect) => !effect.visible)) return null + const shadow = effects.length > 0 ? plainDropShadow(effects[0]) : null + const rest = shadow ? effects.slice(1) : effects + if (rest.length > 1 || rest.some((effect) => effect.type !== 'LAYER_BLUR')) return null + const props: JSXProp[] = [] + if (shadow) props.push(['shadow', shadow]) + for (const blur of rest) props.push(['blur', blur.radius]) + return props +} + +export function collectEffectProps(node: SceneNode, props: JSXProp[]): void { + if (node.effects.length === 0) return + props.push(...(effectShorthand(node.effects) ?? [['effects', node.effects.map(effectValue)]])) +} diff --git a/packages/design-jsx/src/export/props.ts b/packages/design-jsx/src/export/props.ts index d6db518d3..42dadd5dd 100644 --- a/packages/design-jsx/src/export/props.ts +++ b/packages/design-jsx/src/export/props.ts @@ -6,15 +6,14 @@ import { collectCornerRadii, collectPadding, emitPadding, - formatShadow, formatTracks, - getNodeContext, - solidFillColor, - solidStroke + getNodeContext } from './helpers' +import { collectEffectProps, collectFillProps, collectStrokeProps } from './paint' +import { collectStateProps } from './state' +import type { JSXProp } from './value' -/** A prop name and its value: a string, number, boolean, or number list. */ -export type JSXProp = [name: string, value: string | number | boolean | number[]] +export type { JSXProp } from './value' /** The element that represents each node type; other types are not exported. */ export const NODE_TYPE_TO_TAG: Partial> = { @@ -125,16 +124,8 @@ function collectCornerRadiiProps(node: SceneNode, props: JSXProp[]): void { } function collectAppearanceProps(node: SceneNode, props: JSXProp[]): void { - const bg = solidFillColor(node.fills) - if (bg) props.push(['bg', bg]) - - const stroke = solidStroke(node.strokes) - if (stroke) { - props.push(['stroke', stroke.color]) - if (stroke.weight !== 1) props.push(['strokeWidth', stroke.weight]) - if (stroke.dash) props.push(['strokeDash', stroke.dash]) - } - + collectFillProps(node, props) + collectStrokeProps(node, props) collectCornerRadiiProps(node, props) if (node.cornerSmoothing > 0) props.push(['cornerSmoothing', node.cornerSmoothing]) @@ -144,16 +135,7 @@ function collectAppearanceProps(node: SceneNode, props: JSXProp[]): void { props.push(['blendMode', node.blendMode.toLowerCase()]) } if (node.clipsContent) props.push(['overflow', 'hidden']) - - for (const effect of node.effects) { - if (!effect.visible) continue - if (effect.type === 'DROP_SHADOW' || effect.type === 'INNER_SHADOW') { - const shadow = formatShadow(effect) - if (shadow) props.push(['shadow', shadow]) - } else if (effect.type === 'LAYER_BLUR' || effect.type === 'BACKGROUND_BLUR') { - props.push(['blur', effect.radius]) - } - } + collectEffectProps(node, props) } function collectPositionProps( @@ -161,7 +143,12 @@ function collectPositionProps( ctx: ReturnType, props: JSXProp[] ): void { - if (ctx.parentIsAutoLayout || ctx.parentIsGrid) return + if (ctx.parentIsAutoLayout || ctx.parentIsGrid) { + // Absolute children of a layout keep their position; the rest are placed by it. + if (node.layoutPositioning !== 'ABSOLUTE') return + props.push(['position', 'absolute'], ['x', node.x], ['y', node.y]) + return + } if (node.x !== 0) props.push(['x', node.x]) if (node.y !== 0) props.push(['y', node.y]) } @@ -225,9 +212,13 @@ function collectTextNodeProps(node: SceneNode, props: JSXProp[]): void { else props.push(['weight', node.fontWeight]) } if (direction === 'RTL') props.push(['dir', 'rtl']) + if (node.italic) props.push(['italic', true]) if (node.textAlignHorizontal !== 'LEFT') { props.push(['textAlign', node.textAlignHorizontal.toLowerCase()]) } + if (node.textAlignVertical !== 'TOP') { + props.push(['textAlignVertical', node.textAlignVertical.toLowerCase()]) + } if (node.lineHeight != null) props.push(['lineHeight', node.lineHeight]) if (node.letterSpacing !== 0) props.push(['letterSpacing', node.letterSpacing]) if (node.textDecoration !== 'NONE') @@ -235,11 +226,26 @@ function collectTextNodeProps(node: SceneNode, props: JSXProp[]): void { if (node.textCase !== 'ORIGINAL') props.push(['textCase', node.textCase.toLowerCase()]) if (node.maxLines != null) props.push(['maxLines', node.maxLines]) if (node.textTruncation === 'ENDING' && node.maxLines == null) props.push(['truncate', true]) - const textColor = solidFillColor(node.fills) - if (textColor) { - const bgIdx = props.findIndex(([k]) => k === 'bg') - if (bgIdx !== -1) props.splice(bgIdx, 1) - props.push(['color', textColor]) +} + +const TEXT_AUTO_RESIZE: Record = { + NONE: 'none', + WIDTH_AND_HEIGHT: 'width', + HEIGHT: 'height', + TRUNCATE: 'truncate' +} + +/** The renderer infers auto-resize from the width props; say so only when it differs. */ +function collectTextAutoResizeProp( + node: SceneNode, + ctx: ReturnType, + props: JSXProp[] +): void { + const hasWidth = props.some(([key]) => key === 'w') + const grows = ctx.parentIsAutoLayout && node.layoutGrow > 0 + const inferred = hasWidth || grows ? 'HEIGHT' : 'WIDTH_AND_HEIGHT' + if (node.textAutoResize !== inferred) { + props.push(['textAutoResize', TEXT_AUTO_RESIZE[node.textAutoResize]]) } } @@ -266,8 +272,12 @@ export function collectProps(node: SceneNode, graph: SceneGraph): JSXProp[] { if (ctx.isFlex) collectFlexAlignmentProps(node, props) if (ctx.isAutoLayout) collectAutoLayoutPaddingProps(node, props) collectAppearanceProps(node, props) - if (node.type === 'TEXT') collectTextNodeProps(node, props) + if (node.type === 'TEXT') { + collectTextNodeProps(node, props) + collectTextAutoResizeProp(node, ctx, props) + } collectShapeNodeProps(node, props) + collectStateProps(node, ctx, graph, props) return props } diff --git a/packages/design-jsx/src/export/state.ts b/packages/design-jsx/src/export/state.ts new file mode 100644 index 000000000..b2028bbed --- /dev/null +++ b/packages/design-jsx/src/export/state.ts @@ -0,0 +1,65 @@ +import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' + +import type { getNodeContext } from './helpers' +import { helperCall, type JSXProp, type JSXValue } from './value' + +/** Constraints apply where a parent does not lay its children out. */ +function hasConstraints(node: SceneNode, ctx: ReturnType): boolean { + return !ctx.parentIsAutoLayout || node.layoutPositioning === 'ABSOLUTE' +} + +function collectConstraintProps( + node: SceneNode, + ctx: ReturnType, + props: JSXProp[] +): void { + if (!hasConstraints(node, ctx)) return + const horizontal = node.horizontalConstraint === 'MIN' ? undefined : node.horizontalConstraint + const vertical = node.verticalConstraint === 'MIN' ? undefined : node.verticalConstraint + if (!horizontal && !vertical) return + props.push([ + 'constraints', + { horizontal: horizontal?.toLowerCase(), vertical: vertical?.toLowerCase() } + ]) +} + +function collectSizeLimitProps(node: SceneNode, props: JSXProp[]): void { + if (node.minWidth != null) props.push(['minW', node.minWidth]) + if (node.maxWidth != null) props.push(['maxW', node.maxWidth]) + if (node.minHeight != null) props.push(['minH', node.minHeight]) + if (node.maxHeight != null) props.push(['maxH', node.maxHeight]) +} + +/** + * Variables are named when the name is unique in the document, which is how `designVar` + * resolves them, and referenced by ID otherwise. + */ +function variableRef(graph: SceneGraph, variableId: string): JSXValue { + const variable = graph.variables.get(variableId) + if (!variable) return variableId + const sameName = [...graph.variables.values()].filter((v) => v.name === variable.name) + return sameName.length === 1 ? helperCall('designVar', variable.name) : variableId +} + +function collectBindingProps(node: SceneNode, graph: SceneGraph, props: JSXProp[]): void { + const bindings = Object.entries(node.boundVariables).toSorted(([a], [b]) => a.localeCompare(b)) + if (bindings.length === 0) return + props.push([ + 'bind', + Object.fromEntries(bindings.map(([field, id]) => [field, variableRef(graph, id)])) + ]) +} + +export function collectStateProps( + node: SceneNode, + ctx: ReturnType, + graph: SceneGraph, + props: JSXProp[] +): void { + collectConstraintProps(node, ctx, props) + collectSizeLimitProps(node, props) + if (node.isMask) props.push(['mask', node.maskType.toLowerCase()]) + if (!node.visible) props.push(['visible', false]) + if (node.locked) props.push(['locked', true]) + collectBindingProps(node, graph, props) +} diff --git a/packages/design-jsx/src/export/value.ts b/packages/design-jsx/src/export/value.ts new file mode 100644 index 000000000..cfbd60749 --- /dev/null +++ b/packages/design-jsx/src/export/value.ts @@ -0,0 +1,68 @@ +import { es, jsx, type SyntaxNode } from '@open-pencil/codegen' + +/** A paint, effect, or variable helper call inside a prop value, such as `solid("#FF0000")`. */ +export class HelperCall { + constructor( + readonly helper: string, + readonly args: JSXValue[] + ) {} +} + +/** A prop value: plain data, possibly holding helper calls. Undefined object fields are left out. */ +export type JSXValue = + | string + | number + | boolean + | null + | HelperCall + | JSXValue[] + | { [key: string]: JSXValue | undefined } + +/** A prop name and its value. */ +export type JSXProp = [name: string, value: JSXValue] + +/** A call with trailing undefined arguments dropped, so defaults stay implicit. */ +export function helperCall(helper: string, ...args: (JSXValue | undefined)[]): HelperCall { + const last = args.findLastIndex((arg) => arg !== undefined) + return new HelperCall( + helper, + args.slice(0, last + 1).map((arg) => arg ?? null) + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Plain scene data, such as a paint with no helper, as a prop value. Throws on values JSX + * cannot write back, such as binary data. + */ +export function plainValue(value: unknown): JSXValue { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return value + if (typeof value === 'number' && Number.isFinite(value)) return value + if (Array.isArray(value)) return value.map(plainValue) + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => + item === undefined ? [] : [[key, plainValue(item)]] + ) + ) + } + throw new TypeError(`Cannot write a ${typeof value} value as JSX`) +} + +/** The expression for a value, printed through the codegen builders. */ +export function valueSyntax(value: JSXValue): SyntaxNode { + if (value === null) return { type: 'Literal', value: null } + if (value instanceof HelperCall) return es.call(value.helper, value.args.map(valueSyntax)) + if (Array.isArray(value)) return es.array(value.map(valueSyntax)) + if (typeof value === 'object') { + return es.object( + Object.entries(value).flatMap(([key, item]) => + item === undefined ? [] : [[key, valueSyntax(item)] as const] + ) + ) + } + return jsx.literal(value) +} diff --git a/packages/design-jsx/src/overrides/effects.ts b/packages/design-jsx/src/overrides/effects.ts new file mode 100644 index 000000000..865f32326 --- /dev/null +++ b/packages/design-jsx/src/overrides/effects.ts @@ -0,0 +1,51 @@ +import type { Effect, SceneNode } from '@open-pencil/scene-graph' +import { parseColor } from '@open-pencil/scene-graph/color' +import { TRANSPARENT } from '@open-pencil/scene-graph/constants' + +function isEffect(value: unknown): value is Effect { + return ( + value !== null && + typeof value === 'object' && + 'type' in value && + 'radius' in value && + 'visible' in value + ) +} + +function shadowShorthand(value: string): Effect | null { + const parts = value.split(/\s+/) + if (parts.length < 4) return null + return { + type: 'DROP_SHADOW', + color: parseColor(parts.slice(3).join(' ')), + offset: { x: Number.parseFloat(parts[0]), y: Number.parseFloat(parts[1]) }, + radius: Number.parseFloat(parts[2]), + spread: 0, + visible: true + } +} + +/** `effects` takes structured effects; `shadow` and `blur` append one drop shadow or layer blur. */ +export function applyEffectOverrides(props: Record, o: Partial): void { + if (Array.isArray(props.effects)) { + const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect)) + if (effects.length > 0) o.effects = effects + } + + const shadow = typeof props.shadow === 'string' ? shadowShorthand(props.shadow) : null + if (shadow) o.effects = [...(o.effects ?? []), shadow] + + if (typeof props.blur === 'number') { + o.effects = [ + ...(o.effects ?? []), + { + type: 'LAYER_BLUR', + radius: props.blur, + visible: true, + color: { ...TRANSPARENT }, + offset: { x: 0, y: 0 }, + spread: 0 + } + ] + } +} diff --git a/packages/design-jsx/src/overrides/state.ts b/packages/design-jsx/src/overrides/state.ts new file mode 100644 index 000000000..1317f552d --- /dev/null +++ b/packages/design-jsx/src/overrides/state.ts @@ -0,0 +1,65 @@ +import type { ConstraintType, SceneNode } from '@open-pencil/scene-graph' + +const CONSTRAINT_VALUES: Record = { + min: 'MIN', + center: 'CENTER', + max: 'MAX', + stretch: 'STRETCH', + scale: 'SCALE' +} + +const MASK_TYPES: Record = { + alpha: 'ALPHA', + luminance: 'LUMINANCE', + vector: 'VECTOR' +} + +function constraintValue(value: unknown): ConstraintType | undefined { + return typeof value === 'string' ? CONSTRAINT_VALUES[value.toLowerCase()] : undefined +} + +/** The `constraints` prop, shaped like Figma's `constraints` object. */ +interface ConstraintsValue { + horizontal?: unknown + vertical?: unknown +} + +function isConstraintsValue(value: unknown): value is ConstraintsValue { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function applyConstraints(value: unknown, o: Partial): void { + if (!isConstraintsValue(value)) return + const h = constraintValue(value.horizontal) + const v = constraintValue(value.vertical) + if (h) o.horizontalConstraint = h + if (v) o.verticalConstraint = v +} + +function sizeLimit(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +/** + * Visibility, locking, mask, constraint, and size-limit props. Constraints follow Figma's + * `constraints` object with its values in lowercase, as `blendMode` is. + */ +export function applyStateOverrides(props: Record, o: Partial): void { + if (typeof props.visible === 'boolean') o.visible = props.visible + if (typeof props.locked === 'boolean') o.locked = props.locked + applyConstraints(props.constraints, o) + + const minWidth = sizeLimit(props.minW) + const maxWidth = sizeLimit(props.maxW) + const minHeight = sizeLimit(props.minH) + const maxHeight = sizeLimit(props.maxH) + if (minWidth !== undefined) o.minWidth = minWidth + if (maxWidth !== undefined) o.maxWidth = maxWidth + if (minHeight !== undefined) o.minHeight = minHeight + if (maxHeight !== undefined) o.maxHeight = maxHeight + + if (props.mask) { + o.isMask = true + o.maskType = (typeof props.mask === 'string' && MASK_TYPES[props.mask]) || 'ALPHA' + } +} diff --git a/packages/design-jsx/src/overrides/strokes.ts b/packages/design-jsx/src/overrides/strokes.ts new file mode 100644 index 000000000..da12b4171 --- /dev/null +++ b/packages/design-jsx/src/overrides/strokes.ts @@ -0,0 +1,129 @@ +import type { SceneNode, Stroke, StrokeCap, StrokeJoin } from '@open-pencil/scene-graph' +import { parseColor } from '@open-pencil/scene-graph/color' +import type { Color } from '@open-pencil/scene-graph/primitives' + +const ALIGN_VALUES: Record = { + inside: 'INSIDE', + center: 'CENTER', + outside: 'OUTSIDE' +} + +const CAP_VALUES: Record = { + none: 'NONE', + round: 'ROUND', + square: 'SQUARE', + arrow_lines: 'ARROW_LINES', + arrow_equilateral: 'ARROW_EQUILATERAL' +} + +const JOIN_VALUES: Record = { + miter: 'MITER', + bevel: 'BEVEL', + round: 'ROUND' +} + +/** One entry of the `strokes` prop; enums are lowercase, as `blendMode` is. */ +export interface StrokeValue { + color: string | Color + /** Defaults to the colour's alpha. */ + opacity?: number + weight?: number + align?: string + dash?: number[] + cap?: string + join?: string + visible?: boolean +} + +function isColor(value: unknown): value is Color { + return value !== null && typeof value === 'object' && 'r' in value && 'a' in value +} + +function isStrokeValue(value: unknown): value is StrokeValue { + if (value === null || typeof value !== 'object' || !('color' in value)) return false + return typeof value.color === 'string' || isColor(value.color) +} + +function enumValue(values: Record, value: unknown): T | undefined { + return typeof value === 'string' ? values[value.toLowerCase()] : undefined +} + +function dashValue(value: unknown): number[] | undefined { + if (!Array.isArray(value)) return undefined + const dash = value.filter((item): item is number => typeof item === 'number') + return dash.length > 0 ? dash : undefined +} + +function toStroke(value: StrokeValue): Stroke { + const color = typeof value.color === 'string' ? parseColor(value.color) : value.color + const stroke: Stroke = { + color, + opacity: typeof value.opacity === 'number' ? value.opacity : color.a, + visible: value.visible ?? true, + weight: value.weight ?? 1, + align: enumValue(ALIGN_VALUES, value.align) ?? 'INSIDE' + } + const dash = dashValue(value.dash) + if (dash) stroke.dashPattern = dash + const cap = enumValue(CAP_VALUES, value.cap) + if (cap) stroke.cap = cap + const join = enumValue(JOIN_VALUES, value.join) + if (join) stroke.join = join + return stroke +} + +/** `strokeWeights` names each side; a side it leaves out has no stroke. */ +interface StrokeWeights { + top?: unknown + right?: unknown + bottom?: unknown + left?: unknown +} + +function isStrokeWeights(value: unknown): value is StrokeWeights { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function sideWeight(value: unknown): number { + return typeof value === 'number' ? value : 0 +} + +function applyStrokeWeights(value: unknown, o: Partial): void { + if (!isStrokeWeights(value)) return + o.independentStrokeWeights = true + o.borderTopWeight = sideWeight(value.top) + o.borderRightWeight = sideWeight(value.right) + o.borderBottomWeight = sideWeight(value.bottom) + o.borderLeftWeight = sideWeight(value.left) +} + +/** `strokes` takes structured strokes; `stroke` and its sibling props describe a single one. */ +export function applyStrokeOverrides(props: Record, o: Partial): void { + applyStrokeWeights(props.strokeWeights, o) + // Node-level cap and join, which strokes without their own use. + const nodeCap = enumValue(CAP_VALUES, props.strokeCap) + if (nodeCap) o.strokeCap = nodeCap + const nodeJoin = enumValue(JOIN_VALUES, props.strokeJoin) + if (nodeJoin) o.strokeJoin = nodeJoin + // The node's own dash pattern, separate from the stroke-local dash `strokeDash` sets. + const nodeDash = dashValue(props.dashPattern) + if (nodeDash) o.dashPattern = nodeDash + + if (Array.isArray(props.strokes)) { + const strokes = props.strokes.filter(isStrokeValue).map(toStroke) + if (strokes.length > 0) o.strokes = strokes + return + } + + const color = props.stroke ?? props.border ?? props.borderColor + if (typeof color !== 'string' && !isColor(color)) return + o.strokes = [ + toStroke({ + color, + weight: + (props.strokeWidth as number | undefined) ?? (props.borderWidth as number | undefined), + align: props.strokeAlign as string | undefined, + dash: props.strokeDash as number[] | undefined + }) + ] +} diff --git a/packages/design-jsx/src/props-overrides.ts b/packages/design-jsx/src/props-overrides.ts index 8867889be..6c3b4f74f 100644 --- a/packages/design-jsx/src/props-overrides.ts +++ b/packages/design-jsx/src/props-overrides.ts @@ -1,17 +1,11 @@ -/* eslint-disable max-lines -- JSX property conversion stays together to preserve shared coercion rules */ - -import type { - Effect, - Fill, - GridTrack, - LayoutMode, - SceneNode, - Stroke -} from '@open-pencil/scene-graph' -import { colorToFill, parseColor } from '@open-pencil/scene-graph/color' -import { TRANSPARENT } from '@open-pencil/scene-graph/constants' +import type { Fill, GridTrack, LayoutMode, SceneNode } from '@open-pencil/scene-graph' +import { colorToFill } from '@open-pencil/scene-graph/color' import type { Color, JSONObject } from '@open-pencil/scene-graph/primitives' +import { applyEffectOverrides } from './overrides/effects' +import { applyStateOverrides } from './overrides/state' +import { applyStrokeOverrides } from './overrides/strokes' + const WEIGHT_MAP: Record = { normal: 400, medium: 500, @@ -55,7 +49,8 @@ const TEXT_ALIGN_ALIAS_MAP: Record = { const TEXT_AUTO_RESIZE_MAP: Record = { none: 'NONE', width: 'WIDTH_AND_HEIGHT', - height: 'HEIGHT' + height: 'HEIGHT', + truncate: 'TRUNCATE' } const DIRECTION_MAP: Record = { @@ -69,17 +64,6 @@ function parseDirection(value: unknown): SceneNode['textDirection'] | undefined return DIRECTION_MAP[value.toLowerCase()] ?? 'AUTO' } -function parseStroke(value: string | Color, width: number): Stroke { - const color = typeof value === 'string' ? parseColor(value) : value - return { - color, - opacity: color.a, - visible: true, - weight: width, - align: 'INSIDE' - } -} - function numberFromPx(value: unknown): number | undefined { if (typeof value === 'number') return value if (typeof value !== 'string') return undefined @@ -208,14 +192,6 @@ function applyFillOverride(props: Record, o: Partial if (isFillValue(bg)) o.fills = [fillFromValue(bg)] } -function applyStrokeOverride(props: Record, o: Partial): void { - const stroke = props.stroke ?? props.border ?? props.borderColor - if (typeof stroke !== 'string' && !isColor(stroke)) return - const strokeWidth = - (props.strokeWidth as number | undefined) ?? (props.borderWidth as number | undefined) ?? 1 - o.strokes = [parseStroke(stroke, strokeWidth)] -} - function applyCornerOverrides(props: Record, o: Partial): void { const rounded = props.rounded ?? props.cornerRadius ?? props.borderRadius if (typeof rounded === 'number') o.cornerRadius = rounded @@ -238,7 +214,7 @@ function applyCornerOverrides(props: Record, o: Partial, o: Partial): void { applyFillOverride(props, o) - applyStrokeOverride(props, o) + applyStrokeOverrides(props, o) applyCornerOverrides(props, o) if (props.opacity !== undefined) o.opacity = props.opacity as number @@ -247,14 +223,6 @@ function applyVisualOverrides(props: Record, o: Partial = { - luminance: 'LUMINANCE', - vector: 'VECTOR' - } - o.maskType = maskTypeMap[props.mask as string] ?? 'ALPHA' - } } function applyTransformOverrides(props: Record, o: Partial): void { @@ -451,9 +419,6 @@ function applyLayoutOverrides( applyPaddingOverrides(props, o) if (props.grow !== undefined) o.layoutGrow = props.grow as number - - if (props.minW !== undefined) o.width = Math.max(o.width ?? 0, props.minW as number) - if (props.maxW !== undefined) o.width = Math.min(o.width ?? Infinity, props.maxW as number) } function applyTextStyleOverrides(props: Record, o: Partial): void { @@ -464,6 +429,8 @@ function applyTextStyleOverrides(props: Record, o: Partial, o: Partial): void { - if (Array.isArray(props.effects)) { - const effects = props.effects.filter(isEffect).map((effect) => structuredClone(effect)) - if (effects.length > 0) o.effects = effects - } - +function applyShapeOverrides(props: Record, o: Partial): void { if (props.points !== undefined) o.pointCount = props.points as number if (props.innerRadius !== undefined) o.starInnerRadius = props.innerRadius as number if (props.pointCount !== undefined) o.pointCount = props.pointCount as number - - if (typeof props.shadow === 'string') { - const parts = props.shadow.split(/\s+/) - if (parts.length >= 4) { - const c = parseColor(parts.slice(3).join(' ')) - o.effects = [ - ...(o.effects ?? []), - { - type: 'DROP_SHADOW', - color: c, - offset: { x: Number.parseFloat(parts[0]), y: Number.parseFloat(parts[1]) }, - radius: Number.parseFloat(parts[2]), - spread: 0, - visible: true - } - ] - } - } - - if (typeof props.blur === 'number') { - o.effects = [ - ...(o.effects ?? []), - { - type: 'LAYER_BLUR', - radius: props.blur, - visible: true, - color: { ...TRANSPARENT }, - offset: { x: 0, y: 0 }, - spread: 0 - } - ] - } } export function propsToOverrides( @@ -603,7 +523,9 @@ export function propsToOverrides( applyVisualOverrides(props, o) applyLayoutOverrides(props, o, w, h, isText, parentLayout) if (isText) applyTextOverrides(props, o, parentLayout) - applyShapeAndEffectOverrides(props, o) + applyShapeOverrides(props, o) + applyEffectOverrides(props, o) + applyStateOverrides(props, o) return o } diff --git a/packages/design-jsx/src/reference/authoring.md b/packages/design-jsx/src/reference/authoring.md index fb2b76271..24716a91d 100644 --- a/packages/design-jsx/src/reference/authoring.md +++ b/packages/design-jsx/src/reference/authoring.md @@ -12,14 +12,16 @@ This reference describes scene creation, not React DOM output. Use the `render` - For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow. - `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`. - `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately. +- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it. - Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed. ## Paint, text, and artwork - `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`. +- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`. - `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct. - `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning. -- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available. +- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available. - `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible. - Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work. diff --git a/packages/design-jsx/src/schema.ts b/packages/design-jsx/src/schema.ts index 0f4e2018a..2e02ec3d9 100644 --- a/packages/design-jsx/src/schema.ts +++ b/packages/design-jsx/src/schema.ts @@ -60,6 +60,7 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [ 'top', 'left', 'position', + 'constraints', 'p', 'padding', 'px', @@ -80,6 +81,11 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [ 'borderWidth', 'strokeAlign', 'strokeDash', + 'strokeCap', + 'strokeJoin', + 'dashPattern', + 'strokes', + 'strokeWeights', 'rounded', 'borderRadius', 'roundedTL', @@ -93,6 +99,9 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [ 'rotate', 'rotation', 'overflow', + 'mask', + 'visible', + 'locked', 'shadow', 'blur', 'effects', @@ -102,6 +111,7 @@ export const DESIGN_JSX_SUPPORTED_PROPERTY_NAMES = [ 'fontFamily', 'weight', 'fontWeight', + 'italic', 'color', 'text', 'characters', diff --git a/packages/design-jsx/src/tree.ts b/packages/design-jsx/src/tree.ts index 901c4d904..f1635d0e4 100644 --- a/packages/design-jsx/src/tree.ts +++ b/packages/design-jsx/src/tree.ts @@ -137,6 +137,7 @@ export type StyleProps = { strokeWidth?: number | DesignVariable strokeAlign?: 'inside' | 'outside' | 'center' strokeDash?: number[] | boolean + dashPattern?: number[] rounded?: number | DesignVariable roundedTL?: number | DesignVariable roundedTR?: number | DesignVariable diff --git a/packages/design-jsx/tests/export.test.ts b/packages/design-jsx/tests/export.test.ts index ff055e634..67c1ddbe5 100644 --- a/packages/design-jsx/tests/export.test.ts +++ b/packages/design-jsx/tests/export.test.ts @@ -221,7 +221,7 @@ describe('sceneNodeToJSX', () => { expect(jsx).toContain('strokeWidth={2}') }) - test('hidden children are excluded', () => { + test('hidden children are kept and marked hidden', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { name: 'Parent', @@ -239,9 +239,14 @@ describe('sceneNodeToJSX', () => { height: 50, visible: false }) - const jsx = sceneNodeToJSX(frame.id, graph) - expect(jsx).toContain('Visible') - expect(jsx).not.toContain('Hidden') + expect(sceneNodeToJSX(frame.id, graph)).toBe( + [ + '', + ' ', + ' ', + '' + ].join('\n') + ) }) test('empty text node renders self-closing', () => { @@ -301,6 +306,54 @@ describe('sceneNodeToJSX', () => { expect(jsx).toContain('blur={4}') }) + test('effects: two shadows become one effects prop, not duplicate attributes', () => { + const graph = makeGraph() + const shadow = { + type: 'DROP_SHADOW' as const, + color: { r: 0, g: 0, b: 0, a: 0.2 }, + offset: { x: 0, y: 2 }, + radius: 4, + spread: 0, + visible: true + } + const node = graph.createNode('RECTANGLE', pageId(graph), { + name: 'Card', + width: 10, + height: 10, + effects: [shadow, { ...shadow, offset: { x: 0, y: 8 }, radius: 16 }] + }) + expect(sceneNodeToJSX(node.id, graph)).toBe( + [ + '' + ].join('\n') + ) + }) + + test('effects: a background blur is not exported as a layer blur', () => { + const graph = makeGraph() + const node = graph.createNode('RECTANGLE', pageId(graph), { + name: 'Glass', + width: 10, + height: 10, + effects: [ + { + type: 'BACKGROUND_BLUR', + color: { r: 0, g: 0, b: 0, a: 0 }, + offset: { x: 0, y: 0 }, + radius: 12, + spread: 0, + visible: true + } + ] + }) + expect(sceneNodeToJSX(node.id, graph)).toBe( + '' + ) + }) + test('grid layout frame', () => { const graph = makeGraph() const frame = graph.createNode('FRAME', pageId(graph), { diff --git a/packages/docs/programmable/jsx-renderer.md b/packages/docs/programmable/jsx-renderer.md index 27deb281a..ec7272ac6 100644 --- a/packages/docs/programmable/jsx-renderer.md +++ b/packages/docs/programmable/jsx-renderer.md @@ -28,7 +28,7 @@ openpencil export design.fig -f jsx # OpenPencil format openpencil export design.fig -f jsx --style tailwind # Tailwind classes ``` -Exported OpenPencil JSX can be edited and rendered back into the document. Check supported features and actual rendering before claiming round-trip fidelity. See [CLI exports](./cli/exporting). +Exported OpenPencil JSX can be edited and rendered back into the document. The export writes hidden and locked layers, constraints, size limits, stacked, gradient, and image fills, strokes, effects, masks, and variable bindings, so rendering an export reproduces them and `diff_jsx` shows changes to any of them. Rich text with mixed styles, vector paths, layout grids, shared styles, and component property definitions are not written yet. Instances are written as frames with their content, so exported JSX stands on its own. See [CLI exports](./cli/exporting). ## Visual Diffing diff --git a/packages/docs/reference/design-authoring.md b/packages/docs/reference/design-authoring.md index 0e6a96fbd..e04aa7748 100644 --- a/packages/docs/reference/design-authoring.md +++ b/packages/docs/reference/design-authoring.md @@ -14,14 +14,16 @@ This reference describes scene creation, not React DOM output. Use the `render` - For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow. - `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`. - `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately. +- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it. - Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed. ## Paint, text, and artwork - `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`. +- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`. - `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct. - `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning. -- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available. +- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available. - `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible. - Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work. @@ -70,4 +72,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a **Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`. -**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`. \ No newline at end of file +**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`. \ No newline at end of file diff --git a/packages/fig/docs/observations/styled-saved-glyphs.md b/packages/fig/docs/observations/styled-saved-glyphs.md index c195d7f8b..ae585af46 100644 --- a/packages/fig/docs/observations/styled-saved-glyphs.md +++ b/packages/fig/docs/observations/styled-saved-glyphs.md @@ -1,6 +1,6 @@ # Styled saved glyph fixture -[`tests/fixtures/styled-saved-glyphs.json`](../../../../tests/fixtures/styled-saved-glyphs.json), which the saved-glyph and occurrence-text canvas specs load, is a reduced SceneGraph conversion of a temporary Figma-authored text node containing `office affine` at 40 px Inter Regular. The first word has a red solid fill at 50% opacity; the second has a blue solid fill. The node was captured from Figma's Save local copy canvas payload, then the temporary page was removed. +`tests/fixtures/styled-saved-glyphs.json`, which the saved-glyph and occurrence-text canvas specs load, is a reduced SceneGraph conversion of a temporary Figma-authored text node containing `office affine` at 40 px Inter Regular. The first word has a red solid fill at 50% opacity; the second has a blue solid fill. The node was captured from Figma's Save local copy canvas payload, then the temporary page was removed. Only typography, dimensions, fills, style runs, and saved glyph outlines/character indices are retained. This is not a full document or font binary. It contains 13 glyphs for 13 characters and **does not prove ligature coverage**. diff --git a/skills/open-pencil/references/design-authoring.md b/skills/open-pencil/references/design-authoring.md index 0e6a96fbd..e04aa7748 100644 --- a/skills/open-pencil/references/design-authoring.md +++ b/skills/open-pencil/references/design-authoring.md @@ -14,14 +14,16 @@ This reference describes scene creation, not React DOM output. Use the `render` - For wrapping text in a column, prefer `w="fill"`; fixed-width text can use `textAutoResize="height"`. `maxLines` / `truncate` are intentional truncation, not fixes for accidental overflow. - `wrap` and `rowGap` enable wrapped flex rows. `grid`, `columns`, and `rows` enable grid (for example `columns="1fr 200px 1fr"`). Grid children use `colStart`, `rowStart`, `colSpan`, and `rowSpan`. The current grid `gap` shorthand takes precedence over `columnGap` and `rowGap`. - `flow="auto"|"ltr"|"rtl"` controls container flow; text `dir` controls writing direction. Preserve these separately. +- Outside auto-layout, and for `position="absolute"` children, `constraints={{ horizontal, vertical }}` sets how a layer follows its parent's resizing, with Figma's values in lowercase: `"min"`, `"center"`, `"max"`, `"stretch"`, or `"scale"`. `minW`, `maxW`, `minH`, and `maxH` bound a layer's size. `visible={false}` hides a layer and `locked` locks it. - Use measured node bounds and the existing `arrange` tool for independent artboards. Prefer layout constraints to calculating child coordinates; ordinary JavaScript arithmetic is appropriate when real geometry calculations are needed. ## Paint, text, and artwork - `bg` / `fill`, `stroke`, and text `color` accept colors and supported variable references. Set colors explicitly for predictable contrast. `fills` accepts structured paints; gradient helpers include `linearGradient`, `radialGradient`, `angularGradient`, and `diamondGradient`. +- `stroke` describes one stroke, refined by `strokeWidth`, `strokeAlign` (`"inside"`, `"center"`, `"outside"`), `strokeDash`, `strokeCap`, and `strokeJoin`. `strokes` takes several, as objects with `color`, `weight`, `align`, `dash`, `cap`, `join`, and `visible`; `strokeWeights={{ top, right, bottom, left }}` sets per-side widths. `dashPattern` sets the node's own dash pattern, separate from the per-stroke `strokeDash`. - `rounded` and `roundedTL`/`roundedTR`/`roundedBL`/`roundedBR` control corners. `strokeWidth`, `opacity`, `rotate`, and `blendMode` control appearance. `overflow="hidden"` clips content; do not hide accidental text overflow to make a broken layout appear correct. - `effects` accepts structured effects such as `dropShadow`, `innerShadow`, and `layerBlur`. `shadow="offsetX offsetY blur #color"` and `blur` are convenient shorthands. Effect helpers take `radius`, as Figma's effects do; when a JSX string is rendered, an option a paint or effect helper does not support is reported as a warning. -- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `lineHeight`, `letterSpacing`, `textAlign`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available. +- Text content belongs inside `Text`. Use `size`, `font`, `weight`, `italic`, `lineHeight`, `letterSpacing`, `textAlign`, `textAlignVertical`, `textDecoration`, and `textCase`. Verify fonts actually load before judging dimensions; do not assume every font is available. - `Icon` uses an Iconify name, size, and color. Prefer icons to emoji when reliable vector output is needed. Image fills belong on appropriate leaf shapes, not containers whose children must remain visible. - Design JSX props are the portable authoring interface. Some CSS-style aliases are supported, but this is not a browser CSS engine; do not assume arbitrary HTML, classes, or styles work. @@ -70,4 +72,4 @@ Generated from the renderer metadata. This inventory lists accepted names, not a **Helpers:** `solid`, `gradient`, `linearGradient`, `radialGradient`, `angularGradient`, `diamondGradient`, `dropShadow`, `innerShadow`, `layerBlur`, `backgroundBlur`, `foregroundBlur`, `designVar`, `defineVars`. -**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`. \ No newline at end of file +**Properties:** `name`, `key`, `flex`, `flow`, `dir`, `gap`, `wrap`, `rowGap`, `columnGap`, `justify`, `justifyContent`, `items`, `align`, `alignItems`, `grow`, `w`, `h`, `width`, `height`, `minW`, `maxW`, `minH`, `maxH`, `x`, `y`, `top`, `left`, `position`, `constraints`, `p`, `padding`, `px`, `py`, `pt`, `pr`, `pb`, `pl`, `bg`, `fill`, `fills`, `background`, `backgroundColor`, `stroke`, `border`, `borderColor`, `strokeWidth`, `borderWidth`, `strokeAlign`, `strokeDash`, `strokeCap`, `strokeJoin`, `dashPattern`, `strokes`, `strokeWeights`, `rounded`, `borderRadius`, `roundedTL`, `roundedTR`, `roundedBL`, `roundedBR`, `cornerRadius`, `cornerSmoothing`, `opacity`, `blendMode`, `rotate`, `rotation`, `overflow`, `mask`, `visible`, `locked`, `shadow`, `blur`, `effects`, `size`, `fontSize`, `font`, `fontFamily`, `weight`, `fontWeight`, `italic`, `color`, `text`, `characters`, `content`, `value`, `title`, `textAlign`, `textAlignHorizontal`, `textHorizontalAlignment`, `textAlignVertical`, `textVerticalAlignment`, `textAutoResize`, `lineHeight`, `letterSpacing`, `textDecoration`, `textCase`, `maxLines`, `truncate`, `grid`, `columns`, `rows`, `colStart`, `rowStart`, `col`, `row`, `colSpan`, `rowSpan`, `points`, `pointCount`, `innerRadius`, `label`, `style`, `bind`, `component`, `componentId`, `properties`, `propertyRefs`, `of`. \ No newline at end of file