chore: merge master into docs/current-workflows

This commit is contained in:
Danila Poyarkov 2026-09-15 23:37:12 +03:00
commit 12282fbc84
15 changed files with 301 additions and 123 deletions

7
.claude/settings.json Normal file
View file

@ -0,0 +1,7 @@
{
"attribution": {
"commit": "",
"pr": "",
"sessionUrl": false
}
}

View file

@ -14,7 +14,7 @@ Before opening a PR, read `CONTRIBUTING.md` and `AGENTS.md`. PRs should explain
### AI assistance
<!-- If an LLM materially helped create or modify this PR, list the model names. Otherwise write “None”. No prompts or transcripts are required. -->
<!-- If an LLM materially helped create or modify this PR, list the model names. Otherwise write “None”. This is disclosure, not co-authorship; omit tool promotional signatures and session links. No prompts or transcripts are required. -->
Models: None

View file

@ -70,7 +70,8 @@ App dialogs compose the Reka-backed components under `src/components/ui/dialog/`
- `bun run dev` — fixed `http://localhost:1420` server for Playwright, Tauri, and Dev Containers.
- `bun run check` — complete build, lint, type, architecture, docs, package, dependency, security, tooling, and duplication gate.
- `bun run format` — format and sort imports.
- `bun run test:unit` / `bun run test` — engine/unit and Playwright suites.
- `bun run test:unit` / `bun run test` — engine/unit and app Playwright suites.
- `bun run test:storybook` — the Storybook Playwright project in `playwright.config.ts`. Test scripts select their server; direct Playwright commands start both servers unless `OPENPENCIL_TEST_SERVER=app|storybook|all` is set.
- `bun run tauri dev` — desktop app with hot reload.
- `bun open-pencil --help` — current CLI command list.
@ -99,12 +100,21 @@ PR CI always classifies changed paths through `tools/ci/`. Docs-only changes run
For user-facing work, add one present-tense outcome under the single appropriate `Unreleased` category: `Breaking changes`, `Added`, `Changed`, `Fixed`, `Performance`, or `Security`. Treat it as release notes, not a commit log: omit tests, benchmarks, CI, internal refactors/tooling, and bugs both introduced and fixed since the last release. After merges, compare the whole section with changes since the latest release, preserve important outcomes, consolidate related work, and remove duplicate bullets/headings. End sentences with periods and retain relevant issue/PR references. Update `README.md` when appropriate and this file when architecture or conventions change. Keep internal plans in ignored `scratch/`, not published docs.
Before finalizing `Unreleased`:
- Compare released behavior at the latest published tag with the final implementation, not just commit subjects. Verify questionable fixes existed at that tag; fold fixes to newly added features into their final feature description.
- Check public exports, model/config/data contracts, and peer requirements for removals, renames, and upgrade instructions under `Breaking changes`.
- Remove superseded intermediate behavior and duplicate outcomes across categories. State platform requirements and concrete supported behavior instead of unqualified compatibility or performance claims.
- Run `bun run check:changelog`. Keep historical sections unchanged during routine cleanup; release publication uses the matching tagged section, not regenerated prose.
## Commit messages
`commitlint.config.ts` enforces message structure through the **Commit messages** CI job on all PRs, including docs-only changes. Run `bun run check:commits --last` or pass `--from`/`--to` for a branch range. Preserve the release exception and product casing when changing rules; CI gate policy lives in `tools/ci/src/policy.ts`.
Use Conventional Commits (`feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `build`, `ci`, `chore`) for regular work. Keep subjects short, imperative, and narrowly scoped; explain rationale in the body. Preserve product casing such as DOM/CSS, HTML, JSX, Tailwind, Kiwi, `.fig`, MCP, CLI, AI, ACP, and i18n. Release commits use `Release vX.Y.Z`.
Keep AI assistance in the PR's AI assistance section, not commit authorship or `Co-authored-by` trailers. Do not append tool-generated promotional signatures or session links. Preserve human co-author credits and required third-party notices. Follow the vendor-neutral attribution policy in `CONTRIBUTING.md`; the existing commitlint gate checks known AI co-author identities without rewriting base history.
PR titles use Conventional Commits because GitHub uses them as merge subjects. The separate **PR title** workflow validates titles, including title edits, without rerunning the full CI suite. Preserve the conventional subject when merging via CLI/API; if setting it explicitly with `gh pr merge --subject`, use the validated PR title. Give branch-update merges explicit subjects such as `chore: merge master into <branch>`. Commitlint's default merge exceptions are not a naming convention. Do not rewrite published history solely to normalize messages.
## CLI
@ -210,7 +220,7 @@ Keep responsibilities distinct: engine tests cover state contracts, Playwright b
- Section/frame title text never scales — render at fixed font size, ellipsize to fit
- Rulers are rendered on the canvas (not DOM), with selection range badges that don't overlap tick numbers
- Remote cursors: Figma-style colored arrows with white border + name pill, rendered in screen space
- Pixel-affecting renderer features need committed visual coverage, not just mock/geometry assertions. Add or update a Playwright canvas snapshot for changes to fills, gradients, images, blend modes, masks, boolean geometry, corners, strokes, shadows, blur, text rendering, or demo showcase scenes. Use targeted snapshot updates such as `bunx playwright test tests/e2e/canvas/renderer-visuals.spec.ts --project=openpencil --update-snapshots` and then rerun the same test without `--update-snapshots`.
- Pixel-affecting renderer features need committed visual coverage, not just mock/geometry assertions. Add or update a Playwright canvas snapshot for changes to fills, gradients, images, blend modes, masks, boolean geometry, corners, strokes, shadows, blur, text rendering, or demo showcase scenes. Use targeted snapshot updates such as `bun run test tests/e2e/canvas/renderer-visuals.spec.ts --update-snapshots` and then rerun the same test without `--update-snapshots`.
## Scene graph

View file

@ -4,58 +4,55 @@
### Breaking changes
- Use MCP SDK v2 server/client types for programmatic MCP integrations. Define custom tools with native Valibot `input` schemas and execution metadata instead of `params`, `ParamDef`, or `paramToZod()`; tool arguments and effects derive from this shared contract. Tool exposure defaults to inclusion, with independent `mcp`, `ai`, and `webmcp` exclusions; execution support and user permissions still apply.
- Use MCP SDK v2 server/client types for programmatic MCP integrations. Define custom tools with native Valibot `input` schemas and execution metadata instead of `params`, `ParamDef`, or `paramToZod()`.
- Replace Scene Graph `overrides` records with `instanceOverrides`, using separate `self` and `descendants` maps. Rename `figmaDerivedLayout`, `figmaDerivedTextGlyphs`, and `FigmaDerivedTextGlyph` to `derivedLayout`, `derivedTextGlyphs`, and `DerivedTextGlyph`.
- Update custom Vue SDK binding providers to implement `getBindingId()` and handle `unresolved`. Replace `setValue()` with `prepareEdit()`, returning a stable edit key, captured value, setter, and restoration callback.
- Replace Vue SDK `useDialogMessages()`, `dialogMessages`, and their catalog keys with the corresponding product-domain message composables and catalogs.
- Use Vue 3.5.41 or newer within Vue 3 for the Vue SDK, and CanvasKit 0.41.1 or newer when supplying its optional CanvasKit peer. Update custom CanvasKit integrations to use `PathBuilder` and immutable `Path` operations.
### Added
- Expose design inspection and undoable layer-property and variable edits to browser agents through experimental WebMCP in supporting browsers, with explicit Off, Inspect, and Edit access controls in Settings.
- Control custom tool exposure independently through `mcp`, `ai`, and `webmcp` exclusions. Tools are included by default, subject to execution support and user permissions.
- Bind Design JSX spacing, sizing, corners, and typography directly to numeric document variables.
- Define component properties and assign instance values in Design JSX using stable property IDs.
- Save AI conversations and attachment previews locally, switch between chats, rename or delete them, and browse saved transcripts across documents. Choose whether reasoning stays collapsed, expands while thinking, or stays expanded, with animated disclosure controls that respect reduced motion.
- Add a searchable command palette for editor and application actions.
- Search current AI provider catalogs from model pickers, with curated recommendations, recent compatible models, and offline fallbacks.
- Render triangle and line arrow stroke caps on lines and open vector paths, and choose them from the stroke cap picker.
- Expose component properties and instance-swap targets through the Figma API and automation.
- Normalize imported stroke dash patterns for more reliable `.fig` compatibility.
- Create, select, move, duplicate, transfer, and delete canvas and frame guides directly from rulers, with undoable edits, measurements, context-menu actions, and `.fig` round-trip fidelity.
- Open to a unified home with recent and configured storage documents in grid or list layouts, and open multiple selected design files in separate tabs.
- Snap vector points, moved layers, and resized edges to nearby geometry, guides, frame and canvas bounds, and whole-pixel coordinates, with visible alignment guides and persistent snapping preferences.
- Run Pi through AI SDK HarnessAgent as a configurable desktop provider with saved model profiles, secure credentials, existing MCP design tools, and per-profile thinking and permission settings.
- Combine components into variant sets through Figma API scripts and automation.
- Add local AI usage and technical diagnostics, including token telemetry, provider/model summaries, recent failures, configurable retention, export, and clear controls. (#588)
- Add local AI usage and technical diagnostics, including token telemetry, provider/model summaries, recent failures, configurable retention, export, and clear controls (#588).
- Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout.
- Show temporary Figma-style distance measurements between selected and Option/Alt-hovered layers. (#491)
- Edit Design JSX and HTML/CSS previews in CodeMirror, with theme-aware highlighting, Tailwind viewing, completion, diagnostics, bounded execution, and session-level undo. (#130)
- Set provider-specific reasoning effort on supported AI model profiles. (#454)
- Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503)
- Show temporary Figma-style distance measurements between selected and Option/Alt-hovered layers (#491).
- Edit Design JSX and HTML/CSS previews in CodeMirror, with theme-aware highlighting, Tailwind viewing, completion, diagnostics, bounded execution, and session-level undo (#130).
- Set provider-specific reasoning effort on supported AI model profiles (#454).
- Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API, MCP, and `openpencil fonts [file] --json`. Choose `warn`, `strict`, or `allow` font-substitution policies for file-backed CLI raster and PDF exports with `--font-policy` (#503, #625).
- Add reusable remote MCP connections for ACP agents, with Streamable HTTP endpoints and credential-backed bearer tokens.
- Author and manage multidimensional component variants and published component libraries, including revision previews, linked-instance updates, stable library identities, offline catalogs, storage-backed catalogs, and read-only library definitions. (#239)
- Recover unsaved and pathless documents locally, with settings to disable recovery and remove retained snapshots. (#487, #574)
- Inspect selected designs with a configured Vision model and attach images to AI chat with bounded analysis and previews. (#232, #471)
- Pin selected layers as explicit AI chat context, show collapsible reasoning, copy individual responses, and grow the composer with multiline prompts. (#13)
- Render streaming AI responses with the upstream Comark-based Markdown pipeline and optional Shiki code highlighting without the former project fork.
- Author and manage multidimensional component variants and published component libraries, including revision previews, linked-instance updates, stable library identities, offline catalogs, storage-backed catalogs, and read-only library definitions (#239).
- Recover unsaved and pathless documents locally, including after closing their tabs, with options to disable recovery and restore or discard retained snapshots (#487, #505, #574).
- Inspect selected designs with a configured Vision model and attach images to AI chat with bounded analysis and previews (#232, #471).
- Pin selected layers as explicit AI chat context, show collapsible reasoning, copy individual responses, and grow the composer with multiline prompts (#13).
### Changed
- Explore editable component, typography, and paint comparisons in the demo, with the original examples preserved on a reference page.
- Use compact desktop Home search actions with consistent responsive layout and control sizing.
- Keep applied and available Effect styles concise, and collapse equal independent corner fields when all four use the same variable.
- Keep pixel-grid rounding invisible while showing alignment guides only for real geometry, objects, and canvas/layout guides.
- Copy selections with embedded images into Figma while preserving typed geometry, text sizing, images, components, variables, modes, and shared styles for lossless in-app paste.
- Embed images when copying selections into Figma, and preserve geometry, text sizing, component links, variables, modes, and shared styles when pasting within OpenPencil.
- Choose the app theme and whether animations follow the system or stay off under Appearance in General Settings, with live updates and persistent preferences.
- Put unbound fill and stroke style pickers in section headers, preserve applied and missing style rows, and remove the redundant Dimensions heading for text layers.
- Simplify property panels with fill and stroke style pickers in section headers, concise effect style rows, and collapsed equal corner fields when all four share a variable. Preserve applied and missing styles and remove the redundant Dimensions heading for text layers.
- Open variable pickers below their trigger when space permits, flipping above near the viewport edge.
- Keep AI chat preferences with the model overview and edit models in a fixed-size Settings pane with explicit Save and Cancel actions.
- Match page-list density to the layer tree and add subtle, reduced-motion-aware dialog transitions.
- Fade in streaming Markdown list items and code lines without animating completed responses.
- Vertically center shaped section titles and allow renaming a section by double-clicking its canvas label.
- Load supported online fonts before revealing imported pages, preserve substituted text during editing, and shape canvas labels with bundled Inter typography.
- Upgrade CanvasKit to 0.41 and use immutable renderer paths through `PathBuilder`.
- Upgrade direct model chat providers and transports to AI SDK 7 while retaining the local ACP execution path.
- Localize file, clipboard, collaboration, chat, vectorization, storage, recovery, and component-library notifications in every supported language.
- Complete translated app, accessibility, font, color, file, clipboard, collaboration, chat, vectorization, storage, recovery, component-library, and connection feedback across supported locales, and synchronize document language with the selected locale.
- Separate local MCP server controls, browser WebMCP access, and remote connections in Settings, with inline searchable tool permissions.
- Show translated field errors, hints, and consistent contextual alerts in Settings forms, focus the first invalid field on submission, and explain missing requirements instead of silently disabling Save or Test.
- Pan horizontally with Shift+wheel while preserving native horizontal trackpad movement.
@ -66,80 +63,67 @@
- Avoid recursive desktop HTTP proxy requests when font downloads intercept Tauri IPC traffic.
- Keep FIT image fills proportional, centered, and fully visible without stretching or cropped edges.
- Preserve edited instance text, including cleared labels, when saving and reopening `.fig` files.
- Honor `.pen` frame layout defaults and sizing and padding shorthands so imported auto-layout frames keep their computed dimensions and child positions. (#564)
- Honor `.pen` frame layout defaults and sizing and padding shorthands so imported auto-layout frames keep their computed dimensions and child positions (#564).
- Avoid macOS Keychain prompts during credential status checks and pause repeated credential access after failures until explicitly retried from Settings.
- Honor explicit Design JSX instance dimensions and preserve authored overrides through component synchronization.
- Route browser Command/Ctrl plus and minus shortcuts to canvas zoom instead of page zoom.
- Resolve `$name` references in imported `.pen` fills, stroke fills, font families, dimensions, and spacing without requiring a `--` prefix. (#563)
- Resolve `$name` references in imported `.pen` fills, stroke fills, font families, dimensions, and spacing without requiring a `--` prefix (#563).
- Resolve bound fields in each layer’s mode, keep variable edits scoped and undoable, and make broken bindings visible and recoverable.
- Display letter spacing in pixels and support explicit automatic line height.
- Prevent the stock photo tool from replacing text, lines, structural layers, or containers with content while supporting closed shape geometry.
- Preserve explicit text alignment metadata on imported Figma vectors across save and reload.
- Preserve explicit normal blend modes on imported Figma text and vector nodes across save and reload.
- Preserve implicit fixed-size text inside imported Figma auto-layout frames across save and reload.
- Preserve imported Figma text alignment metadata, explicit normal blend modes on text and vectors, and implicit fixed text sizing in auto-layout frames across save and reload.
- Render imported Figma strokes with odd-length dash patterns correctly.
- Stop showing a misleading desktop-only warning when web font loading or catalog lookup fails.
- Preserve source text offsets when resolving fallback languages after text-case transformations.
- Track character coverage restored from downloaded font cache entries.
- Resolve fallback fonts reliably for cached characters, mixed-language text, and text-case transformations.
- Preserve imported Figma divider-line geometry during auto-layout recomputation, preventing half-pixel shifts on save and reload.
- Resolve package imports under Node and Bun from ordinary tarballs while preserving Bun source-first workspace execution. (#663)
- Resolve package imports under Node and Bun from ordinary tarballs while preserving Bun source-first workspace execution (#663).
- Use the user's home directory as the default MCP file root on Windows, avoiding the caller's unreliable working directory.
- Open legacy raw `.fig` files that store the Kiwi document and thumbnail without a ZIP wrapper. (#582)
- Preserve a frame's auto-layout HUG sizing mode when converting it into a component with `create_component`.
- Run `openpencil import` on Node so npm-installed CLI users no longer encounter `Bun is not defined`. (#575)
- Open legacy raw `.fig` files that store the Kiwi document and thumbnail without a ZIP wrapper (#582).
- Preserve a frame's auto-layout HUG sizing, variable bindings, and variable modes when converting it into a component through the Figma API or automation (#595).
- Run `openpencil import` on Node so npm-installed CLI users no longer encounter `Bun is not defined` (#575).
- Generate recent-file previews from the conventional `Cover` page without modifying the source file.
- Isolate browser-development MCP servers behind worktree-aware Portless WebSocket routes and per-runtime socket/discovery paths, preventing concurrent worktrees from competing for port 7600 or the global MCP socket.
- Resolve Vue SDK semantic test selectors correctly in non-browser runtimes. (#397)
- Commit vector vertex and Bézier-handle edits when the pointer is released and keep transformed vector-edit overlays aligned. (#586)
- Preserve app-created component properties and instance-swap targets across `.fig` save and reload cycles. (#548)
- Reconnect desktop automation to an already-running MCP server through its discovery file. (#546)
- Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text. (#539)
- Match AI chat code-block colors and backgrounds to the active theme, and let desktop users select and copy chat text without replacing it with canvas layers. (#537, #538)
- Resolve Vue SDK semantic test selectors correctly in non-browser runtimes (#397).
- Commit vector vertex and Bézier-handle edits when the pointer is released and keep transformed vector-edit overlays aligned (#586).
- Preserve app-created component properties and instance-swap targets across `.fig` save and reload cycles (#548).
- Reconnect desktop automation to an already-running MCP server through its discovery file (#546).
- Keep text-editing carets, hit testing, and selection highlights aligned with vertically centered or bottom-aligned text (#539).
- Match AI chat code-block colors and backgrounds to the active theme, and let desktop users select and copy chat text without replacing it with canvas layers (#537, #538).
- Restore visible above, below, and child drop feedback while dragging layers in the Layers panel.
- Place editor-created instances beside nested source components in world space, including transformed parents.
- Prevent malformed collaboration updates from corrupting synchronized nodes or derived text rendering.
- Transfer native `.fig` exports over binary Tauri IPC, preventing large desktop saves from being truncated or exhausting WebView memory. (#484)
- Keep unsaved source-less documents recoverable after their editor tab is closed.
- Decode zstd-compressed FIG containers, reject invalid compressed payloads, and preserve exact fixture byte ranges. (#397)
- Compose caller CSS with Tailwind defaults when importing DOM/CSS documents. (#397)
- Preserve desktop HTTP timeout, abort, and empty-response semantics. (#397)
- Report whether missing Figma clipboard images were actually fetched. (#397)
- Report exhausted provider credit, request failures, and output-token limits through localized chat toasts and copied diagnostics. (#451, #454)
- Transfer native `.fig` exports over binary Tauri IPC, preventing large desktop saves from being truncated or exhausting WebView memory (#484).
- Decode zstd-compressed FIG containers and reject invalid compressed payloads (#397).
- Compose caller CSS with Tailwind defaults when importing DOM/CSS documents (#397).
- Preserve desktop HTTP timeout, abort, and empty-response semantics (#397).
- Report whether missing Figma clipboard images were actually fetched (#397).
- Report exhausted provider credit, request failures, and output-token limits through localized chat toasts and copied diagnostics (#451, #454).
- Prevent Windows desktop crashes when loading large system fonts for non-Latin text.
- Preserve open vector segments when the same vector network also contains filled regions. (#450)
- Match Figma Plugin API behavior for `rescale()`, page `backgrounds`, and nullable visual `absoluteRenderBounds`. (#442)
- Keep imported Figma instances linked to their remapped source components so later component edits update existing instances. (#385)
- Preserve open vector segments when the same vector network also contains filled regions (#450).
- Match Figma Plugin API behavior for `rescale()`, page `backgrounds`, and nullable visual `absoluteRenderBounds` (#442).
- Keep imported and pasted Figma instances linked to their remapped source components so later component edits update existing instances (#385).
- Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas.
- Preserve selected layers when browser clipboard serialization fails during cut operations, and fall back to the session clipboard when system clipboard access is unavailable. (#568)
- Treat MCP tool results with an omitted `isError` field as successful while preserving explicit MCP errors. (#583)
- Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance.
- Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and synchronize document language with the selected locale.
- Preserve effective nested instance text overrides when importing complex Figma component hierarchies. (#102)
- Preserve committed desktop text input when the WebView supplies text through the input event before updating the hidden text field (#607).
- Preserve selected layers when browser clipboard serialization fails during cut operations, and fall back to the session clipboard when system clipboard access is unavailable (#568).
- Treat MCP tool results with an omitted `isError` field as successful while preserving explicit MCP errors (#583).
- Preserve effective nested instance text overrides when importing complex Figma component hierarchies (#102).
- Preserve SVG clip paths, including clip shapes referenced through `<use>`, when importing editable vectors.
- Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452)
- Preserve component links when pasting Figma instances so later component edits continue to update them.
- Stop local MCP servers after the app disconnects instead of leaving orphaned background processes. (#494)
- Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements (#452).
- Stop local MCP servers after the app disconnects instead of leaving orphaned background processes (#494).
- Prevent unbounded instance duplication when editing Figma-imported or pasted components with serialized or renamed children, keep extra instance children in their intended order, and avoid pasted instances re-linking pre-existing instances during clipboard import.
- Prevent unbounded instance duplication when editing Figma-imported or pasted components with serialized or renamed children, keep extra instance children stable instead of yanking them to the front, and avoid pasted instances re-linking pre-existing instances during clipboard import.
### Performance
- Scope automation and Figma API layout reconciliation to graph nodes and parent containers actually changed by each mutation.
- Reduce unnecessary layout work after automation and Figma API edits by updating only affected layers and containers.
- Keep rapid trackpad zoom reversals and effect-heavy document navigation responsive by cancelling obsolete reconstruction and reusing safe raster snapshots.
- Show the FIG page list from a lightweight Kiwi scan before materializing the full document.
- Avoid redundant collaboration writes when synchronized node fields have not changed.
- Release obsolete streamed Markdown parser history after each AI response completes, preventing chat memory from multiplying with every streamed chunk. (#544)
- Open large documents faster by using cached world positions while finding layers under the pointer. (#527)
- Coalesce writable-document autosaves that overlap an active `.fig` export while preserving a trailing save for newer edits. (#528)
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive. (#500)
- Index Figma clipboard children once during import instead of rescanning every pasted node, keeping large flat pastes linear. (#500)
- Release completed streaming-response state to reduce retained chat memory (#544).
- Open large documents faster by reducing repeated position calculations when finding layers under the pointer (#527).
- Avoid redundant autosaves while a `.fig` export is in progress, while ensuring newer edits are saved afterward (#528).
- Defer JSX generation and syntax highlighting until the Code panel is active, keeping large canvas selections responsive (#500).
- Paste large, flat Figma selections faster by avoiding repeated scans of clipboard layers (#500).
- Reduce peak memory during `.fig` export by sharing immutable binary resources with the isolated export graph.
## 0.14.0 — 2026-08-10
### Breaking changes

View file

@ -111,6 +111,14 @@ See [`AGENTS.md`](./AGENTS.md) for the full architecture reference, code convent
Follow the commit-message conventions in [`AGENTS.md`](./AGENTS.md). Update `CHANGELOG.md` for user-facing changes.
### Attribution
AI-assisted contributions are welcome. Credit human collaborators in commit authorship and `Co-authored-by` trailers; don't add AI assistants as co-authors or append tool-generated promotional signatures. Record AI assistance in the PR's existing AI assistance section instead. Preserve human attribution and required third-party notices.
The committed `.claude/settings.json` disables Claude Code's automatic commit/PR attribution and session links. Other tools should follow the same policy. This does not prohibit AI use, ordinary discussion of tools, or legitimate maintenance-bot workflows.
The Commit messages check flags known AI co-author identities in newly introduced commits, including merge and release commits. If it flags an automatically added trailer, remove only that trailer using the amendment guidance below; keep human credits and the PR disclosure. Unknown identities and promotional prose remain subject to normal review. Existing base-branch history is not rewritten.
### Commit messages
The **Commit messages** CI job checks every commit introduced by a PR, including docs-only PRs. It does not lint existing base-branch history or GitHub's synthetic merge commit. The aggregate CI result requires this job to pass.
@ -119,7 +127,7 @@ Use `type(optional-scope): short description`, for example `fix(MCP): preserve c
PR titles follow the same convention because GitHub uses them as merge subjects. The separate **PR title** workflow checks new and updated PRs, including title edits, without rerunning the full CI suite. Title validation disables commitlint's default merge/revert exceptions. Release titles and commits retain the exact `Release vX.Y.Z` convention.
Commit-range validation retains commitlint's default merge/revert exceptions, but they are not a naming convention. Preserve the validated PR title when merging via CLI/API, and use explicit conventional subjects for branch updates, for example `chore: merge master into my-branch`. Do not rewrite published history solely to normalize messages. These checks validate structure, not whether a description is meaningful or the type is appropriate.
Commit-range validation retains commitlint's default merge/revert exceptions, but they are not a naming convention. Preserve the validated PR title when merging via CLI/API, and use explicit conventional subjects for branch updates, for example `chore: merge master into my-branch`. Do not rewrite published history solely to normalize messages. These checks validate structure and known AI co-author identities, not whether a description is meaningful or the type is appropriate.
```sh
bun run check:commits --last

View file

@ -91,6 +91,7 @@
"@arethetypeswrong/cli": "0.18.4",
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@commitlint/is-ignored": "^21.2.2",
"@commitlint/types": "^21.2.0",
"@feature-sliced/steiger-plugin": "^0.5.8",
"@figma/plugin-typings": "^1.134.0",

View file

@ -1,10 +1,15 @@
import isIgnored from '@commitlint/is-ignored'
import type { UserConfig } from '@commitlint/types'
import { hasAICoauthor, noAICoauthors } from './tools/ci/src/commit-attribution'
export default {
extends: ['@commitlint/config-conventional'],
// PR titles must not bypass validation through Git's generated-message exceptions.
defaultIgnores: process.env.COMMITLINT_PR_TITLE !== '1',
// Apply attribution policy before allowing generated-message or release exceptions.
defaultIgnores: false,
plugins: [{ rules: { 'no-ai-coauthors': noAICoauthors } }],
rules: {
'no-ai-coauthors': [2, 'always'],
'type-enum': [
2,
'always',
@ -16,6 +21,11 @@ export default {
'body-max-line-length': [0],
'footer-max-line-length': [0]
},
ignores: [(message) => /^Release v\d+\.\d+\.\d+$/.test(message.split('\n')[0] ?? '')],
ignores: [
(message) =>
!hasAICoauthor(message) &&
(/^Release v\d+\.\d+\.\d+$/.test(message.split('\n')[0] ?? '') ||
(process.env.COMMITLINT_PR_TITLE !== '1' && isIgnored(message)))
],
helpUrl: 'https://github.com/open-pencil/open-pencil/blob/master/CONTRIBUTING.md#commit-messages'
} satisfies UserConfig

View file

@ -55,10 +55,11 @@
"check:packages": "bun tools/package-quality/src/cli.ts check",
"check:arch": "steiger .",
"check:vue": "vue-tsc --noEmit -p tsconfig.json && vue-tsc --noEmit -p packages/vue/tsconfig.json",
"test": "playwright test --project=openpencil --grep-invert @real-llm",
"test:real-llm": "playwright test --project=openpencil --grep @real-llm",
"test:update": "playwright test --project=openpencil --update-snapshots",
"test:figma": "playwright test --project=figma",
"test": "OPENPENCIL_TEST_SERVER=app playwright test --project=openpencil --grep-invert @real-llm",
"test:storybook": "OPENPENCIL_TEST_SERVER=storybook playwright test --project=storybook-chromium",
"test:real-llm": "OPENPENCIL_TEST_SERVER=app playwright test --project=openpencil --grep @real-llm",
"test:update": "OPENPENCIL_TEST_SERVER=app playwright test --project=openpencil --update-snapshots",
"test:figma": "OPENPENCIL_TEST_SERVER=app playwright test --project=figma",
"figma:debug": "open -a Figma --args --remote-debugging-port=9222",
"typecheck": "tsgo --noEmit && bun run check:vue",
"test:unit": "bun test ./tests/engine",
@ -164,6 +165,7 @@
"@arethetypeswrong/cli": "0.18.4",
"@commitlint/cli": "^21.2.2",
"@commitlint/config-conventional": "^21.2.2",
"@commitlint/is-ignored": "^21.2.2",
"@commitlint/types": "^21.2.0",
"@feature-sliced/steiger-plugin": "^0.5.8",
"@figma/plugin-typings": "^1.134.0",

View file

@ -6,6 +6,7 @@
| --------------------- | ---------- | -------------------- | --------------- |
| E2E visual regression | Playwright | `bun run test` | `tests/e2e/` |
| Figma CDP reference | Playwright | `bun run test:figma` | `tests/figma/` |
| Storybook components | Playwright | `bun run test:storybook` | `tests/e2e/storybook/` |
| Unit tests | bun:test | `bun run test:unit` | `tests/engine/` |
## E2E Visual Regression
@ -19,13 +20,15 @@ bun run test:update # Regenerate baseline screenshots
### Server ownership and worktrees
The canonical `playwright.config.ts` starts Vite from the current checkout and waits for its HTTP URL. Vite starts and stops its MCP companion. Server reuse is off by default and always off in CI, so a test run cannot silently attach to another checkout on the default port.
The canonical `playwright.config.ts` owns app, Figma, and Storybook projects. The `test`, `test:update`, `test:real-llm`, and `test:figma` scripts select only the app server; `test:storybook` selects only Storybook on port `6017`. Direct Playwright commands start both servers by default. Set `OPENPENCIL_TEST_SERVER=app`, `storybook`, or `all` to select servers explicitly; `--project` selects tests, not servers.
App tests start Vite from the current checkout and wait for its HTTP URL. Vite starts and stops its MCP companion. Server reuse is off by default and always off in CI, so a test run cannot silently attach to another checkout on the default port.
Defaults are app port `1420` and MCP port `7600`. For concurrent worktrees, choose a free, distinct pair:
```sh
OPENPENCIL_TEST_PORT=1482 OPENPENCIL_TEST_MCP_PORT=7682 \
bunx playwright test tests/e2e/settings --project=openpencil
bun run test tests/e2e/settings
```
The configuration passes the app origin and MCP port to Vite; the companion receives matching CORS configuration and a port-specific socket/discovery directory. Port conflicts fail rather than silently selecting another endpoint. Do not reuse ports across concurrent runs.
@ -44,6 +47,15 @@ For intentional local debugging against an already-running matching server, set
The editor supports a test mode that hides UI chrome (toolbar, panels) for clean screenshot capture. Activated via URL parameter.
## Storybook Component Tests
```sh
bun run test:storybook
bun run test:storybook --list
```
The `storybook-chromium` project preserves its own viewport, device scale, screenshot defaults, and reduced-motion browser context. Storybook specs are excluded from the app projects. Snapshot updates use `bun run test:storybook --update-snapshots`.
## Figma CDP Reference Tests
A separate Playwright project connects to Figma via Chrome DevTools Protocol to capture reference screenshots for pixel-perfect comparison.

View file

@ -1,4 +1,4 @@
import { defineConfig } from '@playwright/test'
import { defineConfig, type PlaywrightTestConfig } from '@playwright/test'
const appPort = process.env.OPENPENCIL_TEST_PORT ?? '1420'
const mcpPort = process.env.OPENPENCIL_TEST_MCP_PORT ?? '7600'
@ -14,10 +14,16 @@ if (reuse !== undefined && reuse !== '0' && reuse !== '1') {
throw new Error('OPENPENCIL_TEST_REUSE_SERVER must be 0 or 1')
}
export default defineConfig({
testDir: './tests',
timeout: 15_000,
workers: 1,
const server = process.env.OPENPENCIL_TEST_SERVER ?? 'all'
if (!['app', 'storybook', 'all'].includes(server)) {
throw new Error('OPENPENCIL_TEST_SERVER must be app, storybook, or all')
}
const storybookPort = 6017
if (server === 'all' && [appPort, mcpPort].some((port) => Number(port) === storybookPort)) {
throw new Error('App, MCP, and Storybook test ports must differ')
}
const appDefaults = {
expect: {
toHaveScreenshot: {
maxDiffPixelRatio: 0.01,
@ -37,17 +43,26 @@ export default defineConfig({
launchOptions: {
args: ['--enable-unsafe-swiftshader']
}
},
}
} satisfies Pick<PlaywrightTestConfig, 'expect' | 'use'>
export default defineConfig({
testDir: './tests',
timeout: 15_000,
workers: 1,
projects: [
{
...appDefaults,
name: 'openpencil',
testDir: './tests/e2e',
testIgnore: '**/native/**',
testIgnore: ['**/native/**', '**/storybook/**'],
fullyParallel: false
},
{
...appDefaults,
name: 'openpencil-webkit',
testDir: './tests/e2e',
testIgnore: ['**/native/**', '**/storybook/**'],
testMatch: [
'**/*.webkit.spec.ts',
'**/design/panel.spec.ts',
@ -55,23 +70,53 @@ export default defineConfig({
'**/fonts/settings.spec.ts'
],
use: {
...appDefaults.use,
browserName: 'webkit'
}
},
{
...appDefaults,
name: 'figma',
testDir: './tests/figma'
},
{
name: 'storybook-chromium',
testDir: './tests/e2e/storybook',
timeout: 30_000,
use: {
baseURL: `http://localhost:${storybookPort}`,
viewport: { width: 800, height: 600 },
deviceScaleFactor: 1,
contextOptions: { reducedMotion: 'reduce' }
}
}
],
webServer: {
command: `bun run dev --port ${appPort} --strictPort`,
cwd: import.meta.dirname,
url: origin,
env: {
OPENPENCIL_DEV_ORIGIN: origin,
OPENPENCIL_DEV_MCP_PORT: mcpPort,
PORTLESS_URL: ''
},
reuseExistingServer: !process.env.CI && reuse === '1'
}
webServer: [
...(server === 'storybook'
? []
: [
{
command: `bun run dev --port ${appPort} --strictPort`,
cwd: import.meta.dirname,
url: origin,
env: {
OPENPENCIL_DEV_ORIGIN: origin,
OPENPENCIL_DEV_MCP_PORT: mcpPort,
PORTLESS_URL: ''
},
reuseExistingServer: !process.env.CI && reuse === '1'
}
]),
...(server === 'app'
? []
: [
{
command: `bun run storybook -- --port ${storybookPort} --ci --no-open`,
cwd: import.meta.dirname,
url: `http://localhost:${storybookPort}`,
reuseExistingServer: false,
timeout: 120_000
}
])
]
})

