Adopt motion-v for mobile drawer and toolbar animations

- MobileDrawer: spring-animated height via motion.div :animate + @pan,
  replaces useSwipe + manual rAF. Tab state always set (never null),
  content stays rendered when drawer is closed.
- Toolbar: AnimatePresence with directional slide variants and
  layout-animated width via motion.div layout, replaces manual
  scrollWidth measuring + inline CSS transitions.
- Add SWIPE_VELOCITY_THRESHOLD constant.
- activeRibbonTab type narrowed from union | null to union.
This commit is contained in:
Danila Poyarkov 2026-03-06 08:50:38 +03:00
parent 724e632251
commit a2d7b89709
17 changed files with 1051 additions and 326 deletions

647
ARCHITECTURE-NEXT.md Normal file
View file

@ -0,0 +1,647 @@
# OpenPencil Next: Browser-Native Design Tool
## Vision
A design tool where every element is real HTML/CSS rendered by a real browser engine.
Not a Figma clone. Not a browser wrapper. A new kind of application built from
WebKit's rendering primitives.
## Core Concept: Islands
An **island** is an independent rendering context — its own DOM, styles, layout,
and paint output. Components within an island share CSS context, compose via
Shadow DOM, and have real parent-child relationships. Components on the infinite
canvas that aren't composed together live in separate islands.
```
Infinite canvas
├── Island A ─ one Document, one render surface
│ └── <nav-bar>
│ ├── <logo>
│ └── <auth-button>
├── Island B ─ separate Document, separate surface
│ └── <hero-section>
│ ├── <heading>
│ └── <cta-button>
├── Island C ─ standalone component
│ └── <auth-button> (same component class, isolated context)
├── Island D ─ full page composition
│ └── <app-shell>
│ ├── <nav-bar> (shares CSS with siblings here)
│ ├── <hero-section>
│ └── <footer>
```
Drag a component OUT of an island → new island created (new rendering context).
Drag a component INTO an island → island destroyed, node joins target DOM.
The component definition (Web Component class + styles) is the same — only
the rendering context changes.
## WebKit Building Blocks
### What exists in Source/
| Component | Location | Role |
|-----------|----------|------|
| `WebCore::Page` | `Source/WebCore/page/Page.h` | A rendering context: DOM + CSS + layout + paint. **One per island.** |
| `WebCore::Document` | `Source/WebCore/dom/Document.h` | The DOM tree within a Page. |
| `WebCore::ShadowRoot` | `Source/WebCore/dom/ShadowRoot.h` | Shadow DOM for component style isolation. |
| `CustomElementRegistry` | `Source/WebCore/dom/CustomElementRegistry.h` | Web Component registration. |
| `TextureMapper` | `Source/WebCore/platform/graphics/texmap/TextureMapper.h` | GPU compositor. Draws textures with transforms. |
| `TextureMapperLayer` | `Source/WebCore/platform/graphics/texmap/TextureMapperLayer.h` | A compositing layer with position, size, transform, opacity, filters, children. |
| `CoordinatedPlatformLayer` | `Source/WebCore/platform/graphics/texmap/coordinated/` | Coordinated layer management between main thread and compositor thread. |
| `BitmapTexture` | `Source/WebCore/platform/graphics/texmap/BitmapTexture.h` | A GPU texture that TextureMapper can draw. |
| `LayerTreeHost` | `Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/LayerTreeHost.h` | Orchestrates the compositor. Creates GraphicsLayers, manages the scene. |
| `ThreadedCompositor` | `Source/WebKit/WebProcess/WebPage/CoordinatedGraphics/ThreadedCompositor.h` | Runs compositing on a dedicated thread with its own GL context. |
| `PageOverlay` | `Source/WebCore/page/PageOverlay.h` | Drawable layer on top of a Page with mouse event interception. Two modes: View (fixed) and Document (scrolls). |
| `PageOverlayController` | `Source/WebCore/page/PageOverlayController.h` | Manages overlays. Handles repaint, mouse events, scale changes. |
| `InspectorOverlay` | `Source/WebCore/inspector/InspectorOverlay.h` | Draws selection rects, margin/padding guides, grid overlays — exactly like our meta-layer needs. |
| `WPE Platform` | `Source/WebKit/WPEPlatform/` | Headless rendering abstraction. Display, View, Buffer, Toplevel. No window system needed. |
| `WPEViewHeadless` | `Source/WebKit/WPEPlatform/wpe/headless/` | Renders to GPU buffers at 60fps without a display server. |
| `AcceleratedBackingStore` | `Source/WebKit/UIProcess/wpe/AcceleratedBackingStore.h` | Gets DMA-BUF / SHM buffers from WebKit's renderer. Manages buffer lifecycle. |
| `Skia` | `Source/ThirdParty/skia/` | WPE WebKit already uses Skia for painting (2024+). Same Skia we know. |
| `JavaScriptCore` | `Source/JavaScriptCore/` | JS engine. Each Page gets its own JS global object. |
### How they connect
```
┌──────────────────────┐
│ Our Application │
│ │
│ ┌────────────────┐ │
│ │ Island Manager │ │
│ └───┬────────────┘ │
│ │ │
┌────────────────────────────┼───────────────────────────────┐
│ │ │ │ │
Island A Island B │ Island C Meta-layer
│ │ │ │ │
┌─────────┴──────┐ ┌────────┴───┐ │ ┌─────────┴──────┐ ┌────┴──────┐
│ WebCore::Page │ │ WebCore:: │ │ │ WebCore::Page │ │ PageOverlay│
│ + Document │ │ Page │ │ │ + Document │ │ on master │
│ + ShadowRoots │ │ + Document │ │ │ + ShadowRoots │ │ Page │
│ + Layout │ │ + Layout │ │ │ + Layout │ └────┬──────┘
│ + Paint→Skia │ │ + Paint │ │ │ + Paint→Skia │ │
└───────┬────────┘ └──────┬─────┘ │ └───────┬────────┘ │
│ │ │ │ │
▼ ▼ │ ▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ TextureMapper (GPU Compositor) │
│ │
│ TextureMapperLayer A TextureMapperLayer B TextureMapperLayer C │
│ ┌──────────┐ ┌──────────────┐ ┌──────────┐ │
│ │ pos: 100,200 │ pos: 600,500 │ │ pos: 100,700 │
│ │ size: 400×60│ │ size: 800×400│ │ size: 120×40│ │
│ │ texture: A │ │ texture: B │ │ texture: C │ │
│ └──────────┘ └──────────────┘ └──────────┘ │
│ │
│ Master transform: scale(zoom) × translate(panX, panY) │
│ │
│ Overlay layer (meta): selection rects, handles, guides, rulers │
│ │
└──────────────────────────────────────────────────────────────────────┬─────┘
Screen/Window
```
## Architecture Layers
### Layer 1: Island Runtime
Each island = `WebCore::Page` + the minimum `PageConfiguration` clients.
We implement lightweight stubs for the required clients:
| Client | Our Implementation |
|--------|--------------------|
| `ChromeClient` | Minimal: reports viewport size, device scale. No browser chrome. |
| `EditorClient` | Full: enables contenteditable for text editing in design nodes. |
| `FrameLoaderClient` | Stub: we don't load URLs. We inject HTML directly into the Document. |
| `BackForwardClient` | Stub: no navigation history. |
| `ProgressTrackerClient` | Stub: no loading progress. |
| `SocketProvider` | Stub or real: depends on whether we want fetch() inside islands. |
| `CookieJar` | Stub: no cookies. |
| `DragClient` | Intercepted: we handle drag at the meta-layer level. |
Island lifecycle:
```
createIsland(html: string, css: string, size: {w, h}) → Island
1. Build PageConfiguration with our stub clients
2. Create WebCore::Page
3. Get the Page's Document
4. Parse and inject the HTML into Document
5. Inject scoped CSS (design tokens + island styles)
6. Force layout
7. Render to BitmapTexture via Skia painting
8. Register the texture as a TextureMapperLayer
9. Position the layer in world space
destroyIsland(island: Island) → void
1. Remove TextureMapperLayer from compositor
2. Destroy WebCore::Page (releases Document, layout tree, paint state)
```
### Layer 2: Component System
Components are Web Components registered in every island's `CustomElementRegistry`.
```cpp
// Shared component definitions
struct ComponentDef {
String tagName; // "my-button", "nav-bar"
String html; // shadow root innerHTML
String css; // shadow root styles
String js; // optional: class definition with lifecycle callbacks
Vector<String> slots; // named slots this component accepts
Vector<Property> props; // observed attributes → CSS custom properties
};
// When creating an island, register all known components:
for (auto& def : componentLibrary) {
island.page->document().customElementRegistry()
.define(def.tagName, def.classConstructor);
}
```
Shadow DOM within an island provides:
- **Style isolation**: each component's CSS doesn't leak
- **CSS custom properties pierce through**: design tokens (`--brand-primary`, `--spacing-md`) inherit naturally
- **Slots = children**: `<slot>` elements enable real DOM composition
- **Events bubble**: click inside a button inside a card inside a frame → bubbles through all shadow boundaries
### Layer 3: Compositor (The Infinite Canvas)
Built on `TextureMapper` + `TextureMapperLayer`.
The compositor manages:
- A root `TextureMapperLayer` with the **world transform** (pan + zoom matrix)
- Child `TextureMapperLayer`s, one per island, positioned in world space
- An overlay layer for the meta-layer (selection, handles, guides)
```cpp
class DesignCanvasCompositor {
TextureMapper m_textureMapper;
TextureMapperLayer m_rootLayer; // world transform lives here
TextureMapperLayer m_overlayLayer; // meta-layer on top
// World transform
TransformationMatrix m_worldTransform; // scale(zoom) * translate(pan)
void addIsland(Island& island, FloatPoint worldPos, FloatSize size) {
auto* layer = new TextureMapperLayer();
layer->setPosition(worldPos);
layer->setSize(size);
layer->setContentsLayer(island.platformLayer());
m_rootLayer.addChild(layer);
}
void setZoom(float zoom, FloatPoint origin) {
// Zoom around origin point
m_worldTransform = TransformationMatrix()
.translate(origin.x(), origin.y())
.scale(zoom)
.translate(-origin.x(), -origin.y())
.translate(m_panOffset.x(), m_panOffset.y());
m_rootLayer.setTransform(m_worldTransform);
}
void paint() {
m_textureMapper.beginPainting();
m_rootLayer.prepareForPainting(m_textureMapper);
m_rootLayer.paint(m_textureMapper);
m_overlayLayer.paint(m_textureMapper); // always on top
m_textureMapper.endPainting();
}
};
```
**Resolution management**: when zoom stabilizes, re-render island textures at
the effective resolution (zoom × base size) for crisp text. During zoom animation,
use the existing texture scaled — text looks slightly blurry for ~100ms, same as
every map application.
### Layer 4: Meta-Layer
The meta-layer handles all design-tool interactions. It intercepts pointer events
before they reach any island and draws selection UI on top of everything.
Built on `PageOverlay` (OverlayType::View — fixed to viewport, doesn't scroll).
```cpp
class DesignMetaLayer : public PageOverlayClient {
// Selection state
Vector<IslandNodeRef> m_selectedNodes;
std::optional<DragState> m_dragState;
std::optional<ResizeState> m_resizeState;
void drawRect(PageOverlay&, GraphicsContext& gc, const IntRect&) override {
// Draw selection rectangles
for (auto& node : m_selectedNodes) {
auto worldBounds = nodeWorldBounds(node);
gc.setStrokeColor(Color::blue);
gc.strokeRect(worldBounds, 1.0f / m_zoom); // constant screen-space width
drawResizeHandles(gc, worldBounds);
}
drawGuides(gc);
drawRulers(gc);
drawHoverHighlight(gc);
}
bool mouseEvent(PageOverlay&, const PlatformMouseEvent& event) override {
auto worldPos = screenToWorld(event.position());
// Hit-test: which island, which node?
auto hit = hitTest(worldPos);
if (event.type() == PlatformEvent::Type::MousePressed) {
if (hit.resizeHandle) startResize(hit);
else if (hit.node) startSelection(hit);
else if (hit.empty) startMarquee(worldPos);
return true; // consume event
}
// ... drag, release, hover
}
};
```
**Hit-testing**: transform world-space click → island local coords → call
`Document::elementFromPoint()` on that island's Document. WebKit does the
heavy lifting — it knows exactly which DOM element is at any coordinate,
accounting for z-index, overflow, transforms, etc.
### Layer 5: Property Panel & Tools
The property panel reads and writes real CSS on real DOM elements.
```cpp
// Reading properties (user selects a node)
auto* element = selectedNode.element; // live DOM Element in island's Document
auto computed = element->document().domWindow()->getComputedStyle(*element);
propertyPanel.display = computed->display(); // "flex"
propertyPanel.gap = computed->gap(); // "16px"
propertyPanel.color = computed->color(); // "rgb(51, 51, 51)"
propertyPanel.fontSize = computed->fontSize(); // "14px"
propertyPanel.borderRadius = computed->borderRadius(); // "12px"
// Writing properties (user changes a value)
element->style()->setProperty("gap", "24px");
// Layout automatically recalculates
// Island texture automatically re-renders
// Compositor shows updated result at next frame
```
No translation layer. No property mapping. The design tool properties ARE CSS properties.
### Layer 6: Scene Graph (Persistence)
The scene graph is the serializable representation of the design.
```
DesignFile
├── components: ComponentDef[] // Web Component definitions
├── designTokens: Record<string, string> // CSS custom properties
├── islands: Island[]
│ ├── id: string
│ ├── position: {x, y} // world space
│ ├── size: {width, height} // viewport size
│ └── tree: NodeTree // serialized DOM
│ ├── tagName: string
│ ├── attributes: Record<string, string>
│ ├── inlineStyles: Record<string, string>
│ ├── shadowCSS?: string
│ └── children: NodeTree[]
└── canvasState
├── zoom: number
└── pan: {x, y}
```
**Export to code** = serialize island DOM. The design IS the code.
No translation, no "generate CSS" — it was CSS all along.
## Key Operations
### Drag Component Between Islands
```
User drags <auth-button> from Island A to free canvas at position (500, 800):
1. Meta-layer detects drag from Island A
2. Get the element's outerHTML + computed styles from Island A's Document
3. Remove element from Island A's DOM
4. Island A re-renders (layout recalculates without the removed node)
5. Create new Island C at (500, 800) with the element's HTML
6. Island C renders the standalone component
7. Register Island C's texture in compositor
```
### Drag Component Into Island
```
User drags Island C's <auth-button> into Island D's <nav-bar>:
1. Meta-layer detects drop target = Island D, parent = <nav-bar> element
2. Get the element's outerHTML from Island C
3. Destroy Island C (remove Page, remove compositor layer)
4. Parse HTML and insert into Island D's <nav-bar> DOM
5. Component is now a child of <nav-bar> — shares CSS context
6. Island D re-renders
```
### Responsive Preview
```
User resizes an island's viewport:
1. Update the Page's viewport size
2. CSS media queries fire automatically (the browser does this!)
3. Container queries fire automatically
4. Layout recalculates
5. Island texture re-renders at new size
6. Compositor shows the result
No simulation. Real responsive behavior from the real CSS engine.
```
### Text Editing
```
User double-clicks a text node:
1. Meta-layer passes focus through to the island's Page
2. The browser's native contenteditable activates
3. Cursor, selection, IME — all handled by WebKit
4. Text reflows in real-time with real CSS
5. On blur, meta-layer recaptures input
```
## Platform Strategy
### Phase 1: WPE WebKit (Linux first)
WPE is designed for exactly this: headless WebKit rendering to GPU buffers.
- `WPEDisplayHeadless` — no window system dependency
- `WPEViewHeadless` — renders to DMA-BUF / SHM buffers
- `AcceleratedBackingStore` — manages the buffer pipeline
- Skia painting engine — already integrated in WPE WebKit
- TextureMapper compositor — OpenGL ES, mature, handles transforms/opacity/filters
Build: custom WPE WebKit, compile as a library, link into our C++/Rust app.
The application window itself: either a single real WPE toplevel that we
own (for the panels + canvas viewport), or a native window (GTK, SDL, GLFW)
where we composite WebKit's output.
### Phase 2: macOS
Options:
- **WKWebView per island** — macOS has WKWebView with native CoreAnimation compositing.
Each WKWebView renders to a CALayer. We composite CALayers ourselves in a Metal view.
Higher-level API, less control, but works with Apple's WebKit.
- **Build WPE for macOS** — WPE's platform layer is abstractable. The headless backend
works anywhere with EGL/OpenGL. Harder to build but gives us the same architecture.
- **WebKit from source for macOS** — compile WebCore + TextureMapper directly, bypass
the Cocoa API layer. Maximum control, maximum build complexity.
Recommended: start with WKWebView per island for quick macOS support, migrate
to WPE-on-macOS for unified architecture later.
### Phase 3: Windows
- WebView2 (Chromium) per island — similar to macOS WKWebView approach
- Or build WPE with ANGLE for OpenGL-on-DirectX
- Or use CEF (Chromium Embedded Framework) off-screen rendering
### Phase 4: Cross-Platform Unified
Long-term: ship WPE WebKit on all platforms. One codebase, one compositor,
one architecture. WPE's platform layer is designed to be ported.
## What Changes vs. Current OpenPencil
| Current | Next |
|---------|------|
| Skia CanvasKit (WASM) for all rendering | WebKit for content rendering, TextureMapper/Skia for compositing |
| Yoga WASM for layout (flexbox subset) | Real CSS layout engine (flexbox, grid, container queries, everything) |
| Custom text shaping | WebKit's text engine (ICU, HarfBuzz, CoreText) |
| SceneNode flat map | DOM trees in isolated Pages |
| Kiwi binary .fig format | HTML/CSS-based format (with optional .fig import) |
| Custom component/override system | Web Components + Shadow DOM |
| "Export to code" = generate | "Export to code" = serialize what's already there |
| Vue 3 + TypeScript app | C++/Rust app with WebKit library |
| Tauri desktop wrapper | Native application, IS the browser |
## Implementation Language
The core must be **C++** (WebKit is C++). Options for the application layer:
- **Pure C++** — maximum integration, no FFI overhead
- **Rust + C++ FFI** — Rust for the app layer (scene graph, tools, file I/O),
C++ for WebKit integration. rust-bindgen for the bridge.
- **C++ core + TypeScript UI** — the property panel / layers panel / toolbar
could be one island that's the "app shell", rendering our UI in HTML/CSS.
Meta-layer stays in C++.
The third option is interesting: **the design tool's UI is itself an island**.
Panels are Web Components. The infinite canvas + meta-layer is the native layer.
We eat our own dog food.
## Open Questions
1. **Island granularity**: one Page per top-level frame? Per component? Per page?
Memory/performance testing needed. Each Page has overhead (JS context, style
system, layout state). Hundreds might be fine; thousands might not.
2. **Shared component registry**: when a component class changes, every island
that uses it needs to re-register and re-render. Mechanism: custom element
upgrade? Full re-injection?
3. **Undo/redo**: DOM mutations as the undo unit? Or snapshot the serialized
tree? DOM MutationObserver could track changes for undo.
4. **Collaboration (Yjs)**: sync what — the serialized tree, or DOM mutations?
Could use DOM MutationObserver → Yjs doc, or serialize islands and sync
the serialized form.
5. **Animation timeline**: CSS animations are real, but a design tool needs
a timeline editor. How to introspect/control `Animation` objects from
the meta-layer?
6. **Build complexity**: compiling WebKit from source is a multi-hour endeavor
with many dependencies. CI/CD for 3 platforms will be challenging.
Mitigation: start with one platform (Linux/WPE), get the architecture right.
## Minimal UI
The demo has almost no custom UI. The power is the browser engine underneath.
### Toolbar (4 buttons + zoom)
```
[+ Frame] [+ Text] [+ Code] [⚙ Inspect] zoom: 100%
```
- **+ Frame** — create a new island with a `<div>`, set its size by dragging
- **+ Text** — create a new island with `<p contenteditable>`
- **+ Code** — create a new island, paste raw HTML/CSS into it
- **Inspect** — toggle: click an island to attach DevTools
### Canvas
The infinite canvas. Islands rendered as GPU-composited surfaces.
Click → select (blue border). Drag → move. Drag between islands → reparent.
### DevTools as Property Panel
When an island is focused, **WebKit's built-in inspector connects to it**.
Not a custom property panel. The real DevTools: Elements, Styles, Computed,
Layout, Animations, Console.
```
┌───────────────────────────────────┐
│ [+ Frame] [+ Text] [+Code] [⚙] │
├───────────────────────────────────┤
│ Infinite Canvas │
│ │
│ ┌────────┐ ┌──────────┐ │
│ │Island A│ │ Island B │ │
│ │ │ │ selected▐│ │
│ └────────┘ └──────────┘ │
│ │
├───────────────────────────────────┤
│ 🔍 Inspector (Island B) │
│ Elements │ Styles │ Computed │
│ ▼ <hero-section>
│ ▼ #shadow-root (open) │
<h1>Welcome</h1>
<button>Get Started</button>
│ ───────────────────────────── │
│ display: flex; │
│ gap: 24px; │
│ padding: 48px; │
└───────────────────────────────────┘
```
Switching selected island switches the inspector target. Edit CSS in the
inspector → island re-renders live on the canvas.
This means **every CSS property is editable from day one**. Grid, container
queries, `:has()`, custom properties, animations — whatever the inspector
can show, the user can change.
### DevTools Connection (WebKit Inspector)
WebKit has a built-in remote inspector protocol:
- On macOS: `WKWebView` exposes `_inspector` (private API) or
`WKWebViewConfiguration.preferences._developerExtrasEnabled`
enables right-click → Inspect Element
- On WPE/GTK: `RemoteInspectorServer` listens on a socket,
`webkit_web_view_get_inspector()` returns a `WebKitWebInspector`
- The inspector protocol (JSON-RPC over socket) has domains:
DOM, CSS, Page, Console, Network, Animation, LayerTree, etc.
For the PoC on macOS: enable the inspector on each WKWebView.
When the user selects an island, programmatically show that island's
inspector in a split panel.
## First Milestone: Proof of Concept (macOS)
Platform: macOS with native WebKit (WKWebView) + Core Animation/Metal.
Language: Swift. This is the fastest path to validate the architecture.
WPE (Linux/cross-platform) comes after the concept is proven.
### What to build
A macOS app (no Xcode, Swift Package Manager) that demonstrates:
1. **Metal viewport** — a single Metal view that composites island textures
with a pan/zoom transform matrix
2. **Islands as WKWebViews** — each island is a `WKWebView` rendered
off-screen (hidden from the window, but its `CALayer` is captured
or snapshotted for compositing)
3. **HTML injection**`loadHTMLString()` to put content into each island
4. **Pan/zoom** — scroll wheel / trackpad to zoom and pan the canvas,
applied as a transform on the compositor level
5. **Selection** — click on an island → blue selection border drawn by
the meta-layer (Metal overlay)
6. **DevTools** — selecting an island opens WebKit's inspector for that
WKWebView in a split panel below the canvas
7. **Drag** — move islands around the canvas
8. **Toolbar** — the 4 buttons: + Frame, + Text, + Code, Inspect toggle
### Architecture for macOS PoC
```
┌─────────────────────────────────────────────┐
│ NSWindow │
│ ┌─────────────────────────────────────────┐ │
│ │ Toolbar (NSView / SwiftUI) │ │
│ │ [+ Frame] [+ Text] [+ Code] [⚙] │ │
│ ├─────────────────────────────────────────┤ │
│ │ NSSplitView │ │
│ │ ┌───────────────────────────────────┐ │ │
│ │ │ CanvasView (NSView) │ │ │
│ │ │ │ │ │
│ │ │ Hidden WKWebViews (off-screen) │ │ │
│ │ │ ┌─────┐ ┌─────┐ ┌─────┐ │ │ │
│ │ │ │ WK1 │ │ WK2 │ │ WK3 │ │ │ │
│ │ │ └─────┘ └─────┘ └─────┘ │ │ │
│ │ │ │ │ │
│ │ │ Canvas layer (Core Animation) │ │ │
│ │ │ - world transform (pan/zoom) │ │ │
│ │ │ - child CALayers (island renders) │ │ │
│ │ │ - overlay layer (selection/guides)│ │ │
│ │ │ │ │ │
│ │ ├───────────────────────────────────┤ │ │
│ │ │ Inspector panel (WKWebView) │ │ │
│ │ │ Connected to selected island │ │ │
│ │ └───────────────────────────────────┘ │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
```
Each island `WKWebView` is a real WebKit rendering context with its own
DOM, CSS, JS. Its rendered output is captured (via `takeSnapshot`,
layer mirroring, or `snapshotView`) and composited onto the canvas.
For live updates: `WKWebView`'s `CALayer` can be re-rendered via
`CALayerHost` or by positioning the actual WKWebView within the canvas
view and applying the world transform via Core Animation. This avoids
constant snapshotting — the WKWebViews ARE the canvas content, just
transformed by our pan/zoom matrix.
### File structure
```
open-pencil-next/
├── Package.swift
├── Sources/
│ ├── App/
│ │ ├── main.swift
│ │ └── AppDelegate.swift
│ ├── Canvas/
│ │ ├── CanvasView.swift — the infinite canvas (NSView + Core Animation)
│ │ ├── IslandManager.swift — creates/destroys WKWebView islands
│ │ ├── MetaLayer.swift — selection borders, handles, guides (CALayer)
│ │ └── HitTesting.swift — world-space → island → DOM element
│ ├── Islands/
│ │ ├── Island.swift — island model (WKWebView + position + size)
│ │ ├── IslandRenderer.swift — manages WKWebView lifecycle and content
│ │ └── ComponentRegistry.swift — shared Web Component definitions
│ ├── Inspector/
│ │ ├── InspectorPanel.swift — split panel hosting WebKit inspector
│ │ └── InspectorBridge.swift — connects inspector to selected island
│ ├── Toolbar/
│ │ └── ToolbarView.swift — the 4 buttons
│ └── Model/
│ ├── SceneGraph.swift — serializable scene (islands + nodes)
│ └── DesignTokens.swift — CSS custom properties shared across islands
└── Tests/
└── ...
```

View file

@ -13,6 +13,9 @@
### Internal
- Add `motion-v` for declarative animations — used in mobile drawer (spring-animated height with pan gestures) and toolbar (layout-animated category switching with directional slide transitions)
- Mobile drawer: replace `useSwipe` + manual rAF animation with `motion.div` `:animate` + `@pan`/`@panEnd`; always-on tab state (no more null `activeRibbonTab`); content stays rendered when closed
- Mobile toolbar: replace manual `scrollWidth` measuring + inline CSS transitions with `motion.div layout` + `AnimatePresence` directional slide variants
- Mobile UI cleanup: extract shared `colorToCSS` util to core, `initials` to `src/utils/text`, `toolIcons` to `src/utils/tools`; replace hand-rolled dropdowns with reka-ui Popover/DropdownMenu; narrow `mobileDrawerSnap` type to string union; move magic numbers to constants; disable PWA service worker in dev mode
## 0.7.0 — 2026-03-05

View file

@ -25,6 +25,7 @@
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"lib0": "^0.2.117",
"motion-v": "^2.0.0",
"prismjs": "^1.30.0",
"reka-ui": "^2.9.0",
"tailwindcss": "^4.2.1",
@ -646,6 +647,8 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@nuxt/kit": ["@nuxt/kit@3.21.1", "", { "dependencies": { "c12": "^3.3.3", "consola": "^3.4.2", "defu": "^6.1.4", "destr": "^2.0.5", "errx": "^0.1.0", "exsolve": "^1.0.8", "ignore": "^7.0.5", "jiti": "^2.6.1", "klona": "^2.0.6", "knitwork": "^1.3.0", "mlly": "^1.8.0", "ohash": "^2.0.11", "pathe": "^2.0.3", "pkg-types": "^2.3.0", "rc9": "^3.0.0", "scule": "^1.3.0", "semver": "^7.7.4", "tinyglobby": "^0.2.15", "ufo": "^1.6.3", "unctx": "^2.5.0", "untyped": "^2.0.0" } }, "sha512-QORZRjcuTKgo++XP1Pc2c2gqwRydkaExrIRfRI9vFsPA3AzuHVn5Gfmbv1ic8y34e78mr5DMBvJlelUaeOuajg=="],
"@open-pencil/acp": ["@open-pencil/acp@workspace:packages/acp"],
"@open-pencil/cli": ["@open-pencil/cli@workspace:packages/cli"],
@ -1140,6 +1143,8 @@
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@ -1248,6 +1253,8 @@
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
@ -1258,6 +1265,8 @@
"doctypes": ["doctypes@1.1.0", "", {}, "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@ -1278,6 +1287,8 @@
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"errx": ["errx@0.1.0", "", {}, "sha512-fZmsRiDNv07K6s2KkKFTiD2aIvECa7++PKyD5NC32tpRw46qZA3sOz+aM+/V9V0GDHxVTKLziveV4JhzBHDp9Q=="],
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
@ -1362,6 +1373,8 @@
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"framer-motion": ["framer-motion@12.35.0", "", { "dependencies": { "motion-dom": "^12.35.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="],
@ -1398,6 +1411,8 @@
"get-symbol-description": ["get-symbol-description@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6" } }, "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg=="],
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
"gitignore-to-glob": ["gitignore-to-glob@0.3.0", "", {}, "sha512-mk74BdnK7lIwDHnotHddx1wsjMOFIThpLY3cPNniJ/2fA/tlLzHnFxIdR+4sLOu5KGgQJdij4kjJ2RoUNnCNMA=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
@ -1430,6 +1445,8 @@
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hi-base32": ["hi-base32@0.5.1", "", {}, "sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA=="],
"hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="],
@ -1452,6 +1469,8 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"interface-datastore": ["interface-datastore@8.3.2", "", { "dependencies": { "interface-store": "^6.0.0", "uint8arrays": "^5.1.0" } }, "sha512-R3NLts7pRbJKc3qFdQf+u40hK8XWc0w4Qkx3OFEstC80VoaDUABY/dXA2EJPhtNC+bsrf41Ehvqb6+pnIclyRA=="],
@ -1626,6 +1645,10 @@
"katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="],
"klona": ["klona@2.0.6", "", {}, "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA=="],
"knitwork": ["knitwork@1.3.0", "", {}, "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw=="],
"leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="],
"lib0": ["lib0@0.2.117", "", { "dependencies": { "isomorphic.js": "^0.2.4" }, "bin": { "0serve": "bin/0serve.js", "0gentesthtml": "bin/gentesthtml.js", "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js" } }, "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw=="],
@ -1812,6 +1835,12 @@
"mortice": ["mortice@3.3.1", "", { "dependencies": { "abort-error": "^1.0.0", "it-queue": "^1.1.0", "main-event": "^1.0.0" } }, "sha512-t3oESfijIPGsmsdLEKjF+grHfrbnKSXflJtgb1wY14cjxZpS6GnhHRXTxxzCAoCCnq1YYfpEPwY3gjiCPhOufQ=="],
"motion-dom": ["motion-dom@12.35.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA=="],
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"motion-v": ["motion-v@2.0.0", "", { "dependencies": { "framer-motion": "^12.29.2", "hey-listen": "^1.0.8", "motion-dom": "^12.29.2", "motion-utils": "^12.29.2" }, "peerDependencies": { "@vueuse/core": ">=10.0.0", "vue": ">=3.0.0" } }, "sha512-oQuQMrPhti+Zps6OosOaW3b/eqzaGAuwI54XHJKq/dIWtQWcNzfyhTo4VB5xmp7yLN+3BE9FKF6skLsynfgbHQ=="],
"mqtt": ["mqtt@5.15.0", "", { "dependencies": { "@types/readable-stream": "^4.0.21", "@types/ws": "^8.18.1", "commist": "^3.2.0", "concat-stream": "^2.0.0", "debug": "^4.4.1", "help-me": "^5.0.0", "lru-cache": "^10.4.3", "minimist": "^1.2.8", "mqtt-packet": "^9.0.2", "number-allocator": "^1.0.14", "readable-stream": "^4.7.0", "rfdc": "^1.4.1", "socks": "^2.8.6", "split2": "^4.2.0", "worker-timers": "^8.0.23", "ws": "^8.18.3" }, "bin": { "mqtt": "build/bin/mqtt.js", "mqtt_pub": "build/bin/pub.js", "mqtt_sub": "build/bin/sub.js" } }, "sha512-KC+wAssYk83Qu5bT8YDzDYgUJxPhbLeVsDvpY2QvL28PnXYJzC2WkKruyMUgBAZaQ7h9lo9k2g4neRNUUxzgMw=="],
"mqtt-packet": ["mqtt-packet@9.0.2", "", { "dependencies": { "bl": "^6.0.8", "debug": "^4.3.4", "process-nextick-args": "^2.0.1" } }, "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA=="],
@ -1828,6 +1857,8 @@
"netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
"node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="],
@ -1836,6 +1867,8 @@
"number-allocator": ["number-allocator@1.0.14", "", { "dependencies": { "debug": "^4.3.1", "js-sdsl": "4.3.0" } }, "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA=="],
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
@ -1976,6 +2009,8 @@
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"rc9": ["rc9@3.0.0", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.5" } }, "sha512-MGOue0VqscKWQ104udASX/3GYDcKyPI4j4F8gu/jHHzglpmy9a/anZK3PNe8ug6aZFl+9GxLtdhe3kVZuMaQbA=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
@ -2038,7 +2073,7 @@
"search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
@ -2188,6 +2223,8 @@
"unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="],
"unctx": ["unctx@2.5.0", "", { "dependencies": { "acorn": "^8.15.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.21", "unplugin": "^2.3.11" } }, "sha512-p+Rz9x0R7X+CYDkT+Xg8/GhpcShTlU8n+cf9OtOEf7zEQsNcCZO1dPKNRDqvUTaq+P32PMMkxWHwfrxkqfqAYg=="],
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"unicode-canonical-property-names-ecmascript": ["unicode-canonical-property-names-ecmascript@2.0.1", "", {}, "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg=="],
@ -2224,6 +2261,8 @@
"unplugin-vue-components": ["unplugin-vue-components@31.0.0", "", { "dependencies": { "chokidar": "^5.0.0", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "obug": "^2.1.1", "picomatch": "^4.0.3", "tinyglobby": "^0.2.15", "unplugin": "^2.3.11", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "@nuxt/kit": "^3.2.2 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@nuxt/kit"] }, "sha512-4ULwfTZTLuWJ7+S9P7TrcStYLsSRkk6vy2jt/WTfgUEUb0nW9//xxmrfhyHUEVpZ2UKRRwfRb8Yy15PDbVZf+Q=="],
"untyped": ["untyped@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "defu": "^6.1.4", "jiti": "^2.4.2", "knitwork": "^1.2.0", "scule": "^1.3.0" }, "bin": { "untyped": "dist/cli.mjs" } }, "sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g=="],
"upath": ["upath@1.2.0", "", {}, "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@ -2356,8 +2395,18 @@
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-regexp-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@libp2p/crypto/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="],
"@libp2p/crypto/@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
@ -2416,6 +2465,10 @@
"@waku/utils/chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="],
"babel-plugin-polyfill-corejs2/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"c12/rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
"concat-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"filelist/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="],
@ -2440,6 +2493,8 @@
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
"p-queue/p-timeout": ["p-timeout@7.0.1", "", {}, "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg=="],
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
@ -2450,6 +2505,8 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"unctx/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"vitepress/@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"vitepress/@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],

View file

@ -50,6 +50,7 @@
"fflate": "^0.8.2",
"fzstd": "^0.1.1",
"lib0": "^0.2.117",
"motion-v": "^2.0.0",
"prismjs": "^1.30.0",
"reka-ui": "^2.9.0",
"tailwindcss": "^4.2.1",

View file

@ -1060,10 +1060,10 @@ export class SkiaRenderer {
}
const abs = graph.getAbsolutePosition(flash.nodeId)
const sx = abs.x * zoom + this.panX
const sy = abs.y * zoom + this.panY
const sw = node.width * zoom
const sh = node.height * zoom
const cx = (abs.x + node.width / 2) * zoom + this.panX
const cy = (abs.y + node.height / 2) * zoom + this.panY
const hw = (node.width / 2) * zoom
const hh = (node.height / 2) * zoom
let opacity: number
let extraPad: number
@ -1072,7 +1072,7 @@ export class SkiaRenderer {
const t = elapsed / FLASH_ATTACK_MS
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2
opacity = ease
extraPad = (1 - ease) * FLASH_OVERSHOOT / zoom
extraPad = (1 - ease) * FLASH_OVERSHOOT
} else if (elapsed < FLASH_ATTACK_MS + FLASH_HOLD_MS) {
opacity = 1
extraPad = 0
@ -1088,12 +1088,16 @@ export class SkiaRenderer {
paint.setColor(ck.Color4f(FLASH_COLOR.r, FLASH_COLOR.g, FLASH_COLOR.b, opacity))
paint.setStrokeWidth(FLASH_STROKE_WIDTH)
canvas.save()
if (node.rotation !== 0) canvas.rotate(node.rotation, cx, cy)
const rect = ck.RRectXY(
ck.LTRBRect(sx - pad, sy - pad, sx + sw + pad, sy + sh + pad),
ck.LTRBRect(cx - hw - pad, cy - hh - pad, cx + hw + pad, cy + hh + pad),
r,
r
)
canvas.drawRRect(rect, paint)
canvas.restore()
}
}
@ -3195,6 +3199,7 @@ export class SkiaRenderer {
for (const pic of this.nodePictureCache.values()) pic?.delete()
this.nodePictureCache.clear()
this.scenePicture?.delete()
this._flashPaint?.delete()
this.profiler.destroy()
this.surface.delete()
}

View file

@ -15,43 +15,24 @@ export interface AIAdapterOptions {
onFlashNodes?: (nodeIds: string[]) => void
}
const READ_ONLY_TOOLS = new Set([
'get_selection',
'get_page_tree',
'get_node',
'find_nodes',
'list_pages',
'list_variables',
'list_collections',
'node_bounds',
'node_ancestors',
'node_children',
'node_tree',
'node_bindings',
'list_fonts',
'get_variable',
'find_variables',
'get_collection',
'export_svg',
'export_image',
'viewport_get',
'page_bounds',
'eval'
])
function extractIdsFromArray(arr: unknown[]): string[] {
const ids: string[] = []
for (const item of arr) {
if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string') {
ids.push((item as Record<string, unknown>).id as string)
}
}
return ids
}
function extractNodeIds(result: unknown): string[] {
if (!result || typeof result !== 'object') return []
const obj = result as Record<string, unknown>
if (typeof obj.deleted === 'string') return []
const ids: string[] = []
if (typeof obj.id === 'string') ids.push(obj.id)
if (typeof obj.deleted === 'string') return []
if (Array.isArray(obj.selection)) {
for (const item of obj.selection) {
if (item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string') {
ids.push((item as Record<string, unknown>).id as string)
}
}
}
if (Array.isArray(obj.selection)) ids.push(...extractIdsFromArray(obj.selection))
if (Array.isArray(obj.results)) ids.push(...extractIdsFromArray(obj.results))
return ids
}
@ -73,7 +54,6 @@ export function toolsToAI(
shape[key] = paramToValibot(v, param)
}
const isReadOnly = READ_ONLY_TOOLS.has(def.name)
result[def.name] = tool({
description: def.description,
inputSchema: valibotSchema(v.object(shape as any)),
@ -81,7 +61,7 @@ export function toolsToAI(
options.onBeforeExecute?.()
try {
const execResult = await def.execute(options.getFigma(), args as any)
if (!isReadOnly && options.onFlashNodes) {
if (def.mutates && options.onFlashNodes) {
const ids = extractNodeIds(execResult)
if (ids.length > 0) options.onFlashNodes(ids)
}

View file

@ -6,6 +6,7 @@ import type { FigmaNodeProxy } from '../figma-api'
export const createShape = defineTool({
name: 'create_shape',
mutates: true,
description:
'Create a shape on the canvas. Use FRAME for containers/cards, RECTANGLE for solid blocks, ELLIPSE for circles, TEXT for labels, SECTION for page sections.',
params: {
@ -47,6 +48,7 @@ export const createShape = defineTool({
export const render = defineTool({
name: 'render',
mutates: true,
description:
'Render JSX to design nodes. Primary creation tool — creates entire component trees in one call. Example: <Frame name="Card" w={320} h="hug" flex="col" gap={16} p={24} bg="#FFF" rounded={16}><Text size={18} weight="bold">Title</Text></Frame>',
params: {
@ -68,6 +70,7 @@ export const render = defineTool({
export const createComponent = defineTool({
name: 'create_component',
mutates: true,
description: 'Convert a frame/group into a component.',
params: {
id: { type: 'string', description: 'Node ID to convert', required: true }
@ -82,6 +85,7 @@ export const createComponent = defineTool({
export const createInstance = defineTool({
name: 'create_instance',
mutates: true,
description: 'Create an instance of a component.',
params: {
component_id: { type: 'string', description: 'Component node ID', required: true },
@ -100,6 +104,7 @@ export const createInstance = defineTool({
export const createPage = defineTool({
name: 'create_page',
mutates: true,
description: 'Create a new page.',
params: {
name: { type: 'string', description: 'Page name', required: true }
@ -113,6 +118,7 @@ export const createPage = defineTool({
export const createVector = defineTool({
name: 'create_vector',
mutates: true,
description: 'Create a vector node with optional path data.',
params: {
x: { type: 'number', description: 'X position', required: true },
@ -156,6 +162,7 @@ export const createVector = defineTool({
export const createSlice = defineTool({
name: 'create_slice',
mutates: true,
description: 'Create a slice (export region) on the canvas.',
params: {
x: { type: 'number', description: 'X position', required: true },

View file

@ -5,6 +5,7 @@ import { defineTool } from './schema'
export const setFill = defineTool({
name: 'set_fill',
mutates: true,
description: 'Set the fill color of a node. Accepts hex (#ff0000) or named color.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -22,6 +23,7 @@ export const setFill = defineTool({
export const setStroke = defineTool({
name: 'set_stroke',
mutates: true,
description: 'Set the stroke (border) of a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -54,6 +56,7 @@ export const setStroke = defineTool({
export const setEffects = defineTool({
name: 'set_effects',
mutates: true,
description:
'Set effects on a node (drop shadow, inner shadow, blur). Pass an array or a single effect.',
params: {
@ -94,6 +97,7 @@ export const setEffects = defineTool({
export const updateNode = defineTool({
name: 'update_node',
mutates: true,
description:
'Update properties of an existing node: position, size, opacity, corner radius, visibility, text, font.',
params: {
@ -160,6 +164,7 @@ export const updateNode = defineTool({
export const setLayout = defineTool({
name: 'set_layout',
mutates: true,
description: 'Set auto-layout (flexbox) on a frame. Direction, alignment, spacing, padding.',
params: {
id: { type: 'string', description: 'Frame node ID', required: true },
@ -208,6 +213,7 @@ export const setLayout = defineTool({
export const setConstraints = defineTool({
name: 'set_constraints',
mutates: true,
description: 'Set resize constraints for a node within its parent.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -237,6 +243,7 @@ export const setConstraints = defineTool({
export const setRotation = defineTool({
name: 'set_rotation',
mutates: true,
description: 'Set rotation angle of a node in degrees.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -252,6 +259,7 @@ export const setRotation = defineTool({
export const setOpacity = defineTool({
name: 'set_opacity',
mutates: true,
description: 'Set opacity of a node (0-1).',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -267,6 +275,7 @@ export const setOpacity = defineTool({
export const setRadius = defineTool({
name: 'set_radius',
mutates: true,
description: 'Set corner radius. Use individual corners for independent values.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -292,6 +301,7 @@ export const setRadius = defineTool({
export const setMinMax = defineTool({
name: 'set_minmax',
mutates: true,
description: 'Set min/max width and height constraints on a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -319,6 +329,7 @@ export const setMinMax = defineTool({
export const setText = defineTool({
name: 'set_text',
mutates: true,
description: 'Set text content of a text node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -334,6 +345,7 @@ export const setText = defineTool({
export const setFont = defineTool({
name: 'set_font',
mutates: true,
description: 'Set font properties of a text node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -358,6 +370,7 @@ export const setFont = defineTool({
export const setFontRange = defineTool({
name: 'set_font_range',
mutates: true,
description: 'Set font properties for a text range.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -385,6 +398,7 @@ export const setFontRange = defineTool({
export const setTextResize = defineTool({
name: 'set_text_resize',
mutates: true,
description: 'Set text auto-resize mode.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -405,6 +419,7 @@ export const setTextResize = defineTool({
export const setVisible = defineTool({
name: 'set_visible',
mutates: true,
description: 'Set visibility of a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -420,6 +435,7 @@ export const setVisible = defineTool({
export const setBlend = defineTool({
name: 'set_blend',
mutates: true,
description: 'Set blend mode of a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -457,6 +473,7 @@ export const setBlend = defineTool({
export const setLocked = defineTool({
name: 'set_locked',
mutates: true,
description: 'Set locked state of a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -472,6 +489,7 @@ export const setLocked = defineTool({
export const setStrokeAlign = defineTool({
name: 'set_stroke_align',
mutates: true,
description: 'Set stroke alignment of a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -492,6 +510,7 @@ export const setStrokeAlign = defineTool({
export const setTextProperties = defineTool({
name: 'set_text_properties',
mutates: true,
description:
'Set text layout properties: alignment, auto-resize, text case, decoration, truncation.',
params: {
@ -544,6 +563,7 @@ export const setTextProperties = defineTool({
export const setLayoutChild = defineTool({
name: 'set_layout_child',
mutates: true,
description:
'Configure auto-layout child: sizing (FIXED/HUG/FILL), grow, alignment, absolute positioning.',
params: {

View file

@ -116,6 +116,7 @@ export const listPages = defineTool({
export const switchPage = defineTool({
name: 'switch_page',
mutates: true,
description: 'Switch to a different page by name or ID.',
params: {
page: { type: 'string', description: 'Page name or ID', required: true }
@ -161,6 +162,7 @@ export const pageBounds = defineTool({
export const selectNodes = defineTool({
name: 'select_nodes',
mutates: true,
description: 'Select one or more nodes by ID.',
params: {
ids: { type: 'string[]', description: 'Node IDs to select', required: true }

View file

@ -23,6 +23,7 @@ export interface ParamDef {
export interface ToolDef {
name: string
description: string
mutates?: boolean
params: Record<string, ParamDef>
execute: (figma: FigmaAPI, args: Record<string, any>) => unknown
}
@ -48,6 +49,7 @@ type ResolvedParams<P extends Record<string, ParamDef>> = {
export function defineTool<P extends Record<string, ParamDef>>(def: {
name: string
description: string
mutates?: boolean
params: P
execute: (figma: FigmaAPI, args: ResolvedParams<P>) => unknown
}): ToolDef {

View file

@ -4,6 +4,7 @@ import type { FigmaNodeProxy } from '../figma-api'
export const deleteNode = defineTool({
name: 'delete_node',
mutates: true,
description: 'Delete a node by ID.',
params: {
id: { type: 'string', description: 'Node ID to delete', required: true }
@ -18,6 +19,7 @@ export const deleteNode = defineTool({
export const cloneNode = defineTool({
name: 'clone_node',
mutates: true,
description: 'Clone (duplicate) a node.',
params: {
id: { type: 'string', description: 'Node ID to clone', required: true }
@ -32,6 +34,7 @@ export const cloneNode = defineTool({
export const renameNode = defineTool({
name: 'rename_node',
mutates: true,
description: 'Rename a node in the layers panel.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -47,6 +50,7 @@ export const renameNode = defineTool({
export const reparentNode = defineTool({
name: 'reparent_node',
mutates: true,
description: 'Move a node into a different parent.',
params: {
id: { type: 'string', description: 'Node ID to move', required: true },
@ -64,6 +68,7 @@ export const reparentNode = defineTool({
export const groupNodes = defineTool({
name: 'group_nodes',
mutates: true,
description: 'Group selected nodes.',
params: {
ids: { type: 'string[]', description: 'Node IDs to group', required: true }
@ -81,6 +86,7 @@ export const groupNodes = defineTool({
export const ungroupNode = defineTool({
name: 'ungroup_node',
mutates: true,
description: 'Ungroup a group node.',
params: {
id: { type: 'string', description: 'Group node ID', required: true }
@ -95,6 +101,7 @@ export const ungroupNode = defineTool({
export const flattenNodes = defineTool({
name: 'flatten_nodes',
mutates: true,
description: 'Flatten nodes into a single vector.',
params: {
ids: { type: 'string[]', description: 'Node IDs to flatten', required: true }
@ -107,6 +114,7 @@ export const flattenNodes = defineTool({
export const nodeToComponent = defineTool({
name: 'node_to_component',
mutates: true,
description: 'Convert one or more frames/groups into components.',
params: {
ids: { type: 'string[]', description: 'Node IDs to convert', required: true }
@ -138,6 +146,7 @@ export const nodeBounds = defineTool({
export const nodeMove = defineTool({
name: 'node_move',
mutates: true,
description: 'Move a node to new coordinates.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -155,6 +164,7 @@ export const nodeMove = defineTool({
export const nodeResize = defineTool({
name: 'node_resize',
mutates: true,
description: 'Resize a node.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -244,6 +254,7 @@ export const nodeBindings = defineTool({
export const nodeReplaceWith = defineTool({
name: 'node_replace_with',
mutates: true,
description: 'Replace a node with JSX content.',
params: {
id: { type: 'string', description: 'Node ID to replace', required: true },
@ -264,6 +275,7 @@ export const nodeReplaceWith = defineTool({
export const arrangeNodes = defineTool({
name: 'arrange',
mutates: true,
description:
'Arrange top-level nodes on the canvas in a grid, row, or column layout. Useful after batch creation to tidy up overlapping frames.',
params: {

View file

@ -61,6 +61,7 @@ export const findVariables = defineTool({
export const createVariable = defineTool({
name: 'create_variable',
mutates: true,
description: 'Create a new variable in a collection.',
params: {
name: { type: 'string', description: 'Variable name', required: true },
@ -93,6 +94,7 @@ export const createVariable = defineTool({
export const setVariable = defineTool({
name: 'set_variable',
mutates: true,
description: 'Set the value of a variable for a specific mode.',
params: {
id: { type: 'string', description: 'Variable ID', required: true },
@ -118,6 +120,7 @@ export const setVariable = defineTool({
export const deleteVariable = defineTool({
name: 'delete_variable',
mutates: true,
description: 'Delete a variable.',
params: {
id: { type: 'string', description: 'Variable ID', required: true }
@ -130,6 +133,7 @@ export const deleteVariable = defineTool({
export const bindVariable = defineTool({
name: 'bind_variable',
mutates: true,
description: 'Bind a variable to a node property (fills, strokes, opacity, width, height, etc.).',
params: {
node_id: { type: 'string', description: 'Node ID', required: true },
@ -165,6 +169,7 @@ export const getCollection = defineTool({
export const createCollection = defineTool({
name: 'create_collection',
mutates: true,
description: 'Create a new variable collection.',
params: {
name: { type: 'string', description: 'Collection name', required: true }
@ -176,6 +181,7 @@ export const createCollection = defineTool({
export const deleteCollection = defineTool({
name: 'delete_collection',
mutates: true,
description: 'Delete a variable collection and all its variables.',
params: {
id: { type: 'string', description: 'Collection ID', required: true }

View file

@ -2,6 +2,7 @@ import { defineTool, nodeSummary } from './schema'
export const booleanUnion = defineTool({
name: 'boolean_union',
mutates: true,
description: 'Union (combine) multiple nodes.',
params: {
ids: { type: 'string[]', description: 'Node IDs to union', required: true }
@ -14,6 +15,7 @@ export const booleanUnion = defineTool({
export const booleanSubtract = defineTool({
name: 'boolean_subtract',
mutates: true,
description: 'Subtract the second node from the first.',
params: {
ids: { type: 'string[]', description: 'Node IDs (first minus rest)', required: true }
@ -26,6 +28,7 @@ export const booleanSubtract = defineTool({
export const booleanIntersect = defineTool({
name: 'boolean_intersect',
mutates: true,
description: 'Intersect multiple nodes.',
params: {
ids: { type: 'string[]', description: 'Node IDs to intersect', required: true }
@ -38,6 +41,7 @@ export const booleanIntersect = defineTool({
export const booleanExclude = defineTool({
name: 'boolean_exclude',
mutates: true,
description: 'Exclude (XOR) multiple nodes.',
params: {
ids: { type: 'string[]', description: 'Node IDs to exclude', required: true }
@ -64,6 +68,7 @@ export const pathGet = defineTool({
export const pathSet = defineTool({
name: 'path_set',
mutates: true,
description: 'Set vector path data on a node. Provide a VectorNetwork JSON.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -80,6 +85,7 @@ export const pathSet = defineTool({
export const pathScale = defineTool({
name: 'path_scale',
mutates: true,
description: 'Scale vector path from center.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -113,6 +119,7 @@ export const pathScale = defineTool({
export const pathFlip = defineTool({
name: 'path_flip',
mutates: true,
description: 'Flip vector path horizontally or vertically.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -151,6 +158,7 @@ export const pathFlip = defineTool({
export const pathMove = defineTool({
name: 'path_move',
mutates: true,
description: 'Move all path points by an offset.',
params: {
id: { type: 'string', description: 'Node ID', required: true },
@ -182,6 +190,7 @@ export const viewportGet = defineTool({
export const viewportSet = defineTool({
name: 'viewport_set',
mutates: true,
description: 'Set viewport position and zoom.',
params: {
x: { type: 'number', description: 'Center X', required: true },
@ -196,6 +205,7 @@ export const viewportSet = defineTool({
export const viewportZoomToFit = defineTool({
name: 'viewport_zoom_to_fit',
mutates: true,
description: 'Zoom viewport to fit specified nodes.',
params: {
ids: { type: 'string[]', description: 'Node IDs to fit in view', required: true }

View file

@ -1,21 +1,22 @@
<script setup lang="ts">
import { useElementSize, useSwipe, useWindowSize } from '@vueuse/core'
import { computed, ref, useTemplateRef } from 'vue'
import { useElementSize, useWindowSize } from '@vueuse/core'
import { motion } from 'motion-v'
import type { PanInfo } from 'motion-v'
import { computed, ref } from 'vue'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
import LayerTree from './LayerTree.vue'
import PagesPanel from './PagesPanel.vue'
import { HALF_FRAC, HUD_TOP, SWIPE_THRESHOLD } from '@/constants'
import { HALF_FRAC, HUD_TOP, SWIPE_THRESHOLD, SWIPE_VELOCITY_THRESHOLD } from '@/constants'
import { useEditorStore } from '@/stores/editor'
type Snap = 'closed' | 'half' | 'full'
const store = useEditorStore()
const drawerRef = useTemplateRef<HTMLElement>('drawer')
const headerRef = useTemplateRef<HTMLElement>('header')
const headerRef = ref<HTMLElement | null>(null)
const { height: headerH } = useElementSize(headerRef, { width: 0, height: 56 })
const { height: windowH } = useWindowSize()
@ -27,36 +28,22 @@ const snap = computed({
}
})
const isLayersActive = computed(
() => store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'
)
const isDesignActive = computed(
() => store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'
)
const isOpen = computed(() => snap.value !== 'closed')
function selectTab(tab: 'panels' | 'code' | 'ai') {
if (store.state.activeRibbonTab === tab && snap.value !== 'closed') {
store.state.activeRibbonTab = null
const isPanelActive = computed(() => isOpen.value && store.state.activeRibbonTab === 'panels')
function selectTab(tab: 'panels' | 'code' | 'ai', panelMode?: 'layers' | 'design') {
const isSameTab =
store.state.activeRibbonTab === tab && (!panelMode || store.state.panelMode === panelMode)
if (isSameTab && isOpen.value) {
snap.value = 'closed'
return
}
store.state.activeRibbonTab = tab
if (snap.value === 'closed') snap.value = 'half'
}
function selectPanel(mode: 'layers' | 'design') {
if (
store.state.activeRibbonTab === 'panels' &&
store.state.panelMode === mode &&
snap.value !== 'closed'
) {
store.state.activeRibbonTab = null
snap.value = 'closed'
return
}
store.state.activeRibbonTab = 'panels'
store.state.panelMode = mode
if (snap.value === 'closed') snap.value = 'half'
if (panelMode) store.state.panelMode = panelMode
if (!isOpen.value) snap.value = 'half'
}
function snapHeight(s: Snap): number {
@ -70,66 +57,58 @@ function snapHeight(s: Snap): number {
}
}
function swipeUp() {
if (snap.value === 'closed') {
if (!store.state.activeRibbonTab) store.state.activeRibbonTab = 'panels'
snap.value = 'half'
} else {
snap.value = 'full'
}
}
function swipeDown() {
if (snap.value === 'full') {
snap.value = 'half'
} else {
snap.value = 'closed'
store.state.activeRibbonTab = null
}
}
const dragging = ref(false)
const dragOffset = ref(0)
const drawerSwipe = useSwipe(drawerRef, {
threshold: SWIPE_THRESHOLD,
onSwipe() {
dragOffset.value = Math.max(0, -drawerSwipe.lengthY.value)
},
onSwipeEnd(_e, direction) {
if (direction === 'up') swipeUp()
else if (direction === 'down') swipeDown()
requestAnimationFrame(() => {
dragOffset.value = 0
})
function onPanStart() {
dragging.value = true
}
function onPan(_e: PointerEvent, info: PanInfo) {
const maxHeight = snapHeight('full')
const raw = snapHeight(snap.value) - info.offset.y
dragOffset.value = snapHeight(snap.value) - Math.max(headerH.value, Math.min(maxHeight, raw))
}
function onPanEnd(_e: PointerEvent, info: PanInfo) {
dragging.value = false
dragOffset.value = 0
const isSwipeUp = info.offset.y < -SWIPE_THRESHOLD || info.velocity.y < -SWIPE_VELOCITY_THRESHOLD
const isSwipeDown = info.offset.y > SWIPE_THRESHOLD || info.velocity.y > SWIPE_VELOCITY_THRESHOLD
if (isSwipeUp) {
if (snap.value === 'closed') snap.value = 'half'
else snap.value = 'full'
} else if (isSwipeDown) {
if (snap.value === 'full') snap.value = 'half'
else snap.value = 'closed'
}
}
const drawerHeight = computed(() => {
const base = snapHeight(snap.value)
return Math.max(headerH.value, base - dragOffset.value)
})
const drawerTransform = computed(() => {
if (drawerSwipe.isSwiping.value && dragOffset.value > 0) {
return `translateY(${dragOffset.value}px)`
}
return 'translateY(0)'
})
const springTransition = { type: 'spring' as const, damping: 30, stiffness: 300 }
const immediateTransition = { duration: 0 }
const drawerHeight = computed(() => `${snapHeight(snap.value)}px`)
const isOpen = computed(() => snap.value !== 'closed')
const drawerTransition = computed(() => (dragging.value ? immediateTransition : springTransition))
</script>
<template>
<div
ref="drawer"
<motion.div
data-test-id="mobile-drawer"
class="fixed inset-x-0 bottom-0 z-30 flex flex-col rounded-t-3xl bg-panel shadow-[0_-2px_10px_rgba(0,0,0,0.3)] pb-[env(safe-area-inset-bottom)]"
:class="drawerSwipe.isSwiping.value ? '' : 'transition-[height] duration-300 ease-out'"
:style="{
height: `calc(${drawerHeight} + env(safe-area-inset-bottom))`,
transform: drawerTransform
}"
class="fixed inset-x-0 bottom-0 z-30 flex touch-none flex-col rounded-t-3xl bg-panel shadow-[0_-2px_10px_rgba(0,0,0,0.3)] pb-[env(safe-area-inset-bottom)]"
:animate="{ height: `${drawerHeight}px` }"
:transition="drawerTransition"
@panStart="onPanStart"
@pan="onPan"
@panEnd="onPanEnd"
>
<!-- Header: grab handle + tabs (always visible) -->
<nav
ref="header"
ref="headerRef"
aria-label="Mobile panel navigation"
class="flex shrink-0 flex-col"
role="tablist"
@ -141,11 +120,13 @@ const isOpen = computed(() => snap.value !== 'closed')
<div
role="tab"
data-test-id="mobile-ribbon-layers"
:aria-selected="isLayersActive"
:aria-selected="isPanelActive && store.state.panelMode === 'layers'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
:class="isLayersActive ? 'text-accent' : 'text-muted'"
@click="selectPanel('layers')"
:class="
isPanelActive && store.state.panelMode === 'layers' ? 'text-accent' : 'text-muted'
"
@click="selectTab('panels', 'layers')"
>
<icon-lucide-layers class="size-4" />
</div>
@ -153,11 +134,13 @@ const isOpen = computed(() => snap.value !== 'closed')
<div
role="tab"
data-test-id="mobile-ribbon-design"
:aria-selected="isDesignActive"
:aria-selected="isPanelActive && store.state.panelMode === 'design'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center gap-1.5 px-4 text-xs outline-none transition-colors select-none"
:class="isDesignActive ? 'text-accent' : 'text-muted'"
@click="selectPanel('design')"
:class="
isPanelActive && store.state.panelMode === 'design' ? 'text-accent' : 'text-muted'
"
@click="selectTab('panels', 'design')"
>
<icon-lucide-sliders-horizontal class="size-4" />
</div>
@ -167,10 +150,10 @@ const isOpen = computed(() => snap.value !== 'closed')
<div
role="tab"
data-test-id="mobile-ribbon-code"
:aria-selected="store.state.activeRibbonTab === 'code'"
:aria-selected="isOpen && store.state.activeRibbonTab === 'code'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
:class="store.state.activeRibbonTab === 'code' ? 'text-accent' : 'text-muted'"
:class="isOpen && store.state.activeRibbonTab === 'code' ? 'text-accent' : 'text-muted'"
@click="selectTab('code')"
>
<icon-lucide-code class="size-4" />
@ -179,10 +162,10 @@ const isOpen = computed(() => snap.value !== 'closed')
<div
role="tab"
data-test-id="mobile-ribbon-ai"
:aria-selected="store.state.activeRibbonTab === 'ai'"
:aria-selected="isOpen && store.state.activeRibbonTab === 'ai'"
tabindex="0"
class="flex h-full cursor-pointer items-center justify-center px-3 outline-none transition-colors select-none"
:class="store.state.activeRibbonTab === 'ai' ? 'text-accent' : 'text-muted'"
:class="isOpen && store.state.activeRibbonTab === 'ai' ? 'text-accent' : 'text-muted'"
@click="selectTab('ai')"
>
<icon-lucide-sparkles class="size-4" />
@ -190,51 +173,43 @@ const isOpen = computed(() => snap.value !== 'closed')
</div>
</nav>
<!-- Content (only when open) -->
<template v-if="isOpen">
<div data-test-id="mobile-drawer-content" class="min-h-0 flex-1 overflow-y-auto">
<div
data-test-id="mobile-drawer-content"
class="min-h-0 flex-1 overflow-y-auto"
@touchstart.stop
@touchmove.stop
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'"
data-test-id="mobile-drawer-layers"
class="flex h-full flex-col"
>
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'layers'"
data-test-id="mobile-drawer-layers"
class="flex h-full flex-col"
>
<PagesPanel />
<div class="border-t border-border" />
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
Layers
</header>
<LayerTree class="min-h-0 flex-1" />
</div>
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'"
data-test-id="mobile-drawer-design"
class="flex h-full flex-col"
>
<DesignPanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'code'"
data-test-id="mobile-drawer-code"
class="flex h-full flex-col"
>
<CodePanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'ai'"
data-test-id="mobile-drawer-ai"
class="flex h-full flex-col"
>
<ChatPanel />
</div>
<PagesPanel />
<div class="border-t border-border" />
<header class="shrink-0 px-3 py-2 text-[11px] uppercase tracking-wider text-muted">
Layers
</header>
<LayerTree class="min-h-0 flex-1" />
</div>
</template>
</div>
<div
v-show="store.state.activeRibbonTab === 'panels' && store.state.panelMode === 'design'"
data-test-id="mobile-drawer-design"
class="flex h-full flex-col"
>
<DesignPanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'code'"
data-test-id="mobile-drawer-code"
class="flex h-full flex-col"
>
<CodePanel />
</div>
<div
v-show="store.state.activeRibbonTab === 'ai'"
data-test-id="mobile-drawer-ai"
class="flex h-full flex-col"
>
<ChatPanel />
</div>
</div>
</motion.div>
</template>

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { ref, computed } from 'vue'
import {
DropdownMenuRoot,
DropdownMenuTrigger,
@ -7,7 +7,8 @@ import {
DropdownMenuItem,
DropdownMenuPortal
} from 'reka-ui'
import { useBreakpoints, useWindowSize } from '@vueuse/core'
import { useBreakpoints } from '@vueuse/core'
import { AnimatePresence, motion } from 'motion-v'
import IconChevronDown from '~icons/lucide/chevron-down'
import IconChevronLeft from '~icons/lucide/chevron-left'
@ -32,7 +33,6 @@ import type { Tool } from '@/stores/editor'
const store = useEditorStore()
const breakpoints = useBreakpoints({ mobile: 768 })
const { width: viewportW } = useWindowSize()
const isMobile = breakpoints.smaller('mobile')
const toolLabels: Record<Tool, string> = {
@ -100,13 +100,6 @@ const mobileCategory = ref(0)
const hasPrev = computed(() => mobileCategory.value > 0)
const hasNext = computed(() => mobileCategory.value < CATEGORY_COUNT - 1)
function prevCategory() {
if (hasPrev.value) mobileCategory.value--
}
function nextCategory() {
if (hasNext.value) mobileCategory.value++
}
let toastTimer: ReturnType<typeof setTimeout> | undefined
function onActionTap(item: ActionItem) {
@ -118,32 +111,25 @@ function onActionTap(item: ActionItem) {
}, ACTION_TOAST_DURATION)
}
const cat0Ref = ref<HTMLElement | null>(null)
const cat1Ref = ref<HTMLElement | null>(null)
const cat2Ref = ref<HTMLElement | null>(null)
const catRefs = [cat0Ref, cat1Ref, cat2Ref]
const slideDirection = ref(1)
const wrapperW = ref(0)
const wrapperH = ref(0)
const measured = ref(false)
function measure() {
const el = catRefs[mobileCategory.value]?.value
if (el) {
const maxPillW = viewportW.value - 80
wrapperW.value = Math.min(el.scrollWidth, maxPillW)
wrapperH.value = el.scrollHeight
measured.value = true
}
const slideVariants = {
initial: (dir: number) => ({ opacity: 0, x: dir * 20 }),
animate: { opacity: 1, x: 0 },
exit: (dir: number) => ({ opacity: 0, x: dir * -20 })
}
onMounted(() => {
nextTick(measure)
})
function goPrev() {
if (!hasPrev.value) return
slideDirection.value = -1
mobileCategory.value--
}
watch([mobileCategory, viewportW], () => {
nextTick(measure)
})
function goNext() {
if (!hasNext.value) return
slideDirection.value = 1
mobileCategory.value++
}
</script>
<template>
@ -240,153 +226,164 @@ watch([mobileCategory, viewportW], () => {
maxWidth: 'calc(100vw - 2rem)',
bottom: `calc(56px + env(safe-area-inset-bottom) + 0.75rem)`
}"
@touchstart.stop
>
<button
<motion.button
data-test-id="mobile-toolbar-prev"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm transition-opacity select-none"
:class="hasPrev ? 'text-muted opacity-100' : 'pointer-events-none opacity-0'"
@click="prevCategory"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm select-none"
:class="hasPrev ? 'text-muted' : 'pointer-events-none'"
:animate="{ opacity: hasPrev ? 1 : 0 }"
:transition="{ duration: 0.15 }"
@click="goPrev"
>
<IconChevronLeft class="size-3.5" />
</button>
</motion.button>
<div
<motion.div
layout
data-test-id="mobile-toolbar-container"
class="relative flex h-11 items-center overflow-hidden rounded-[8px] border border-border bg-panel px-2 shadow-lg"
:style="measured ? { width: wrapperW + 16 + 'px', transition: 'width 250ms ease' } : {}"
:transition="{ layout: { type: 'spring', damping: 30, stiffness: 500 } }"
>
<div
ref="cat0Ref"
data-test-id="mobile-toolbar-tools"
class="flex gap-0.5 transition-opacity duration-200"
:class="
mobileCategory === 0
? 'relative opacity-100'
: 'absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-0'
"
>
<template v-for="tool in TOOLS" :key="tool.key">
<div v-if="tool.flyout && tool.flyout.length > 1" class="flex items-center">
<AnimatePresence mode="popLayout" :custom="slideDirection">
<motion.div
v-if="mobileCategory === 0"
key="tools"
data-test-id="mobile-toolbar-tools"
class="flex gap-0.5"
:variants="slideVariants"
initial="initial"
animate="animate"
exit="exit"
:transition="{ duration: 0.15 }"
>
<template v-for="tool in TOOLS" :key="tool.key">
<div v-if="tool.flyout && tool.flyout.length > 1" class="flex items-center">
<button
:data-test-id="`mobile-toolbar-tool-${activeKeyForTool(tool).toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool)
? 'bg-accent text-white'
: 'bg-transparent text-muted active:bg-hover'
"
@click="store.setTool(activeKeyForTool(tool))"
>
<component :is="toolIcons[activeKeyForTool(tool)]" class="size-4" />
</button>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:data-test-id="`mobile-toolbar-flyout-${tool.key.toLowerCase()}`"
class="flex h-8 w-3 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool)
? 'bg-accent text-white'
: 'bg-transparent text-muted active:bg-hover'
"
>
<IconChevronDown class="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`mobile-toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'
: 'text-surface hover:bg-hover'
"
@select="store.setTool(sub)"
>
<component :is="toolIcons[sub]" class="size-3.5" />
<span class="flex-1">{{ toolLabels[sub] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<button
:data-test-id="`mobile-toolbar-tool-${activeKeyForTool(tool).toLowerCase()}`"
v-else
:data-test-id="`mobile-toolbar-tool-${tool.key.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool)
? 'bg-accent text-white'
: 'bg-transparent text-muted active:bg-hover'
"
@click="store.setTool(activeKeyForTool(tool))"
@click="store.setTool(tool.key)"
>
<component :is="toolIcons[activeKeyForTool(tool)]" class="size-4" />
<component :is="toolIcons[tool.key]" class="size-4" />
</button>
</template>
</motion.div>
<DropdownMenuRoot>
<DropdownMenuTrigger as-child>
<button
:data-test-id="`mobile-toolbar-flyout-${tool.key.toLowerCase()}`"
class="flex h-8 w-3 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool)
? 'bg-accent text-white'
: 'bg-transparent text-muted active:bg-hover'
"
>
<IconChevronDown class="size-2.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="top"
:side-offset="8"
align="start"
class="min-w-32 rounded-lg border border-border bg-panel p-1 shadow-lg"
>
<DropdownMenuItem
v-for="sub in tool.flyout"
:key="sub"
:data-test-id="`mobile-toolbar-flyout-item-${sub.toLowerCase()}`"
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs outline-none transition-colors"
:class="
store.state.activeTool === sub
? 'bg-accent text-white'
: 'text-surface hover:bg-hover'
"
@select="store.setTool(sub)"
>
<component :is="toolIcons[sub]" class="size-3.5" />
<span class="flex-1">{{ toolLabels[sub] }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<motion.div
v-else-if="mobileCategory === 1"
key="edit"
data-test-id="mobile-toolbar-edit"
class="flex gap-0.5"
:variants="slideVariants"
initial="initial"
animate="animate"
exit="exit"
:transition="{ duration: 0.15 }"
>
<button
v-else
:data-test-id="`mobile-toolbar-tool-${tool.key.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none transition-colors select-none"
:class="
isActive(tool) ? 'bg-accent text-white' : 'bg-transparent text-muted active:bg-hover'
"
@click="store.setTool(tool.key)"
v-for="item in editActions"
:key="item.label"
:data-test-id="`mobile-toolbar-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
@click="onActionTap(item)"
>
<component :is="toolIcons[tool.key]" class="size-4" />
<component :is="item.icon" class="size-4" />
</button>
</template>
</div>
</motion.div>
<div
ref="cat1Ref"
data-test-id="mobile-toolbar-edit"
class="flex gap-0.5 transition-opacity duration-200"
:class="
mobileCategory === 1
? 'relative opacity-100'
: 'absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-0'
"
>
<button
v-for="item in editActions"
:key="item.label"
:data-test-id="`mobile-toolbar-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
@click="onActionTap(item)"
<motion.div
v-else
key="arrange"
data-test-id="mobile-toolbar-arrange"
class="flex gap-0.5"
:variants="slideVariants"
initial="initial"
animate="animate"
exit="exit"
:transition="{ duration: 0.15 }"
>
<component :is="item.icon" class="size-4" />
</button>
</div>
<button
v-for="item in arrangeActions"
:key="item.label"
:data-test-id="`mobile-toolbar-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
@click="onActionTap(item)"
>
<component :is="item.icon" class="size-4" />
</button>
</motion.div>
</AnimatePresence>
</motion.div>
<div
ref="cat2Ref"
data-test-id="mobile-toolbar-arrange"
class="flex gap-0.5 transition-opacity duration-200"
:class="
mobileCategory === 2
? 'relative opacity-100'
: 'absolute left-2 top-1/2 -translate-y-1/2 pointer-events-none opacity-0'
"
>
<button
v-for="item in arrangeActions"
:key="item.label"
:data-test-id="`mobile-toolbar-${item.label.toLowerCase()}`"
class="flex size-8 cursor-pointer items-center justify-center rounded-[6px] border-none bg-transparent text-muted transition-colors select-none active:bg-hover active:text-surface"
@click="onActionTap(item)"
>
<component :is="item.icon" class="size-4" />
</button>
</div>
</div>
<button
<motion.button
data-test-id="mobile-toolbar-next"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm transition-opacity select-none"
:class="hasNext ? 'text-muted opacity-100' : 'pointer-events-none opacity-0'"
@click="nextCategory"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-panel shadow-sm select-none"
:class="hasNext ? 'text-muted' : 'pointer-events-none'"
:animate="{ opacity: hasNext ? 1 : 0 }"
:transition="{ duration: 0.15 }"
@click="goNext"
>
<IconChevronRight class="size-3.5" />
</button>
</motion.button>
</div>
</template>

View file

@ -106,6 +106,7 @@ export const HALF_FRAC = 3 / 7
export const HUD_TOP = 12 + 32 + 6 + 32 + 12
export const SWIPE_THRESHOLD = 30
export const SWIPE_VELOCITY_THRESHOLD = 500
export const ACTION_TOAST_DURATION = 800
export const DRAG_DEAD_ZONE = 4

View file

@ -191,7 +191,7 @@ export function createEditorStore() {
renderVersion: 0,
sceneVersion: 0,
loading: false,
activeRibbonTab: null as 'panels' | 'code' | 'ai' | null,
activeRibbonTab: 'panels' as 'panels' | 'code' | 'ai',
panelMode: 'design' as 'layers' | 'design',
actionToast: null as string | null,
mobileDrawerSnap: 'closed' as 'closed' | 'half' | 'full',