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:
parent
3accbbb835
commit
60bb484ee4
14
AGENTS.md
14
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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 } : {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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]',
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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')"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue