diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b91139c82..cbf6522d9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -25,7 +25,7 @@ bun run check Package publishing changes should also run: ```sh -bun scripts/smoke-packages.ts +bun run test:packages ``` ## Code conventions diff --git a/AGENTS.md b/AGENTS.md index 65fa6e843..a35c4822b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ The root app (`src/`) is the Tauri/Vite desktop editor. App-specific editor, doc | `@open-pencil/core/editor` | createEditor, Editor, EditorState | — | | `@open-pencil/core/tools` | ToolDef, ALL_TOOLS, AI adapter | diff | | `@open-pencil/core/kiwi` | .fig parse/serialize, codec, protocol | fflate, fzstd | +| `@open-pencil/core/clipboard` | Figma/OpenPencil clipboard parsing and import helpers | — | | `@open-pencil/core/rpc` | RPC commands for CLI | — | | `@open-pencil/core/lint` | design linter rules and presets | — | | `@open-pencil/core/profiler` | render profiling | — | @@ -96,7 +97,8 @@ The app editor session (`src/app/editor/session/create.ts`) is a thin Vue wrappe - `bun run check` — type-aware lint + typecheck via oxlint + tsgo + architecture checks (run before committing) - `bun run check:arch` — Steiger architecture lint for project-specific import boundaries - `bun run check:vue` — vue-tsc type-check for .vue files (has pre-existing errors, fix progressively) -- `bun run test:dupes` — jscpd copy-paste detection across all TS sources +- `bun run test:dupes` — jscpd copy-paste detection across product TS sources +- `bun run test:tools` — tests for private repo tooling under `tools/*` - `bun run format` — oxfmt with import sorting - `bun test ./tests/engine` — unit tests - `bun run test` — Playwright visual regression @@ -146,6 +148,7 @@ Run all quality gates (see [Code quality](#code-quality) for the self-review che bun run check # oxlint + tsgo type-aware lint & typecheck bun run format # oxfmt bun run test:dupes # jscpd — zero clones +bun run test:tools # private repo tooling tests bun run test:unit # bun:test bun run test # Playwright E2E ``` @@ -253,6 +256,20 @@ OpenPencil follows a Reka UI-inspired component namespace structure: - Multi-file root components live inside their component namespace folder, not beside it. - Use subfolders for multi-file domains instead of sibling files with repeated prefixes. Prefer `selection/container.ts`, `selection/hit-test.ts` over `selection-container.ts`, `selection-hit-test.ts`. When adding a second file for a domain (e.g. `eval-wrap.ts` next to `eval.ts`), create the folder immediately (`eval/index.ts` + `eval/wrap.ts`) instead of prefixing. Oxlint catches sibling prefix files when a sibling folder exists; Steiger catches 3+ sibling files with the same prefix. The convention applies even before either rule triggers. +### Repo tools and scripts + +Private repository tooling lives under `tools//`, not as ad-hoc root scripts. Use kebab-case domain folders and split by capability inside `src/`: + +```text +tools// + package.json + src/index.ts + src/.ts + tests/.test.ts +``` + +Use `scripts/` only for tiny compatibility entrypoint shims that import `../tools//src/...`; do not put implementation logic there. Workflow helpers, release packaging helpers, architecture rules, package checks, visual-oracle utilities, and other maintainable programs belong in `tools/` with focused tests when they contain logic. Steiger enforces tool layout and script shims. `bun run check` includes `bun run test:tools`, and lint/format cover `tools/`. + - `@/` import alias for app cross-directory imports; app feature code lives under `src/app/*` - Use package-local aliases inside workspace packages: `#vue/*` in `packages/vue`, `#cli/*` in `packages/cli`, `#mcp/*` in `packages/mcp`, and `#core/*` when core code needs an alias. Prefer relative imports within nearby core modules when that is clearer than an alias. - No `any` — use proper types, generics, declaration merging @@ -280,6 +297,7 @@ Before submitting a PR, run the full quality gate and do a self-review: bun run check # oxlint + tsgo type-aware lint & typecheck — zero errors required bun run format # oxfmt with import sorting bun run test:dupes # jscpd — zero clones required +bun run test:tools # private repo tooling tests bun run test:unit # bun:test bun run test # Playwright E2E ``` diff --git a/oxlint.json b/oxlint.json index bb4022c85..d4a995247 100644 --- a/oxlint.json +++ b/oxlint.json @@ -464,7 +464,8 @@ }, { "files": [ - "scripts/**/*.ts" + "scripts/**/*.ts", + "tools/**/*.ts" ], "rules": { "no-console": "off" diff --git a/package.json b/package.json index dcd0f4ab1..796b6608a 100644 --- a/package.json +++ b/package.json @@ -17,8 +17,8 @@ "format": "oxfmt --write .oxfmtrc.json vite.config.ts vite/ src/ packages/core/src/ packages/cli/src/ packages/mcp/src/ packages/vue/src/ tests scripts/ tools/", "format:check": "bun run format && status=$(git status --porcelain -uall) && test -z \"$status\" || (echo \"$status\" && exit 1)", "check": "bun run build:packages && bun run lint && tsgo --noEmit && bun run check:vue && bun run check:i18n && bun run check:packages && bun run check:arch && bun run test:type-shapes && bun run test:tools && bun run test:dupes", - "check:i18n": "bun scripts/check-locales.ts", - "check:packages": "bun scripts/check-package-metadata.ts", + "check:i18n": "bun tools/i18n/src/check-locales.ts", + "check:packages": "bun tools/package-quality/src/check-metadata.ts", "check:arch": "steiger .", "check:vue": "vue-tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p packages/vue/tsconfig.json", "test": "playwright test --project=openpencil", @@ -27,17 +27,17 @@ "figma:debug": "open -a Figma --args --remote-debugging-port=9222", "test:unit": "bun test ./tests/engine", "test:coverage": "bun test --coverage ./tests/engine", - "test:type-shapes": "bun scripts/type-shapes.ts", + "test:type-shapes": "bun tools/type-shapes/src/index.ts", "test:tools": "bun tools/test.ts", "test:dupes": "jscpd packages/core/src packages/cli/src src --min-lines 5 --min-tokens 50 --format typescript --threshold 0", - "test:packages": "bun scripts/check-package-metadata.ts && bun scripts/smoke-packages.ts", + "test:packages": "bun tools/package-quality/src/check-metadata.ts && bun tools/package-quality/src/smoke.ts", "build:packages": "bun --filter @open-pencil/core build && bun --filter @open-pencil/vue build && bun --filter @open-pencil/mcp build && bun --filter @open-pencil/cli build", "open-pencil": "bun packages/cli/src/index.ts", "docs:dev": "bun --filter @open-pencil/docs dev", "docs:build": "bun --filter @open-pencil/docs build", "docs:preview": "bun --filter @open-pencil/docs preview", - "visual-compare": "bun scripts/visual-compare.ts", - "generate:tauri-menu": "bun scripts/generate-tauri-menu.ts" + "visual-compare": "bun tools/visual-oracles/src/compare.ts", + "generate:tauri-menu": "bun tools/tauri-menu/src/generate.ts" }, "dependencies": { "@ai-sdk/anthropic": "^3.0.74", diff --git a/packages/core/package.json b/packages/core/package.json index 5c36d11c4..1a34dce99 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -113,6 +113,11 @@ "default": "./dist/kiwi/index.js", "import": "./dist/kiwi/index.js" }, + "./clipboard": { + "types": "./dist/clipboard.d.ts", + "default": "./dist/clipboard.js", + "import": "./dist/clipboard.js" + }, "./constants": { "types": "./dist/constants.d.ts", "default": "./dist/constants.js", diff --git a/packages/docs/development/roadmap.md b/packages/docs/development/roadmap.md index a3c3451f1..c8aed67dd 100644 --- a/packages/docs/development/roadmap.md +++ b/packages/docs/development/roadmap.md @@ -19,7 +19,7 @@ OpenPencil is moving toward production-grade Figma compatibility while keeping d ### Figma fidelity - Preserve and round-trip more Figma metadata safely. -- Add visual regression coverage for full multi-page `.fig` documents. `scripts/export-fixture-visuals.ts` exports current smoke fixture pages to `/tmp` for manual comparison without committing large images; `tests/fixtures/figma-oracles/visual-comparison-report.json` records the current Figma-vs-OpenPencil oracle diff findings. +- Add visual regression coverage for full multi-page `.fig` documents. `tools/visual-oracles/src/export-fixtures.ts` exports current smoke fixture pages to `/tmp` for manual comparison without committing large images; `tests/fixtures/figma-oracles/visual-comparison-report.json` records the current Figma-vs-OpenPencil oracle diff findings. - Close high-impact renderer gaps: remaining mask edge cases, blend isolation, pattern fills, and broader variable-font fixtures. - Improve boolean operation editing/export now that imported Figma `BOOLEAN_OPERATION` nodes remain boolean operations. diff --git a/scripts/export-fixture-visuals.ts b/scripts/export-fixture-visuals.ts index 4bba74d11..39c2cec5b 100644 --- a/scripts/export-fixture-visuals.ts +++ b/scripts/export-fixture-visuals.ts @@ -1,55 +1,2 @@ #!/usr/bin/env bun - -import { mkdirSync } from 'node:fs' -import { parseArgs } from 'node:util' - -import { $ } from 'bun' - -interface FixtureExportTarget { - file: string - page: string - outputName: string - heavy?: boolean -} - -const TARGETS: FixtureExportTarget[] = [ - { - file: 'tests/fixtures/gold-preview.fig', - page: 'Page 1', - outputName: 'gold-preview-page-1.png' - }, - { - file: 'tests/fixtures/material3.fig', - page: 'Getting started', - outputName: 'material3-getting-started.png' - }, - { - file: 'tests/fixtures/nuxtui.fig', - page: 'Components', - outputName: 'nuxtui-components.png', - heavy: true - } -] - -const { values } = parseArgs({ - options: { - output: { type: 'string', short: 'o', default: '/tmp/open-pencil-fixture-visuals' }, - heavy: { type: 'boolean', default: false } - } -}) - -const outputDir = values.output ?? '/tmp/open-pencil-fixture-visuals' -mkdirSync(outputDir, { recursive: true }) - -for (const target of TARGETS) { - if (target.heavy && !values.heavy) { - console.log(`Skipping ${target.file} (${target.page}); pass --heavy to include it.`) - continue - } - - const outputPath = `${outputDir}/${target.outputName}` - console.log(`Exporting ${target.file} / ${target.page} → ${outputPath}`) - await $`bun open-pencil export ${target.file} --page ${target.page} --output ${outputPath}` -} - -console.log(`Fixture visuals written to ${outputDir}`) +import '../tools/visual-oracles/src/export-fixtures' diff --git a/scripts/generate-tauri-menu.ts b/scripts/generate-tauri-menu.ts index dada21fb4..1407f4c72 100644 --- a/scripts/generate-tauri-menu.ts +++ b/scripts/generate-tauri-menu.ts @@ -1,35 +1,2 @@ -import { mkdirSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' - -import { APP_MENU_SCHEMA } from '../src/app/shell/menu/schema' -import type { AppMenuEntry, AppMenuGroupSchema } from '../src/app/shell/menu/schema' -import { shortcutTokenToAccelerator } from '../src/app/shell/menu/shortcut' - -function isNativeVisible(entry: { target?: string }): boolean { - return entry.target !== 'browser' -} - -function cleanEntry(entry: AppMenuEntry): unknown | null { - if (!isNativeVisible(entry)) return null - if (entry.type === 'separator') return { type: 'separator' } - return { - id: entry.id, - label: entry.label, - accelerator: entry.accelerator ?? shortcutTokenToAccelerator(entry.shortcut), - checkbox: entry.checkbox, - sub: entry.sub?.map(cleanEntry).filter(Boolean) - } -} - -function cleanGroup(group: AppMenuGroupSchema): unknown | null { - if (!isNativeVisible(group)) return null - return { - label: group.label, - items: group.items.map(cleanEntry).filter(Boolean) - } -} - -const outputPath = 'desktop/generated/menu.json' -const menu = APP_MENU_SCHEMA.map(cleanGroup).filter(Boolean) -mkdirSync(dirname(outputPath), { recursive: true }) -writeFileSync(outputPath, `${JSON.stringify(menu, null, 2)}\n`) +#!/usr/bin/env bun +import '../tools/tauri-menu/src/generate' diff --git a/scripts/visual-bisect.ts b/scripts/visual-bisect.ts index 4c14065bd..0f0165daa 100644 --- a/scripts/visual-bisect.ts +++ b/scripts/visual-bisect.ts @@ -1,334 +1,2 @@ #!/usr/bin/env bun - -import { existsSync, mkdirSync } from 'node:fs' -import { basename } from 'node:path' -import { parseArgs } from 'node:util' - -import { $ } from 'bun' - -import { parseColor } from '@open-pencil/core/color' -import { headlessRenderNodes, initCanvasKit, parseFigFile } from '@open-pencil/core/io' -import { computeAllLayouts } from '@open-pencil/core/layout' -import type { SceneGraph } from '@open-pencil/core/scene-graph' - -interface DiffMetrics { - mean: number - above5: number - above10: number - above20: number - above40: number - above80: number -} - -interface BisectResult { - depth: number - indices: number[] - names: string[] - metrics: DiffMetrics - figmaPath: string - openPencilPath: string -} - -const { values, positionals } = parseArgs({ - allowPositionals: true, - options: { - page: { type: 'string', short: 'p' }, - 'figma-page-id': { type: 'string' }, - output: { type: 'string', short: 'o', default: '/tmp/open-pencil-visual-bisect' }, - scale: { type: 'string', default: '1' }, - threshold: { type: 'string', default: '0.25' }, - depth: { type: 'string', default: '8' }, - 'min-size': { type: 'string', default: '1' }, - background: { type: 'string', default: '#f9f9f9' }, - 'root-node-id': { type: 'string' }, - 'figma-root-id': { type: 'string' } - } -}) - -const figPath = positionals[0] -if (!figPath || !values.page || !values['figma-page-id']) { - console.error(`Usage: - bun scripts/visual-bisect.ts --page Primitives --figma-page-id 1:22 [options] - -Options: - --output DIR Output directory (default: /tmp/open-pencil-visual-bisect) - --scale N Export scale (default: 1) - --threshold PERCENT Stop splitting groups under this >40 diff percent (default: 0.25) - --depth N Max bisection depth (default: 8) - --min-size N Stop splitting groups at this child count (default: 1) - --background HEX Matte color for transparent pixels (default: #f9f9f9) - --root-node-id ID OpenPencil node whose children should be bisected - --figma-root-id ID Matching Figma node whose children should be bisected - -What it does: - It hides all top-level page children except a candidate subset in both Figma - and OpenPencil, exports that subset from both renderers, diffs the images, - then recursively splits only subsets that still differ. This isolates which - top-level page children introduce visual differences without staring at the - full-page diff.`) - process.exit(1) -} - -const pageName = values.page -const figmaPageId = values['figma-page-id'] -const outputDir = values.output ?? '/tmp/open-pencil-visual-bisect' -const scale = Number(values.scale ?? '1') -const threshold = Number(values.threshold ?? '0.25') -const maxDepth = Number(values.depth ?? '8') -const minSize = Number(values['min-size'] ?? '1') -const matteColor = colorBytes(values.background ?? '#f9f9f9') - -if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) - -const graph = await loadGraph(figPath, pageName) -const page = graph.getPages().find((candidate) => candidate.name === pageName) -if (!page) throw new Error(`Page not found in .fig: ${pageName}`) -const rootNodeId = values['root-node-id'] ?? page.id -const figmaRootId = values['figma-root-id'] ?? figmaPageId -const rootNode = graph.getNode(rootNodeId) -if (!rootNode) throw new Error(`Root node not found in .fig: ${rootNodeId}`) -const childIds = [...rootNode.childIds] -const childNames = childIds.map((id) => graph.getNode(id)?.name ?? id) - -console.log(`Visual bisect: ${basename(figPath)} / ${pageName}`) -console.log(`Root: ${rootNode.name} (${rootNode.id})`) -console.log(`Children: ${childIds.length}`) -console.log(`Output: ${outputDir}`) - -const initialVisibility = await captureFigmaVisibility(figmaRootId) -const results: BisectResult[] = [] - -try { - await bisect( - childIds.map((_, index) => index), - 0 - ) -} finally { - await restoreFigmaVisibility(figmaRootId, initialVisibility) -} - -results.sort((a, b) => b.metrics.above40 - a.metrics.above40 || a.indices.length - b.indices.length) -await writeReport(results) - -console.log('\nTop suspects:') -for (const result of results.slice(0, 12)) { - console.log( - `${result.indices.join(',')} | >40 ${result.metrics.above40.toFixed(3)}% | ${result.names.join(' / ')}` - ) -} -console.log(`\nReport: ${outputDir}/report.md`) - -async function loadGraph(path: string, targetPageName: string): Promise { - const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()) - const parsed = await parseFigFile( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), - { - populate: 'all' - } - ) - const targetPage = parsed.getPages().find((candidate) => candidate.name === targetPageName) - if (targetPage) computeAllLayouts(parsed, targetPage.id) - return parsed -} - -async function bisect(indices: number[], depth: number): Promise { - if (indices.length === 0 || depth > maxDepth) return - - const result = await compareSubset(indices, depth) - const label = `[depth ${depth}] [${indices.join(',')}] >40=${result.metrics.above40.toFixed(3)}% ${result.names.join(' / ')}` - console.log(label) - - if (result.metrics.above40 <= threshold) return - results.push(result) - - if (indices.length <= minSize) return - const mid = Math.floor(indices.length / 2) - await bisect(indices.slice(0, mid), depth + 1) - await bisect(indices.slice(mid), depth + 1) -} - -async function compareSubset(indices: number[], depth: number): Promise { - const stem = `d${depth}-${indices[0]}-${indices.at(-1)}-${indices.length}` - const figmaPath = `${outputDir}/${stem}-figma.png` - const openPencilPath = `${outputDir}/${stem}-open-pencil.png` - - await exportFigmaSubset(figmaRootId, indices, figmaPath) - await exportOpenPencilSubset(indices, openPencilPath) - - return { - depth, - indices, - names: indices.map((index) => childNames[index]), - metrics: await diffImages(figmaPath, openPencilPath), - figmaPath, - openPencilPath - } -} - -async function exportOpenPencilSubset(indices: number[], path: string): Promise { - const indexSet = new Set(indices) - const changed: Array<{ id: string; visible: boolean }> = [] - for (let index = 0; index < childIds.length; index++) { - const node = graph.getNode(childIds[index]) - if (!node) continue - changed.push({ id: node.id, visible: node.visible }) - node.visible = indexSet.has(index) - } - - try { - const nodeIds = - rootNode.type === 'CANVAS' ? indices.map((index) => childIds[index]) : [rootNode.id] - const data = await headlessRenderNodes(graph, page.id, nodeIds, { - scale, - format: 'PNG', - trimTransparent: nodeIds.every((id) => graph.getNode(id)?.type === 'TEXT') - }) - if (!data) throw new Error(`OpenPencil render produced no image for ${indices.join(',')}`) - await Bun.write(path, data) - } finally { - for (const entry of changed) { - const node = graph.getNode(entry.id) - if (node) node.visible = entry.visible - } - } -} - -async function exportFigmaSubset(pageId: string, indices: number[], path: string): Promise { - await setFigmaVisibleIndices(pageId, indices) - await $`figma-use export node ${pageId} --output ${path} --scale ${String(scale)}`.quiet() -} - -async function captureFigmaVisibility(pageId: string): Promise { - const code = ` - const node = figma.getNodeById(${JSON.stringify(pageId)}); - if (!node || !('children' in node)) throw new Error('Node has no children: ${pageId}'); - return node.children.map((child) => child.visible); - ` - const out = await $`figma-use eval ${code} --json`.quiet() - return JSON.parse(out.text().trim()) -} - -async function setFigmaVisibleIndices(pageId: string, indices: number[]): Promise { - const code = ` - const visible = new Set(${JSON.stringify(indices)}); - const node = figma.getNodeById(${JSON.stringify(pageId)}); - if (!node || !('children' in node)) throw new Error('Node has no children: ${pageId}'); - node.children.forEach((child, index) => { child.visible = visible.has(index); }); - ` - await $`figma-use eval ${code}`.quiet() -} - -async function restoreFigmaVisibility(pageId: string, visibility: boolean[]): Promise { - const code = ` - const visibility = ${JSON.stringify(visibility)}; - const node = figma.getNodeById(${JSON.stringify(pageId)}); - if (!node || !('children' in node)) return; - node.children.forEach((child, index) => { child.visible = visibility[index] ?? child.visible; }); - ` - await $`figma-use eval ${code}`.quiet().nothrow() -} - -async function diffImages(expectedPath: string, actualPath: string): Promise { - const ck = await initCanvasKit() - const expected = ck.MakeImageFromEncoded(await Bun.file(expectedPath).bytes()) - const actual = ck.MakeImageFromEncoded(await Bun.file(actualPath).bytes()) - if (!expected || !actual) throw new Error('Failed to decode PNGs') - - try { - const width = Math.min(expected.width(), actual.width()) - const height = Math.min(expected.height(), actual.height()) - const imageInfo = { - width, - height, - colorType: ck.ColorType.RGBA_8888, - alphaType: ck.AlphaType.Unpremul, - colorSpace: ck.ColorSpace.SRGB - } - const expectedPixels = expected.readPixels(0, 0, imageInfo) - const actualPixels = actual.readPixels(0, 0, imageInfo) - if (!expectedPixels || !actualPixels) throw new Error('Failed to read image pixels') - - let sum = 0 - let above5 = 0 - let above10 = 0 - let above20 = 0 - let above40 = 0 - let above80 = 0 - const total = width * height - const bg = matteColor - for (let pixel = 0; pixel < total; pixel++) { - const offset = pixel * 4 - const expectedRgb = compositeRgb(expectedPixels, offset, bg) - const actualRgb = compositeRgb(actualPixels, offset, bg) - const dr = Math.abs(expectedRgb[0] - actualRgb[0]) - const dg = Math.abs(expectedRgb[1] - actualRgb[1]) - const db = Math.abs(expectedRgb[2] - actualRgb[2]) - const max = Math.max(dr, dg, db) - sum += dr + dg + db - if (max > 5) above5++ - if (max > 10) above10++ - if (max > 20) above20++ - if (max > 40) above40++ - if (max > 80) above80++ - } - - return { - mean: sum / total, - above5: (above5 / total) * 100, - above10: (above10 / total) * 100, - above20: (above20 / total) * 100, - above40: (above40 / total) * 100, - above80: (above80 / total) * 100 - } - } finally { - expected.delete() - actual.delete() - } -} - -async function writeReport(items: BisectResult[]): Promise { - const lines = [ - `# Visual bisect report`, - ``, - `- File: ${figPath}`, - `- Page: ${pageName}`, - `- Figma page id: ${figmaPageId}`, - `- Scale: ${scale}`, - `- Threshold (>40): ${threshold}%`, - ``, - `| depth | indices | >40 | >20 | mean | names | images |`, - `|---:|---|---:|---:|---:|---|---|` - ] - - for (const item of items) { - lines.push( - `| ${item.depth} | ${item.indices.join(',')} | ${item.metrics.above40.toFixed(3)}% | ${item.metrics.above20.toFixed(3)}% | ${item.metrics.mean.toFixed(3)} | ${item.names.map(escapePipes).join('
')} | [figma](${item.figmaPath}) / [open-pencil](${item.openPencilPath}) |` - ) - } - - await Bun.write(`${outputDir}/report.md`, `${lines.join('\n')}\n`) -} - -function colorBytes(input: string): readonly [number, number, number] { - const color = parseColor(input) - return [color.r * 255, color.g * 255, color.b * 255] -} - -function compositeRgb( - pixels: Uint8Array, - offset: number, - bg: readonly [number, number, number] -): [number, number, number] { - const alpha = pixels[offset + 3] / 255 - if (alpha >= 1) return [pixels[offset], pixels[offset + 1], pixels[offset + 2]] - if (alpha <= 0) return [bg[0], bg[1], bg[2]] - return [ - pixels[offset] * alpha + bg[0] * (1 - alpha), - pixels[offset + 1] * alpha + bg[1] * (1 - alpha), - pixels[offset + 2] * alpha + bg[2] * (1 - alpha) - ] -} - -function escapePipes(value: string): string { - return value.replaceAll('|', '\\|') -} +import '../tools/visual-oracles/src/bisect' diff --git a/scripts/visual-compare.ts b/scripts/visual-compare.ts index 8b834f08f..ff1d42f7a 100644 --- a/scripts/visual-compare.ts +++ b/scripts/visual-compare.ts @@ -1,296 +1,2 @@ #!/usr/bin/env bun -/** - * Visual comparison pipeline: Figma vs OpenPencil renderer. - * - * Copy an element in Figma, then run: - * bun scripts/visual-compare.ts [--scale 2] [--output /tmp/visual-compare] - * - * Or pass a node ID directly (skips clipboard): - * bun scripts/visual-compare.ts --node 1:23 [--scale 2] - * - * Outputs: - * figma.png — exported from real Figma - * ours.png — rendered by OpenPencil headless SkiaRenderer - * diff.png — visual diff (red = changed pixels) - */ - -import { existsSync, mkdirSync, writeFileSync } from 'node:fs' -import { parseArgs } from 'node:util' - -import { $ } from 'bun' - -import { SkiaRenderer } from '@open-pencil/core/canvas' -import { renderNodesToImage, initCanvasKit } from '@open-pencil/core/io' -import { computeAllLayouts } from '@open-pencil/core/layout' -import { SceneGraph } from '@open-pencil/core/scene-graph' -import { fontManager } from '@open-pencil/core/text' - -import { parseFigmaClipboard, importClipboardNodes } from '#core/clipboard' - -const { values: opts } = parseArgs({ - options: { - scale: { type: 'string', default: '2' }, - output: { type: 'string', short: 'o', default: '/tmp/visual-compare' }, - node: { type: 'string', short: 'n' }, - resize: { type: 'boolean', default: false }, - fuzz: { type: 'string', default: '1%' }, - 'alpha-diff': { type: 'boolean', default: false }, - 'metrics-json': { type: 'string' } - } -}) - -const scale = Number(opts.scale) -const outputDir = opts.output ?? '/tmp/visual-compare' -const figmaPath = `${outputDir}/figma.png` -const oursPath = `${outputDir}/ours.png` -const normalizedOursPath = `${outputDir}/ours-normalized.png` -const diffPath = `${outputDir}/diff.png` -const metricsPath = opts['metrics-json'] ?? `${outputDir}/metrics.json` -const alphaFigmaPath = `${outputDir}/figma-alpha.png` -const alphaOursPath = `${outputDir}/ours-alpha.png` -const alphaDiffPath = `${outputDir}/diff-alpha.png` - -if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) - -if (opts.node) { - await runWithNodeId(opts.node) -} else { - await runWithClipboard() -} - -// --- Mode 1: Clipboard --- - -async function runWithClipboard() { - console.log('📋 Reading clipboard…') - const html = await readClipboardHtml() - if (!html) bail('No HTML on clipboard. Copy an element in Figma first.') - - const parsed = await parseFigmaClipboard(html) - if (!parsed) bail('Clipboard has no Figma data. Copy an element in Figma first.') - console.log(` ${parsed.nodes.length} node changes, ${parsed.blobs.length} blobs`) - - console.log('🖼️ Rendering with OpenPencil…') - await renderOurs(html) - - console.log('🎨 Pasting into Figma & exporting…') - await ensureFigmaConnected() - await renderFigmaViaPaste() - - await diff() -} - -// --- Mode 2: Node ID --- - -async function runWithNodeId(nodeId: string) { - await ensureFigmaConnected() - - console.log(`🎨 Exporting node ${nodeId} from Figma…`) - await $`figma-use export node ${nodeId} --output ${figmaPath} --scale ${String(scale)}`.quiet() - console.log(` → ${figmaPath}`) - - console.log('📋 Exporting clipboard data from Figma…') - // Select the node, copy, read clipboard, render with our engine - const nodeIdLiteral = JSON.stringify(nodeId) - await $`figma-use eval ${`const n = figma.getNodeById(${nodeIdLiteral}); if (!n) return; let page = n.parent; while (page && page.type !== 'PAGE') page = page.parent; if (page) { await figma.setCurrentPageAsync(page); page.selection = [n]; }`}`.quiet() - await Bun.sleep(200) - await $`osascript -e 'tell application "Figma" to activate'`.quiet() - await Bun.sleep(300) - await $`osascript -e 'tell application "System Events" to keystroke "c" using command down'`.quiet() - await Bun.sleep(1000) - - const html = await readClipboardHtml() - if (!html) bail('Failed to copy from Figma') - const parsed = await parseFigmaClipboard(html) - if (!parsed) bail('Clipboard has no Figma data after copy') - - console.log('🖼️ Rendering with OpenPencil…') - await renderOurs(html) - - await diff() -} - -// --- Rendering --- - -async function renderOurs(html: string) { - const result = await parseFigmaClipboard(html) - if (!result) throw new Error('Failed to parse clipboard') - - const graph = new SceneGraph() - const pageId = graph.getPages()[0].id - - const createdIds = importClipboardNodes(result.nodes, graph, pageId, 0, 0, result.blobs) - if (createdIds.length === 0) throw new Error('No nodes imported from clipboard') - - computeAllLayouts(graph) - - const families = new Set() - for (const node of graph.getAllNodes()) { - if (node.fontFamily) families.add(node.fontFamily) - } - for (const family of families) { - await fontManager.loadFont(family) - } - - const ck = await initCanvasKit() - const surface = ck.MakeSurface(1, 1) - if (!surface) throw new Error('Failed to create CanvasKit surface') - const renderer = new SkiaRenderer(ck, surface) - renderer.viewportWidth = 1 - renderer.viewportHeight = 1 - renderer.dpr = 1 - - const data = renderNodesToImage(ck, renderer, graph, pageId, createdIds, { - scale, - format: 'PNG' - }) - - surface.delete() - if (!data) throw new Error('Render produced no image') - await Bun.write(oursPath, data) - console.log(` → ${oursPath}`) -} - -async function renderFigmaViaPaste() { - // Create temp page so we don't pollute the user's work - await $`figma-use eval ${'(() => { const p = figma.createPage(); p.name = "__visual_compare__"; figma.currentPage = p; return p.id; })()'} --json`.quiet() - - try { - // Activate Figma and paste - await $`osascript -e 'tell application "Figma" to activate'`.quiet() - await Bun.sleep(500) - await $`osascript -e 'tell application "System Events" to keystroke "v" using command down'`.quiet() - await Bun.sleep(2000) - - // Get pasted selection - const selJson = await $`figma-use selection get --json`.quiet() - const selection = JSON.parse(selJson.text().trim()) - if (!selection.length) throw new Error('Nothing pasted. Ensure clipboard has Figma data.') - - const nodeId = selection[0].id - - // Export from Figma - await $`figma-use export node ${nodeId} --output ${figmaPath} --scale ${String(scale)}`.quiet() - console.log(` → ${figmaPath}`) - } finally { - // Clean up: remove temp page - await $`figma-use eval ${'(() => { const ps = figma.root.children; const tmp = ps.find(p => p.name === "__visual_compare__"); if (tmp) { const other = ps.find(p => p !== tmp); if (other) figma.currentPage = other; tmp.remove(); } })()'}` - .quiet() - .nothrow() - } -} - -// --- Diff --- - -async function diff() { - console.log('🔍 Computing diff…') - - const figmaSize = (await $`identify -format '%wx%h' ${figmaPath}`.quiet()).text().trim() - const oursSize = (await $`identify -format '%wx%h' ${oursPath}`.quiet()).text().trim() - - const compareOursPath = figmaSize === oursSize ? oursPath : normalizedOursPath - if (figmaSize !== oursSize) { - const mode = opts.resize ? 'resizing' : 'padding/cropping without scaling' - console.log(` ⚠ Size mismatch: Figma ${figmaSize}, Ours ${oursSize} → ${mode}`) - if (opts.resize) { - await $`magick ${oursPath} -resize ${figmaSize}! ${normalizedOursPath}`.quiet() - } else { - await $`magick ${oursPath} -background none -gravity northwest -extent ${figmaSize} ${normalizedOursPath}`.quiet() - } - } - - const result = - await $`magick compare -metric AE -highlight-color red -lowlight-color '#FFFFFF33' -compose src ${figmaPath} ${compareOursPath} ${diffPath}` - .quiet() - .nothrow() - - const fuzzResult = - await $`magick compare -metric AE -fuzz ${opts.fuzz} ${figmaPath} ${compareOursPath} null:` - .quiet() - .nothrow() - const rmseResult = await $`magick compare -metric RMSE ${figmaPath} ${compareOursPath} null:` - .quiet() - .nothrow() - const rmse = rmseResult.stderr.toString().trim() - const diffPixels = Number.parseInt(result.stderr.toString().trim(), 10) || 0 - const fuzzPixels = Number.parseInt(fuzzResult.stderr.toString().trim(), 10) || 0 - const [w, h] = figmaSize.split('x').map(Number) - const total = w * h - const pct = (diffPixels / total) * 100 - const fuzzPct = (fuzzPixels / total) * 100 - const alphaMetrics = opts['alpha-diff'] ? await diffAlpha(compareOursPath, total) : null - const metrics = { - figmaSize, - openPencilSize: oursSize, - comparedOpenPencilPath: compareOursPath, - resized: Boolean(opts.resize && figmaSize !== oursSize), - normalized: figmaSize !== oursSize, - differentPixels: diffPixels, - differentPercent: Number(pct.toFixed(2)), - fuzz: opts.fuzz, - fuzzDifferentPixels: fuzzPixels, - fuzzDifferentPercent: Number(fuzzPct.toFixed(2)), - rmse, - alpha: alphaMetrics - } - writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`) - - console.log(` → ${diffPath}`) - console.log( - ` ${diffPixels.toLocaleString()} different pixels (${pct.toFixed(2)}% of ${total.toLocaleString()})` - ) - console.log( - ` ${fuzzPixels.toLocaleString()} different pixels with ${opts.fuzz} fuzz (${fuzzPct.toFixed(2)}%)` - ) - console.log(` RMSE ${rmse}`) - if (alphaMetrics) { - console.log( - ` Alpha AE ${alphaMetrics.differentPixels.toLocaleString()} pixels (${alphaMetrics.differentPercent.toFixed(2)}%)` - ) - } - console.log(` Metrics → ${metricsPath}`) - console.log(`\n✅ Done! Images in ${outputDir}/`) -} - -async function diffAlpha(compareOursPath: string, total: number) { - await $`magick ${figmaPath} -alpha extract ${alphaFigmaPath}`.quiet() - await $`magick ${compareOursPath} -alpha extract ${alphaOursPath}`.quiet() - const result = - await $`magick compare -metric AE -highlight-color red -lowlight-color '#FFFFFF33' -compose src ${alphaFigmaPath} ${alphaOursPath} ${alphaDiffPath}` - .quiet() - .nothrow() - const differentPixels = Number.parseInt(result.stderr.toString().trim(), 10) || 0 - return { - path: alphaDiffPath, - differentPixels, - differentPercent: (differentPixels / total) * 100 - } -} - -// --- Helpers --- - -async function readClipboardHtml(): Promise { - const proc = Bun.spawn( - [ - 'swift', - '-e', - 'import AppKit; if let h = NSPasteboard.general.string(forType: .html) { print(h) } else { exit(1) }' - ], - { stdout: 'pipe', stderr: 'pipe' } - ) - const text = await new Response(proc.stdout).text() - return (await proc.exited) === 0 ? text.trim() : null -} - -async function ensureFigmaConnected() { - const s = await $`figma-use status`.quiet().nothrow() - if (s.exitCode !== 0) { - bail( - 'figma-use not connected. Start Figma with:\n open -a Figma --args --remote-debugging-port=9222' - ) - } -} - -function bail(msg: string): never { - console.error(msg) - process.exit(1) -} +import '../tools/visual-oracles/src/compare' diff --git a/steiger.config.ts b/steiger.config.ts index 1e137d73b..66fb9e929 100644 --- a/steiger.config.ts +++ b/steiger.config.ts @@ -1,7 +1,7 @@ import fsd from '@feature-sliced/steiger-plugin' import { defineConfig } from 'steiger' -import { openPencilArchitecturePlugin } from './scripts/steiger-rules.ts' +import { openPencilArchitecturePlugin } from './tools/architecture/src/steiger-rules.ts' // OpenPencil is not laid out as canonical Feature-Sliced Design layers. // Keep Steiger focused on project-specific architecture boundaries instead of @@ -37,7 +37,9 @@ export default defineConfig([ 'open-pencil/no-non-ui-imports-in-shared-ui': 'error', 'open-pencil/no-app-imports-in-shared-ui': 'error', 'open-pencil/no-property-panel-internals-outside-panel': 'error', - 'open-pencil/no-ui-imports-in-core': 'error' + 'open-pencil/no-ui-imports-in-core': 'error', + 'open-pencil/scripts-are-entrypoint-shims': 'error', + 'open-pencil/strict-tools-layout': 'error' } } ]) diff --git a/tests/fixtures/figma-oracles/visual-comparison-report.json b/tests/fixtures/figma-oracles/visual-comparison-report.json index 82232aa07..cba7af2ab 100644 --- a/tests/fixtures/figma-oracles/visual-comparison-report.json +++ b/tests/fixtures/figma-oracles/visual-comparison-report.json @@ -1,6 +1,6 @@ { "source": { - "tool": "scripts/visual-compare.ts", + "tool": "tools/visual-oracles/src/compare.ts", "captured": "2026-05-22", "outputRoot": "/tmp/open-pencil-oracles" }, diff --git a/tools/architecture/package.json b/tools/architecture/package.json new file mode 100644 index 000000000..0a09dc9c3 --- /dev/null +++ b/tools/architecture/package.json @@ -0,0 +1,5 @@ +{ + "name": "@open-pencil/architecture-tools", + "private": true, + "type": "module" +} diff --git a/scripts/steiger-rules.ts b/tools/architecture/src/steiger-rules.ts similarity index 94% rename from scripts/steiger-rules.ts rename to tools/architecture/src/steiger-rules.ts index dce22c128..8daff720b 100644 --- a/scripts/steiger-rules.ts +++ b/tools/architecture/src/steiger-rules.ts @@ -210,6 +210,27 @@ const preferDomainFoldersOverFilenamePrefixes: Rule = { } } +const scriptsAreEntrypointShims = createTextRule( + 'open-pencil/scripts-are-entrypoint-shims', + (sourceRel, content) => { + if (!sourceRel.startsWith('scripts/')) return [] + if (/^#!\/usr\/bin\/env bun\s+import ['"]\.\.\/tools\/[^'"]+['"]\s*;?$/u.test(content.trim())) return [] + return [{ message: 'Root scripts must be tiny shims. Move implementation logic into tools//.' }] + } +) + +const TOOL_LAYOUT_MESSAGE = 'Tool files must live under tools//src/** or tools//tests/*.test.ts.' + +const strictToolsLayout = createFileRule('open-pencil/strict-tools-layout', (sourceRel) => { + if (!sourceRel.startsWith('tools/') || !TEXT_EXTENSIONS.has(path.extname(sourceRel))) return null + if (sourceRel === 'tools/test.ts') return null + const [, domain, segment] = sourceRel.split('/') + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(domain)) return 'Tool package folders must use kebab-case domain names.' + if (segment === 'src') return null + if (segment === 'tests' && sourceRel.endsWith('.test.ts')) return null + return TOOL_LAYOUT_MESSAGE +}) + const strictTestFilePlacement = createFileRule( 'open-pencil/strict-test-file-placement', (sourceRel) => { @@ -549,6 +570,8 @@ export const openPencilArchitecturePlugin = { meta: { name: 'open-pencil-architecture', version: '0.0.0' }, ruleDefinitions: [ preferDomainFoldersOverFilenamePrefixes, + scriptsAreEntrypointShims, + strictToolsLayout, strictTestFilePlacement, noMisplacedEngineTestDomainPaths, noKitchenSinkEngineBasicTests, diff --git a/tools/i18n/package.json b/tools/i18n/package.json new file mode 100644 index 000000000..0dd2ae1b5 --- /dev/null +++ b/tools/i18n/package.json @@ -0,0 +1,5 @@ +{ + "name": "@open-pencil/i18n-tools", + "private": true, + "type": "module" +} diff --git a/scripts/check-locales.ts b/tools/i18n/src/check-locales.ts similarity index 100% rename from scripts/check-locales.ts rename to tools/i18n/src/check-locales.ts diff --git a/tools/package-quality/package.json b/tools/package-quality/package.json new file mode 100644 index 000000000..a81073e2d --- /dev/null +++ b/tools/package-quality/package.json @@ -0,0 +1,5 @@ +{ + "name": "@open-pencil/package-quality-tools", + "private": true, + "type": "module" +} diff --git a/scripts/check-package-metadata.ts b/tools/package-quality/src/check-metadata.ts similarity index 100% rename from scripts/check-package-metadata.ts rename to tools/package-quality/src/check-metadata.ts diff --git a/scripts/smoke-packages.ts b/tools/package-quality/src/smoke.ts similarity index 97% rename from scripts/smoke-packages.ts rename to tools/package-quality/src/smoke.ts index a8b72af2d..216e46943 100644 --- a/scripts/smoke-packages.ts +++ b/tools/package-quality/src/smoke.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { fileURLToPath } from 'node:url' -const rootDir = fileURLToPath(new URL('..', import.meta.url)) +const rootDir = fileURLToPath(new URL('../../..', import.meta.url)) const packageDirs = ['packages/core', 'packages/vue', 'packages/mcp', 'packages/cli'] function run(command: string[], cwd = rootDir): string { diff --git a/tools/tauri-menu/package.json b/tools/tauri-menu/package.json new file mode 100644 index 000000000..2a2001a51 --- /dev/null +++ b/tools/tauri-menu/package.json @@ -0,0 +1,5 @@ +{ + "name": "@open-pencil/tauri-menu-tools", + "private": true, + "type": "module" +} diff --git a/tools/tauri-menu/src/generate.ts b/tools/tauri-menu/src/generate.ts new file mode 100644 index 000000000..d241c3faa --- /dev/null +++ b/tools/tauri-menu/src/generate.ts @@ -0,0 +1,35 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +import { APP_MENU_SCHEMA } from '@/app/shell/menu/schema' +import type { AppMenuEntry, AppMenuGroupSchema } from '@/app/shell/menu/schema' +import { shortcutTokenToAccelerator } from '@/app/shell/menu/shortcut' + +function isNativeVisible(entry: { target?: string }): boolean { + return entry.target !== 'browser' +} + +function cleanEntry(entry: AppMenuEntry): unknown | null { + if (!isNativeVisible(entry)) return null + if (entry.type === 'separator') return { type: 'separator' } + return { + id: entry.id, + label: entry.label, + accelerator: entry.accelerator ?? shortcutTokenToAccelerator(entry.shortcut), + checkbox: entry.checkbox, + sub: entry.sub?.map(cleanEntry).filter(Boolean) + } +} + +function cleanGroup(group: AppMenuGroupSchema): unknown | null { + if (!isNativeVisible(group)) return null + return { + label: group.label, + items: group.items.map(cleanEntry).filter(Boolean) + } +} + +const outputPath = 'desktop/generated/menu.json' +const menu = APP_MENU_SCHEMA.map(cleanGroup).filter(Boolean) +mkdirSync(dirname(outputPath), { recursive: true }) +writeFileSync(outputPath, `${JSON.stringify(menu, null, 2)}\n`) diff --git a/tools/type-shapes/package.json b/tools/type-shapes/package.json new file mode 100644 index 000000000..0365037c7 --- /dev/null +++ b/tools/type-shapes/package.json @@ -0,0 +1,5 @@ +{ + "name": "@open-pencil/type-shapes-tools", + "private": true, + "type": "module" +} diff --git a/scripts/type-shapes.ts b/tools/type-shapes/src/index.ts similarity index 99% rename from scripts/type-shapes.ts rename to tools/type-shapes/src/index.ts index c66c0cf50..13c93aa09 100644 --- a/scripts/type-shapes.ts +++ b/tools/type-shapes/src/index.ts @@ -7,7 +7,8 @@ const roots = [ 'packages/cli/src', 'packages/mcp/src', 'tests', - 'scripts' + 'scripts', + 'tools' ] type ShapeLocation = { file: string; line: number; name: string } diff --git a/tools/visual-oracles/package.json b/tools/visual-oracles/package.json new file mode 100644 index 000000000..18abfc624 --- /dev/null +++ b/tools/visual-oracles/package.json @@ -0,0 +1,5 @@ +{ + "name": "@open-pencil/visual-oracle-tools", + "private": true, + "type": "module" +} diff --git a/scripts/analyze-pattern-oracle.ts b/tools/visual-oracles/src/analyze-pattern.ts similarity index 100% rename from scripts/analyze-pattern-oracle.ts rename to tools/visual-oracles/src/analyze-pattern.ts diff --git a/scripts/analyze-text-decoration-oracle.ts b/tools/visual-oracles/src/analyze-text-decoration.ts similarity index 100% rename from scripts/analyze-text-decoration-oracle.ts rename to tools/visual-oracles/src/analyze-text-decoration.ts diff --git a/tools/visual-oracles/src/bisect.ts b/tools/visual-oracles/src/bisect.ts new file mode 100644 index 000000000..4c14065bd --- /dev/null +++ b/tools/visual-oracles/src/bisect.ts @@ -0,0 +1,334 @@ +#!/usr/bin/env bun + +import { existsSync, mkdirSync } from 'node:fs' +import { basename } from 'node:path' +import { parseArgs } from 'node:util' + +import { $ } from 'bun' + +import { parseColor } from '@open-pencil/core/color' +import { headlessRenderNodes, initCanvasKit, parseFigFile } from '@open-pencil/core/io' +import { computeAllLayouts } from '@open-pencil/core/layout' +import type { SceneGraph } from '@open-pencil/core/scene-graph' + +interface DiffMetrics { + mean: number + above5: number + above10: number + above20: number + above40: number + above80: number +} + +interface BisectResult { + depth: number + indices: number[] + names: string[] + metrics: DiffMetrics + figmaPath: string + openPencilPath: string +} + +const { values, positionals } = parseArgs({ + allowPositionals: true, + options: { + page: { type: 'string', short: 'p' }, + 'figma-page-id': { type: 'string' }, + output: { type: 'string', short: 'o', default: '/tmp/open-pencil-visual-bisect' }, + scale: { type: 'string', default: '1' }, + threshold: { type: 'string', default: '0.25' }, + depth: { type: 'string', default: '8' }, + 'min-size': { type: 'string', default: '1' }, + background: { type: 'string', default: '#f9f9f9' }, + 'root-node-id': { type: 'string' }, + 'figma-root-id': { type: 'string' } + } +}) + +const figPath = positionals[0] +if (!figPath || !values.page || !values['figma-page-id']) { + console.error(`Usage: + bun scripts/visual-bisect.ts --page Primitives --figma-page-id 1:22 [options] + +Options: + --output DIR Output directory (default: /tmp/open-pencil-visual-bisect) + --scale N Export scale (default: 1) + --threshold PERCENT Stop splitting groups under this >40 diff percent (default: 0.25) + --depth N Max bisection depth (default: 8) + --min-size N Stop splitting groups at this child count (default: 1) + --background HEX Matte color for transparent pixels (default: #f9f9f9) + --root-node-id ID OpenPencil node whose children should be bisected + --figma-root-id ID Matching Figma node whose children should be bisected + +What it does: + It hides all top-level page children except a candidate subset in both Figma + and OpenPencil, exports that subset from both renderers, diffs the images, + then recursively splits only subsets that still differ. This isolates which + top-level page children introduce visual differences without staring at the + full-page diff.`) + process.exit(1) +} + +const pageName = values.page +const figmaPageId = values['figma-page-id'] +const outputDir = values.output ?? '/tmp/open-pencil-visual-bisect' +const scale = Number(values.scale ?? '1') +const threshold = Number(values.threshold ?? '0.25') +const maxDepth = Number(values.depth ?? '8') +const minSize = Number(values['min-size'] ?? '1') +const matteColor = colorBytes(values.background ?? '#f9f9f9') + +if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) + +const graph = await loadGraph(figPath, pageName) +const page = graph.getPages().find((candidate) => candidate.name === pageName) +if (!page) throw new Error(`Page not found in .fig: ${pageName}`) +const rootNodeId = values['root-node-id'] ?? page.id +const figmaRootId = values['figma-root-id'] ?? figmaPageId +const rootNode = graph.getNode(rootNodeId) +if (!rootNode) throw new Error(`Root node not found in .fig: ${rootNodeId}`) +const childIds = [...rootNode.childIds] +const childNames = childIds.map((id) => graph.getNode(id)?.name ?? id) + +console.log(`Visual bisect: ${basename(figPath)} / ${pageName}`) +console.log(`Root: ${rootNode.name} (${rootNode.id})`) +console.log(`Children: ${childIds.length}`) +console.log(`Output: ${outputDir}`) + +const initialVisibility = await captureFigmaVisibility(figmaRootId) +const results: BisectResult[] = [] + +try { + await bisect( + childIds.map((_, index) => index), + 0 + ) +} finally { + await restoreFigmaVisibility(figmaRootId, initialVisibility) +} + +results.sort((a, b) => b.metrics.above40 - a.metrics.above40 || a.indices.length - b.indices.length) +await writeReport(results) + +console.log('\nTop suspects:') +for (const result of results.slice(0, 12)) { + console.log( + `${result.indices.join(',')} | >40 ${result.metrics.above40.toFixed(3)}% | ${result.names.join(' / ')}` + ) +} +console.log(`\nReport: ${outputDir}/report.md`) + +async function loadGraph(path: string, targetPageName: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()) + const parsed = await parseFigFile( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + { + populate: 'all' + } + ) + const targetPage = parsed.getPages().find((candidate) => candidate.name === targetPageName) + if (targetPage) computeAllLayouts(parsed, targetPage.id) + return parsed +} + +async function bisect(indices: number[], depth: number): Promise { + if (indices.length === 0 || depth > maxDepth) return + + const result = await compareSubset(indices, depth) + const label = `[depth ${depth}] [${indices.join(',')}] >40=${result.metrics.above40.toFixed(3)}% ${result.names.join(' / ')}` + console.log(label) + + if (result.metrics.above40 <= threshold) return + results.push(result) + + if (indices.length <= minSize) return + const mid = Math.floor(indices.length / 2) + await bisect(indices.slice(0, mid), depth + 1) + await bisect(indices.slice(mid), depth + 1) +} + +async function compareSubset(indices: number[], depth: number): Promise { + const stem = `d${depth}-${indices[0]}-${indices.at(-1)}-${indices.length}` + const figmaPath = `${outputDir}/${stem}-figma.png` + const openPencilPath = `${outputDir}/${stem}-open-pencil.png` + + await exportFigmaSubset(figmaRootId, indices, figmaPath) + await exportOpenPencilSubset(indices, openPencilPath) + + return { + depth, + indices, + names: indices.map((index) => childNames[index]), + metrics: await diffImages(figmaPath, openPencilPath), + figmaPath, + openPencilPath + } +} + +async function exportOpenPencilSubset(indices: number[], path: string): Promise { + const indexSet = new Set(indices) + const changed: Array<{ id: string; visible: boolean }> = [] + for (let index = 0; index < childIds.length; index++) { + const node = graph.getNode(childIds[index]) + if (!node) continue + changed.push({ id: node.id, visible: node.visible }) + node.visible = indexSet.has(index) + } + + try { + const nodeIds = + rootNode.type === 'CANVAS' ? indices.map((index) => childIds[index]) : [rootNode.id] + const data = await headlessRenderNodes(graph, page.id, nodeIds, { + scale, + format: 'PNG', + trimTransparent: nodeIds.every((id) => graph.getNode(id)?.type === 'TEXT') + }) + if (!data) throw new Error(`OpenPencil render produced no image for ${indices.join(',')}`) + await Bun.write(path, data) + } finally { + for (const entry of changed) { + const node = graph.getNode(entry.id) + if (node) node.visible = entry.visible + } + } +} + +async function exportFigmaSubset(pageId: string, indices: number[], path: string): Promise { + await setFigmaVisibleIndices(pageId, indices) + await $`figma-use export node ${pageId} --output ${path} --scale ${String(scale)}`.quiet() +} + +async function captureFigmaVisibility(pageId: string): Promise { + const code = ` + const node = figma.getNodeById(${JSON.stringify(pageId)}); + if (!node || !('children' in node)) throw new Error('Node has no children: ${pageId}'); + return node.children.map((child) => child.visible); + ` + const out = await $`figma-use eval ${code} --json`.quiet() + return JSON.parse(out.text().trim()) +} + +async function setFigmaVisibleIndices(pageId: string, indices: number[]): Promise { + const code = ` + const visible = new Set(${JSON.stringify(indices)}); + const node = figma.getNodeById(${JSON.stringify(pageId)}); + if (!node || !('children' in node)) throw new Error('Node has no children: ${pageId}'); + node.children.forEach((child, index) => { child.visible = visible.has(index); }); + ` + await $`figma-use eval ${code}`.quiet() +} + +async function restoreFigmaVisibility(pageId: string, visibility: boolean[]): Promise { + const code = ` + const visibility = ${JSON.stringify(visibility)}; + const node = figma.getNodeById(${JSON.stringify(pageId)}); + if (!node || !('children' in node)) return; + node.children.forEach((child, index) => { child.visible = visibility[index] ?? child.visible; }); + ` + await $`figma-use eval ${code}`.quiet().nothrow() +} + +async function diffImages(expectedPath: string, actualPath: string): Promise { + const ck = await initCanvasKit() + const expected = ck.MakeImageFromEncoded(await Bun.file(expectedPath).bytes()) + const actual = ck.MakeImageFromEncoded(await Bun.file(actualPath).bytes()) + if (!expected || !actual) throw new Error('Failed to decode PNGs') + + try { + const width = Math.min(expected.width(), actual.width()) + const height = Math.min(expected.height(), actual.height()) + const imageInfo = { + width, + height, + colorType: ck.ColorType.RGBA_8888, + alphaType: ck.AlphaType.Unpremul, + colorSpace: ck.ColorSpace.SRGB + } + const expectedPixels = expected.readPixels(0, 0, imageInfo) + const actualPixels = actual.readPixels(0, 0, imageInfo) + if (!expectedPixels || !actualPixels) throw new Error('Failed to read image pixels') + + let sum = 0 + let above5 = 0 + let above10 = 0 + let above20 = 0 + let above40 = 0 + let above80 = 0 + const total = width * height + const bg = matteColor + for (let pixel = 0; pixel < total; pixel++) { + const offset = pixel * 4 + const expectedRgb = compositeRgb(expectedPixels, offset, bg) + const actualRgb = compositeRgb(actualPixels, offset, bg) + const dr = Math.abs(expectedRgb[0] - actualRgb[0]) + const dg = Math.abs(expectedRgb[1] - actualRgb[1]) + const db = Math.abs(expectedRgb[2] - actualRgb[2]) + const max = Math.max(dr, dg, db) + sum += dr + dg + db + if (max > 5) above5++ + if (max > 10) above10++ + if (max > 20) above20++ + if (max > 40) above40++ + if (max > 80) above80++ + } + + return { + mean: sum / total, + above5: (above5 / total) * 100, + above10: (above10 / total) * 100, + above20: (above20 / total) * 100, + above40: (above40 / total) * 100, + above80: (above80 / total) * 100 + } + } finally { + expected.delete() + actual.delete() + } +} + +async function writeReport(items: BisectResult[]): Promise { + const lines = [ + `# Visual bisect report`, + ``, + `- File: ${figPath}`, + `- Page: ${pageName}`, + `- Figma page id: ${figmaPageId}`, + `- Scale: ${scale}`, + `- Threshold (>40): ${threshold}%`, + ``, + `| depth | indices | >40 | >20 | mean | names | images |`, + `|---:|---|---:|---:|---:|---|---|` + ] + + for (const item of items) { + lines.push( + `| ${item.depth} | ${item.indices.join(',')} | ${item.metrics.above40.toFixed(3)}% | ${item.metrics.above20.toFixed(3)}% | ${item.metrics.mean.toFixed(3)} | ${item.names.map(escapePipes).join('
')} | [figma](${item.figmaPath}) / [open-pencil](${item.openPencilPath}) |` + ) + } + + await Bun.write(`${outputDir}/report.md`, `${lines.join('\n')}\n`) +} + +function colorBytes(input: string): readonly [number, number, number] { + const color = parseColor(input) + return [color.r * 255, color.g * 255, color.b * 255] +} + +function compositeRgb( + pixels: Uint8Array, + offset: number, + bg: readonly [number, number, number] +): [number, number, number] { + const alpha = pixels[offset + 3] / 255 + if (alpha >= 1) return [pixels[offset], pixels[offset + 1], pixels[offset + 2]] + if (alpha <= 0) return [bg[0], bg[1], bg[2]] + return [ + pixels[offset] * alpha + bg[0] * (1 - alpha), + pixels[offset + 1] * alpha + bg[1] * (1 - alpha), + pixels[offset + 2] * alpha + bg[2] * (1 - alpha) + ] +} + +function escapePipes(value: string): string { + return value.replaceAll('|', '\\|') +} diff --git a/tools/visual-oracles/src/compare.ts b/tools/visual-oracles/src/compare.ts new file mode 100644 index 000000000..7a181c1b1 --- /dev/null +++ b/tools/visual-oracles/src/compare.ts @@ -0,0 +1,295 @@ +#!/usr/bin/env bun +/** + * Visual comparison pipeline: Figma vs OpenPencil renderer. + * + * Copy an element in Figma, then run: + * bun scripts/visual-compare.ts [--scale 2] [--output /tmp/visual-compare] + * + * Or pass a node ID directly (skips clipboard): + * bun scripts/visual-compare.ts --node 1:23 [--scale 2] + * + * Outputs: + * figma.png — exported from real Figma + * ours.png — rendered by OpenPencil headless SkiaRenderer + * diff.png — visual diff (red = changed pixels) + */ + +import { existsSync, mkdirSync, writeFileSync } from 'node:fs' +import { parseArgs } from 'node:util' + +import { $ } from 'bun' + +import { SkiaRenderer } from '@open-pencil/core/canvas' +import { importClipboardNodes, parseFigmaClipboard } from '@open-pencil/core/clipboard' +import { renderNodesToImage, initCanvasKit } from '@open-pencil/core/io' +import { computeAllLayouts } from '@open-pencil/core/layout' +import { SceneGraph } from '@open-pencil/core/scene-graph' +import { fontManager } from '@open-pencil/core/text' + +const { values: opts } = parseArgs({ + options: { + scale: { type: 'string', default: '2' }, + output: { type: 'string', short: 'o', default: '/tmp/visual-compare' }, + node: { type: 'string', short: 'n' }, + resize: { type: 'boolean', default: false }, + fuzz: { type: 'string', default: '1%' }, + 'alpha-diff': { type: 'boolean', default: false }, + 'metrics-json': { type: 'string' } + } +}) + +const scale = Number(opts.scale) +const outputDir = opts.output ?? '/tmp/visual-compare' +const figmaPath = `${outputDir}/figma.png` +const oursPath = `${outputDir}/ours.png` +const normalizedOursPath = `${outputDir}/ours-normalized.png` +const diffPath = `${outputDir}/diff.png` +const metricsPath = opts['metrics-json'] ?? `${outputDir}/metrics.json` +const alphaFigmaPath = `${outputDir}/figma-alpha.png` +const alphaOursPath = `${outputDir}/ours-alpha.png` +const alphaDiffPath = `${outputDir}/diff-alpha.png` + +if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true }) + +if (opts.node) { + await runWithNodeId(opts.node) +} else { + await runWithClipboard() +} + +// --- Mode 1: Clipboard --- + +async function runWithClipboard() { + console.log('📋 Reading clipboard…') + const html = await readClipboardHtml() + if (!html) bail('No HTML on clipboard. Copy an element in Figma first.') + + const parsed = await parseFigmaClipboard(html) + if (!parsed) bail('Clipboard has no Figma data. Copy an element in Figma first.') + console.log(` ${parsed.nodes.length} node changes, ${parsed.blobs.length} blobs`) + + console.log('🖼️ Rendering with OpenPencil…') + await renderOurs(html) + + console.log('🎨 Pasting into Figma & exporting…') + await ensureFigmaConnected() + await renderFigmaViaPaste() + + await diff() +} + +// --- Mode 2: Node ID --- + +async function runWithNodeId(nodeId: string) { + await ensureFigmaConnected() + + console.log(`🎨 Exporting node ${nodeId} from Figma…`) + await $`figma-use export node ${nodeId} --output ${figmaPath} --scale ${String(scale)}`.quiet() + console.log(` → ${figmaPath}`) + + console.log('📋 Exporting clipboard data from Figma…') + // Select the node, copy, read clipboard, render with our engine + const nodeIdLiteral = JSON.stringify(nodeId) + await $`figma-use eval ${`const n = figma.getNodeById(${nodeIdLiteral}); if (!n) return; let page = n.parent; while (page && page.type !== 'PAGE') page = page.parent; if (page) { await figma.setCurrentPageAsync(page); page.selection = [n]; }`}`.quiet() + await Bun.sleep(200) + await $`osascript -e 'tell application "Figma" to activate'`.quiet() + await Bun.sleep(300) + await $`osascript -e 'tell application "System Events" to keystroke "c" using command down'`.quiet() + await Bun.sleep(1000) + + const html = await readClipboardHtml() + if (!html) bail('Failed to copy from Figma') + const parsed = await parseFigmaClipboard(html) + if (!parsed) bail('Clipboard has no Figma data after copy') + + console.log('🖼️ Rendering with OpenPencil…') + await renderOurs(html) + + await diff() +} + +// --- Rendering --- + +async function renderOurs(html: string) { + const result = await parseFigmaClipboard(html) + if (!result) throw new Error('Failed to parse clipboard') + + const graph = new SceneGraph() + const pageId = graph.getPages()[0].id + + const createdIds = importClipboardNodes(result.nodes, graph, pageId, 0, 0, result.blobs) + if (createdIds.length === 0) throw new Error('No nodes imported from clipboard') + + computeAllLayouts(graph) + + const families = new Set() + for (const node of graph.getAllNodes()) { + if (node.fontFamily) families.add(node.fontFamily) + } + for (const family of families) { + await fontManager.loadFont(family) + } + + const ck = await initCanvasKit() + const surface = ck.MakeSurface(1, 1) + if (!surface) throw new Error('Failed to create CanvasKit surface') + const renderer = new SkiaRenderer(ck, surface) + renderer.viewportWidth = 1 + renderer.viewportHeight = 1 + renderer.dpr = 1 + + const data = renderNodesToImage(ck, renderer, graph, pageId, createdIds, { + scale, + format: 'PNG' + }) + + surface.delete() + if (!data) throw new Error('Render produced no image') + await Bun.write(oursPath, data) + console.log(` → ${oursPath}`) +} + +async function renderFigmaViaPaste() { + // Create temp page so we don't pollute the user's work + await $`figma-use eval ${'(() => { const p = figma.createPage(); p.name = "__visual_compare__"; figma.currentPage = p; return p.id; })()'} --json`.quiet() + + try { + // Activate Figma and paste + await $`osascript -e 'tell application "Figma" to activate'`.quiet() + await Bun.sleep(500) + await $`osascript -e 'tell application "System Events" to keystroke "v" using command down'`.quiet() + await Bun.sleep(2000) + + // Get pasted selection + const selJson = await $`figma-use selection get --json`.quiet() + const selection = JSON.parse(selJson.text().trim()) + if (!selection.length) throw new Error('Nothing pasted. Ensure clipboard has Figma data.') + + const nodeId = selection[0].id + + // Export from Figma + await $`figma-use export node ${nodeId} --output ${figmaPath} --scale ${String(scale)}`.quiet() + console.log(` → ${figmaPath}`) + } finally { + // Clean up: remove temp page + await $`figma-use eval ${'(() => { const ps = figma.root.children; const tmp = ps.find(p => p.name === "__visual_compare__"); if (tmp) { const other = ps.find(p => p !== tmp); if (other) figma.currentPage = other; tmp.remove(); } })()'}` + .quiet() + .nothrow() + } +} + +// --- Diff --- + +async function diff() { + console.log('🔍 Computing diff…') + + const figmaSize = (await $`identify -format '%wx%h' ${figmaPath}`.quiet()).text().trim() + const oursSize = (await $`identify -format '%wx%h' ${oursPath}`.quiet()).text().trim() + + const compareOursPath = figmaSize === oursSize ? oursPath : normalizedOursPath + if (figmaSize !== oursSize) { + const mode = opts.resize ? 'resizing' : 'padding/cropping without scaling' + console.log(` ⚠ Size mismatch: Figma ${figmaSize}, Ours ${oursSize} → ${mode}`) + if (opts.resize) { + await $`magick ${oursPath} -resize ${figmaSize}! ${normalizedOursPath}`.quiet() + } else { + await $`magick ${oursPath} -background none -gravity northwest -extent ${figmaSize} ${normalizedOursPath}`.quiet() + } + } + + const result = + await $`magick compare -metric AE -highlight-color red -lowlight-color '#FFFFFF33' -compose src ${figmaPath} ${compareOursPath} ${diffPath}` + .quiet() + .nothrow() + + const fuzzResult = + await $`magick compare -metric AE -fuzz ${opts.fuzz} ${figmaPath} ${compareOursPath} null:` + .quiet() + .nothrow() + const rmseResult = await $`magick compare -metric RMSE ${figmaPath} ${compareOursPath} null:` + .quiet() + .nothrow() + const rmse = rmseResult.stderr.toString().trim() + const diffPixels = Number.parseInt(result.stderr.toString().trim(), 10) || 0 + const fuzzPixels = Number.parseInt(fuzzResult.stderr.toString().trim(), 10) || 0 + const [w, h] = figmaSize.split('x').map(Number) + const total = w * h + const pct = (diffPixels / total) * 100 + const fuzzPct = (fuzzPixels / total) * 100 + const alphaMetrics = opts['alpha-diff'] ? await diffAlpha(compareOursPath, total) : null + const metrics = { + figmaSize, + openPencilSize: oursSize, + comparedOpenPencilPath: compareOursPath, + resized: Boolean(opts.resize && figmaSize !== oursSize), + normalized: figmaSize !== oursSize, + differentPixels: diffPixels, + differentPercent: Number(pct.toFixed(2)), + fuzz: opts.fuzz, + fuzzDifferentPixels: fuzzPixels, + fuzzDifferentPercent: Number(fuzzPct.toFixed(2)), + rmse, + alpha: alphaMetrics + } + writeFileSync(metricsPath, `${JSON.stringify(metrics, null, 2)}\n`) + + console.log(` → ${diffPath}`) + console.log( + ` ${diffPixels.toLocaleString()} different pixels (${pct.toFixed(2)}% of ${total.toLocaleString()})` + ) + console.log( + ` ${fuzzPixels.toLocaleString()} different pixels with ${opts.fuzz} fuzz (${fuzzPct.toFixed(2)}%)` + ) + console.log(` RMSE ${rmse}`) + if (alphaMetrics) { + console.log( + ` Alpha AE ${alphaMetrics.differentPixels.toLocaleString()} pixels (${alphaMetrics.differentPercent.toFixed(2)}%)` + ) + } + console.log(` Metrics → ${metricsPath}`) + console.log(`\n✅ Done! Images in ${outputDir}/`) +} + +async function diffAlpha(compareOursPath: string, total: number) { + await $`magick ${figmaPath} -alpha extract ${alphaFigmaPath}`.quiet() + await $`magick ${compareOursPath} -alpha extract ${alphaOursPath}`.quiet() + const result = + await $`magick compare -metric AE -highlight-color red -lowlight-color '#FFFFFF33' -compose src ${alphaFigmaPath} ${alphaOursPath} ${alphaDiffPath}` + .quiet() + .nothrow() + const differentPixels = Number.parseInt(result.stderr.toString().trim(), 10) || 0 + return { + path: alphaDiffPath, + differentPixels, + differentPercent: (differentPixels / total) * 100 + } +} + +// --- Helpers --- + +async function readClipboardHtml(): Promise { + const proc = Bun.spawn( + [ + 'swift', + '-e', + 'import AppKit; if let h = NSPasteboard.general.string(forType: .html) { print(h) } else { exit(1) }' + ], + { stdout: 'pipe', stderr: 'pipe' } + ) + const text = await new Response(proc.stdout).text() + return (await proc.exited) === 0 ? text.trim() : null +} + +async function ensureFigmaConnected() { + const s = await $`figma-use status`.quiet().nothrow() + if (s.exitCode !== 0) { + bail( + 'figma-use not connected. Start Figma with:\n open -a Figma --args --remote-debugging-port=9222' + ) + } +} + +function bail(msg: string): never { + console.error(msg) + process.exit(1) +} diff --git a/tools/visual-oracles/src/export-fixtures.ts b/tools/visual-oracles/src/export-fixtures.ts new file mode 100644 index 000000000..4bba74d11 --- /dev/null +++ b/tools/visual-oracles/src/export-fixtures.ts @@ -0,0 +1,55 @@ +#!/usr/bin/env bun + +import { mkdirSync } from 'node:fs' +import { parseArgs } from 'node:util' + +import { $ } from 'bun' + +interface FixtureExportTarget { + file: string + page: string + outputName: string + heavy?: boolean +} + +const TARGETS: FixtureExportTarget[] = [ + { + file: 'tests/fixtures/gold-preview.fig', + page: 'Page 1', + outputName: 'gold-preview-page-1.png' + }, + { + file: 'tests/fixtures/material3.fig', + page: 'Getting started', + outputName: 'material3-getting-started.png' + }, + { + file: 'tests/fixtures/nuxtui.fig', + page: 'Components', + outputName: 'nuxtui-components.png', + heavy: true + } +] + +const { values } = parseArgs({ + options: { + output: { type: 'string', short: 'o', default: '/tmp/open-pencil-fixture-visuals' }, + heavy: { type: 'boolean', default: false } + } +}) + +const outputDir = values.output ?? '/tmp/open-pencil-fixture-visuals' +mkdirSync(outputDir, { recursive: true }) + +for (const target of TARGETS) { + if (target.heavy && !values.heavy) { + console.log(`Skipping ${target.file} (${target.page}); pass --heavy to include it.`) + continue + } + + const outputPath = `${outputDir}/${target.outputName}` + console.log(`Exporting ${target.file} / ${target.page} → ${outputPath}`) + await $`bun open-pencil export ${target.file} --page ${target.page} --output ${outputPath}` +} + +console.log(`Fixture visuals written to ${outputDir}`) diff --git a/scripts/pixel-image.ts b/tools/visual-oracles/src/pixel-image.ts similarity index 100% rename from scripts/pixel-image.ts rename to tools/visual-oracles/src/pixel-image.ts diff --git a/scripts/update-visual-report.ts b/tools/visual-oracles/src/update-report.ts similarity index 100% rename from scripts/update-visual-report.ts rename to tools/visual-oracles/src/update-report.ts