View file

@ -1,20 +0,0 @@
import { defineConfig } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e/storybook',
timeout: 30_000,
workers: 1,
use: {
baseURL: 'http://localhost:6017',
viewport: { width: 800, height: 600 },
deviceScaleFactor: 1,
reducedMotion: 'reduce'
},
projects: [{ name: 'storybook-chromium' }],
webServer: {
command: 'bun run storybook -- --port 6017 --ci --no-open',
url: 'http://localhost:6017',
reuseExistingServer: false,
timeout: 120_000
}
})

View file

@ -0,0 +1,35 @@
import { execFileSync } from 'node:child_process'
import type { SyncRule } from '@commitlint/types'
// Match tool identities, not people's names or entire vendor domains.
const AI_COAUTHOR_EMAILS = new Set([
'noreply@anthropic.com',
'noreply@openai.com',
'codex@openai.com',
'noreply@cursor.com',
'noreply@cursor.sh',
'gemini-cli@google.com',
'175728472+copilot@users.noreply.github.com'
])
const AI_GITHUB_EMAIL =
/^(?:\d+\+)?(?:claude|copilot|codex|cursoragent|gemini-code-assist|chatgpt-codex-connector)\[bot\]@users\.noreply\.github\.com$/i
export function hasAICoauthor(message: string): boolean {
// Git distinguishes real trailers from quoted examples and ordinary body text.
const trailers = execFileSync('git', ['interpret-trailers', '--parse'], {
input: message,
encoding: 'utf8'
})
return trailers.split('\n').some((line) => {
const value = /^co-authored-by:\s*(.*)$/i.exec(line)?.[1]?.trim()
if (!value) return false
const email = (/<([^<>]+)>$/.exec(value)?.[1] ?? value).trim().toLowerCase()
return AI_COAUTHOR_EMAILS.has(email) || AI_GITHUB_EMAIL.test(email)
})
}
export const noAICoauthors: SyncRule = (parsed) => [
!hasAICoauthor(parsed.raw),
'AI-assisted contributions are welcome. Remove AI-assistant Co-authored-by trailers and keep disclosure in the PR’s AI assistance section. Preserve human co-author credits.'
]

