refactor(ui): remove test-id prop APIs

- Forward data-test-id attrs through rendered controls instead of exposing test-id props
- Move shell chrome components into the Shell namespace
- Remove internal planning docs from published VitePress docs
- Add guardrails and contributor guidance for test hooks and component placement
This commit is contained in:
Danila Poyarkov 2026-07-01 09:05:27 +03:00
parent 3accbbb835
commit 60bb484ee4
64 changed files with 211 additions and 985 deletions

View file

@ -164,7 +164,7 @@ bun run test # Playwright E2E
- `AGENTS.md` (this file) — contributor/agent reference: architecture, conventions, how to release.
- `packages/docs/` — VitePress site deployed at `openpencil.dev`. User guide, SDK, automation, reference, and development docs.
When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Update `AGENTS.md` when architecture or conventions change.
When adding features, update `CHANGELOG.md` (Unreleased section) and `README.md` (if user-facing). Update `AGENTS.md` when architecture or conventions change. Do not put speculative/internal implementation plans in `packages/docs/**`; VitePress docs are published. Keep temporary plans in ignored `scratch/` or distill durable public direction into the canonical roadmap.
## Commit messages
@ -358,6 +358,14 @@ Self-review checklist:
## UI
### Component structure
- `src/components/ui/**` is the app design-system layer: reusable visual primitives, wrappers around Reka UI primitives, low-level styled controls, and UI class helpers. These files must not import app services/stores or feature panels.
- `src/components/Shell/**` is for app shell chrome and global app services rendered as components (menu bar, toast viewport, update/status chrome). Shell components may use app shell/editor stores.
- `src/components/properties/**`, `src/components/chat/**`, `src/components/LayerTree/**`, `src/components/Toolbar/**`, and similar folders are feature/domain component namespaces. Keep feature-specific controls there unless they are genuinely reusable UI primitives.
- Root-level `src/components/*.vue` is for broad editor panels/surfaces that are assembled by views or shell layout. Do not add new root-level base controls; create a domain folder or move reusable primitives to `src/components/ui/**`.
- Test hooks should be `data-test-id` attributes owned by the rendered markup or generated internally from semantic component state. Do not add `testId`, `visibilityTestId`, `triggerTestId`, or other test-id props to component APIs.
- Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.)
- Vue UI styling APIs must follow the existing `:ui` / `tailwind-variants` slot pattern. Do not add one-off `fooClass`, `barClass`, `emptyActionClass`, etc. props to components; define a typed `Ui` object with named slots and merge through the local `use*UI()` helper or a `ui` prop.
- Do not pass imperative setters/actions through slots as `:set-*`, `:update-*`, `:request-*`, `:toggle-*`, etc. unless the component is explicitly a renderless primitive whose whole contract is slot actions. Prefer `v-model`, emitted events, normal component props, or owned default UI. For DOM refs/focus, use VueUse (`templateRef`, `unrefElement`, `useFocus`, etc.) instead of ref callback plumbing through slots.
@ -365,7 +373,7 @@ Self-review checklist:
- Editor commands share `packages/vue/src/editor/commands/registry.ts` as the canonical source for shortcut display tokens, keyboard bindings, and context-menu test IDs. Store portable shortcuts such as `MOD+D`, `MOD+SHIFT+H`, and `MOD+ALT+K`; format them with `formatShortcut()` at render time so macOS shows `⌘`/`⌥` and Windows/Linux show `Ctrl`/`Alt`.
- Labels and translations must not contain shortcut text. Keep labels semantic (`Add auto layout`, `Show/Hide`) and render shortcuts from command metadata. Steiger enforces this for `packages/vue/src/i18n/messages.ts` and locale JSON files.
- Canvas context-menu structure lives in `packages/vue/src/editor/menu-model/canvas.ts`. Do not hand-build command grouping in `src/components/CanvasMenu.vue`; the component should render menu entries and provide app-specific actions only when unavoidable.
- Browser and Tauri menus share `src/app/shell/menu/schema.ts` as the canonical menu model. Do not add menu items directly in `src/components/AppMenu.vue` or `desktop/src/menu.rs`.
- Browser and Tauri menus share `src/app/shell/menu/schema.ts` as the canonical menu model. Do not add menu items directly in `src/components/Shell/AppMenu.vue` or `desktop/src/menu.rs`.
- Regenerate the native menu with `bun run generate:tauri-menu` after editing the shared menu schema; `desktop/generated/menu.json` is consumed by the Tauri menu builder. Tauri also runs this generator from `desktop/tauri.conf.json` via `beforeDevCommand` and `beforeBuildCommand`.
- Every shared menu item with an `id` must be handled by `src/app/shell/menu/use.ts`, an editor command, or explicitly marked browser/native-only in the schema.
- Tailwind 4 for styling — no inline CSS, no component-level `<style>` blocks
@ -374,7 +382,7 @@ Self-review checklist:
- Number input spinner hiding is global CSS in `app.css`, not per-component
- ScrubInput (drag-to-change number) — cursor and pointerdown on outer container, not inner spans
- Icons: use unplugin-icons with Iconify/Lucide (`<icon-lucide-*>`) — don't use raw SVG or Unicode symbols
- App menu (`src/components/AppMenu.vue`) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden when `IS_TAURI` is true
- App menu (`src/components/Shell/AppMenu.vue`) — browser-only menu bar using reka-ui Menubar components; Tauri uses native menus, so menu is hidden when `IS_TAURI` is true
- Sections are draggable by title pill, not by the area to the right of the title
- CSS `contain: paint layout style` on side panels to isolate repaints from WebGL canvas

View file

@ -451,34 +451,26 @@ const TEST_ID_FORMAT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
const noRawTestIdStringProps = {
meta: {
docs: {
description: 'Disallow raw testId string props — use TestIdProps or RequiredTestIdProps'
description: 'Disallow test-id component props — use data-test-id attrs or internal semantic ids'
}
},
create(context) {
const file = normalizedFilename(context)
if (file.endsWith('/packages/vue/src/testing/test-id.ts')) return {}
if (!file.endsWith('.vue')) return {}
function isTestIdKey(key) {
return key?.type === 'Identifier' && key.name === 'testId'
}
function isStringType(member) {
return member.typeAnnotation?.typeAnnotation?.type === 'TSStringKeyword'
}
function report(node, optional) {
context.report({
node,
message: optional
? 'Use TestIdProps instead of declaring testId?: string directly.'
: 'Use RequiredTestIdProps or TestId instead of declaring testId: string directly.'
})
if (key?.type !== 'Identifier') return false
return key.name === 'testId' || /TestId$/u.test(key.name)
}
return {
TSPropertySignature(node) {
if (!isTestIdKey(node.key) || !isStringType(node)) return
report(node, !!node.optional)
if (!isTestIdKey(node.key)) return
context.report({
node,
message:
'Do not expose test-id component props. Let callers pass data-test-id attrs or derive internal ids from semantic component state.'
})
}
}
}
@ -567,7 +559,9 @@ const noInvalidTestIdAttributes = {
if (hasInvalidSpelling || invalidId !== null) return
if (
isStaticVueAttribute(templateNode, 'data-testid') ||
isVueBindDirective(templateNode, 'data-testid')
isVueBindDirective(templateNode, 'data-testid') ||
isStaticVueAttribute(templateNode, 'test-id') ||
isVueBindDirective(templateNode, 'test-id')
) {
hasInvalidSpelling = true
return
@ -579,7 +573,7 @@ const noInvalidTestIdAttributes = {
if (hasInvalidSpelling) {
context.report({
node,
message: 'Use data-test-id instead of data-testid.'
message: 'Use data-test-id attrs instead of data-testid or test-id component props.'
})
return
}

View file

@ -1,35 +0,0 @@
# DOM/CSS parser audit
OpenPencil's DOM/CSS compatibility layer should not grow hand-rolled CSS parsing. Browser conversion should use native DOM/CSSOM and `getComputedStyle()`. Headless conversion may keep narrow approximations only as temporary test/CLI support and should prefer dependency-backed parsers or browser-oracle paths for new CSS behavior.
## Current dependency-backed pieces
- HTML parsing: `parse5` in the headless runtime, native `DOMParser` in the browser runtime.
- Stylesheet parsing and headless inline style declaration parsing: `@acemir/cssom` in the headless runtime, native CSSOM in the browser runtime.
- CSS value tokenization for shadow values: `postcss-value-parser`.
- Tailwind generation: Tailwind v4 `compile()` / `build()`.
- Color parsing: `@open-pencil/core/color` (`culori`-backed).
## Current hand-rolled approximations
These are intentionally limited and should not be expanded without replacing them or proving a dependency cannot cover the use case.
| Area | File | Current behavior | Preferred direction |
|---|---|---|---|
| Selector matching/specificity | `packages/dom-css/src/headless-css.ts` | Supports simple tag/id/class selectors plus descendant and child combinators. Rejects pseudo/classes and attributes. | Replace with a selector engine over DesignDOM or run browser/runtime oracle for complex CSS. |
| Shorthand expansion | `packages/dom-css/src/headless-css.ts` | Expands simple margin/padding boxes, border color/width, and background color. | Use parsed declarations from CSSOM/native computed style; avoid adding new shorthand parsers. |
| `calc()` / custom properties | `packages/dom-css/src/headless-css.ts` | Resolves variables by direct lookup and only handles `calc(<number><unit> * <number>)`. | Browser runtime for real computed values; do not add arithmetic or fallback parsing manually. |
| JSX object style serialization | `packages/dom-css/src/jsx/runtime.ts` | Serializes object style props to inline CSS strings with simple camelCase to kebab-case conversion. | Keep as JSX authoring serialization only; use CSSOM/native parsing after HTML parsing. |
| Shadow values | `packages/dom-css/src/css-values.ts` | Uses `postcss-value-parser` tokenization for one simple outer shadow layer. Multiple shadows and inset shadows remain unsupported. | Keep using value-parser/browser-computed values; do not add string splitting for complex shadow grammar. |
| Numeric lengths | `packages/dom-css/src/css-values.ts` | Parses px/rem-ish numbers with `Number.parseFloat`. | Consume browser-computed pixel values where available; keep headless numeric parsing narrow. |
## Rule for new mapping work
- Do not add regex/string parsers for CSS grammar such as gradients, transforms, filters, complex shadows, selectors, variable fallback, or calc arithmetic.
- Prefer browser `getComputedStyle()` fixtures as oracle coverage.
- If headless support is required, first look for a maintained parser/runtime dependency and document why it was chosen.
- If a temporary approximation is unavoidable, keep it narrow, add tests that define its limits, and list it in this audit.
## Recently rejected
Linear-gradient parsing was started with manual comma/direction parsing and removed. Gradient support should be implemented only through a proper CSS value parser or browser-native extracted data that can be mapped without parsing CSS grammar manually.

View file

@ -1,109 +0,0 @@
# `@open-pencil/fig` package plan
`@open-pencil/fig` is the next package-split stage after `@open-pencil/kiwi`. It should own `.fig` document policy while `@open-pencil/kiwi` remains a pure low-level Kiwi/schema/container package and `@open-pencil/core` remains the editable SceneGraph/editor package.
## Package boundary
| Package | Owns | Must not own |
|---|---|---|
| `@open-pencil/kiwi` | Kiwi schema runtime, Figma Kiwi protocol/schema data, low-level codec, FIG Kiwi containers, GUID helpers, raw parse helpers | SceneGraph conversion, Figma compatibility policy, raw metadata invalidation |
| `@open-pencil/fig` | `.fig` read/write orchestration, raw metadata preservation/invalidation policy, SceneGraph ⇄ NodeChange conversion, component/instance interpretation, fixture/oracle helpers | Editor actions, Vue/app UI, CLI formatting, MCP transport |
| `@open-pencil/core` | SceneGraph data model, renderer, layout, editor actions, tools | Low-level Kiwi codec internals once `@open-pencil/fig` owns `.fig` policy |
## Initial public API sketch
```ts
import { readFig, writeFig } from '@open-pencil/fig'
const document = await readFig(bytes, { preserveRawMetadata: true })
const nextBytes = await writeFig(document.graph, { source: document.source })
```
Potential subpaths:
- `@open-pencil/fig` — high-level `.fig` read/write API
- `@open-pencil/fig/node-change` — SceneGraph ⇄ NodeChange conversion helpers
- `@open-pencil/fig/metadata` — raw metadata protection/invalidation helpers
- `@open-pencil/fig/instances` — component/instance interpretation helpers
- `@open-pencil/fig/oracles` — test-only oracle fixture helpers if they prove reusable outside core tests
Do not expose low-level Kiwi codec names from `@open-pencil/fig`; consumers that need raw codec access should use `@open-pencil/kiwi/fig/*` directly.
## Migration inventory
Candidate code currently in `@open-pencil/core`:
- `packages/core/src/io/formats/fig/**`
- `packages/core/src/kiwi/fig/import.ts`
- `packages/core/src/kiwi/fig/lazy-import.ts`
- `packages/core/src/kiwi/fig/node-change/**`
- `packages/core/src/kiwi/fig/instance-overrides/**`
- `packages/core/src/kiwi/fig/parse/worker.ts`
- `packages/core/src/kiwi/fig/parse/transfer.ts`
Keep these in core until the new package has package-local tests and published-style smoke coverage. Do not move editor-specific operations, renderer code, or app document stores.
## Milestones
### 1. Define package shell
- Add `packages/fig/package.json`, `tsconfig.json`, `tsdown.config.ts`, package-local tests, and `README.md`.
- Depend on `@open-pencil/core` for SceneGraph types and `@open-pencil/kiwi` for low-level FIG/Kiwi helpers.
- Add package metadata checks and tarball smoke imports before moving behavior.
### 2. Move pure `.fig` orchestration helpers
- Move read/write orchestration wrappers that do not depend on app/editor state.
- Preserve current core public exports with thin compatibility barrels only where public API stability requires them.
- Avoid deep compatibility shims for private paths.
### 3. Move NodeChange conversion policy
- Move SceneGraph ⇄ NodeChange conversion modules with tests covering:
- text styles and glyph metadata
- boolean operation `EXCLUDE` ⇄ Kiwi `XOR`
- fills/strokes/effects raw field preservation
- masks and component metadata
- variable bindings and style refs
### 4. Move raw metadata invalidation/protection
- Centralize source metadata invalidation rules in `@open-pencil/fig`.
- Keep tests that mutate SceneGraph fields and verify stale raw fields are invalidated.
- Preserve real Figma/oracle evidence in fixtures rather than guessing schema fields.
### 5. Move instance/component interpretation
- Move component props, derived symbol data, instance override resolution, and sync helpers once NodeChange conversion is stable.
- Keep package-local fixtures for component sets, variants, nested instances, and override propagation.
### 6. Rewire core consumers
- Make core import `.fig` policy through public `@open-pencil/fig` exports.
- Keep app, CLI, and MCP consuming core-level document APIs unless a direct `@open-pencil/fig` import is clearly better.
- Run architecture checks to ensure package boundaries stay acyclic.
## Tests and smoke coverage
Each milestone should keep these passing:
```sh
bun run check
bun scripts/smoke-packages.ts
bun test packages/fig/tests
bun test tests/engine/io/fig
bun test tests/engine/kiwi
```
The smoke script should eventually cover:
- `await import('@open-pencil/fig')`
- `await import('@open-pencil/fig/node-change')`
- a minimal `.fig` read/write round trip through packed packages
## Non-goals
- Do not move browser/app file picker logic into `@open-pencil/fig`.
- Do not move renderer behavior or visual comparison scripts into `@open-pencil/fig` unless they become package-local test utilities.
- Do not guess unsupported Figma schema fields. Use Figma/oracle payloads first.
- Do not merge `@open-pencil/fig` and `@open-pencil/kiwi`; the low-level codec package must remain SceneGraph-agnostic.

View file

@ -1,136 +0,0 @@
# `@open-pencil/kiwi` extraction plan
`@open-pencil/kiwi` is the standalone package for scene-graph-agnostic Kiwi runtime code. It owns low-level Kiwi schema parsing, Figma Kiwi schema data, binary message encode/decode, FIG Kiwi container framing helpers, and raw `.fig` parse helpers that return structural `NodeChange` data.
`.fig` import/export policy still lives in `@open-pencil/core` until a future `@open-pencil/fig` split. Core owns SceneGraph conversion, raw metadata invalidation, component/instance interpretation, and app/CLI-facing document I/O.
## Current package boundary
### In `@open-pencil/kiwi`
| Path | Scope |
|---|---|
| `packages/kiwi/src/schema-runtime/**` | Pure Kiwi schema parsing, validation, binary encode/decode, and byte-buffer utilities. |
| `packages/kiwi/src/fig/schema/fig.kiwi` | Static Figma Kiwi schema text. |
| `packages/kiwi/src/fig/schema.ts` | Parses and validates the bundled Figma Kiwi schema. |
| `packages/kiwi/src/fig/protocol.ts` | Low-level Figma multiplayer/Kiwi byte inspection and message-type helpers. |
| `packages/kiwi/src/fig/types.ts` | Minimal structural `GUID`, `Color`, `Vector`, and `Matrix` types used by low-level FIG helpers. |
| `packages/kiwi/src/fig/guid.ts` | GUID string formatting/parsing helpers used by low-level FIG data. |
| `packages/kiwi/src/fig/variable-bindings.ts` | Variable binding binary encode/decode helpers. |
| `packages/kiwi/src/fig/codec.ts` | Figma Kiwi message encode/decode and structural `NodeChange` helpers. |
| `packages/kiwi/src/fig/container.ts` | `fig-kiwi` byte container framing and compression helpers. |
| `packages/kiwi/src/fig/parse.ts` | `.fig` zip/canvas parsing into structural node changes, blobs, images, schema bytes, and container version. |
Public exports:
```json
{
".": "./dist/index.js",
"./schema-runtime": "./dist/schema-runtime.js",
"./fig": "./dist/fig.js",
"./fig/codec": "./dist/fig/codec.js",
"./fig/container": "./dist/fig/container.js",
"./fig/guid": "./dist/fig/guid.js",
"./fig/parse": "./dist/fig/parse.js"
}
```
`@open-pencil/kiwi` must not import `@open-pencil/core`, `#core/*`, app code, Vue code, CLI code, or MCP code.
### Kept in `@open-pencil/core` for now
| Path | Reason it stays |
|---|---|
| `packages/core/src/kiwi/fig/import.ts` | Creates `SceneGraph`, imports variables/components/pages, and applies OpenPencil source metadata. |
| `packages/core/src/kiwi/fig/lazy-import.ts` | Stores lazy import context in `SceneGraph` weak maps. |
| `packages/core/src/kiwi/fig/node-change/**` | Converts between Figma `NodeChange` records and OpenPencil `SceneNode`, text, vector, paint, font, and layout types. |
| `packages/core/src/kiwi/fig/instance-overrides/**` | Resolves Figma component/instance override semantics into `SceneGraph`. |
| `packages/core/src/kiwi/fig/parse/transfer.ts` | Serializes `SceneGraph` data for worker transfer. |
| `packages/core/src/kiwi/fig/parse/worker.ts` | Worker glue: parses through `@open-pencil/kiwi/fig/parse`, then imports into `SceneGraph`. |
| `packages/core/src/kiwi/fig/file.ts` | Core `.fig` I/O re-export. |
| `packages/core/src/io/formats/fig/**` | App/CLI-facing `.fig` read/write policy and renderer-backed export behavior. |
`@open-pencil/core/kiwi` remains the compatibility barrel for existing public consumers, but deep core shims for moved Kiwi internals have been removed. Internal core code imports low-level helpers directly from `@open-pencil/kiwi`.
## Completed extraction sequence
1. Added `packages/kiwi` with schema runtime, Figma schema data, package-local tests, build, and dist smoke.
2. Moved low-level protocol/schema helpers into `@open-pencil/kiwi`.
3. Moved variable binding binary helpers into `@open-pencil/kiwi`.
4. Removed core color/type dependencies from the low-level FIG codec by making color normalization caller-owned and defining structural FIG types in Kiwi.
5. Moved Figma Kiwi codec, parse helpers, and container helpers into `@open-pencil/kiwi`.
6. Rewired core internals and tests to import moved helpers from `@open-pencil/kiwi` directly.
7. Removed redundant deep core re-export shims for moved codec/parse/container modules.
8. Moved pure FIG GUID parsing/formatting helpers into `@open-pencil/kiwi`.
9. Fixed SceneGraph `EXCLUDE` export to serialize as Kiwi/Figma `XOR`, keeping low-level codec types aligned with the Figma enum.
## Package-local test plan
`packages/kiwi/tests/**` should continue to cover:
1. **Schema runtime smoke**
- Parse a small inline Kiwi schema.
- Validate field numbers and enum values.
- Compile it, encode a message, decode it back.
2. **Bundled Figma schema guard**
- Validate the bundled `fig.kiwi` schema.
- Assert stable high-value Figma schema facts.
3. **Protocol helpers**
- Zstd detection.
- Kiwi message type reading.
- Varint parsing.
- `fig-wire` header handling.
4. **Variable binding binary helpers**
- Varint encode/decode coverage.
- Figma variable ID parsing.
- Paint/node-change variable binding byte injection.
5. **Figma message codec**
- `initCodec()` idempotency.
- Non-empty schema bytes.
- Minimal message encode/decode.
- Variable-bound paint message encoding.
6. **Container and parse helpers**
- `buildFigKiwi()` / `parseFigKiwiChunks()` round-trip.
- Sync/async decompression.
- Invalid container rejection.
- Plugin data deduplication.
7. **Dist smoke**
- Import built package outputs from `dist`.
- Validate schema/runtime/codec/container/parse exports.
- Assert no `@open-pencil/core` dependency is required by package dist.
Repo-level tests under `tests/engine/io/fig/**`, `tests/engine/kiwi/**`, and Figma oracle fixtures remain in place. Package-local tests prove package isolation; repo-level tests prove OpenPencil `.fig` behavior did not regress.
## Next extraction candidates
Move only helpers that are proven scene-graph-agnostic and useful to a future `@open-pencil/fig` package. Good candidates may include small byte helpers or structural data utilities that do not depend on `SceneGraph`.
Do not move yet:
- `NodeChange``SceneNode` conversion.
- Instance override interpretation.
- Raw metadata preservation/invalidation policy.
- Renderer/editor fallback behavior.
- `.fig` import/export APIs that need core document policy.
## Validation commands
```sh
cd packages/kiwi && bun run check
cd ../..
bun run check
```
For targeted behavior checks, prefer focused repo tests such as:
```sh
bun test tests/engine/kiwi/schema-runtime.test.ts tests/engine/io/fig/roundtrip/basic.test.ts
```
Boolean operation export now maps SceneGraph `EXCLUDE` to the Kiwi/Figma `XOR` enum. Import remains tolerant of legacy `EXCLUDE` values in structural test data but real encoded messages should use `XOR`.

View file

@ -1,132 +0,0 @@
# Package split plan
OpenPencil is splitting stable compatibility layers into independently publishable packages while keeping editor/runtime behavior intact. Each package boundary should be narrow, covered by package-local checks, and consumed through public workspace exports.
## Goals
- Keep renderer/editor core free of DOM and browser-only dependencies.
- Let `.fig`, Kiwi, and DOM/CSS compatibility evolve without forcing consumers to install unrelated heavy dependencies.
- Preserve import/export fidelity by moving code only after oracle coverage exists.
- Keep public APIs narrow and package-local checks available for every standalone package.
## Current package boundary
- `@open-pencil/core` owns scene graph, renderer, editor actions, Figma API compatibility, `.fig` SceneGraph policy, and format import/export.
- `@open-pencil/dom-css` owns DesignDOM, CSS runtimes, HTML/JSX/Tailwind projection, and SceneGraph ⇄ DesignDOM conversion.
- `@open-pencil/kiwi` owns pure Kiwi schema/runtime code plus low-level Figma Kiwi codec, container, and parse helpers.
- App, CLI, MCP, and Vue SDK consume packages through public workspace exports only.
See [`kiwi-package-plan.md`](./kiwi-package-plan.md) for the detailed `@open-pencil/kiwi` inventory, package-local test plan, and remaining split boundaries. See [`fig-package-plan.md`](./fig-package-plan.md) for the staged `@open-pencil/fig` split. See [`dom-css-parser-audit.md`](./dom-css-parser-audit.md) for the DOM/CSS rule against expanding hand-rolled CSS parsing.
## Candidate packages
### `@open-pencil/kiwi`
Status: extracted.
Scope:
- Kiwi binary schema runtime.
- Figma Kiwi schema data and validation.
- Low-level Figma Kiwi protocol/message helpers.
- Structural `NodeChange` encode/decode.
- FIG GUID parsing/formatting helpers.
- `fig-kiwi` container framing and raw parse helpers that do not create `SceneGraph` objects.
Does not include:
- SceneGraph conversion.
- Raw metadata invalidation policy.
- Component/instance override interpretation.
- Renderer/editor code.
Minimum maintenance criteria:
- Package-local typecheck, unit tests, build, and dist smoke.
- No `@open-pencil/core`, `#core/*`, app, CLI, MCP, or Vue imports.
- Existing repo-level Kiwi/FIG tests continue to pass through public package APIs.
### `@open-pencil/fig`
Scope:
- `.fig` document read/write policy.
- Figma node-change import/export.
- Raw metadata preservation and invalidation policy.
- Figma component/instance interpretation.
- Figma oracle fixtures and compatibility helpers.
Should depend on:
- `@open-pencil/core` scene graph types and conversion policy while those remain core-owned.
- `@open-pencil/kiwi` for low-level schema/runtime/container/codec helpers.
Should not include:
- Canvas rendering.
- Editor UI/actions.
- DOM/CSS compatibility.
Minimum exit criteria:
- Import/export round-trip tests moved or duplicated as package-local coverage.
- Heavy Figma fixture coverage still available at repo level.
- Public API supports CLI/MCP/app document I/O without private path imports.
## Current inventory
In `@open-pencil/kiwi`:
- `packages/kiwi/src/schema-runtime/**`
- `packages/kiwi/src/fig/schema/**`
- `packages/kiwi/src/fig/schema.ts`
- `packages/kiwi/src/fig/protocol.ts`
- `packages/kiwi/src/fig/types.ts`
- `packages/kiwi/src/fig/guid.ts`
- `packages/kiwi/src/fig/variable-bindings.ts`
- `packages/kiwi/src/fig/codec.ts`
- `packages/kiwi/src/fig/container.ts`
- `packages/kiwi/src/fig/parse.ts`
Likely `@open-pencil/fig` candidates:
- `packages/core/src/kiwi/fig/file.ts`
- `packages/core/src/kiwi/fig/parse/transfer.ts`
- `packages/core/src/kiwi/fig/parse/worker.ts`
- `packages/core/src/kiwi/fig/import.ts`
- `packages/core/src/kiwi/fig/lazy-import.ts`
- `packages/core/src/kiwi/fig/node-change/**`
- `packages/core/src/kiwi/fig/instance-overrides/**`
- `packages/core/src/io/formats/fig/**`
Keep in `@open-pencil/core` unless proven otherwise:
- `SceneGraph` and node type definitions.
- Renderer/editor fallback behavior.
- Layout, text measurement, and canvas-specific code.
- Generic IO registry contracts that other formats use.
## Migration checklist
1. Add package-local tests before moving files.
2. Confirm every moved module imports only allowed public package exports.
3. Prefer direct imports from the new package for internal code; avoid accumulating deep re-export shims.
4. Preserve only intentional public compatibility barrels, such as `@open-pencil/core/kiwi`, when external consumers need a deprecation window.
5. Move one boundary at a time: schema/runtime first, low-level codec/container/parse second, `.fig` policy last.
6. Keep fixture/oracle tests in the repo-level suite even after package-local tests exist.
7. Run package smoke checks from a temporary consumer project before publishing.
## Migration order
1. Keep `@open-pencil/dom-css` standalone and stabilize its browser/headless runtime split.
2. Extract pure Kiwi runtime/codecs behind `@open-pencil/kiwi` without moving `.fig` SceneGraph policy.
3. Move `.fig` document policy and node-change conversion into `@open-pencil/fig` when the boundary is clear.
4. Update core/app/CLI/MCP imports to consume public package exports.
5. Keep compatibility re-exports in `@open-pencil/core` only if existing consumers need a deprecation window.
## Non-goals
- Do not guess Figma schema fields during the split.
- Do not move renderer-specific fallback behavior into file-format packages.
- Do not add browser DOM dependencies to core or file-format packages.
- Do not widen app/CLI imports to private package source paths.

View file

@ -1,298 +0,0 @@
# UI parity and hardening plan
OpenPencil's headless editor architecture is moving in the right direction: the Vue SDK exposes renderless primitives and composables, while the app shell owns product-specific presentation. The current app UI is still MVP-grade. This plan turns the existing shell into a Figma-grade, maintainable interface without weakening the headless SDK boundary.
## Goals
- Preserve the headless SDK model: controls and editor logic live in `packages/vue/src/**`; the app shell renders opinionated OpenPencil UI in `src/components/**`.
- Replace ad-hoc panel markup with reusable UI primitives so future feature work is fast and consistent.
- Make large documents feel production-grade, especially the Layers panel.
- Close high-impact Figma panel gaps: mixed values, constraints, stroke caps/joins, corner smoothing, shared styles, component properties, and richer typography.
- Add guardrails so UI quality does not regress.
## Current assessment
### Strong foundation
- `packages/vue/src/primitives/**` already follows a useful renderless-root pattern similar to Reka UI.
- `packages/vue/src/controls/**` keeps much property editing logic outside app components.
- App components use `data-test-id` consistently enough to support reliable regression tests.
- The menu and command registry direction is healthy and should remain the canonical source for shortcuts and command behavior.
### Main gaps
- `src/components/ui/**` is mostly class factories, not a complete UI kit. App sections repeatedly hand-build icon buttons, panel rows, toggle groups, and section headers.
- Several templates call `use*UI()` helpers inline, which recomputes variants during render and spreads design-system knowledge into many files.
- Some components still use raw inline SVG icons, which creates inconsistent visual language and violates the project convention.
- `PropertyListRoot` erases item types, causing casts and non-null assertions in property sections.
- `LayerTreeRoot` rebuilds and remounts the whole tree on every scene mutation via `sceneVersion` and `treeKey`, which will not scale to real `.fig` documents.
- Multi-select mixed values are not a first-class editing state across the panel.
- Important Figma-like controls are missing or incomplete in the UI even when the scene graph or renderer already supports the data.
## Phase 1: Build the panel UI kit
**Purpose:** Create shared components for the repeated panel patterns before adding new features.
### Files to add or refactor
- `src/components/ui/IconButton.vue`
- `src/components/ui/SegmentedControl.vue`
- `src/components/ui/PanelSection.vue`
- `src/components/ui/PanelRow.vue`
- `src/components/ui/panel.ts` for slot variants and shared panel class composition
- Optional icon namespace under `src/components/icons/` or an Iconify custom collection for OpenPencil-specific micro-icons
### Work items
1. Add `IconButton.vue` that owns tooltip wrapping, icon rendering, size variants, pressed/active state, disabled state, and test-id forwarding.
2. Add `SegmentedControl.vue` for small enumerations currently rendered as repeated toggle buttons or dropdowns.
3. Add `PanelSection.vue` for the standard section wrapper, label, optional add button, optional collapse toggle, and action slot.
4. Add `PanelRow.vue` for fixed-density property rows and common two-column input layout.
5. Replace repeated button markup in:
- `src/components/properties/PositionSection.vue`
- `src/components/properties/StrokeSection.vue`
- `src/components/properties/EffectsSection.vue`
- `src/components/properties/ExportSection.vue`
- `src/components/properties/FillSection.vue`
- `src/components/properties/AppearanceSection.vue`
- `src/components/AssetsPanel.vue`
- `src/components/AppMenu.vue`
6. Replace raw inline SVGs in app templates with icon components.
7. Hoist all `use*UI()` calls out of templates into `<script setup>` constants/computed values, or hide them inside the new UI components.
### Acceptance criteria
- `rg 'use\w+UI\(' src/components --glob '*.vue'` has no matches inside template blocks.
- `rg '<svg' src/components --glob '*.vue'` returns zero, except for explicitly approved third-party content rendering.
- Position, Stroke, Effects, Export, Fill, and Appearance sections use `PanelSection` and `PanelRow`.
- Toggle-button visual states are implemented once through `SegmentedControl` or `IconButton` variants.
## Phase 2: Fix typed property-list boundaries
**Purpose:** Remove template casts and non-null assertions by making renderless primitives expose precise slot types.
### Files to refactor
- `packages/vue/src/primitives/PropertyList/PropertyListRoot.vue`
- `packages/vue/src/primitives/PropertyList/context.ts`
- `packages/vue/src/controls/stroke/helpers.ts`
- `src/components/properties/StrokeSection.vue`
- `src/components/properties/EffectsSection.vue`
- `src/components/properties/FillSection.vue`
- `src/components/properties/LayoutSection/SizeControls.vue`
### Work items
1. Make `PropertyListRoot` generic over the array prop key (`fills`, `strokes`, `effects`) so slot props expose `Fill[]`, `Stroke[]`, or `Effect[]` exactly.
2. Use `defineSlots` to document the slot contract and preserve type information in consuming templates.
3. Change the slot shape so consumers do not need `activeNode!`; provide guarded actions or null-safe helpers instead.
4. Move stroke dash-pattern and independent-side logic from `StrokeSection.vue` into `packages/vue/src/controls/stroke/helpers.ts`.
5. Replace fake node casts with explicit helper functions that operate on real nodes and return typed patches.
### Acceptance criteria
- `rg 'as Stroke|as Fill|as Effect|as unknown|activeNode!' src/components --glob '*.vue'` returns zero.
- `StrokeSection.vue` becomes mostly markup and delegates stroke-specific mutations to SDK helpers.
- `bun run check` passes.
## Phase 3: Rewrite the Layers panel for scale
**Purpose:** Make the Layers panel usable on large imported Figma documents.
### Files to refactor
- `packages/vue/src/primitives/LayerTree/LayerTreeRoot.vue`
- `packages/vue/src/primitives/LayerTree/LayerTreeItem.vue`
- `packages/vue/src/primitives/LayerTree/context.ts`
- `packages/vue/src/primitives/LayerTree/useLayerDrag.ts`
- `src/components/LayerTree.vue`
- New app namespace: `src/components/LayerTree/LayerRow.vue`, `LayerRowRename.vue`, `LayerRowActions.vue`, `DropIndicator.vue`
### Work items
1. Replace the recursive tree object with a flat visible-row model derived from the current page and expanded state.
2. Remove `treeKey` remounting. Tree state must survive node edits, scroll, rename focus, and dragging.
3. Subscribe to editor lifecycle events instead of watching only `sceneVersion`:
- `node:created`
- `node:updated`
- `node:deleted`
- `node:reparented`
- `node:reordered`
- `page:changed`
- `selection:changed`
4. Patch rows incrementally for name, visibility, lock, and type changes. Rebuild the flat visible list only for structural changes and expansion changes.
5. Add virtualization over the flat row list. Use fixed row height and keep selection scroll-to behavior by index rather than by per-row DOM refs.
6. Split the 200+ line app template into row subcomponents.
7. Add range selection with Shift and preserve additive selection with Cmd/Ctrl.
8. Add focused and unfocused selected-row visual states.
### Acceptance criteria
- Scrubbing a selected node's X/Y/W/H no longer remounts the Layers tree.
- Scroll position and rename focus survive unrelated scene updates.
- A 5,000-node document remains responsive while editing properties.
- Add tests under `tests/engine/vue/` for visible-row derivation and under `tests/e2e/perf/` for large-tree interaction.
## Phase 4: Make mixed values first-class
**Purpose:** Multi-select editing should behave like Figma, not like first-node editing.
### Files to refactor
- `packages/vue/src/controls/node-props/helpers.ts`
- `packages/vue/src/controls/prop-scrub/use.ts`
- `packages/vue/src/primitives/ScrubInput/**`
- Every property section under `src/components/properties/**`
### Work items
1. Promote the existing `MIXED` symbol into a public SDK concept for control roots.
2. Make `ScrubInputRoot` and the app `ScrubInput.vue` display a mixed placeholder and commit typed input to all selected nodes.
3. Add mixed display semantics to color rows, selects, segmented controls, toggles, and variable-bound inputs.
4. Ensure each panel section distinguishes:
- inactive/not applicable
- empty list
- mixed value
- explicit value
5. Add e2e coverage for multi-select editing: position, fill, stroke, opacity, typography, effects, and layout.
### Acceptance criteria
- Multi-select no longer silently displays the first selected node's value as if it applied to all nodes.
- A mixed field shows a clear mixed placeholder and a first edit applies uniformly to all selected nodes.
- Mixed array props (`fills`, `strokes`, `effects`) show clear add/replace behavior.
## Phase 5: Close high-impact Figma panel gaps
Each item should be implemented as SDK logic plus app presentation. Do not put durable editing logic directly in app components.
### 5.1 Constraints
- Add `packages/vue/src/controls/constraints/use.ts`.
- Add `packages/vue/src/primitives/ConstraintsControl/`.
- Add `src/components/properties/ConstraintsSection.vue`.
- Use a Figma-style pin/grid control plus horizontal and vertical mode selectors.
### 5.2 Stroke caps, joins, and miter limit
- Extend `packages/vue/src/controls/stroke/**`.
- Extend `src/components/properties/StrokeSection.vue`.
- Use segmented controls for cap and join modes; use a scrub input for miter limit.
### 5.3 Corner smoothing and independent corners
- Extend `packages/vue/src/controls/appearance/**`.
- Extend `src/components/properties/AppearanceSection.vue`.
- Add smoothing control and independent-corner presentation that does not depend on inline SVGs.
### 5.4 Shared styles
- Add or expose core model support for fill, stroke, text, effect, and grid style identifiers.
- Add SDK controls for style binding/unbinding.
- Add app UI in Fill, Stroke, Typography, Effects, and future Layout Grid sections.
- Preserve Figma style IDs on import/export where safe.
### 5.5 Component properties
- Add `packages/vue/src/controls/component-props/use.ts`.
- Add `src/components/properties/ComponentPropsSection.vue`.
- Expose text, boolean, variant, and instance-swap properties when available.
### 5.6 Blend modes and effect styles
- Extend Fill and Effects sections with blend-mode pickers.
- Add effect style binding once shared styles exist.
### 5.7 Typography depth
- Extend Typography controls for text case, vertical alignment, justification, truncation/max lines, and OpenType/font variation fields where supported by the scene graph and renderer.
### Acceptance criteria
- Each new capability has SDK control tests and at least one app e2e test.
- New sections are renderless-friendly: third-party shells can use the SDK logic without OpenPencil app components.
## Phase 6: Polish interaction and density
**Purpose:** Make the panels feel precise and Figma-like.
### Work items
1. Standardize panel density:
- 28px control rows
- consistent section padding
- consistent 4px/8px row gaps
- strict two-column input rhythm for paired numeric fields
2. Extend `src/app.css` theme tokens:
- selected row background
- selected row unfocused background
- panel secondary background
- subtle border/focus tokens
- warning/success/action states for both dark and light themes
3. Extend scrub input interactions:
- Shift-drag for coarse increments
- modifier-drag for fine increments
- ArrowUp/ArrowDown increments
- math expression commit if a safe dependency or parser is selected deliberately
4. Add consistent focus rings and keyboard navigation for panel fields, segmented controls, and layer rows.
5. Audit empty, disabled, and not-applicable states for all property sections.
6. Align toolbar flyout grouping with design-tool expectations: selection/move tools, frame/section tools, shape tools, drawing tools, text/comment/resource tools.
### Acceptance criteria
- Panel screenshot tests cover single rectangle, text selection, multi-select, instance selection, empty canvas, and layer tree with nested groups.
- Keyboard-only panel navigation works for the main property sections.
- No one-off raw Tailwind control rows remain outside the shared panel primitives unless explicitly justified.
## Phase 7: Testing and guardrails
### Tests to add
- `tests/e2e/panels/visual.spec.ts` for DOM screenshots of panels.
- `tests/e2e/properties/mixed-values.spec.ts`.
- `tests/e2e/layers/large-tree.spec.ts` or `tests/e2e/perf/layers.spec.ts`.
- `tests/engine/vue/layer-tree.test.ts` for flat visible-row derivation.
- SDK-level tests for each new control under existing engine/vue coverage patterns.
### Static checks to add
- No `use*UI()` calls inside Vue templates.
- No raw `<svg>` inside app templates unless the file is explicitly allowlisted.
- No `as Stroke`, `as Fill`, `as Effect`, or non-null `activeNode!` in app templates.
- No new property-section component over 250 lines without a documented split.
## Recommended sequencing
1. **Phase 1:** UI kit consolidation.
2. **Phase 2:** typed property-list boundaries.
3. **Phase 4:** mixed values, because it affects every future section.
4. **Phase 3:** Layers rewrite can run in parallel with 1-2 because it touches a separate surface.
5. **Phase 5:** feature parity streams in parallel after 1, 2, and 4.
6. **Phase 6:** density and interaction polish continuously, but final pass after 5.
7. **Phase 7:** add guardrails as soon as Phase 1 introduces replacement patterns.
## Parallel worktree split
Suggested future-agent branches:
- `ui-kit-foundation`: Phase 1 and related guardrails.
- `property-list-types`: Phase 2.
- `layers-virtualized`: Phase 3.
- `mixed-values`: Phase 4.
- `constraints-section`: Phase 5.1.
- `stroke-controls-parity`: Phase 5.2.
- `appearance-corners`: Phase 5.3.
- `shared-styles`: Phase 5.4.
- `component-props-panel`: Phase 5.5.
- `typography-depth`: Phase 5.7.
## Definition of done
OpenPencil's UI moves out of MVP status when:
- Panels are composed from shared panel primitives rather than ad-hoc markup.
- Large layer trees are virtualized and incremental.
- Mixed values behave predictably across multi-select.
- The highest-impact Figma-compatible properties are editable in the UI.
- Screenshot and static checks prevent visual and structural regression.
- SDK controls remain reusable by custom shells, preserving the broader goal of building OpenPencil as a set of Lego-like packages for custom Figma-like editors.

View file

@ -28,7 +28,7 @@ OpenPencil maps browser-computed DOM/CSS styles into SceneGraph fields through `
| `border-style: dashed/dotted` | `dashPattern` | Unsupported border styles fall back to solid. |
| `border-radius`, `border-*-radius` | corner radii | Independent corners are preserved when sides differ. |
| `opacity` | node opacity | Numeric computed value. |
| `box-shadow` | drop shadow | First simple outer shadow only; see parser audit before expanding. |
| `box-shadow` | drop shadow | First simple outer shadow only; complex shadow lists require maintained parser or browser-computed support before mapping. |
| `<img src="data:...">` | image fill | Data URL images are stored in the graph image map. |
| `<img src="https://...">` | preserved source URL metadata | External URL fetching is not performed; the URL is retained for HTML round-trip. |
| `object-fit: contain/cover` | image `FIT` / `FILL` scale mode | `scale-down` maps to `FIT`; other object-fit values are not mapped yet. |
@ -62,4 +62,4 @@ These values are collected or covered by browser oracle tests but do not yet hav
## Headless limitations
The headless runtime uses maintained parsers for HTML (`parse5`) and stylesheets/inline declarations (`@acemir/cssom`), but still has limited approximations for selector matching, shorthand expansion, `calc()`, and simple shadows. Do not expand those with ad hoc parsers. See [`../development/dom-css-parser-audit.md`](../development/dom-css-parser-audit.md).
The headless runtime uses maintained parsers for HTML (`parse5`) and stylesheets/inline declarations (`@acemir/cssom`), but still has limited approximations for selector matching, shorthand expansion, `calc()`, and simple shadows. Do not expand those with ad hoc parsers; prefer browser `getComputedStyle()` oracle coverage or maintained parser dependencies for new CSS behavior.

View file

@ -61,14 +61,7 @@ export {
variablesAddTestId
} from '#vue/testing/test-id'
export { vTestId } from '#vue/testing/v-test-id'
export type {
RequiredTestIdProps,
TestId,
TestIdProps,
WithoutTestId,
WithRequiredTestId,
WithTestId
} from '#vue/testing/test-id'
export type { TestId } from '#vue/testing/test-id'
/** Property-panel composables. */
export { usePosition } from '#vue/controls/position/use'

View file

@ -1,19 +1,5 @@
export type TestId = string
export type TestIdProps = {
testId?: TestId
}
export type RequiredTestIdProps = {
testId: TestId
}
export type WithTestId<TProps extends object = object> = TProps & TestIdProps
export type WithRequiredTestId<TProps extends object = object> = TProps & RequiredTestIdProps
export type WithoutTestId<TProps extends object> = Omit<TProps, keyof TestIdProps>
export function testId(id?: TestId | null): { 'data-test-id'?: TestId } {
return id ? { 'data-test-id': id } : {}
}

View file

@ -4,7 +4,7 @@ import { useHead } from '@unhead/vue'
import { TooltipProvider } from 'reka-ui'
import { provideEditor, useI18n } from '@open-pencil/vue'
import AppToast from '@/components/AppToast.vue'
import AppToast from '@/components/Shell/AppToast.vue'
import { useEditorStore } from '@/app/editor/active-store'
import { toast } from '@/app/shell/ui'
import { useAppTheme } from '@/app/shell/theme'

View file

@ -181,7 +181,7 @@ function insertSelectedAsset() {
<AppInput
v-model="query"
type="search"
test-id="assets-search"
data-test-id="assets-search"
size="sm"
:placeholder="panels.searchLocalComponents"
/>

View file

@ -101,7 +101,7 @@ function copyReference() {
<div class="flex items-center gap-1.5">
<span class="text-[11px] text-muted">JSX</span>
<AppTextButton
test-id="code-panel-format-toggle"
data-test-id="code-panel-format-toggle"
:ui="{ base: 'rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
@click="toggleFormat"
>
@ -110,7 +110,7 @@ function copyReference() {
</div>
<div class="flex items-center gap-1">
<AppTextButton
test-id="code-panel-import-toggle"
data-test-id="code-panel-import-toggle"
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
@click="toggleImporter"
>
@ -119,7 +119,7 @@ function copyReference() {
</AppTextButton>
<Tip :label="dialogs.copyJSXReference">
<AppTextButton
test-id="code-panel-copy-ref"
data-test-id="code-panel-copy-ref"
:ui="{
base: 'flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] hover:bg-hover'
}"
@ -130,7 +130,7 @@ function copyReference() {
</AppTextButton>
</Tip>
<AppTextButton
test-id="code-panel-copy"
data-test-id="code-panel-copy"
:ui="{ base: 'flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
@click="copyCode"
>
@ -154,7 +154,7 @@ function copyReference() {
</div>
</div>
<AppTextButton
test-id="code-panel-paste-import"
data-test-id="code-panel-paste-import"
:ui="{ base: 'rounded px-1.5 py-0.5 text-[11px] hover:bg-hover' }"
@click="pasteImportHTML"
>
@ -185,7 +185,7 @@ function copyReference() {
<div class="flex items-center justify-between gap-2">
<span class="text-[11px] text-muted">Import replaces the current document.</span>
<AppTextButton
test-id="code-panel-import"
data-test-id="code-panel-import"
:ui="{
base: [
'rounded px-2 py-1 text-[11px]',

View file

@ -12,7 +12,7 @@ const collab = useCollabPanelContext()
<AppInput
:model-value="collab.shareUrl"
readonly
test-id="collab-room-link"
data-test-id="collab-room-link"
:ui="{ base: 'min-w-0 flex-1' }"
@focus="selectTarget($event)"
/>

View file

@ -15,7 +15,7 @@ const collab = useCollabPanelContext()
<label class="mb-1 block text-xs text-muted">{{ collab.dialogs.yourName }}</label>
<AppInput
v-model="collab.nameDraft"
test-id="collab-name-input"
data-test-id="collab-name-input"
:placeholder="collab.dialogs.enterYourName"
autofocus
@enter="collab.join"

View file

@ -10,7 +10,7 @@ const collab = useCollabPanelContext()
<label class="mb-1 block text-xs text-muted">{{ collab.dialogs.yourName }}</label>
<AppInput
v-model="collab.nameDraft"
test-id="collab-name-input"
data-test-id="collab-name-input"
:placeholder="collab.dialogs.enterYourName"
@enter="collab.share"
/>
@ -35,7 +35,7 @@ const collab = useCollabPanelContext()
<div class="flex items-center gap-1.5">
<AppInput
v-model="collab.joinInput"
test-id="collab-join-input"
data-test-id="collab-join-input"
:placeholder="collab.dialogs.pasteRoomLinkOrId"
:ui="{ base: 'min-w-0 flex-1' }"
@enter="collab.join"

View file

@ -13,7 +13,7 @@ const ctx = useColorPickerPanelContext()
<div class="flex flex-col gap-2">
<AppSelect
class="w-[120px]"
test-id="color-format-select"
data-test-id="color-format-select"
:model-value="ctx.fieldFormat"
:options="ctx.fieldOptions"
@update:model-value="ctx.setFieldFormat"

View file

@ -47,7 +47,7 @@ const ctx = useColorPickerPanelContext()
:display="{ value: Math.round(ctx.hsbColor.s), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hsbSaturation"
:thumb-fill="colorToCSS(ctx.sliderPreview.hsbSaturation)"
test-id="color-slider-hsb-s"
data-test-id="color-slider-hsb-s"
@update:model-value="ctx.updateHSBChannelValue('s', $event)"
/>
@ -60,7 +60,7 @@ const ctx = useColorPickerPanelContext()
:display="{ value: Math.round(ctx.hsbColor.b), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hsbBrightness"
:thumb-fill="colorToCSS(ctx.sliderPreview.hsbBrightness)"
test-id="color-slider-hsb-b"
data-test-id="color-slider-hsb-b"
@update:model-value="ctx.updateHSBChannelValue('b', $event)"
/>

View file

@ -47,7 +47,7 @@ const ctx = useColorPickerPanelContext()
:display="{ value: Math.round(ctx.hslColor.s ?? 0), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hslSaturation"
:thumb-fill="colorToCSS(ctx.sliderPreview.hslSaturation)"
test-id="color-slider-hsl-s"
data-test-id="color-slider-hsl-s"
@update:model-value="ctx.updateHSLChannelValue('s', $event)"
/>
@ -60,7 +60,7 @@ const ctx = useColorPickerPanelContext()
:display="{ value: Math.round(ctx.hslColor.l ?? 0), min: 0, max: 100, step: 1 }"
:gradient-style="ctx.sliderGradient.hslLightness"
:thumb-fill="colorToCSS(ctx.sliderPreview.hslLightness)"
test-id="color-slider-hsl-l"
data-test-id="color-slider-hsl-l"
@update:model-value="ctx.updateHSLChannelValue('l', $event)"
/>

View file

@ -18,7 +18,7 @@ const ctx = useColorPickerPanelContext()
gradient-style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
:thumb-fill="colorToCSS(ctx.sliderPreview.hue)"
:ui="{ root: 'gap-0', label: 'hidden', input: 'hidden' }"
test-id="color-slider-hue"
data-test-id="color-slider-hue"
@update:model-value="ctx.updateRGBAHue"
/>
@ -32,7 +32,7 @@ const ctx = useColorPickerPanelContext()
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS({ ...ctx.color, a: 1 })})`"
:thumb-fill="colorToCSS(ctx.color)"
:ui="{ root: 'gap-0', label: 'hidden', input: 'hidden' }"
test-id="color-slider-alpha"
data-test-id="color-slider-alpha"
@update:model-value="ctx.updateRGBAAlpha"
/>
</template>

View file

@ -19,7 +19,7 @@ const ctx = useColorPickerPanelContext()
:display="{ value: Math.round(ctx.okhcl.okhcl.h), min: 0, max: 360, step: 1 }"
gradient-style="background: linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000);"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclHue ?? ctx.color)"
test-id="color-slider-okhcl-h"
data-test-id="color-slider-okhcl-h"
@update:model-value="ctx.updateOkHCLChannel('h', $event)"
/>
@ -38,7 +38,7 @@ const ctx = useColorPickerPanelContext()
}"
:gradient-style="ctx.okhclSliderGradient?.okhclChroma ?? undefined"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclChroma ?? ctx.color)"
test-id="color-slider-okhcl-c"
data-test-id="color-slider-okhcl-c"
@update:model-value="ctx.updateOkHCLChannel('c', $event)"
/>
@ -57,7 +57,7 @@ const ctx = useColorPickerPanelContext()
}"
:gradient-style="ctx.okhclSliderGradient?.okhclLightness ?? undefined"
:thumb-fill="colorToCSS(ctx.okhclSliderPreview?.okhclLightness ?? ctx.color)"
test-id="color-slider-okhcl-l"
data-test-id="color-slider-okhcl-l"
@update:model-value="ctx.updateOkHCLChannel('l', $event)"
/>
@ -77,7 +77,7 @@ const ctx = useColorPickerPanelContext()
checkerboard
:gradient-style="`background: linear-gradient(to right, transparent, ${colorToCSS(ctx.color)})`"
:thumb-fill="colorToCSS(ctx.color)"
test-id="color-slider-okhcl-a"
data-test-id="color-slider-okhcl-a"
@update:model-value="ctx.updateOkHCLChannel('a', $event)"
/>

