From e396df3224efb716e79f8381f97fa870ae352140 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 28 Mar 2026 03:02:29 +0300 Subject: [PATCH] Add .pen document import Co-authored-by: Anton Soldatov --- CHANGELOG.md | 1 + packages/core/src/index.ts | 2 + packages/core/src/io/formats.ts | 22 + packages/core/src/io/index.ts | 1 + packages/core/src/pen-convert.ts | 463 +++++++++++++ packages/core/src/pen-file.ts | 513 +++++++++++++++ src/composables/use-menu.ts | 17 +- src/stores/tabs.ts | 19 +- tests/engine/pen-file.test.ts | 37 ++ tests/fixtures/pencil_button.pen | 113 ++++ tests/fixtures/pencil_simple.pen | 1049 ++++++++++++++++++++++++++++++ 11 files changed, 2227 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/pen-convert.ts create mode 100644 packages/core/src/pen-file.ts create mode 100644 tests/engine/pen-file.test.ts create mode 100644 tests/fixtures/pencil_button.pen create mode 100644 tests/fixtures/pencil_simple.pen diff --git a/CHANGELOG.md b/CHANGELOG.md index e7804caee..0531d5e72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Export selection or current page as `.fig` from the app export UI and app menu - New CLI commands: `open-pencil convert` for document conversion and `open-pencil formats` to inspect readable/writable/exportable formats - CLI export now supports `.fig` output and routes PNG/JPG/WEBP/SVG/JSX/`.fig` through the shared IO layer +- `Open…` now supports `.pen` Pencil documents through the shared document reader pipeline while keeping `.fig` as the native save format ### Fixes diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dcf924e07..c26c6e69e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -297,6 +297,8 @@ export { type OpenPencilClipboardData } from './clipboard' +export { readPenFile, parsePenFile } from './pen-file' + export { readFigFile, parseFigFile, diff --git a/packages/core/src/io/formats.ts b/packages/core/src/io/formats.ts index bc8e19588..304aca754 100644 --- a/packages/core/src/io/formats.ts +++ b/packages/core/src/io/formats.ts @@ -1,6 +1,7 @@ import { exportFigFile } from '../fig-export' import { headlessRenderNodes } from '../headless-render' import { parseFigFile } from '../kiwi' +import { parsePenFile } from '../pen-file' import { sceneNodeToJSX, selectionToJSX } from '../render' import { renderNodesToImage } from '../render-image' import { renderNodesToSVG } from '../svg-export' @@ -195,6 +196,26 @@ export const figFormat: IOFormatAdapter = { } } +export const penFormat: IOFormatAdapter = { + id: 'pen', + label: 'Pencil Document', + role: 'interchange-document', + category: 'document', + extensions: ['pen'], + mimeTypes: ['application/json', 'text/plain'], + support: { + readDocument: true + }, + matchesFile(fileName, mimeType) { + return lowerExt(fileName) === 'pen' || mimeType === 'application/json' + }, + async readDocument(input) { + const text = new TextDecoder().decode(input.data) + const graph = parsePenFile(text) + return { graph, sourceFormat: 'pen' } + } +} + export const pngFormat = rasterFormat('PNG') export const jpgFormat = rasterFormat('JPG') export const webpFormat = rasterFormat('WEBP') @@ -268,6 +289,7 @@ export const jsxFormat: IOFormatAdapter = { export const BUILTIN_IO_FORMATS: IOFormatAdapter[] = [ figFormat, + penFormat, pngFormat, jpgFormat, webpFormat, diff --git a/packages/core/src/io/index.ts b/packages/core/src/io/index.ts index acdc6eaa9..3d27374dc 100644 --- a/packages/core/src/io/index.ts +++ b/packages/core/src/io/index.ts @@ -3,6 +3,7 @@ export { extractExportGraph } from './subgraph' export { BUILTIN_IO_FORMATS, figFormat, + penFormat, pngFormat, jpgFormat, webpFormat, diff --git a/packages/core/src/pen-convert.ts b/packages/core/src/pen-convert.ts new file mode 100644 index 000000000..24979bc2b --- /dev/null +++ b/packages/core/src/pen-convert.ts @@ -0,0 +1,463 @@ +import { parseColor } from './color' +import { generateId } from './scene-graph' + +import type { + Color, + Effect, + Fill, + LayoutAlign, + LayoutCounterAlign, + LayoutMode, + LayoutSizing, + NodeType, + SceneGraph, + SceneNode, + Stroke, + StrokeCap, + StrokeJoin, + TextAlignVertical, + Variable, + VariableCollection, + VariableCollectionMode, + VariableType, + VariableValue +} from './scene-graph' +import type { Vector } from './types' + +export interface PenDocument { + version: string + children: PenNode[] + themes?: Record + variables?: Record +} + +export interface PenVariable { + type: 'color' | 'string' | 'number' + value: PenVariableValue[] | PenVariableValue | string | number +} + +interface PenVariableValue { + value: string | number + theme?: Record +} + +interface PenStroke { + align: 'inside' | 'center' | 'outside' + thickness: number | { top?: number; right?: number; bottom?: number; left?: number } + fill?: string + join?: string + cap?: string +} + +interface PenEffect { + type: string + shadowType?: string + color?: string + offset?: Vector + blur?: number + spread?: number +} + +interface PenFillObject { + type: string + color: string + enabled?: boolean +} + +type PenFill = string | PenFillObject | PenFillObject[] + +export interface PenNode { + type: string + id: string + name?: string + x?: number + y?: number + width?: number | string + height?: number | string + fill?: PenFill + opacity?: number + enabled?: boolean + clip?: boolean + rotation?: number + flipX?: boolean + flipY?: boolean + reusable?: boolean + cornerRadius?: number | string | (number | string)[] + stroke?: PenStroke + effect?: PenEffect | PenEffect[] + layout?: string + gap?: number + padding?: number | number[] + justifyContent?: string + alignItems?: string + children?: PenNode[] + content?: string + fontFamily?: string + fontSize?: number + fontWeight?: string | number + lineHeight?: number + letterSpacing?: number + textAlign?: string + textAlignVertical?: string + textGrowth?: string + ref?: string + descendants?: Record> + slot?: string[] + geometry?: string + iconFontName?: string + iconFontFamily?: string + weight?: number + model?: string + theme?: Record +} + +export interface VarContext { + byName: Map + activeModeId: string + collectionId: string + modeByThemeName: Map + resolveColor(ref: string): Color + resolveNumber(ref: string): number + resolveString(ref: string): string + setActiveTheme(themeName: string): void +} + +function penVarTypeToSceneType(t: string): VariableType { + if (t === 'color') return 'COLOR' + if (t === 'number') return 'FLOAT' + return 'STRING' +} + +function penValueToSceneValue(raw: string | number, type: VariableType): VariableValue { + if (type === 'COLOR' && typeof raw === 'string') return parseColor(raw) + if (type === 'FLOAT' && typeof raw === 'number') return raw + if (type === 'STRING') return String(raw) + if (typeof raw === 'number') return raw + return String(raw) +} + +function defaultForType(type: VariableType): VariableValue { + if (type === 'COLOR') return { r: 0, g: 0, b: 0, a: 1 } + if (type === 'FLOAT') return 0 + if (type === 'BOOLEAN') return false + return '' +} + +export function isVarRef(val: unknown): val is string { + return typeof val === 'string' && val.startsWith('$--') +} + +function varName(ref: string): string { + return ref.replace(/^\$/, '') +} + +export function bindIfVar(node: SceneNode, field: string, val: unknown, ctx: VarContext): void { + if (!isVarRef(val)) return + const entry = ctx.byName.get(varName(val)) + if (entry) node.boundVariables[field] = entry.id +} + +export function buildVarContext( + graph: SceneGraph, + penVars: Record, + themes: Record +): VarContext { + const collectionId = generateId() + const modes: VariableCollectionMode[] = [] + const themeKeys = Object.keys(themes) + + if (themeKeys.length > 0) { + const themeKey = themeKeys[0] + for (const modeName of themes[themeKey]) { + modes.push({ modeId: generateId(), name: modeName }) + } + } + if (modes.length === 0) { + modes.push({ modeId: generateId(), name: 'Default' }) + } + + const collection: VariableCollection = { + id: collectionId, + name: 'Variables', + modes, + defaultModeId: modes[0].modeId, + variableIds: [] + } + graph.addCollection(collection) + + const modeByThemeValue = new Map() + if (themeKeys.length > 0) { + const themeKey = themeKeys[0] + for (const mode of modes) { + modeByThemeValue.set(`${themeKey}:${mode.name}`, mode.modeId) + } + } + + const byName = new Map() + + for (const [name, def] of Object.entries(penVars)) { + const varId = generateId() + const varType = penVarTypeToSceneType(def.type) + const valuesByMode: Record = {} + + if (Array.isArray(def.value)) { + for (const entry of def.value) { + if (entry.theme) { + const [tKey, tVal] = Object.entries(entry.theme)[0] + const modeId = modeByThemeValue.get(`${tKey}:${tVal}`) + if (modeId) valuesByMode[modeId] = penValueToSceneValue(entry.value, varType) + } else { + valuesByMode[modes[0].modeId] = penValueToSceneValue(entry.value, varType) + } + } + } else { + valuesByMode[modes[0].modeId] = penValueToSceneValue(def.value as string | number, varType) + } + + for (const mode of modes) { + if (!(mode.modeId in valuesByMode)) { + valuesByMode[mode.modeId] = valuesByMode[modes[0].modeId] ?? defaultForType(varType) + } + } + + const variable: Variable = { + id: varId, + name, + type: varType, + collectionId, + valuesByMode, + description: '', + hiddenFromPublishing: false + } + graph.addVariable(variable) + byName.set(name, { id: varId, variable }) + } + + let activeModeId = modes[0].modeId + + function resolveVal(ref: string): VariableValue | undefined { + const entry = byName.get(ref.replace(/^\$/, '')) + if (!entry) return undefined + return ( + entry.variable.valuesByMode[activeModeId] ?? Object.values(entry.variable.valuesByMode)[0] + ) + } + + return { + byName, + activeModeId, + collectionId, + modeByThemeName: modeByThemeValue, + resolveColor(ref: string): Color { + const val = resolveVal(ref) + if (val === undefined) return parseColor(ref) + if (typeof val === 'object' && 'r' in val) return val + if (typeof val === 'string') return parseColor(val) + return { r: 0, g: 0, b: 0, a: 1 } + }, + resolveNumber(ref: string): number { + const val = resolveVal(ref) + return typeof val === 'number' ? val : 0 + }, + resolveString(ref: string): string { + const val = resolveVal(ref) + return typeof val === 'string' ? val : '' + }, + setActiveTheme(themeName: string) { + const modeId = modeByThemeValue.get(`theme:${themeName}`) + if (modeId) { + activeModeId = modeId + graph.activeMode.set(collectionId, modeId) + } + } + } +} + +function parseFillColor(fill: string | PenFillObject, ctx: VarContext): Color { + const raw = typeof fill === 'string' ? fill : fill.color + return isVarRef(raw) ? ctx.resolveColor(raw) : parseColor(raw) +} + +export function convertFill(fill: PenFill | undefined, ctx: VarContext, node?: SceneNode): Fill[] { + if (fill === undefined) return [] + const fills = Array.isArray(fill) ? fill : [fill] + return fills.map((item, index) => { + const visible = typeof item === 'string' ? true : item.enabled !== false + const color = parseFillColor(item, ctx) + const result: Fill = { type: 'SOLID', visible, opacity: color.a, color } + if (node) bindIfVar(node, `fills[${index}]`, typeof item === 'string' ? item : item.color, ctx) + return result + }) +} + +function strokeWeight(stroke: PenStroke): number { + return typeof stroke.thickness === 'number' + ? stroke.thickness + : Math.max(...Object.values(stroke.thickness)) +} + +export function convertStroke( + stroke: PenStroke | undefined, + ctx: VarContext, + node?: SceneNode +): Stroke[] { + if (!stroke?.fill) return [] + const color = isVarRef(stroke.fill) ? ctx.resolveColor(stroke.fill) : parseColor(stroke.fill) + let align: Stroke['align'] = 'CENTER' + if (stroke.align === 'inside') align = 'INSIDE' + else if (stroke.align === 'outside') align = 'OUTSIDE' + + const result: Stroke = { + visible: true, + color, + opacity: color.a, + weight: strokeWeight(stroke), + align, + dashPattern: [] + } + if (node) { + bindIfVar(node, 'strokes[0]', stroke.fill, ctx) + if (typeof stroke.thickness === 'object') { + node.independentStrokeWeights = true + node.borderTopWeight = stroke.thickness.top ?? 0 + node.borderRightWeight = stroke.thickness.right ?? 0 + node.borderBottomWeight = stroke.thickness.bottom ?? 0 + node.borderLeftWeight = stroke.thickness.left ?? 0 + } + node.strokeJoin = mapStrokeJoin(stroke.join) + node.strokeCap = mapStrokeCap(stroke.cap) + } + return [result] +} + +function mapStrokeJoin(join: string | undefined): StrokeJoin { + if (join === 'round') return 'ROUND' + if (join === 'bevel') return 'BEVEL' + return 'MITER' +} + +function mapStrokeCap(cap: string | undefined): StrokeCap { + if (cap === 'round') return 'ROUND' + if (cap === 'square') return 'SQUARE' + return 'NONE' +} + +export function convertEffects(effect: PenEffect | PenEffect[] | undefined): Effect[] { + if (!effect) return [] + const effects = Array.isArray(effect) ? effect : [effect] + return effects.flatMap((item) => { + if (item.type !== 'shadow') return [] + const color = item.color ? parseColor(item.color) : { r: 0, g: 0, b: 0, a: 0.25 } + return [ + { + type: item.shadowType === 'inner' ? 'INNER_SHADOW' : 'DROP_SHADOW', + visible: true, + blendMode: 'NORMAL', + color, + offset: item.offset ?? { x: 0, y: 0 }, + radius: item.blur ?? 0, + spread: item.spread ?? 0 + } satisfies Effect + ] + }) +} + +export function applyCornerRadius( + node: SceneNode, + radius: PenNode['cornerRadius'], + ctx: VarContext +): void { + if (radius === undefined) return + if (Array.isArray(radius)) { + const values = radius.map((value) => parseSize(value, 0, ctx).value) + node.independentCorners = true + node.topLeftRadius = values[0] ?? 0 + node.topRightRadius = values[1] ?? 0 + node.bottomRightRadius = values[2] ?? 0 + node.bottomLeftRadius = values[3] ?? 0 + return + } + node.cornerRadius = parseSize(radius, 0, ctx).value +} + +export function applyPadding(node: SceneNode, padding: PenNode['padding']): void { + if (padding === undefined) return + if (Array.isArray(padding)) { + node.paddingTop = padding[0] ?? 0 + node.paddingRight = padding[1] ?? 0 + node.paddingBottom = padding[2] ?? 0 + node.paddingLeft = padding[3] ?? 0 + return + } + node.paddingTop = padding + node.paddingRight = padding + node.paddingBottom = padding + node.paddingLeft = padding +} + +export function parseSize(value: number | string | undefined, fallback: number, ctx?: VarContext) { + if (value === undefined) return { value: fallback, sizing: 'FIXED' as LayoutSizing } + if (typeof value === 'number') return { value, sizing: 'FIXED' as LayoutSizing } + if (value === 'fill_container') return { value: fallback, sizing: 'FILL' as LayoutSizing } + if (value === 'hug_content') return { value: fallback, sizing: 'HUG' as LayoutSizing } + if (isVarRef(value) && ctx) + return { value: ctx.resolveNumber(value), sizing: 'FIXED' as LayoutSizing } + const parsed = Number(value) + return { value: Number.isFinite(parsed) ? parsed : fallback, sizing: 'FIXED' as LayoutSizing } +} + +export function mapLayoutMode(pen: PenNode): LayoutMode { + if (pen.layout === 'row' || pen.layout === 'horizontal') return 'HORIZONTAL' + if (pen.layout === 'column' || pen.layout === 'vertical') return 'VERTICAL' + return 'NONE' +} + +export function mapJustifyContent(value: string | undefined): LayoutAlign { + if (value === 'center') return 'CENTER' + if (value === 'end') return 'MAX' + if (value === 'space-between') return 'SPACE_BETWEEN' + return 'MIN' +} + +export function mapAlignItems(value: string | undefined): LayoutCounterAlign { + if (value === 'center') return 'CENTER' + if (value === 'end') return 'MAX' + if (value === 'stretch') return 'STRETCH' + return 'MIN' +} + +export function mapTextAlign(value: string | undefined): SceneNode['textAlignHorizontal'] { + if (value === 'center') return 'CENTER' + if (value === 'right' || value === 'end') return 'RIGHT' + if (value === 'justified') return 'JUSTIFIED' + return 'LEFT' +} + +export function mapTextAlignVertical(value: string | undefined): TextAlignVertical { + if (value === 'center') return 'CENTER' + if (value === 'bottom' || value === 'end') return 'BOTTOM' + return 'TOP' +} + +export function mapFontWeight(value: string | number | undefined): number { + if (typeof value === 'number') return value + if (value === 'thin') return 100 + if (value === 'extralight') return 200 + if (value === 'light') return 300 + if (value === 'medium') return 500 + if (value === 'semibold') return 600 + if (value === 'bold') return 700 + if (value === 'extrabold') return 800 + if (value === 'black') return 900 + return 400 +} + +export function mapNodeType(pen: PenNode): NodeType { + if (pen.type === 'frame') return pen.reusable ? 'COMPONENT' : 'FRAME' + if (pen.type === 'rectangle') return 'RECTANGLE' + if (pen.type === 'ellipse') return 'ELLIPSE' + if (pen.type === 'text' || pen.type === 'icon_font') return 'TEXT' + if (pen.type === 'path') return 'VECTOR' + if (pen.type === 'ref') return 'INSTANCE' + return 'FRAME' +} diff --git a/packages/core/src/pen-file.ts b/packages/core/src/pen-file.ts new file mode 100644 index 000000000..1c92cec5b --- /dev/null +++ b/packages/core/src/pen-file.ts @@ -0,0 +1,513 @@ +import { copyEffects, copyFills, copyStrokes } from './copy' +import { + applyCornerRadius, + applyPadding, + bindIfVar, + buildVarContext, + convertEffects, + convertFill, + convertStroke, + isVarRef, + mapAlignItems, + mapFontWeight, + mapJustifyContent, + mapLayoutMode, + mapNodeType, + mapTextAlign, + mapTextAlignVertical, + parseSize, + type PenDocument, + type PenNode, + type VarContext +} from './pen-convert' +import { SceneGraph } from './scene-graph' +import { populateInstanceChildren } from './scene-graph-instances' +import { parseSVGPath } from './svg-path-parse' + +import type { LayoutMode, LayoutSizing, SceneNode, VectorNetwork } from './scene-graph' + +function scaleVectorNetwork(vn: VectorNetwork, targetW: number, targetH: number): void { + if (vn.vertices.length === 0) return + let minX = Infinity + let maxX = -Infinity + let minY = Infinity + let maxY = -Infinity + for (const v of vn.vertices) { + minX = Math.min(minX, v.x) + maxX = Math.max(maxX, v.x) + minY = Math.min(minY, v.y) + maxY = Math.max(maxY, v.y) + } + const vnW = maxX - minX + const vnH = maxY - minY + if (vnW < 0.01 || vnH < 0.01) return + const sx = targetW / vnW + const sy = targetH / vnH + if (Math.abs(sx - 1) < 0.01 && Math.abs(sy - 1) < 0.01) return + for (const v of vn.vertices) { + v.x = (v.x - minX) * sx + v.y = (v.y - minY) * sy + } + for (const s of vn.segments) { + s.tangentStart = { x: s.tangentStart.x * sx, y: s.tangentStart.y * sy } + s.tangentEnd = { x: s.tangentEnd.x * sx, y: s.tangentEnd.y * sy } + } +} + +function resolveFontFamily(raw: string | undefined, ctx: VarContext): string { + if (!raw) return 'Inter' + if (isVarRef(raw)) return ctx.resolveString(raw) + return raw +} + +function buildBaseOverrides(pen: PenNode): Partial { + return { + id: pen.id, + name: pen.name ?? (pen.type === 'icon_font' ? (pen.iconFontName ?? 'Icon') : pen.type), + x: pen.x ?? 0, + y: pen.y ?? 0, + visible: pen.enabled !== false, + opacity: pen.opacity ?? 1, + rotation: pen.rotation ?? 0, + flipX: pen.flipX ?? false, + flipY: pen.flipY ?? false, + clipsContent: pen.clip ?? false, + boundVariables: {} + } +} + +function applyAutoLayout( + overrides: Partial, + layoutMode: LayoutMode, + pen: PenNode, + widthSizing: LayoutSizing, + heightSizing: LayoutSizing +): void { + overrides.layoutMode = layoutMode + overrides.primaryAxisAlign = mapJustifyContent(pen.justifyContent) + overrides.counterAxisAlign = mapAlignItems(pen.alignItems) + overrides.itemSpacing = pen.gap ?? 0 + + if (layoutMode === 'VERTICAL') { + overrides.primaryAxisSizing = heightSizing + overrides.counterAxisSizing = widthSizing + } else { + overrides.primaryAxisSizing = widthSizing + overrides.counterAxisSizing = heightSizing + } +} + +function applyTextProps(node: SceneNode, pen: PenNode, ctx: VarContext): void { + node.text = pen.type === 'icon_font' ? (pen.iconFontName ?? '') : (pen.content ?? '') + node.fontFamily = + pen.type === 'icon_font' + ? (pen.iconFontFamily ?? 'Material Symbols Sharp') + : resolveFontFamily(pen.fontFamily, ctx) + node.fontSize = pen.fontSize ?? 14 + node.fontWeight = mapFontWeight( + pen.fontWeight ?? (pen.type === 'icon_font' ? pen.weight : undefined) + ) + node.textAlignHorizontal = mapTextAlign(pen.textAlign) + node.textAlignVertical = mapTextAlignVertical(pen.textAlignVertical) + if (pen.lineHeight !== undefined) { + node.lineHeight = pen.lineHeight < 5 ? pen.lineHeight * node.fontSize : pen.lineHeight + } + if (pen.letterSpacing !== undefined) node.letterSpacing = pen.letterSpacing + node.textAutoResize = pen.textGrowth === 'fixed-width' ? 'HEIGHT' : 'WIDTH_AND_HEIGHT' + if (pen.fontFamily && isVarRef(pen.fontFamily)) { + bindIfVar(node, 'fontFamily', pen.fontFamily, ctx) + } +} + +function resolveSizing(pen: PenNode, ctx: VarContext) { + const isTextLike = pen.type === 'text' || pen.type === 'icon_font' + const defaultSize = isTextLike ? 20 : 100 + const defaultW = isTextLike && pen.width === undefined ? 10_000 : defaultSize + const w = parseSize(pen.width, defaultW, ctx) + const h = parseSize(pen.height, defaultSize, ctx) + const layout = mapLayoutMode(pen) + + if (pen.width === undefined && layout !== 'NONE') w.sizing = 'HUG' + if (pen.height === undefined && layout !== 'NONE') h.sizing = 'HUG' + + return { w, h, layout, isTextLike } +} + +function inheritLayoutFromComp(node: SceneNode, pen: PenNode, comp: SceneNode): void { + const wasRow = node.layoutMode === 'HORIZONTAL' + node.layoutMode = comp.layoutMode + node.primaryAxisAlign = comp.primaryAxisAlign + node.counterAxisAlign = comp.counterAxisAlign + const isRow = node.layoutMode === 'HORIZONTAL' + if (wasRow !== isRow) { + const oldP = node.primaryAxisSizing + node.primaryAxisSizing = node.counterAxisSizing + node.counterAxisSizing = oldP + } + const widthAxis = isRow ? 'primaryAxisSizing' : 'counterAxisSizing' + const heightAxis = isRow ? 'counterAxisSizing' : 'primaryAxisSizing' + if (pen.width === undefined) node[widthAxis] = comp[widthAxis] + if (pen.height === undefined) node[heightAxis] = comp[heightAxis] + if (pen.gap === undefined) node.itemSpacing = comp.itemSpacing + if (pen.padding === undefined) { + node.paddingTop = comp.paddingTop + node.paddingRight = comp.paddingRight + node.paddingBottom = comp.paddingBottom + node.paddingLeft = comp.paddingLeft + } + if (pen.clip === undefined) node.clipsContent = comp.clipsContent +} + +function applyRefVisuals( + node: SceneNode, + pen: PenNode, + compPen: PenNode | undefined, + ctx: VarContext +): void { + if (!compPen) return + if (pen.fill === undefined && compPen.fill !== undefined) + node.fills = convertFill(compPen.fill, ctx, node) + if (pen.stroke === undefined && compPen.stroke) + node.strokes = convertStroke(compPen.stroke, ctx, node) + if (pen.effect === undefined && compPen.effect) node.effects = convertEffects(compPen.effect) + if (pen.cornerRadius === undefined) applyCornerRadius(node, compPen.cornerRadius, ctx) +} + +function applyRefProps( + node: SceneNode, + pen: PenNode, + graph: SceneGraph, + componentIds: Map, + penSources: Map, + ctx: VarContext +): void { + if (!pen.ref) return + node.componentId = componentIds.get(pen.ref) ?? pen.ref + const comp = graph.getNode(node.componentId) + if (!comp) return + if (pen.width === undefined) node.width = comp.width + if (pen.height === undefined) node.height = comp.height + if (pen.layout === undefined) inheritLayoutFromComp(node, pen, comp) + applyRefVisuals(node, pen, penSources.get(pen.ref), ctx) +} + +function applyAllRefProps( + penNodes: PenNode[], + graph: SceneGraph, + componentIds: Map, + penSources: Map, + ctx: VarContext +): void { + for (const pen of penNodes) { + if (pen.type === 'ref') { + const node = graph.getNode(pen.id) + if (node) applyRefProps(node, pen, graph, componentIds, penSources, ctx) + } + if (pen.children) applyAllRefProps(pen.children, graph, componentIds, penSources, ctx) + } +} + +function applyTheme(theme: Record, ctx: VarContext): void { + const themeName = Object.values(theme)[0] + if (themeName) ctx.setActiveTheme(themeName) +} + +// eslint-disable-next-line complexity -- .pen node mapping touches many format-specific fields +function createSceneNode( + pen: PenNode, + parentId: string, + graph: SceneGraph, + ctx: VarContext, + componentIds: Map, + penSources: Map +): string | null { + if (pen.type === 'prompt') return null + if (pen.theme) applyTheme(pen.theme, ctx) + + const { w, h, layout, isTextLike } = resolveSizing(pen, ctx) + const overrides = buildBaseOverrides(pen) + overrides.width = w.value + overrides.height = h.value + + const parentLayout = graph.getNode(parentId)?.layoutMode ?? 'NONE' + if (layout !== 'NONE') { + const widthSizing = + parentLayout === 'NONE' && w.sizing === 'FILL' ? ('FIXED' as LayoutSizing) : w.sizing + const heightSizing = + parentLayout === 'NONE' && h.sizing === 'FILL' ? ('FIXED' as LayoutSizing) : h.sizing + applyAutoLayout(overrides, layout, pen, widthSizing, heightSizing) + } + + const node = graph.createNode(mapNodeType(pen), parentId, overrides) + + if (pen.fill !== undefined) node.fills = convertFill(pen.fill, ctx, node) + if (pen.stroke) node.strokes = convertStroke(pen.stroke, ctx, node) + node.effects = convertEffects(pen.effect) + applyCornerRadius(node, pen.cornerRadius, ctx) + applyPadding(node, pen.padding) + + if (isTextLike) { + applyTextProps(node, pen, ctx) + if (parentLayout === 'NONE' && pen.width === undefined && !pen.textGrowth) { + node.textAutoResize = 'NONE' + node.width = node.text.length * node.fontSize * 0.65 + node.height = node.fontSize * (node.lineHeight ? node.lineHeight / node.fontSize : 1.2) + } + } + + if (pen.type === 'path' && pen.geometry) { + node.vectorNetwork = parseSVGPath(pen.geometry) + scaleVectorNetwork(node.vectorNetwork, node.width, node.height) + } + + if (parentLayout !== 'NONE') { + const parentVertical = parentLayout === 'VERTICAL' + if (w.sizing === 'FILL') { + if (parentVertical) node.layoutAlignSelf = 'STRETCH' + else node.layoutGrow = 1 + } + if (h.sizing === 'FILL') { + if (parentVertical) node.layoutGrow = 1 + else node.layoutAlignSelf = 'STRETCH' + } + } + + if (pen.reusable) { + componentIds.set(pen.id, node.id) + penSources.set(pen.id, pen) + } + + if (pen.children) { + for (const child of pen.children) { + createSceneNode(child, node.id, graph, ctx, componentIds, penSources) + } + } + + return node.id +} + +function collectByNameType( + graph: SceneGraph, + parentId: string, + name: string, + type: string, + out: SceneNode[], + depth: number +): void { + if (depth > 2) return + const parent = graph.getNode(parentId) + if (!parent) return + for (const childId of parent.childIds) { + const child = graph.getNode(childId) + if (!child) continue + if (child.name === name && child.type === type) out.push(child) + collectByNameType(graph, childId, name, type, out, depth + 1) + } +} + +function findCloneByComponentId( + graph: SceneGraph, + parentId: string, + origId: string +): SceneNode | undefined { + const parent = graph.getNode(parentId) + if (!parent) return undefined + for (const childId of parent.childIds) { + const child = graph.getNode(childId) + if (!child) continue + if (child.componentId === origId) return child + const deep = findCloneByComponentId(graph, childId, origId) + if (deep) return deep + } + return undefined +} + +function findCloneByNameFallback( + graph: SceneGraph, + parentId: string, + origId: string +): SceneNode | undefined { + const orig = graph.getNode(origId) + if (!orig) return undefined + const matches: SceneNode[] = [] + collectByNameType(graph, parentId, orig.name, orig.type, matches, 0) + return matches.length === 1 ? matches[0] : undefined +} + +function applyOverrideProps( + target: SceneNode, + overrideData: Partial, + ctx: VarContext +): void { + if (overrideData.fill !== undefined) target.fills = convertFill(overrideData.fill, ctx, target) + if (overrideData.content !== undefined) target.text = overrideData.content + if (overrideData.x !== undefined) target.x = overrideData.x + if (overrideData.y !== undefined) target.y = overrideData.y + if (overrideData.enabled !== undefined) target.visible = overrideData.enabled + if (overrideData.width !== undefined) + target.width = parseSize(overrideData.width, target.width, ctx).value + if (overrideData.height !== undefined) + target.height = parseSize(overrideData.height, target.height, ctx).value + if (overrideData.rotation !== undefined) target.rotation = overrideData.rotation + if (overrideData.name !== undefined) target.name = overrideData.name +} + +function populateInstances(graph: SceneGraph): void { + for (const node of graph.getAllNodes()) { + if (node.type === 'INSTANCE' && node.componentId && node.childIds.length === 0) { + const component = graph.getNode(node.componentId) + if (component) populateInstanceChildren(graph, node.id, node.componentId) + } + } +} + +function applyDescendantOverrides( + graph: SceneGraph, + pen: PenNode, + ctx: VarContext, + componentIds: Map, + penSources: Map +): void { + if (pen.type !== 'ref' || !pen.descendants) return + const instanceNode = graph.getNode(pen.id) + if (!instanceNode) return + + for (const [origId, overrideData] of Object.entries(pen.descendants)) { + const clone = + findCloneByComponentId(graph, instanceNode.id, origId) ?? + findCloneByNameFallback(graph, instanceNode.id, origId) + + if (clone) { + if (overrideData.children) { + const toDelete = clone.childIds.slice() + for (const childId of toDelete) graph.deleteNode(childId) + for (const child of overrideData.children) { + createSceneNode(child, clone.id, graph, ctx, componentIds, penSources) + } + } + applyOverrideProps(clone, overrideData, ctx) + continue + } + + if (overrideData.type && overrideData.id) { + createSceneNode( + overrideData as PenNode, + instanceNode.id, + graph, + ctx, + componentIds, + penSources + ) + } + } +} + +function walkAndApplyOverrides( + nodes: PenNode[], + graph: SceneGraph, + ctx: VarContext, + componentIds: Map, + penSources: Map +): void { + for (const pen of nodes) { + applyDescendantOverrides(graph, pen, ctx, componentIds, penSources) + if (pen.children) walkAndApplyOverrides(pen.children, graph, ctx, componentIds, penSources) + } +} + +function collectComponentIds(nodes: PenNode[], map: Map): void { + for (const node of nodes) { + if (node.reusable) map.set(node.id, node.id) + if (node.children) collectComponentIds(node.children, map) + } +} + +function resolveNodeVars(node: SceneNode, graph: SceneGraph, ctx: VarContext): void { + for (const [key, varId] of Object.entries(node.boundVariables)) { + const variable = graph.variables.get(varId) + if (!variable) continue + const modeVal = + variable.valuesByMode[ctx.activeModeId] ?? Object.values(variable.valuesByMode)[0] + if (key.startsWith('fills[') && typeof modeVal === 'object' && 'r' in modeVal) { + const idx = Number.parseInt(key.match(/\d+/)?.[0] ?? '0', 10) + if (node.fills[idx]) node.fills[idx].color = modeVal + } else if (key.startsWith('strokes[') && typeof modeVal === 'object' && 'r' in modeVal) { + const idx = Number.parseInt(key.match(/\d+/)?.[0] ?? '0', 10) + if (node.strokes[idx]) node.strokes[idx].color = modeVal + } + } + for (const childId of node.childIds) { + const child = graph.getNode(childId) + if (child) resolveNodeVars(child, graph, ctx) + } +} + +function resolveThemeVariables(penNodes: PenNode[], graph: SceneGraph, ctx: VarContext): void { + for (const pen of penNodes) { + if (pen.theme) applyTheme(pen.theme, ctx) + const node = graph.getNode(pen.id) + if (node) resolveNodeVars(node, graph, ctx) + if (pen.children) resolveThemeVariables(pen.children, graph, ctx) + } +} + +function fixInstanceWidths(graph: SceneGraph): void { + for (const node of graph.getAllNodes()) { + if (node.type !== 'INSTANCE' || !node.componentId) continue + const comp = graph.getNode(node.componentId) + if (!comp) continue + if (node.width <= 100 && comp.width > 100) node.width = comp.width + if (node.height <= 100 && comp.height > 100) node.height = comp.height + if (comp.layoutGrow > 0) node.layoutGrow = comp.layoutGrow + if (comp.layoutAlignSelf !== 'AUTO') node.layoutAlignSelf = comp.layoutAlignSelf + node.fills = copyFills(node.fills) + node.strokes = copyStrokes(node.strokes) + node.effects = copyEffects(node.effects) + } +} + +function fixTextWidths(graph: SceneGraph): void { + for (const node of graph.getAllNodes()) { + if (node.type !== 'TEXT' || !node.text || node.text.length <= 1) continue + if (node.width >= node.fontSize * 2) continue + node.width = node.text.length * node.fontSize * 0.65 + } +} + +export function parsePenFile(json: string): SceneGraph { + const doc: PenDocument = JSON.parse(json) + const graph = new SceneGraph() + + for (const page of graph.getPages(true)) { + graph.deleteNode(page.id) + } + + const ctx = buildVarContext(graph, doc.variables ?? {}, doc.themes ?? {}) + const componentIds = new Map() + const penSources = new Map() + + collectComponentIds(doc.children, componentIds) + + const page = graph.addPage(doc.children[0]?.name ?? 'Page 1') + for (const child of doc.children) { + createSceneNode(child, page.id, graph, ctx, componentIds, penSources) + } + + applyAllRefProps(doc.children, graph, componentIds, penSources, ctx) + populateInstances(graph) + walkAndApplyOverrides(doc.children, graph, ctx, componentIds, penSources) + populateInstances(graph) + resolveThemeVariables(doc.children, graph, ctx) + fixInstanceWidths(graph) + fixTextWidths(graph) + + if (graph.getPages(true).length === 0) { + graph.addPage('Page 1') + } + + return graph +} + +export async function readPenFile(file: File): Promise { + return parsePenFile(await file.text()) +} diff --git a/src/composables/use-menu.ts b/src/composables/use-menu.ts index 86565acfa..1210f14e6 100644 --- a/src/composables/use-menu.ts +++ b/src/composables/use-menu.ts @@ -5,7 +5,7 @@ import { IS_BROWSER, IS_TAURI } from '@/constants' import { useEditorStore } from '@/stores/editor' import { openFileInNewTab, createTab, closeTab, activeTab } from '@/stores/tabs' -const fileDialog = useFileDialog({ accept: '.fig', multiple: false, reset: true }) +const fileDialog = useFileDialog({ accept: '.fig,.pen', multiple: false, reset: true }) fileDialog.onChange((files) => { const file = files?.[0] if (file) void openFileInNewTab(file) @@ -28,7 +28,7 @@ export async function openFileDialog() { const { open } = await import('@tauri-apps/plugin-dialog') const { readFile } = await import('@tauri-apps/plugin-fs') const path = await open({ - filters: [{ name: 'Figma file', extensions: ['fig'] }], + filters: [{ name: 'Design file', extensions: ['fig', 'pen'] }], multiple: false }) if (!path) return @@ -43,8 +43,12 @@ export async function openFileDialog() { const [handle] = await window.showOpenFilePicker({ types: [ { - description: 'Figma file', - accept: { 'application/octet-stream': ['.fig'] } + description: 'Design file', + accept: { + 'application/octet-stream': ['.fig'], + 'application/json': ['.pen'], + 'text/plain': ['.pen'] + } } ] }) @@ -59,11 +63,16 @@ export async function openFileDialog() { fileDialog.open() } +export async function importFileDialog() { + await openFileDialog() +} + const store = useEditorStore() const MENU_ACTIONS: Partial void>> = { new: () => createTab(), open: () => void openFileDialog(), + import: () => void importFileDialog(), close: () => { if (activeTab.value) closeTab(activeTab.value.id) }, diff --git a/src/stores/tabs.ts b/src/stores/tabs.ts index ab23e1fc6..31b0b6836 100644 --- a/src/stores/tabs.ts +++ b/src/stores/tabs.ts @@ -1,5 +1,7 @@ import { shallowRef, computed, triggerRef } from 'vue' +import { BUILTIN_IO_FORMATS, IORegistry } from '@open-pencil/core' + import { createEditorStore, setActiveEditorStore } from './editor' import type { EditorStore } from './editor' @@ -10,6 +12,8 @@ export interface Tab { store: EditorStore } +const io = new IORegistry(BUILTIN_IO_FORMATS) + let nextTabId = 1 function generateTabId(): string { @@ -82,23 +86,26 @@ export async function openFileInNewTab( const current = activeTab.value const isUntouched = current?.store.state.documentName === 'Untitled' && !current.store.undo.canUndo + const bytes = new Uint8Array(await file.arrayBuffer()) + const { graph: imported } = await io.readDocument({ + name: file.name, + mimeType: file.type || undefined, + data: bytes + }) + const documentName = file.name.replace(/\.[^.]+$/i, '') if (isUntouched) { - const { readFigFile } = await import('@open-pencil/core') - const imported = await readFigFile(file) current.store.replaceGraph(imported) current.store.undo.clear() - current.store.state.documentName = file.name.replace(/\.fig$/i, '') + current.store.state.documentName = documentName current.store.state.selectedIds = new Set() const pageId = current.store.graph.getPages()[0]?.id ?? current.store.graph.rootId await current.store.switchPage(pageId) } else { - const { readFigFile } = await import('@open-pencil/core') - const imported = await readFigFile(file) const store = createEditorStore(imported) createTab(store) store.undo.clear() - store.state.documentName = file.name.replace(/\.fig$/i, '') + store.state.documentName = documentName store.state.selectedIds = new Set() const pageId = store.graph.getPages()[0]?.id ?? store.graph.rootId await store.switchPage(pageId) diff --git a/tests/engine/pen-file.test.ts b/tests/engine/pen-file.test.ts new file mode 100644 index 000000000..681d1e01a --- /dev/null +++ b/tests/engine/pen-file.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { join } from 'node:path' + +import { parsePenFile } from '@open-pencil/core' + +const FIXTURE_DIR = join(import.meta.dir, '..', 'fixtures') + +async function loadFixture(name: string): Promise { + return Bun.file(join(FIXTURE_DIR, name)).text() +} + +describe('parsePenFile', () => { + test('imports variables and theme modes', async () => { + const graph = parsePenFile(await loadFixture('pencil_simple.pen')) + const collections = [...graph.variableCollections.values()] + + expect(collections.length).toBe(1) + expect(collections[0].modes.map((mode) => mode.name)).toEqual(['Light', 'Dark']) + expect(graph.variables.size).toBeGreaterThan(0) + }) + + test('maps reusable frames to components', async () => { + const graph = parsePenFile(await loadFixture('pencil_button.pen')) + const components = [...graph.getAllNodes()].filter((node) => node.type === 'COMPONENT') + + expect(components.length).toBeGreaterThan(0) + expect(components[0]?.name).toContain('Button') + }) + + test('maps path geometry to vector nodes', async () => { + const graph = parsePenFile(await loadFixture('pencil_button.pen')) + const vectors = [...graph.getAllNodes()].filter((node) => node.type === 'VECTOR') + + expect(vectors.length).toBeGreaterThan(0) + expect(vectors.some((node) => (node.vectorNetwork?.vertices.length ?? 0) > 0)).toBe(true) + }) +}) diff --git a/tests/fixtures/pencil_button.pen b/tests/fixtures/pencil_button.pen new file mode 100644 index 000000000..3d8732dee --- /dev/null +++ b/tests/fixtures/pencil_button.pen @@ -0,0 +1,113 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "T3Um0", + "x": 462.7680165404179, + "y": 258.2749319076538, + "name": "Button/Large/Default", + "reusable": true, + "height": 48, + "fill": "$--primary", + "cornerRadius": "$--radius-pill", + "gap": 6, + "padding": [ + 12, + 24 + ], + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "fd4qq", + "name": "plus", + "width": 24, + "height": 24, + "stroke": { + "align": "inside", + "thickness": 1 + }, + "layout": "none", + "children": [ + { + "type": "path", + "id": "NWqkO", + "x": 6, + "y": 6, + "name": "Vector", + "geometry": "M7.07104 3.53555l-3.53552 0-3.53552 0m3.53552-3.53555l0 3.53555 0 3.53549", + "width": 12, + "height": 12, + "stroke": { + "align": "center", + "thickness": 1.5, + "join": "round", + "cap": "round", + "fill": "$--primary-foreground" + } + } + ] + }, + { + "type": "text", + "id": "8RtfK", + "name": "Button", + "fill": "$--primary-foreground", + "content": "Button", + "lineHeight": 1.5555555555555556, + "textAlign": "center", + "textAlignVertical": "middle", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "500" + } + ] + } + ], + "themes": { + "Mode": [ + "Light", + "Dark" + ] + }, + "variables": { + "--primary": { + "type": "color", + "value": [ + { + "value": "#FF8400" + }, + { + "value": "#FF8400", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--radius-pill": { + "type": "number", + "value": 999 + }, + "--primary-foreground": { + "type": "color", + "value": [ + { + "value": "#111111" + }, + { + "value": "#111111", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--font-primary": { + "type": "string", + "value": "JetBrains Mono" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/pencil_simple.pen b/tests/fixtures/pencil_simple.pen new file mode 100644 index 000000000..27c287fb7 --- /dev/null +++ b/tests/fixtures/pencil_simple.pen @@ -0,0 +1,1049 @@ +{ + "version": "2.8", + "children": [ + { + "type": "frame", + "id": "tMcTy", + "x": -342.4820139771605, + "y": 300, + "name": "Step 2 Frame", + "theme": { + "Mode": "Dark" + }, + "clip": true, + "width": 1440, + "layout": "vertical", + "children": [ + { + "type": "frame", + "id": "8vM6Y", + "name": "table", + "width": "fill_container", + "fill": "$--background", + "cornerRadius": "$--radius-none", + "stroke": { + "align": "inside", + "thickness": 1, + "fill": "$--border" + }, + "layout": "vertical", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "yenJQ", + "name": "headerRow", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "sqVKX", + "name": "statusHeader", + "width": 160, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "lmf6z", + "name": "Label", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Status", + "lineHeight": 1.4285714285714286, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "ezfCh", + "name": "nameHeader", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "MyCud", + "name": "Label", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Name", + "lineHeight": 1.4285714285714286, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "0ldrD", + "name": "descHeader", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "TcZUY", + "name": "Label", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Description", + "lineHeight": 1.4285714285714286, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xZ35W", + "name": "dateHeader", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "fn6Yp", + "name": "Label", + "fill": "$--muted-foreground", + "textGrowth": "fixed-width", + "width": "fill_container", + "content": "Return date", + "lineHeight": 1.4285714285714286, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "xQYgw", + "name": "row1", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "xyh1A", + "name": "cell1_1", + "width": 160, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "7sf3L", + "name": "label1", + "fill": "$--color-success", + "cornerRadius": "$--radius-pill", + "stroke": { + "align": "inside", + "thickness": 1 + }, + "gap": 4, + "padding": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "hQfg7", + "name": "icon-Star-2", + "enabled": false, + "clip": true, + "width": 16, + "height": 16, + "fill": { + "type": "color", + "color": "#ffffffff", + "enabled": false + }, + "stroke": { + "align": "inside", + "thickness": 1 + }, + "layout": "none", + "children": [ + { + "type": "path", + "id": "bfsaA", + "x": 1.8756510019302368, + "y": 1.89599609375, + "name": "Vector 2134", + "geometry": "M9.187 0l-2.81 6.376-6.377 2.811 6.377 2.811 2.81 6.378 2.812-6.377 6.376-2.811-6.376-2.811-2.812-6.377z", + "width": 12.25, + "height": 12.250665664672852, + "stroke": { + "align": "center", + "thickness": 1, + "fill": "$--color-success-foreground" + } + } + ] + }, + { + "type": "text", + "id": "vyafU", + "name": "Text", + "fill": "$--color-success-foreground", + "content": "New", + "lineHeight": 1.1428571428571428, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "KcWns", + "name": "cell1_2", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "dmRQr", + "name": "text1_2", + "fill": "$--foreground", + "content": "Aurora Scout", + "fontFamily": "$--font-secondary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "gfjMN", + "name": "cell1_3", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "C5Srv", + "name": "text1_3", + "fill": "$--foreground", + "content": "Lightweight rover ideal for high-speed reconnaissance.", + "fontFamily": "$--font-secondary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "eQLRn", + "name": "cell1_4", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "8V0iw", + "name": "text1_4", + "fill": "$--foreground", + "content": "Sol 543", + "fontFamily": "$--font-secondary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "fBU8e", + "name": "row2", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "IXh0q", + "name": "cell2_1", + "width": 160, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "bBwqn", + "name": "label2", + "fill": "$--color-warning", + "cornerRadius": "$--radius-pill", + "stroke": { + "align": "inside", + "thickness": 1 + }, + "gap": 4, + "padding": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "kuVAj", + "name": "icon-Star-2", + "enabled": false, + "clip": true, + "width": 16, + "height": 16, + "fill": { + "type": "color", + "color": "#ffffffff", + "enabled": false + }, + "stroke": { + "align": "inside", + "thickness": 1 + }, + "layout": "none", + "children": [ + { + "type": "path", + "id": "xpHWn", + "x": 1.8756510019302368, + "y": 1.89599609375, + "name": "Vector 2134", + "geometry": "M9.187 0l-2.81 6.376-6.377 2.811 6.377 2.811 2.81 6.378 2.812-6.377 6.376-2.811-6.376-2.811-2.812-6.377z", + "width": 12.25, + "height": 12.250665664672852, + "stroke": { + "align": "center", + "thickness": 1, + "fill": "$--color-warning-foreground" + } + } + ] + }, + { + "type": "text", + "id": "f8lmB", + "name": "Text", + "fill": "$--color-warning-foreground", + "content": "Active", + "lineHeight": 1.1428571428571428, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "Aq6nR", + "name": "cell2_2", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "QcQDZ", + "name": "text2_2", + "fill": "$--foreground", + "content": "Curiosity-X", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "op4VX", + "name": "cell2_3", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "GAr4U", + "name": "text2_3", + "fill": "$--foreground", + "content": "Reliable all-rounder for exploration and sample collection.", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "8hHaO", + "name": "cell2_4", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "2yr9Q", + "name": "text2_4", + "fill": "$--foreground", + "content": "Sol 527", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "lCEOB", + "name": "row3", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "0V1hD", + "name": "cell3_1", + "width": 160, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "m4d8M", + "name": "label3", + "fill": "$--color-warning", + "cornerRadius": "$--radius-pill", + "stroke": { + "align": "inside", + "thickness": 1 + }, + "gap": 4, + "padding": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "xtD4B", + "name": "icon-Star-2", + "enabled": false, + "clip": true, + "width": 16, + "height": 16, + "fill": { + "type": "color", + "color": "#ffffffff", + "enabled": false + }, + "stroke": { + "align": "inside", + "thickness": 1 + }, + "layout": "none", + "children": [ + { + "type": "path", + "id": "RUy24", + "x": 1.8756510019302368, + "y": 1.89599609375, + "name": "Vector 2134", + "geometry": "M9.187 0l-2.81 6.376-6.377 2.811 6.377 2.811 2.81 6.378 2.812-6.377 6.376-2.811-6.376-2.811-2.812-6.377z", + "width": 12.25, + "height": 12.250665664672852, + "stroke": { + "align": "center", + "thickness": 1, + "fill": "$--color-warning-foreground" + } + } + ] + }, + { + "type": "text", + "id": "4CH4H", + "name": "Text", + "fill": "$--color-warning-foreground", + "content": "Active", + "lineHeight": 1.1428571428571428, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "ISiZP", + "name": "cell3_2", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "89Sn8", + "name": "text3_2", + "fill": "$--foreground", + "content": "Pathfinder Neo", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "byVdB", + "name": "cell3_3", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "OlqlE", + "name": "text3_3", + "fill": "$--foreground", + "content": "Compact model for short-range geological missions.", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "i5v93", + "name": "cell3_4", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "XvQPg", + "name": "text3_4", + "fill": "$--foreground", + "content": "Sol 519", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "vs7w0", + "name": "row4", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "bottom": 1 + }, + "fill": "$--border" + }, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "DuO65", + "name": "cell4_1", + "width": 160, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "zPJ49", + "name": "label4", + "fill": "$--secondary", + "cornerRadius": "$--radius-pill", + "stroke": { + "align": "inside", + "thickness": 1 + }, + "gap": 4, + "padding": 8, + "justifyContent": "center", + "alignItems": "center", + "children": [ + { + "type": "frame", + "id": "HGqBt", + "name": "icon-Star-2", + "enabled": false, + "clip": true, + "width": 16, + "height": 16, + "fill": { + "type": "color", + "color": "#ffffffff", + "enabled": false + }, + "stroke": { + "align": "inside", + "thickness": 1 + }, + "layout": "none", + "children": [ + { + "type": "path", + "id": "Y9iKG", + "x": 1.8756510019302368, + "y": 1.89599609375, + "name": "Vector 2134", + "geometry": "M9.187 0l-2.81 6.376-6.377 2.811 6.377 2.811 2.81 6.378 2.812-6.377 6.376-2.811-6.376-2.811-2.812-6.377z", + "width": 12.25, + "height": 12.250665664672852, + "stroke": { + "align": "center", + "thickness": 1, + "fill": "$--secondary-foreground" + } + } + ] + }, + { + "type": "text", + "id": "VpMV3", + "name": "Text", + "fill": "$--secondary-foreground", + "content": "Maintenance", + "lineHeight": 1.1428571428571428, + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + }, + { + "type": "frame", + "id": "KBNRC", + "name": "cell4_2", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "UP7Sw", + "name": "text4_2", + "fill": "$--foreground", + "content": "Spirit-9", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "aXh0R", + "name": "cell4_3", + "width": "fill_container", + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "iU8Zz", + "name": "text4_3", + "fill": "$--foreground", + "content": "Completed mapping of Sector D-12 successfully.", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + }, + { + "type": "frame", + "id": "xboou", + "name": "cell4_4", + "width": 240, + "stroke": { + "align": "inside", + "thickness": { + "right": 1 + } + }, + "gap": 8, + "padding": 12, + "alignItems": "center", + "children": [ + { + "type": "text", + "id": "WWEhL", + "name": "text4_4", + "fill": "$--foreground", + "content": "—", + "fontFamily": "$--font-primary", + "fontSize": 14, + "fontWeight": "normal" + } + ] + } + ] + } + ] + } + ] + } + ], + "themes": { + "Mode": [ + "Light", + "Dark" + ] + }, + "variables": { + "--background": { + "type": "color", + "value": [ + { + "value": "#F2F3F0" + }, + { + "value": "#111111", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--border": { + "type": "color", + "value": [ + { + "value": "#CBCCC9" + }, + { + "value": "#2E2E2E", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--radius-none": { + "type": "number", + "value": 0 + }, + "--muted-foreground": { + "type": "color", + "value": [ + { + "value": "#666666" + }, + { + "value": "#B8B9B6", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--font-primary": { + "type": "string", + "value": "JetBrains Mono" + }, + "--color-success": { + "type": "color", + "value": [ + { + "value": "#DFE6E1" + }, + { + "value": "#222924", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--radius-pill": { + "type": "number", + "value": 999 + }, + "--color-success-foreground": { + "type": "color", + "value": [ + { + "value": "#004D1A" + }, + { + "value": "#B6FFCE", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--foreground": { + "type": "color", + "value": [ + { + "value": "#111111" + }, + { + "value": "#FFFFFF", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--font-secondary": { + "type": "string", + "value": "Geist" + }, + "--color-warning": { + "type": "color", + "value": [ + { + "value": "#E9E3D8" + }, + { + "value": "#291C0F", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--color-warning-foreground": { + "type": "color", + "value": [ + { + "value": "#804200" + }, + { + "value": "#FF8400", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--secondary": { + "type": "color", + "value": [ + { + "value": "#E7E8E5" + }, + { + "value": "#2E2E2E", + "theme": { + "Mode": "Dark" + } + } + ] + }, + "--secondary-foreground": { + "type": "color", + "value": [ + { + "value": "#111111" + }, + { + "value": "#FFFFFF", + "theme": { + "Mode": "Dark" + } + } + ] + } + } +} \ No newline at end of file