View file

@ -7,7 +7,7 @@ export type ChangeScope = 'docs' | 'code'
export function classifyPaths(paths: readonly string[]): ChangeScope {
if (paths.length === 0) return 'code'
return paths.every((path) => {
if (ROOT_DOCS.has(path)) return true
if (ROOT_DOCS.has(path) || /^packages\/[^/]+\/README\.md$/.test(path)) return true
if (path.startsWith('packages/docs/')) return DOC_ASSET.test(path)
if (path.startsWith('openspec/')) return path.endsWith('.md')
if (path.startsWith('skills/')) return path.endsWith('.md') || path.endsWith('/LICENSE.txt')

View file

@ -3,6 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { hasAICoauthor } from '#ci/commit-attribution'
const root = resolve(import.meta.dir, '../../..')
async function lint(message: string, args: string[] = [], prTitle = false) {
@ -34,6 +36,66 @@ test.each([
expect(result.code).toBe(0)
})
test.each([
'fix: document Claude Code and OpenAI integration',
'fix: preserve credits\n\nCo-authored-by: Claude Martin <claude@example.org>',
'fix: preserve credits\n\nCo-authored-by: person@example.org',
'fix: preserve credits\n\nCo-authored-by: Claude <123+claude@users.noreply.github.com>',
'fix: preserve credits\n\nCo-authored-by: Codex <456+codex@users.noreply.github.com>',
'fix: preserve credits\n\nCo-authored-by: Human Contributor <person@anthropic.com>',
'build: update packages\n\nCo-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>'
])('preserves ordinary mentions, human credits, and maintenance bots: %s', async (message) => {
expect((await lint(message)).code).toBe(0)
})
test.each([
'Claude Sonnet 5 <noreply@anthropic.com>',
'noreply@anthropic.com',
'NOREPLY@OPENAI.COM',
'Codex <codex@openai.com>',
'Cursor <noreply@cursor.com>',
'Gemini CLI <gemini-cli@google.com>',
'Copilot <175728472+Copilot@users.noreply.github.com>',
'Claude <123+claude[bot]@users.noreply.github.com>'
])('recognizes known assistant identities: %s', (identity) => {
expect(hasAICoauthor(`fix: update\n\nCo-authored-by: ${identity}`)).toBe(true)
})
test('parses real trailers without treating fenced examples as authorship', () => {
expect(
hasAICoauthor(
'docs: explain policy\n\n```text\nCo-authored-by: Claude <noreply@anthropic.com>\n```'
)
).toBe(false)
expect(
hasAICoauthor(
'fix: update\n\nCo-authored-by: Human <person@example.org>\ncO-aUtHoReD-bY: Claude <NOREPLY@ANTHROPIC.COM>'
)
).toBe(true)
})
test.each([
'fix: update',
'Merge pull request #1 from contributor/fix',
'Revert "fix: update"',
'Release v0.14.0'
])('attribution cannot bypass lint through an ignored subject: %s', async (subject) => {
const result = await lint(`${subject}\n\nCo-authored-by: Claude <noreply@anthropic.com>`)
expect(result.code).not.toBe(0)
expect(result.output).toContain('no-ai-coauthors')
expect(result.output).toContain('AI-assisted contributions are welcome')
expect(result.output).toContain('Preserve human co-author credits')
})
test.each(['fix: update', 'Release v0.14.0'])(
'rejects bare assistant email trailers: %s',
async (subject) => {
const result = await lint(`${subject}\n\nCo-authored-by: noreply@anthropic.com`)
expect(result.code).not.toBe(0)
expect(result.output).toContain('no-ai-coauthors')
}
)
test.each([
['Fixed stuff', 'type-empty'],
['feature: add tools', 'type-enum'],
@ -85,7 +147,12 @@ test('checks the full PR range while excluding existing base history', async ()
await git('config', 'user.email', 'commitlint@example.invalid')
await git('config', 'commit.gpgsign', 'false')
await git('config', 'core.hooksPath', join(directory, 'no-hooks'))
await git('commit', '--allow-empty', '-m', 'Legacy base message')
await git(
'commit',
'--allow-empty',
'-m',
'Legacy base message\n\nCo-authored-by: Claude <noreply@anthropic.com>'
)
const base = await git('rev-parse', 'HEAD')
await git('commit', '--allow-empty', '-m', 'docs: explain setup')
const args = [
@ -99,11 +166,18 @@ test('checks the full PR range while excluding existing base history', async ()
'HEAD'
]
expect((await lint('', args)).code).toBe(0)
await git(
'commit',
'--allow-empty',
'-m',
'fix: update\n\nCo-authored-by: Claude <noreply@anthropic.com>'
)
await git('commit', '--allow-empty', '-m', 'Bad intermediate commit')
await git('commit', '--allow-empty', '-m', 'fix: valid final commit')
const result = await lint('', args)
expect(result.code).not.toBe(0)
expect(result.output).toContain('Bad intermediate commit')
expect(result.output).toContain('no-ai-coauthors')
expect(result.output).not.toContain('Legacy base message')
} finally {
await rm(directory, { recursive: true, force: true })

View file

@ -14,6 +14,8 @@ const documentation = [
'README.md',
'AGENTS.md',
'CHANGELOG.md',
'packages/vue/README.md',
'packages/core/README.md',
'packages/docs/programmable/sdk/api/components/bindable-value.md',
'packages/docs/public/logo.svg',
'skills/open-pencil/SKILL.md',
@ -23,6 +25,9 @@ const documentation = [
const code = [
'src/app/ai/chat/system-prompt.md',
'packages/core/src/design-jsx/reference/authoring.md',
'packages/core/src/README.md',
'packages/core/README.md.ts',
'packages/core/instructions.md',
'packages/docs/.vitepress/config.ts',
'packages/docs/demo.vue',
'skills/open-pencil/scripts/create.ts',
@ -40,6 +45,11 @@ test.each(code)('code or unknown path: %s', (path) => {
expect(classifyPaths([path])).toBe('code')
expect(classifyPaths([...documentation, path])).toBe('code')
})
test('package READMEs and public guides share the docs-only route', () => {
expect(
classifyPaths(['packages/vue/README.md', 'packages/docs/programmable/sdk/getting-started.md'])
).toBe('docs')
})
test('empty diffs fail safe to code checks', () => {
expect(classifyPaths([])).toBe('code')
})