View file

@ -4,7 +4,7 @@ import { SplitterGroup, SplitterPanel, SplitterResizeHandle } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import AppMenu from './AppMenu.vue'
import AppMenu from '@/components/Shell/AppMenu.vue'
import AssetsPanel from './AssetsPanel.vue'
import LayerTree from './LayerTree/LayerTree.vue'
import PagesPanel from './PagesPanel.vue'

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { inputNumberValue, vTestId, type TestIdProps } from '@open-pencil/vue'
import { inputNumberValue } from '@open-pencil/vue'
import { usePickerSliderUI } from './ui/picker-slider'
type PickerSliderDisplay = {
@ -11,7 +11,7 @@ type PickerSliderDisplay = {
parse?: (value: number) => number
}
interface PickerSliderProps extends TestIdProps {
interface PickerSliderProps {
label: string
modelValue: number
min: number
@ -36,7 +36,6 @@ const {
gradientStyle,
checkerboard = false,
thumbFill = '#fff',
testId,
ui
} = defineProps<PickerSliderProps>()
@ -64,7 +63,7 @@ function thumbLeft(): string {
</script>
<template>
<div :class="cls.root" v-test-id="testId">
<div :class="cls.root">
<span :class="cls.label">{{ label }}</span>
<div :class="cls.track">
<div :class="cls.gradient" :style="gradientStyle" />

View file

@ -55,7 +55,7 @@ function activeKeyForTool(tool: EditorToolDef) {
<ToolbarItem v-else v-slot="{ active, actions }" :tool="tool.key">
<Tip :label="`${toolLabels[tool.key]} (${tool.shortcut})`">
<ToolButton
:test-id="toolbarToolTestId(tool.key)"
:data-test-id="toolbarToolTestId(tool.key)"
:icon="toolIcons[tool.key]"
:active="active || isActive(tool)"
@click="actions.select"

View file

@ -118,7 +118,7 @@ function activeKeyForTool(tool: EditorToolDef) {
<ToolbarItem v-else v-slot="{ active, actions }" :tool="tool.key">
<ToolButton
mobile
:test-id="toolbarToolTestId(tool.key, true)"
:data-test-id="toolbarToolTestId(tool.key, true)"
:icon="toolIcons[tool.key]"
:active="active || activeKeyForTool(tool) === activeTool"
@click="actions.select"

View file

@ -1,14 +1,13 @@
<script setup lang="ts">
import { vTestId, type RequiredTestIdProps } from '@open-pencil/vue'
import type { Component } from 'vue'
interface ToolButtonProps extends RequiredTestIdProps {
interface ToolButtonProps {
icon: Component
active?: boolean
mobile?: boolean
}
const { icon, active = false, mobile = false, testId } = defineProps<ToolButtonProps>()
const { icon, active = false, mobile = false } = defineProps<ToolButtonProps>()
const emit = defineEmits<{
click: []
@ -17,7 +16,6 @@ const emit = defineEmits<{
<template>
<button
v-test-id="testId"
class="flex size-8 cursor-pointer items-center justify-center border-none transition-colors"
:class="[
mobile ? 'rounded-[6px] select-none' : 'rounded-lg',

View file

@ -65,7 +65,7 @@ function activeKeyForTool() {
<div class="flex items-center">
<slot :label="`${toolLabels[activeKeyForTool()]} (${tool.shortcut})`">
<ToolButton
:test-id="toolbarToolTestId(activeKeyForTool(), mobile)"
:data-test-id="toolbarToolTestId(activeKeyForTool(), mobile)"
:icon="toolIcons[activeKeyForTool()]"
:active="isActiveTool(activeKeyForTool())"
:mobile="mobile"

View file

@ -88,7 +88,7 @@ function handleSubmit(e: Event) {
<form class="flex gap-1.5" @submit="handleSubmit">
<AppInput
v-model="input"
test-id="chat-input"
data-test-id="chat-input"
:placeholder="dialogs.describeChange"
:ui="{ base: 'min-w-0 flex-1 placeholder:text-muted' }"
:disabled="isStreaming"

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { promiseTimeout } from '@vueuse/core'
import { computed, onMounted, ref } from 'vue'
import type { TestIdProps } from '@open-pencil/vue'
import AppGroupedSelect from '@/components/ui/AppGroupedSelect.vue'
import {
@ -54,7 +53,7 @@ const displayName = computed(() => {
return providerDef.value.name
})
interface ProviderSelectProps extends TestIdProps {
interface ProviderSelectProps {
ui?: {
trigger?: string
content?: string
@ -64,7 +63,7 @@ interface ProviderSelectProps extends TestIdProps {
}
}
const { ui, testId } = defineProps<ProviderSelectProps>()
const { ui } = defineProps<ProviderSelectProps>()
const groups = computed(() => {
const result: Array<{ label?: string; items: Array<{ value: string; label: string }> }> = []
@ -92,11 +91,5 @@ const groups = computed(() => {
</script>
<template>
<AppGroupedSelect
v-model="providerID"
:groups="groups"
:display-value="displayName"
:ui="ui"
:test-id="testId"
/>
<AppGroupedSelect v-model="providerID" :groups="groups" :display-value="displayName" :ui="ui" />
</template>

View file

@ -1,14 +1,9 @@
<script setup lang="ts">
import type { TestIdProps } from '@open-pencil/vue'
import ProviderSelect from '@/components/chat/ProviderSelect/ProviderSelect.vue'
const { testId = 'provider-selector' } = defineProps<TestIdProps>()
</script>
<template>
<ProviderSelect
:test-id="testId"
:ui="{
trigger:
'w-full justify-between rounded border border-border bg-input px-2.5 py-1.5 text-xs text-surface',

View file

@ -14,8 +14,7 @@ const { dialogs } = useI18n()
v-model="ctx.keyInput"
:label="dialogs.apiKey"
:saved="!!ctx.apiKey"
clear-test-id="provider-settings-clear-key"
input-test-id="provider-settings-api-key"
kind="api"
:placeholder="ctx.hasExistingKey ? dialogs.keySavedReplace : ctx.providerDef.keyPlaceholder"
:key-url="ctx.providerDef.keyURL"
:key-url-label="dialogs.getAPIKeyGeneric"

View file

@ -24,7 +24,7 @@ async function loadModelSuggestions() {
<ProviderSettingsField v-if="ctx.providerDef.supportsCustomBaseURL" :label="dialogs.baseURL">
<ProviderSettingsInput
v-model="ctx.baseURLInput"
test-id="provider-settings-base-url"
data-test-id="provider-settings-base-url"
placeholder="http://localhost:11434/v1"
@change="ctx.save"
/>
@ -34,7 +34,7 @@ async function loadModelSuggestions() {
<AppComboboxInput
v-if="showModelSuggestions"
v-model="ctx.customModelInput"
test-id="provider-settings-custom-model"
data-test-id="provider-settings-custom-model"
:options="suggestedModels.map((model) => ({ value: model.id, label: model.name }))"
placeholder="e.g. meta-llama/llama-3.3-70b-instruct"
@focusin="loadModelSuggestions"
@ -43,7 +43,7 @@ async function loadModelSuggestions() {
<ProviderSettingsInput
v-else
v-model="ctx.customModelInput"
test-id="provider-settings-custom-model"
data-test-id="provider-settings-custom-model"
placeholder="e.g. llama-3.3-70b"
@change="ctx.save"
/>

View file

@ -14,7 +14,7 @@ const { dialogs } = useI18n()
<ProviderSettingsInput
v-model.number="ctx.maxOutputTokens"
type="number"
test-id="provider-settings-max-tokens"
data-test-id="provider-settings-max-tokens"
:min="1024"
:max="128000"
:step="1024"

View file

@ -54,7 +54,7 @@ function onInteractOutside(e: Event) {
>
<div class="flex flex-col gap-2.5">
<h3 class="text-[11px] font-semibold text-surface">{{ dialogs.aiProvider }}</h3>
<ProviderSelectField test-id="provider-settings-provider" />
<ProviderSelectField data-test-id="provider-settings-provider" />
<MaxTokensSection />
<StockPhotoKeysSection />
<CustomEndpointSection />

View file

@ -1,13 +1,14 @@
<script setup lang="ts">
import AppTextButton from '@/components/ui/AppTextButton.vue'
import type { TestIdProps } from '@open-pencil/vue'
interface ProviderSettingsFieldProps extends TestIdProps {
interface ProviderSettingsFieldProps {
label: string
clearLabel?: string
}
const { label, clearLabel, testId } = defineProps<ProviderSettingsFieldProps>()
defineOptions({ inheritAttrs: false })
const { label, clearLabel } = defineProps<ProviderSettingsFieldProps>()
const emit = defineEmits<{ clear: [] }>()
</script>
@ -16,7 +17,7 @@ const emit = defineEmits<{ clear: [] }>()
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<label class="text-[10px] text-muted">{{ label }}</label>
<AppTextButton v-if="clearLabel" :test-id="testId" @click="emit('clear')">
<AppTextButton v-if="clearLabel" v-bind="$attrs" @click="emit('clear')">
{{ clearLabel }}
</AppTextButton>
</div>

View file

@ -1,9 +1,7 @@
<script setup lang="ts">
import type { TestIdProps } from '@open-pencil/vue'
import AppInput from '@/components/ui/AppInput.vue'
interface ProviderSettingsInputProps extends TestIdProps {
interface ProviderSettingsInputProps {
type?: 'text' | 'password' | 'number'
placeholder?: string
min?: number
@ -11,14 +9,7 @@ interface ProviderSettingsInputProps extends TestIdProps {
step?: number
}
const {
type = 'text',
placeholder,
testId,
min,
max,
step
} = defineProps<ProviderSettingsInputProps>()
const { type = 'text', placeholder, min, max, step } = defineProps<ProviderSettingsInputProps>()
const modelValue = defineModel<string | number>({ required: true })
const emit = defineEmits<{ change: [] }>()
@ -28,7 +19,6 @@ const emit = defineEmits<{ change: [] }>()
<AppInput
v-model="modelValue"
:type="type"
:test-id="testId"
:placeholder="placeholder"
:min="min"
:max="max"

View file

@ -1,21 +1,21 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '@open-pencil/vue'
import ProviderSettingsField from '@/components/chat/ProviderSettings/ProviderSettingsField.vue'
import ProviderSettingsInput from '@/components/chat/ProviderSettings/ProviderSettingsInput.vue'
import ProviderSettingsLink from '@/components/chat/ProviderSettings/ProviderSettingsLink.vue'
const { label, modelValue, saved, clearTestId, inputTestId, placeholder, keyUrl, keyUrlLabel } =
defineProps<{
label: string
modelValue: string
saved: boolean
clearTestId: string
inputTestId: string
placeholder: string
keyUrl?: string
keyUrlLabel?: string
}>()
const { label, modelValue, saved, kind, placeholder, keyUrl, keyUrlLabel } = defineProps<{
label: string
modelValue: string
saved: boolean
kind: 'api' | 'pexels' | 'unsplash'
placeholder: string
keyUrl?: string
keyUrlLabel?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
@ -24,19 +24,31 @@ const emit = defineEmits<{
}>()
const { dialogs } = useI18n()
const inputDataTestId = computed(() => {
if (kind === 'pexels') return 'provider-settings-pexels-key'
if (kind === 'unsplash') return 'provider-settings-unsplash-key'
return 'provider-settings-api-key'
})
const clearDataTestId = computed(() => {
if (kind === 'pexels') return 'provider-settings-clear-pexels-key'
if (kind === 'unsplash') return 'provider-settings-clear-unsplash-key'
return 'provider-settings-clear-key'
})
</script>
<template>
<ProviderSettingsField
:label="label"
:clear-label="saved ? dialogs.clear : undefined"
:test-id="clearTestId"
:data-test-id="clearDataTestId"
@clear="emit('clear')"
>
<ProviderSettingsInput
:model-value="modelValue"
type="password"
:test-id="inputTestId"
:data-test-id="inputDataTestId"
:placeholder="placeholder"
@update:model-value="emit('update:modelValue', String($event))"
@change="emit('change')"

View file

@ -13,8 +13,7 @@ const { dialogs } = useI18n()
v-model="ctx.pexelsKeyInput"
:label="dialogs.pexelsAPIKey"
:saved="!!ctx.pexelsApiKey"
clear-test-id="provider-settings-clear-pexels-key"
input-test-id="provider-settings-pexels-key"
kind="pexels"
:placeholder="
ctx.hasExistingPexelsKey ? dialogs.keySavedReplace : dialogs.stockPhotoToolOptional
"
@ -28,8 +27,7 @@ const { dialogs } = useI18n()
v-model="ctx.unsplashKeyInput"
:label="dialogs.unsplashAccessKey"
:saved="!!ctx.unsplashAccessKey"
clear-test-id="provider-settings-clear-unsplash-key"
input-test-id="provider-settings-unsplash-key"
kind="unsplash"
:placeholder="
ctx.hasExistingUnsplashKey ? dialogs.keySavedReplace : dialogs.pexelsAlternativeOptional
"

View file

@ -100,13 +100,13 @@ function save() {
<p class="mb-5 text-center text-xs text-muted">{{ dialogs.connectAIProvider }}</p>
<form v-if="!isACP" class="flex w-full flex-col gap-2" @submit.prevent="save">
<ProviderSelectField test-id="provider-selector" />
<ProviderSelectField data-test-id="provider-selector" />
<!-- Base URL (compatible providers only) -->
<AppInput
v-if="providerDef.supportsCustomBaseURL"
v-model="baseURLInput"
test-id="provider-base-url"
data-test-id="provider-base-url"
:placeholder="dialogs.baseURLPlaceholder"
/>
@ -114,14 +114,14 @@ function save() {
<AppInput
v-if="providerDef.supportsCustomModel && providerID !== 'openrouter'"
v-model="customModelInput"
test-id="provider-custom-model"
data-test-id="provider-custom-model"
:placeholder="dialogs.modelIDPlaceholder"
/>
<AppInput
v-model="keyInput"
type="password"
test-id="api-key-input"
data-test-id="api-key-input"
:placeholder="providerDef.keyPlaceholder"
/>
@ -144,7 +144,7 @@ function save() {
<!-- ACP agent no API key needed -->
<div v-else class="flex w-full flex-col gap-2">
<ProviderSelectField test-id="provider-selector" />
<ProviderSelectField data-test-id="provider-selector" />
<p class="text-center text-[10px] leading-relaxed text-muted">
Uses your existing {{ acpAgent?.name }} subscription.
@ -167,7 +167,7 @@ function save() {
<AppTextButton
v-if="!isACP && providerDef.keyURL"
test-id="api-key-get-link"
data-test-id="api-key-get-link"
underline
:ui="{ base: 'mt-2.5' }"
@click="openExternalLink(providerDef.keyURL as string)"

View file

@ -48,7 +48,7 @@ function onToggleCorners() {
</script>
<template>
<PanelSection v-if="active" :label="panels.appearance" test-id="appearance-section">
<PanelSection v-if="active" :label="panels.appearance" data-test-id="appearance-section">
<template #actions>
<IconButton
:label="panels.toggleVisibility"

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
import { vTestId, type TestIdProps } from '@open-pencil/vue'
import Tip from '@/components/ui/Tip.vue'
interface BoundVariableButtonProps extends TestIdProps {
interface BoundVariableButtonProps {
label: string
}
const { label, testId } = defineProps<BoundVariableButtonProps>()
defineOptions({ inheritAttrs: false })
const { label } = defineProps<BoundVariableButtonProps>()
const emit = defineEmits<{
detach: []
@ -17,7 +17,7 @@ const emit = defineEmits<{
<template>
<Tip :label="label">
<button
v-test-id="testId"
v-bind="$attrs"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-violet-400 hover:text-surface"
@click="emit('detach')"
>

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, useAttrs } from 'vue'
import ScrubInput from '@/components/ScrubInput.vue'
import BoundVariableButton from '@/components/properties/BoundVariableButton.vue'
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import IconButton from '@/components/ui/IconButton.vue'
import Tip from '@/components/ui/Tip.vue'
import { vTestId, useI18n } from '@open-pencil/vue'
import { useI18n, vTestId } from '@open-pencil/vue'
import {
opacityFromPercent,
@ -17,24 +19,11 @@ import { colorToHexRaw } from '@open-pencil/core/color'
import type { ColorVariableBindingApi } from '@/components/properties/color-style-row'
import type { Color } from '@open-pencil/scene-graph/primitives'
const {
item,
index,
activeNodeId,
bindingApi,
visibilityTestId,
applyVariableTestId,
unbindTestId,
variableColor,
removeLabel
} = defineProps<{
const { item, index, activeNodeId, bindingApi, variableColor, removeLabel } = defineProps<{
item: { opacity: number; visible: boolean }
index: number
activeNodeId?: string | null
bindingApi: ColorVariableBindingApi
visibilityTestId: string
applyVariableTestId?: string
unbindTestId?: string
variableColor?: Color
removeLabel: string
}>()
@ -46,6 +35,15 @@ const emit = defineEmits<{
}>()
const { panels, dialogs } = useI18n()
const attrs = useAttrs()
const testPrefix = computed(() => {
const rowId = attrs['data-test-id']
if (rowId === 'stroke-item') return 'stroke'
return 'fill'
})
const visibilityDataTestId = computed(() => `${testPrefix.value}-visibility-${index}`)
const applyVariableDataTestId = computed(() => `${testPrefix.value}-apply-variable-${index}`)
const unbindDataTestId = computed(() => `${testPrefix.value}-unbind-variable`)
</script>
<template>
@ -77,7 +75,7 @@ const { panels, dialogs } = useI18n()
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="applyVariableTestId"
:data-test-id="applyVariableDataTestId"
:create-label="
variableColor && bindingApi.createAndBindVariable
? panels.createColorVariable({ value: colorToHexRaw(variableColor) })
@ -86,7 +84,6 @@ const { panels, dialogs } = useI18n()
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-default-name="bindingApi.searchTerm.value"
:create-test-id="applyVariableTestId ? `${applyVariableTestId}-create` : undefined"
:swatch-background="(variableId) => variableSwatchBackground(bindingApi, variableId)"
@select="activeNodeId && bindingApi.bindVariable(activeNodeId, index, $event.id)"
@create="
@ -98,14 +95,14 @@ const { panels, dialogs } = useI18n()
<BoundVariableButton
v-else-if="activeNodeId && bindingApi.getBoundVariable(activeNodeId, index)"
:test-id="unbindTestId"
:data-test-id="unbindDataTestId"
:label="panels.detachVariable"
@detach="bindingApi.unbindVariable(activeNodeId, index)"
/>
<Tip :label="panels.toggleVisibility">
<button
v-test-id="visibilityTestId"
v-test-id="visibilityDataTestId"
:data-visible="item.visible ? 'true' : 'false'"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@click="emit('toggleVisibility')"

View file

@ -21,7 +21,7 @@ const { panels } = useI18n()
prop-key="effects"
:label="panels.effects"
>
<PanelSection :label="panels.effects" test-id="effects-section">
<PanelSection :label="panels.effects" data-test-id="effects-section">
<template #actions>
<IconButton
:label="panels.addEffect"

View file

@ -8,24 +8,19 @@ import {
DropdownMenuTrigger
} from 'reka-ui'
import { vTestId, type TestIdProps } from '@open-pencil/vue'
import { useInputUI } from '@/components/ui/input'
import { menuItem, useMenuUI } from '@/components/ui/menu'
import Tip from '@/components/ui/Tip.vue'
interface ExportScaleInputProps extends TestIdProps {
interface ExportScaleInputProps {
presets: readonly number[]
clamp: (scale: number) => number
label?: string
}
const {
presets,
clamp,
label,
testId = 'export-scale-input'
} = defineProps<ExportScaleInputProps>()
defineOptions({ inheritAttrs: false })
const { presets, clamp, label } = defineProps<ExportScaleInputProps>()
const modelValue = defineModel<number>({ required: true })
@ -69,7 +64,7 @@ function isActive(scale: number) {
<input
ref="inputRef"
v-model="text"
v-test-id="testId"
v-bind="$attrs"
type="text"
:aria-label="label"
:class="inputClass"

View file

@ -113,7 +113,7 @@ onScopeDispose(() => {
</script>
<template>
<PanelSection :label="panels.export" test-id="export-section">
<PanelSection :label="panels.export" data-test-id="export-section">
<template #actions>
<IconButton :label="panels.addExport" data-test-id="export-section-add" @click="addSetting">
<icon-lucide-plus class="size-3.5" />
@ -132,6 +132,7 @@ onScopeDispose(() => {
>
<ExportScaleInput
v-if="formatSupportsScale(setting.format)"
data-test-id="export-scale-input"
:model-value="setting.scale"
:presets="scales"
:clamp="clampExportScale"
@ -139,6 +140,7 @@ onScopeDispose(() => {
@update:model-value="updateScale(i, $event)"
/>
<AppSelect
data-test-id="app-select-trigger"
:model-value="setting.format"
:options="FORMAT_OPTIONS"
:label="panels.exportFormat"

View file

@ -51,7 +51,7 @@ function updateFillHex(
prop-key="fills"
:label="panels.fill"
>
<PanelSection :label="panels.fill" test-id="fill-section">
<PanelSection :label="panels.fill" data-test-id="fill-section">
<template #actions>
<IconButton
:label="panels.addFill"
@ -70,9 +70,6 @@ function updateFillHex(
:active-node-id="activeNode?.id ?? null"
:binding-api="fillCtx"
:variable-color="fill.type === 'SOLID' ? fill.color : undefined"
:visibility-test-id="`fill-visibility-${i}`"
:apply-variable-test-id="`fill-apply-variable-${i}`"
unbind-test-id="fill-unbind-variable"
data-test-id="fill-item"
:data-test-index="i"
:remove-label="panels.removeFill"

View file

@ -17,7 +17,7 @@ const CONTAINER_TYPES = ['FRAME', 'COMPONENT', 'COMPONENT_SET', 'INSTANCE']
<template>
<LayoutControlsRoot v-slot="ctx">
<template v-if="ctx.node">
<PanelSection :label="panels.layout" test-id="layout-section">
<PanelSection :label="panels.layout" data-test-id="layout-section">
<SizeControls />
</PanelSection>

View file

@ -18,7 +18,6 @@ import BoundVariableButton from '@/components/properties/BoundVariableButton.vue
import VariablePickerPopover from '@/components/properties/VariablePickerPopover.vue'
import { useSelectUI } from '@/components/ui/select'
import {
testId as testIdAttr,
vTestId,
useI18n,
useLayoutControlsContext,
@ -32,7 +31,7 @@ type SizeSelectValue = LayoutSizing | `add-${SizeLimitProp}` | `remove-${SizeLim
type ActiveSizeLimit = {
prop: SizeLimitProp
testId: TestId
testHook: TestId
icon: () => string
value: () => number | null
setLabel: () => string
@ -65,7 +64,7 @@ const widthLimitItems = [
const activeSizeLimits: ActiveSizeLimit[] = [
{
prop: 'minWidth',
testId: 'layout-min-width-input',
testHook: 'layout-min-width-input',
icon: () => panels.value.minWidthShort,
value: () => ctx.node.minWidth,
setLabel: () => panels.value.setToCurrentWidth,
@ -73,7 +72,7 @@ const activeSizeLimits: ActiveSizeLimit[] = [
},
{
prop: 'maxWidth',
testId: 'layout-max-width-input',
testHook: 'layout-max-width-input',
icon: () => panels.value.maxWidthShort,
value: () => ctx.node.maxWidth,
setLabel: () => panels.value.setToCurrentWidth,
@ -81,7 +80,7 @@ const activeSizeLimits: ActiveSizeLimit[] = [
},
{
prop: 'minHeight',
testId: 'layout-min-height-input',
testHook: 'layout-min-height-input',
icon: () => panels.value.minHeightShort,
value: () => ctx.node.minHeight,
setLabel: () => panels.value.setToCurrentHeight,
@ -89,7 +88,7 @@ const activeSizeLimits: ActiveSizeLimit[] = [
},
{
prop: 'maxHeight',
testId: 'layout-max-height-input',
testHook: 'layout-max-height-input',
icon: () => panels.value.maxHeightShort,
value: () => ctx.node.maxHeight,
setLabel: () => panels.value.setToCurrentHeight,
@ -182,7 +181,7 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
<template #suffix>
<BoundVariableButton
v-if="widthVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-width-unbind-variable"
data-test-id="layout-width-unbind-variable"
:label="panels.detachVariable"
@detach="widthVariableBinding.unbindVariable(ctx.node.id)"
/>
@ -193,11 +192,11 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-width-apply-variable'"
:trigger-data-test-id="'layout-width-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.width) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-width-apply-variable-create'"
:create-data-test-id="'layout-width-apply-variable-create'"
@select="bindSizeVariable('width', $event.id)"
@create="createAndBindSizeVariable('width', $event)"
/>
@ -266,7 +265,7 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
<template #suffix>
<BoundVariableButton
v-if="heightVariableBinding.getBoundVariable(ctx.node.id)"
test-id="layout-height-unbind-variable"
data-test-id="layout-height-unbind-variable"
:label="panels.detachVariable"
@detach="heightVariableBinding.unbindVariable(ctx.node.id)"
/>
@ -277,11 +276,11 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
:trigger-label="panels.applyVariable"
:search-placeholder="dialogs.search"
:empty-label="panels.noVariablesFound"
:trigger-test-id="'layout-height-apply-variable'"
:trigger-data-test-id="'layout-height-apply-variable'"
:create-label="panels.createNumberVariable({ value: Math.round(ctx.node.height) })"
:create-name-placeholder="panels.variableName"
:create-submit-label="panels.create"
:create-test-id="'layout-height-apply-variable-create'"
:create-data-test-id="'layout-height-apply-variable-create'"
@select="bindSizeVariable('height', $event.id)"
@create="createAndBindSizeVariable('height', $event)"
/>
@ -351,7 +350,7 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
<div :ref="limitFieldRefs.set" class="min-w-0">
<VariableScrubInput
v-if="ctx.node"
v-bind="testIdAttr(item.testId)"
v-test-id="item.testHook"
:icon="item.icon()"
:model-value="Math.round(item.value() ?? 0)"
:min="0"
@ -366,7 +365,7 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
@update:model-value="(value) => handleLimitSelect(item.prop, value as string)"
>
<SelectTrigger
v-test-id="`${item.testId}-menu`"
v-test-id="`${item.testHook}-menu`"
:reference="limitFieldAnchor(index)"
class="flex shrink-0 cursor-pointer items-center self-stretch border-none bg-transparent px-1 text-[11px] text-muted outline-none"
@pointerdown.stop
@ -395,7 +394,7 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
</VariableScrubInput>
<ScrubInput
v-else
v-bind="testIdAttr(item.testId)"
v-test-id="item.testHook"
:icon="item.icon()"
:model-value="Math.round(item.value() ?? 0)"
:min="0"
@ -408,7 +407,7 @@ function handleSizeSelect(axis: 'width' | 'height', value: SizeSelectValue) {
@update:model-value="(value) => handleLimitSelect(item.prop, value as string)"
>
<SelectTrigger
v-test-id="`${item.testId}-menu`"
v-test-id="`${item.testHook}-menu`"
:reference="limitFieldAnchor(index)"
class="flex shrink-0 cursor-pointer items-center self-stretch border-none bg-transparent px-1 text-[11px] text-muted outline-none"
@pointerdown.stop

View file

@ -13,7 +13,7 @@ const { panels } = useI18n()
</script>
<template>
<PanelSection :label="panels.page ?? 'Page'" test-id="page-section">
<PanelSection :label="panels.page ?? 'Page'" data-test-id="page-section">
<ColorInput :color="pageColor" editable @update="editor.setPageColor($event)" />
</PanelSection>
</template>

View file

@ -28,7 +28,7 @@ function handleAlign(
<PositionControlsRoot
v-slot="{ active, isMulti, xValue, yValue, wValue, hValue, rotationValue, actions }"
>
<PanelSection v-if="active" :label="panels.position" test-id="position-section">
<PanelSection v-if="active" :label="panels.position" data-test-id="position-section">
<PanelRow class="mb-1.5 gap-2">
<PanelRow gap="sm">
<IconButton

View file

@ -65,7 +65,7 @@ function onToggleSides(activeNode: SceneNode | null) {
prop-key="strokes"
:label="panels.stroke"
>
<PanelSection :label="panels.stroke" test-id="stroke-section">
<PanelSection :label="panels.stroke" data-test-id="stroke-section">
<template #actions>
<IconButton
:label="panels.addStroke"
@ -86,9 +86,6 @@ function onToggleSides(activeNode: SceneNode | null) {
:active-node-id="activeNode?.id ?? null"
:binding-api="strokeVarCtx"
:variable-color="stroke.color"
:visibility-test-id="`stroke-visibility-${i}`"
:apply-variable-test-id="`stroke-apply-variable-${i}`"
unbind-test-id="stroke-unbind-variable"
data-test-id="stroke-item"
:data-test-index="i"
:remove-label="panels.removeStroke"

View file

@ -18,7 +18,11 @@ const fontLoader = { load: loadFont }
<template>
<TypographyControlsRoot v-slot="ctx" :font-loader="fontLoader">
<PanelSection v-if="ctx.node.value" :label="panels.typography" test-id="typography-section">
<PanelSection
v-if="ctx.node.value"
:label="panels.typography"
data-test-id="typography-section"
>
<PanelRow class="mb-1.5">
<FontPicker
class="min-w-0 flex-1"

View file

@ -10,7 +10,7 @@ import {
PopoverTrigger
} from 'reka-ui'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, useAttrs, watch } from 'vue'
import { vTestId } from '@open-pencil/vue'
@ -29,8 +29,6 @@ const {
createNamePlaceholder = 'Variable name',
createSubmitLabel = 'Create',
createDefaultName = '',
createTestId,
triggerTestId,
swatchBackground
} = defineProps<{
variables: Variable[]
@ -41,11 +39,11 @@ const {
createNamePlaceholder?: string
createSubmitLabel?: string
createDefaultName?: string
createTestId?: string
triggerTestId?: string
swatchBackground?: (variableId: string) => string
}>()
defineOptions({ inheritAttrs: false })
const emit = defineEmits<{
select: [variable: Variable]
create: [name: string]
@ -57,7 +55,12 @@ const creating = ref(false)
const createName = ref('')
const createInput = ref<HTMLInputElement | null>(null)
const canCreate = computed(() => createName.value.trim().length > 0)
const attrs = useAttrs()
const tooltipCls = useTooltipUI({ content: 'animate-in zoom-in-95 fade-in' })
const createDataTestId = computed(() => {
const triggerId = attrs['data-test-id']
return typeof triggerId === 'string' ? `${triggerId}-create` : undefined
})
watch(open, (value) => {
if (!value) creating.value = false
@ -84,7 +87,7 @@ function submitCreate() {
<PopoverRoot v-model:open="open">
<div class="relative shrink-0">
<PopoverTrigger
v-test-id="triggerTestId"
v-bind="attrs"
:aria-label="triggerLabel"
class="shrink-0 cursor-pointer border-none bg-transparent p-0 text-muted hover:text-surface"
@pointerdown.prevent.stop
@ -163,7 +166,7 @@ function submitCreate() {
class="min-w-0 flex-1 rounded border border-border bg-transparent px-1.5 py-1 text-[11px] text-surface outline-none placeholder:text-muted focus:border-accent"
/>
<button
v-test-id="createTestId"
v-test-id="createDataTestId"
:disabled="!canCreate"
class="rounded border border-border bg-panel px-1.5 py-1 text-[11px] text-surface hover:bg-hover disabled:cursor-not-allowed disabled:opacity-50"
type="submit"
@ -173,7 +176,7 @@ function submitCreate() {
</form>
<button
v-else
v-test-id="createTestId"
v-test-id="createDataTestId"
class="flex w-full cursor-pointer items-center gap-1.5 bg-transparent px-2 py-1.5 text-left text-[11px] text-muted hover:bg-hover hover:text-surface"
@click="startCreate"
>

View file

@ -25,7 +25,7 @@ const { panels } = useI18n()
<template>
<PanelSection
:label="panels.variables"
test-id="variables-section"
data-test-id="variables-section"
:ui="{ label: 'font-medium text-surface' }"
>
<template #actions>

View file

@ -45,7 +45,7 @@ function switchVariant(propertyName: string, newValue: string) {
<PanelSection
v-if="hasVariants"
:label="panels.variants"
test-id="variant-section"
data-test-id="variant-section"
:ui="{ label: 'font-medium text-component' }"
>
<div class="flex flex-col gap-1.5">

View file

@ -10,8 +10,6 @@ import {
type AcceptableValue
} from 'reka-ui'
import type { TestIdProps } from '@open-pencil/vue'
import AppBadge from '@/components/ui/AppBadge.vue'
import { useInputUI } from '@/components/ui/input'
import { useSelectUI } from '@/components/ui/select'
@ -22,7 +20,7 @@ export type AppComboboxOption = {
meta?: string
}
interface AppComboboxInputProps extends TestIdProps {
interface AppComboboxInputProps {
options: AppComboboxOption[]
placeholder?: string
ui?: {
@ -34,12 +32,9 @@ interface AppComboboxInputProps extends TestIdProps {
}
}
const {
options,
placeholder,
testId = 'app-combobox-input',
ui
} = defineProps<AppComboboxInputProps>()
defineOptions({ inheritAttrs: false })
const { options, placeholder, ui } = defineProps<AppComboboxInputProps>()
const modelValue = defineModel<string>({ required: true })
const open = ref(false)
@ -82,7 +77,7 @@ function updateValue(value: AcceptableValue) {
:model-value="modelValue"
:display-value="() => modelValue"
type="text"
:data-test-id="testId"
v-bind="$attrs"
:placeholder="placeholder"
:class="inputClass"
autocomplete="off"

View file

@ -11,8 +11,6 @@ import {
SelectTrigger,
SelectViewport
} from 'reka-ui'
import { vTestId, type TestIdProps } from '@open-pencil/vue'
import { useSelectUI } from '@/components/ui/select'
interface SelectOption<TValue extends string | number> {
@ -33,13 +31,15 @@ interface GroupedSelectUi {
separator?: string
}
interface AppGroupedSelectProps<TValue extends string | number> extends TestIdProps {
interface AppGroupedSelectProps<TValue extends string | number> {
groups: SelectGroupDef<TValue>[]
displayValue: string
ui?: GroupedSelectUi
}
const { groups, displayValue, ui, testId } = defineProps<AppGroupedSelectProps<T>>()
defineOptions({ inheritAttrs: false })
const { groups, displayValue, ui } = defineProps<AppGroupedSelectProps<T>>()
const modelValue = defineModel<T>({ required: true })
@ -57,7 +57,7 @@ const separator = ui?.separator ?? 'mx-1 my-1 h-px bg-border'
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger v-test-id="testId" :class="select.trigger">
<SelectTrigger v-bind="$attrs" :class="select.trigger">
<slot name="value">{{ displayValue }}</slot>
<icon-lucide-chevron-down class="size-2.5 shrink-0 text-muted" />
</SelectTrigger>

View file

@ -1,11 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TestIdProps } from '@open-pencil/vue'
import { useInputUI } from '@/components/ui/input'
interface AppInputProps extends TestIdProps {
interface AppInputProps {
type?: 'text' | 'password' | 'number' | 'search'
placeholder?: string
readonly?: boolean
@ -30,8 +28,7 @@ const {
max,
step,
ui,
size = 'md',
testId
size = 'md'
} = defineProps<AppInputProps>()
const inputClass = computed(() => useInputUI({ size, ui }).base)
@ -48,7 +45,6 @@ const emit = defineEmits<{
<input
v-model="modelValue"
:type="type"
:data-test-id="testId"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"

View file

@ -11,8 +11,6 @@ import {
SelectViewport
} from 'reka-ui'
import { vTestId, type TestIdProps } from '@open-pencil/vue'
import { useSelectUI } from '@/components/ui/select'
import type { SelectUi } from '@/components/ui/select'
@ -21,20 +19,16 @@ interface AppSelectUi extends SelectUi {
indicator?: string
}
interface AppSelectProps<TValue extends string | number> extends TestIdProps {
interface AppSelectProps<TValue extends string | number> {
label?: string
options: { value: TValue; label: string }[]
placeholder?: string
ui?: AppSelectUi
}
const {
options,
label,
placeholder,
ui,
testId = 'app-select-trigger'
} = defineProps<AppSelectProps<T>>()
defineOptions({ inheritAttrs: false })
const { options, label, placeholder, ui } = defineProps<AppSelectProps<T>>()
const modelValue = defineModel<T>({ required: true })
@ -49,7 +43,7 @@ const indicator = ui?.indicator ?? 'absolute left-1.5 inline-flex items-center j
<template>
<SelectRoot v-model="modelValue">
<SelectTrigger v-test-id="testId" :class="select.trigger" :aria-label="label">
<SelectTrigger v-bind="$attrs" :class="select.trigger" :aria-label="label">
<SelectValue :placeholder="placeholder" />
<icon-lucide-chevron-down class="ml-1 size-3 shrink-0 text-muted" />
</SelectTrigger>

View file

@ -1,9 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { twMerge } from 'tailwind-merge'
import type { TestIdProps } from '@open-pencil/vue'
interface AppTextButtonProps extends TestIdProps {
interface AppTextButtonProps {
ui?: {
base?: string
}
@ -11,7 +9,7 @@ interface AppTextButtonProps extends TestIdProps {
underline?: boolean
}
const { ui, size = 'sm', underline = false, testId } = defineProps<AppTextButtonProps>()
const { ui, size = 'sm', underline = false } = defineProps<AppTextButtonProps>()
const emit = defineEmits<{ click: [event: MouseEvent] }>()
@ -26,7 +24,7 @@ const cls = computed(() =>
</script>
<template>
<button type="button" :data-test-id="testId" :class="cls" @click="emit('click', $event)">
<button type="button" :class="cls" @click="emit('click', $event)">
<slot />
</button>
</template>

View file

@ -1,6 +1,4 @@
<script setup lang="ts">
import type { TestIdProps } from '@open-pencil/vue'
import { useSectionUI } from '@/components/ui/section'
interface PanelSectionUi {
@ -8,18 +6,18 @@ interface PanelSectionUi {
wrapper?: string
}
interface PanelSectionProps extends TestIdProps {
interface PanelSectionProps {
label: string
ui?: PanelSectionUi
}
const { label, testId, ui } = defineProps<PanelSectionProps>()
const { label, ui } = defineProps<PanelSectionProps>()
const sectionCls = useSectionUI(ui)
</script>
<template>
<section :data-test-id="testId" :class="sectionCls.wrapper">
<section :class="sectionCls.wrapper">
<div v-if="$slots.actions" class="flex items-center justify-between">
<label :class="sectionCls.label">{{ label }}</label>
<slot name="actions" />

View file

@ -1,26 +1,22 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { TestIdProps } from '@open-pencil/vue'
export interface SegmentedControlOption extends TestIdProps {
export interface SegmentedControlOption {
value: string
label: string
disabled?: boolean
testHook?: string
}
const {
options,
label,
size = 'sm',
testId
} = defineProps<
TestIdProps & {
options: SegmentedControlOption[]
label?: string
size?: 'sm' | 'md'
}
>()
size = 'sm'
} = defineProps<{
options: SegmentedControlOption[]
label?: string
size?: 'sm' | 'md'
}>()
const modelValue = defineModel<string>({ required: true })
const emit = defineEmits<{ change: [value: string] }>()
@ -37,7 +33,6 @@ function select(value: string) {
<template>
<div
:data-test-id="testId"
role="radiogroup"
:aria-label="label"
class="inline-flex min-w-0 items-center gap-0.5 rounded bg-input p-0.5"
@ -45,7 +40,7 @@ function select(value: string) {
<button
v-for="option in options"
:key="option.value"
:data-test-id="option.testId"
:data-test-id="option.testHook"
type="button"
role="radio"
:aria-label="option.label"