Moved the inline pixel asset details (size, usage count, Edit Pixels button) from PropertiesPanel into a new PixelAssetSection.tsx, matching the pattern used by ImageAssetSection and DimensionAssetSection.
Added a local PanelShell component that renders the common panel → header → shapeType + shapeName wrapper. All 8 branches of the panelSelection switch now use it, eliminating the repeated div/span structure.
Replaced 10 separate selected* / documentSelected fields in AppState with a single panelSelection: PanelSelection discriminated union field.
src/store/types.ts: AddedLibraryItemTypeandPanelSelectiontypes; removed the old fields fromAppStatesrc/store/reducer.ts: Updated initial state and all reducer cases (SELECT_*,DESELECT_*, allADD_LIBRARY_*/DELETE_LIBRARY_*cases) to use the unionsrc/components/properties/PropertiesPanel.tsx: Replaced the 8-branch if-waterfall with aswitch (sel.kind)statementsrc/components/tree/TreePanel.tsx: Derives per-sectionselected*props frompanelSelectionat the point of usesrc/components/tree/RichTextStyleSetsSection.tsx: Updated two direct state reads to usepanelSelection
The shape and component dropdowns were hidden behind the canvas when the browser window was narrow. Root cause: the @media (max-width: 640px) rule on the toolbar sets overflow-x: auto, which browsers coerce to overflow: auto on both axes, clipping position: absolute children that extend below the toolbar row.
Fix: the dropdown button now measures its getBoundingClientRect() on open and the menu renders with position: fixed at those coordinates, escaping all overflow containers. Applied to both the shapes dropdown and the components dropdown in Toolbar.tsx.
Six targeted improvements to make the app usable for basic editing on mobile/touch devices.
1. Safe-area insets (index.html, AppShell.module.css)
- Added
viewport-fit=coverto the viewport meta tag - Toolbar padding-top uses
env(safe-area-inset-top)so content clears the notch on iPhones with Dynamic Island / Face ID notch
2. iOS input auto-zoom fix (inputs.module.css)
- iOS Safari zooms the viewport when an input has
font-size < 16px. Added@media (hover: none) and (pointer: coarse)rule settingfont-size: 16pxonnumberInput,textInput,select, andcontentTextarea
3. Larger toolbar touch targets (Toolbar.module.css)
- Added
@media (hover: none) and (pointer: coarse)rule: all.btnbuttons getmin-height: 44px; min-width: 44px(Apple/Google recommended minimum).zoomSelectalso bumped to 44px and 16px font
4. Larger selection handle hit areas (SelectionOverlay.tsx)
- Module-level
IS_TOUCHconstant detects coarse pointer devices once at load - On touch, each resize handle renders a transparent 44px hit zone wrapper with the 8px visual dot centered inside; desktop behaviour unchanged
5. Long-press context menu (useCanvasPointer.ts)
- On
pointerType === 'touch', a 500mssetTimeoutis started onpointerdown - Cancelled on move > 8px or
pointerup; also cancelled immediately if a second pointer goes down (handles pinch-zoom cancellation) - On fire: performs hit-test, selects the tapped shape, and opens the same
CanvasContextMenuused by right-click
6. Mobile bottom bar (MobileBar.tsx, MobileBar.module.css, AppShell.tsx)
- New
<MobileBar>component: only visible on(hover: none) and (pointer: coarse)devices via CSS, only renders whenselection.ids.length > 0 - Fixed bar at bottom (respects
env(safe-area-inset-bottom)), 56px tall, frosted-glass background - Buttons: Layers (toggle left panel), Properties (toggle right panel), Duplicate, Delete
The Font Family field in the StyleSetEditor now lists fonts added to the document (from the Assets panel) at the top of the dropdown, followed by the built-in common fonts. Duplicates are suppressed.
StyleSetEditor: readsstate.document.customFontsviauseAppState()and passes names ascustomFontsprop toStyleElementEditorStyleElementEditor: addedcustomFonts?: string[]prop; merges document fonts first, then common fonts (deduped)
Selecting a rich text style in the assets panel or library now shows its prop sheet in the right-side Properties panel, matching the pattern of gradients and fonts.
AppState: addedselectedRichTextStyleSetIdandselectedRichTextStyleSetSource('document'|'library')ViewAction: addedSELECT_RICH_TEXT_STYLE_SETandDESELECT_RICH_TEXT_STYLE_SETactions; reducer clears all other selection state- New
RichTextStyleSetSectioncomponent (src/components/properties/sections/RichTextStyleSetSection.tsx): editable name field (document only), 0.6× scaled preview of H1/H2/body text, "Edit Style Set…" / "Save to Library" for document styles, "Add to Document" / "Delete from Library" for library styles PropertiesPanel: handlesselectedRichTextStyleSetIdstate — looks up the styleset from document powerup settings or library and rendersRichTextStyleSetSectionRichTextStyleSetsSectionandLibrarySection: dispatchSELECT_RICH_TEXT_STYLE_SETon row click; selection highlight driven by Redux stateRichTextStylePropSheet(inline card) is no longer used in tree views — kept for possible future reuse
Shapes dropdown / right-click menu (src/utils/shapeMenuGroups.tsx, src/components/toolbar/Toolbar.tsx)
buildAddShapeGroupsnow includes registry shapes withcategory === 'shapes'in the Shapes submenu- Toolbar shapes dropdown also includes registry
'shapes'category entries alongside hardcoded SHAPE_TOOLS
CSS scoping fix (src/powerups/richText/styleSetToCSS.ts, RichTextRenderer.tsx)
styleSetToCSSnow takes ascopeClassparameter; each shape uses.rt-${shape.id}, the editor preview uses.rt-preview— prevents one shape's stylesheet from bleeding into other rich-text shapes on the same canvas
StyleSetEditor duplicate fix (src/powerups/richText/StyleSetEditor.tsx)
- Added
activeIdlocal state initialised fromstyleSetIdprop;handleDuplicatenow switchesactiveIdto the copy's id and enters rename mode immediately
Page assets panel (src/components/tree/RichTextStyleSetsSection.tsx, src/components/tree/TreePanel.tsx)
- New
RichTextStyleSetsSectioncomponent showing document-level rich-text stylesets with H1/body color swatches; right-click context menu (Edit, Duplicate, Save to Library, Delete) - Mounted in
TreePanelafterSketchStylesSection; returnsnullwhen the Rich Text PowerUp is not installed
Added a new built-in PowerUp that contributes a rich-text shape type supporting Markdown content rendered with per-element styled typography.
Shape model (src/model/shapes.ts)
- New
RichTextShapeinterface:content(markdown string),styleSetId,padding, optionalbackgroundColor - Added to the
Shapeunion type
PowerUp core (src/powerups/richText/)
types.ts:RichTextStyleEntry,RichTextStyleSet,StyleKey,RichTextDocumentSettingsdefaultStyleSet.ts: clean system-ui default styleset (body/h1–h3/blockquote/code/codeBlock/link)styleSetToCSS.ts: converts aRichTextStyleSetinto scoped CSS for.rt-contentRichTextRenderer.tsx: canvas renderer usingposition:absolutediv +dangerouslySetInnerHTMLfrommarked; edit mode shows a textarea overlay viauseTextEditRichTextPropsRenderer.tsx: properties panel with markdown textarea, styleset selector, background fill toggle/color picker, and "Edit Style Set…" / "New Style Set" buttonsrichTextPowerUp.ts:PowerUpDefinitionthat registers/unregisters the shape type on load/unload
StyleSet editor (Phase 2)
StyleSetEditor.tsx: draggable modal (portal) with element list, per-element style form, live preview, Rename/Duplicate/Save-to-Library actionsStyleElementEditor.tsx: font family (with datalist), size, weight, italic, color, line height, letter spacing controls using existing inputsStyleSetEditor.module.css: two-column layout
Library integration (Phase 3)
src/model/library.ts: added optionalrichTextStyleSets?: RichTextStyleSet[]src/store/types.ts:ADD_RICH_TEXT_STYLE_SET_TO_LIBRARY,REMOVE_RICH_TEXT_STYLE_SET_FROM_LIBRARY,ADD_RICH_TEXT_STYLE_SET_TO_DOCUMENTactionssrc/store/reducer.ts: handles all three actions; "add to document" appends a copy with a fresh id to the PowerUp settingsLibrarySection.tsx: "Rich Text Styles" subsection with color swatches, Add to Document, and delete buttons
Dependency: marked (markdown parser, no transitive deps)
Toolbar.tsximportsuseActionRegistryand computesuniqueRegistryToolbarActions: all registry actions withsurfaces: ['toolbar']that are not already covered by a legacytoolbarActionsentry (deduped bypowerup.toolbar.${id})- The PowerUp toolbar group now shows legacy toolbar actions (with existing physics stop-icon special case) followed by any unique registry toolbar actions
- The group renders whenever either source has entries, so PowerUps using the new
actionsAPI withsurfaces: ['toolbar']gain toolbar buttons without touchingtoolbarActions
vitest.config.tswas missing the@actionsalias, causing all tests in files that import fromshapeMenuGroups.tsxto fail at the module-resolution stagecore.bring-forwardand⌘]inuseDocumentShortcuts.tswere usingdirection: 'up'(decreases array index = rendered earlier = visually behind), which is the wrong direction; corrected todirection: 'down'(increases index = rendered later = visually in front)core.send-backwardand⌘[corrected todirection: 'up'for the same reason- The
directionnaming convention in the reducer is relative to the tree panel position, not the visual z-order;'down'= higher array index = visually in front
PowerUpDefinitiongains optionalactions?: ActionDefinition[]field — additive, all existing PowerUps unchangedusePowerUpsRuntime.tsregistersdefinition.actionsinto the action registry when a PowerUp is installed and unregisters them when it is unloaded- Physics PowerUp demonstrates the new API: two
actionsentries (simulate, export-html) with tags/description appear in the command palette when Physics is installed; existingtoolbarActions/menuActionsstill work unchanged - New PowerUps can skip
toolbarActions/menuActionsentirely and declareactionswith appropriatesurfacesinstead
- New
src/actions/managers/canvasContextMenuManager.ts:ActionManagerfunction that returns registry actions taggedshape-action+context-menusurface that are currently enabled buildSingleShapeGroupsinshapeMenuGroups.tsxaccepts optionalregistryItems,actionCtx,onClose— when provided, registry-driven items replace the hardcoded Duplicate, z-order, and Delete entries viaactionToContextMenuItem; callers that don't pass these get the original hardcoded items unchangedCanvasContextMenu.tsxwires in the manager — shortcut strings in the context menu now come fromActionDefinition.shortcut, the same source as the command palette
- Added "Command Palette ⌘K" as the first item in the View menu, separated from panel toggles by a divider
registry.ts:getAll()was returning a new array on every call;useSyncExternalStorerequires a stable reference between mutations, causing an infinite re-render loop. Fixed by caching_snapshotat module level and only rebuilding it insidenotify().registerManyalso now notifies once after all registrations rather than once per action.
- Added
src/actions/module withActionDefinitiontype (id, title, description, icon, tags, shortcut, surfaces, run, isEnabled, isDanger) ActionContexttype mirrorsPowerUpActionContext— same shape, no circular depactionRegistrysingleton (mirrorsshapeRegistrypattern) withregister,registerMany,getAll,getById,subscribeuseActionRegistry()hook usinguseSyncExternalStorefor reactive palette readsadapters.tswithadaptPowerUpToolbarAction,adaptPowerUpMenuAction,actionToContextMenuItem— bridges existing PowerUp types without breaking themcoreActions.tsregisters ~15 global actions (undo, redo, select-all, delete, duplicate, group, ungroup, z-order, view toggles, settings)- Added
@actionspath alias tovite.config.tsandtsconfig.json
- Added
showCommandPalette: booleantoAppStatewithOPEN_COMMAND_PALETTE,CLOSE_COMMAND_PALETTE,TOGGLE_COMMAND_PALETTEactions Cmd+Kkeyboard shortcut opens/closes the palette (added touseDocumentShortcuts.ts)- New
CommandPalette.tsxcomponent: search input, keyboard navigation (↑/↓/Enter/Esc), shows all registered actions + PowerUp actions (adapted automatically) - Search matches on title, description, and tags; no external library
- Greyed-out styling for disabled actions; red title for danger actions
- Integrated into
AppShell.tsx
- Text/font properties now only appear in the multi-select panel when every selected shape has text (e.g. selecting rect + text no longer shows font controls)
- Fill and stroke sections are unchanged — they intentionally show when any selected shape has them since nearly all shapes support both
- Added
tests/utils/shapeMenuGroups.test.tsxwith 50 tests covering all four builder functions - Tests verify group structure, item labels, keyboard shortcuts, dispatch actions, disabled states, and callback invocations
- Extracted shared
src/utils/shapeMenuGroups.tsxwith four builder functions:buildAddShapeGroups,buildSingleShapeGroups,buildPageGroups,buildMultiSelectGroups - Canvas and tree right-click menus now use identical action sets for shapes and pages (same items, order, labels, and keyboard shortcuts)
- Page right-click in canvas now shows the full page menu (Set Active, Export HTML, Save as Template, reorder, etc.) instead of only the Add shapes submenu
- Unified "Add shapes" submenu structure: separate Containers, Form Controls, and Mockups submenus in both views
- Tree's
addShapeTonow correctly handles pixelimage type (creates a linked pixel asset) - Reorder labels standardized to "Move Up / Move Down / Bring to Front / Send to Back" across both views
- Added top-level
+buttons to the Assets and Library tree headers - Each header now opens a dropdown menu for creating the section’s item types from one place
- Assets actions include image URL adds, dimension presets, pixel images, custom fonts, gradients, and sketch styles
- Library actions include gradients, image imports, dimension presets, custom fonts, and template creation from the current selection/page
- Gradient fills and strokes now render in fixed SVG user space instead of object-bounding-box space, so rectangular shapes no longer squish the gradient
- Added shared gradient angle bounds/clamping, and negative angles like
-45now stay valid in the property sheet - Repeat/mirror gradients keep using the same editor controls, with the span setting controlling how visible the tiling is
- Fill, stroke, and text gradient pickers in the property sheet now show gradients from both the current document and the library
- Added a shared
mergeUniqueByIdhelper plus amergedGradientswrapper so the doc-first, library-fallback behavior stays consistent
All fixes in src/components/tree/TreeNode.tsx:
- Stale drop zone on drop:
handleDropwas readingdropZonefrom React state (which could be stale by the time the drop event fired). Fixed by adding adropZoneRefwritten synchronously inhandleDragOver/handleDragLeaveand read inhandleDrop. Drops now always land in the correct position. - Drop indicator flickering over children:
onDragOver/onDragLeave/onDropwere on the inner.nodediv; when the mouse moved into the.childrensibling div,dragLeavefired and cleared the indicator. Moved the three handlers to the outer.nodeWrapperdiv so children are correctly seen as "inside" the drag target. - Non-container shapes can't receive children:
handleDropnow validates thatzone === 'into'is only allowed for container shape types (page,frame,panel,scrollpanel,group,tabbed-panel); otherwise demoted to'after'. - Pages can't be dragged inside other pages:
handleDroprejects the drop entirely when dragging a page onto a non-root-level target (parentId !== null). A module-leveldraggingPageFlag(set inhandleDragStart, cleared inhandleDragEnd) letshandleDragOversuppress the drop indicator and skippreventDefaulton invalid targets, so the browser shows the "not allowed" cursor rather than a misleading highlight.
- Template rename: double-click on a shape or page template in the Library panel now triggers inline rename (consistent with gradients/images); place/create actions remain in the right-click context menu only
- Canvas context menu: "Save to Library" now appears in the right-click menu on the canvas when a shape is selected, matching the tree view behavior
- Save shape to library: right-click any shape in the layer tree → "Save to Library" — captures the shape and all descendants as a reusable
ShapeTemplate - Save page as template: right-click a page → "Save as Template" — captures the full page subtree as a
PageTemplate - Place shape template: double-click a shape template in the Library panel → places a fresh copy with new IDs on the active page at (50, 50)
- Create page from template: double-click a page template in the Library panel → creates a new page from the template and makes it active
- Promote document assets to library: right-click context menus on document Images, Gradients, Fonts, and Dimensions now include "Add to Library"
- Library model bumped from v2 to v3;
normalizeLibraryinlibraryStorage.tspopulates empty arrays for new fields when loading older libraries - New library actions:
ADD_LIBRARY_SHAPE_TEMPLATE,DELETE_LIBRARY_SHAPE_TEMPLATE,ADD_LIBRARY_PAGE_TEMPLATE,DELETE_LIBRARY_PAGE_TEMPLATE - New document actions (undo-able):
PLACE_SHAPE_TEMPLATE,PLACE_PAGE_TEMPLATE - Extended
RENAME_LIBRARY_ITEMandSELECT_LIBRARY_ITEMto include'shape-template'and'page-template'item types
- "Assets" and "Library" section headers in the layer tree are now collapsible
- Click the header to toggle; a chevron indicates open/closed state
- Extended
SectionHeaderwith optionalcollapsible/collapsed/onToggleprops; non-collapsible usage unchanged
- Added "Duplicate" to the right-click context menu for page items in the layer tree; duplicated page gets " copy" appended to its name
- Added "Move Up", "Move Down", "Move to Top", "Move to Bottom" to the page context menu for reordering pages
- Both features work on any page, not just slides powerup pages
- Reuses existing
DUPLICATE_SHAPESandREORDER_SHAPEreducer actions
- Added
.limnfile format: a valid PNG (thumbnail of the first page) with the full document JSON embedded in atEXtmetadata chunk - New
src/utils/pngMeta.ts: pure PNG byte manipulation —injectTextChunkandextractTextChunkwith a hand-rolled CRC32 implementation; no DOM dependencies, fully unit-tested - New
src/utils/limnFile.ts:encodeLimnPng,decodeLimnPng,downloadLimnFile,uploadLimnFile— encodes JSON as UTF-8 → base64 in the PNG metadata chunk;decodeLimnPngruns the fullfromJSONmigration pipeline so older documents are auto-upgraded - Added
renderPageToBytestosrc/utils/exportPng.tsx: renders the active page via html2canvas, scales to fit within 1200px on the longest side, and adds a white border with a small "Limn" label in the corner; throws (rather than alerting) when the page has no fixed size - Added "Save as Limn..." and "Open Limn..." to the File menu in
Toolbar.tsx(web build) - Added
menu:save-limnandmenu:open-limnevent handlers inuseTauriMenu.ts(Tauri build) - Added
tauriSaveLimnFile,tauriSaveAsLimnFile,tauriOpenLimnFiletotauriStorage.ts - Document
versionfield (currently 4) is preserved in the embedded JSON; existing migration logic handles upgrading any older.limnfile automatically on open - 14 new unit tests: 8 in
tests/utils/pngMeta.test.ts(chunk injection/extraction, CRC, chunk ordering, unicode, large values) and 6 intests/utils/limnFile.test.ts(round-trip, v3→v4 migration, error cases, unicode preservation)
- Added a shared client-side logging layer on top of
debug, with typed log records, structured payload support, and subsystem namespaces for renderer, importer, exporter, and power-ups - Added a docked logging console that opens from the View menu and native Tauri menu, with level filters, subsystem filters, incremental search, copy/clear controls, and expandable JSON payloads
- The console now resizes vertically via a drag handle above the panel, and the log list scrolls instead of compressing entries when space is tight
- Instrumented representative renderer, import/export, menu, and power-up paths to emit structured logs into the new console
- Page settings now support a unified size picker with built-in presets, document
Dimensionassets, libraryDimensionassets, and custom width/height entry - Added reusable
Dimensioncreation dialog in both the document assets and library sections so both flows look and behave the same - Document and library dimension assets can be selected directly in the page properties panel, and the page resolves to a concrete size for export/layout
- Legacy documents upgrade old fixed-size pages to custom page sizes automatically when loaded
New Powerups → Export Physics HTML... menu item (web + Tauri) exports a self-contained .html file that runs a live Matter.js physics simulation in any browser.
- The exported page embeds all shapes from the active page as absolutely-positioned divs with their fill/stroke/border-radius styles
- Only shapes with physics bodies assigned participate in the simulation; others appear as static visuals
- Uses the document's gravity (X/Y) and solver iteration settings, 100px boundary walls around the page, and the same dt-clamped RAF loop as the in-app runtime
- Users can drag shapes with the mouse (Matter.js
MouseConstraint) and use Pause/Resume/Reset controls - Loads Matter.js 0.20.0 from unpkg CDN (requires internet to open)
- New
src/utils/exportPhysicsHtml.tsreusesfillBackground,strokeColor, andgetAllIdsutilities
- Pinch-to-zoom + two-finger pan: new
usePinchGestureshook attaches native pointer listeners to the canvas, tracks two active pointers, and dispatchesZOOM_TO/PAN_BYwith the touch centroid as zoom origin. UsesqueueMicrotaskto defer flag clearing so React's synthetic handlers don't accidentally commit a drag after a pinch ends. - useCanvasPointer: accepts optional
multiTouchActiveRef; all three pointer handlers (onPointerDown,onPointerMove,onPointerUp) bail out while multi-touch is active to avoid conflating pinch with selection/drag. touch-action: noneadded to the canvas element so iOS/Android don't intercept touch events for native scroll/zoom.- Toolbar responsive collapse: at ≤640px, the File/Edit/View/Powerups menu bar and document name are hidden via CSS media query; toolbar gets
overflow-x: autoas a fallback. - Panel auto-collapse: panels start hidden on viewports narrower than 768px;
max-width: 100%prevents them from overflowing the screen edge when resized.
- Canvas now spans the full browser width; the layer and properties panels float above it as translucent overlays
- Panels use
backdrop-filter: blurand a semi-transparent version of the panel background color - Panel opacity is configurable via Settings → Panels → "Side panel opacity" slider (10–100%, default 92%)
- Panel visibility state moved from local component state into Redux (
leftPanelVisible/rightPanelVisible) - View menu gains "Show/Hide Layer Panel" and "Show/Hide Properties Panel" items (web and Tauri)
- Status bar toggle buttons continue to work unchanged
- Panels remain resizable (150–500 px) via drag handles at their inner edges
- Replaced the single "File" dropdown with four top-level menus: File, Edit, View, and Powerups
- File: New, Open, Save, Save As, Import/Export JSON/PNG/PDF, About
- Edit: Undo, Redo, Edit Palettes, Edit Themes, Settings, Document Settings
- View: Show/Hide Grid, Enable/Disable Snap, Dark/Light Mode toggle
- Powerups: Add/Remove powerups from document, Power Up Actions
- Removed the nested Power Ups submenu; Powerups is now a peer menu in the bar
- Solid border on rect/circle:
svgStrokewas applyingstrokeDasharraywhenever thedasharray was non-empty, but the CSS code only did so whenstroke.type === 'dashed'. The default stroke hastype:'solid'withdash:[5,3](stored for when the user switches to dashed), so the fix is to guard:stroke.type === 'dashed' && dash.length > 0. - Text not multiline:
position:absolute; inset:0inside<foreignObject>doesn't reliably constrain width for text wrapping in all browsers. Fixed by using explicitwidth:${w}px; height:${h}px; box-sizing:border-boxon the root div and textarea inside the foreignObject. - Variable font axes (Honk etc.): Added
transform: translateZ(0)to the text display div whenfontVariationSettingsis present — this forces the browser to create a new GPU compositing layer and re-evaluate font axes, working around a known Chromium bug wherefont-variation-settingsinside SVG<foreignObject>can become stale. - ImageShape missing CSS rotation:
transform: buildCSSTransform(transform)was missing from the outer<svg>element, so rotated/scaled images weren't rotated visually. - ImageShape clipping: Replaced
<clipPath>with a nested<svg x=0 y=0 width={w} height={h} overflow="hidden">which is simpler and correctly clips the image (including when crop is set) to the shape bounds.
Replaced DOM <div> rendering with SVG elements for all core primitive shapes:
New utility files (src/utils/):
fillSVG.tsx— convertsFillStyleto SVGfillattribute + inline<linearGradient>,<radialGradient>, or<pattern>defsstrokeStyleSVG.tsx— convertsStrokeStyleto SVGstrokeattributes + optional gradient defs; includescornerRadiiPath()for per-corner SVG pathshadowSVG.tsx— convertsBoxShadow[]to an SVG<filter>element using<feDropShadow>,<feGaussianBlur>, and<feMerge>for multiple/inset shadowssvgTransform.ts— convertsBoundingBoxrotation/scale/skew to an SVGtransformattribute string
Shape changes:
RectShape.tsx— DOM container div + SVG visual layer (<rect>or<path>for per-corner radii); eliminates the webkit-mask gradient stroke hackCircleShape.tsx— DOM container div + SVG<ellipse>visual layerTextShape.tsx— pure SVG island (<svg>with background<rect>+<foreignObject>containing textarea in edit mode and styled div in display mode)ImageShape.tsx— pure SVG island using<image>+<clipPath>for croppingGroupShape.tsx— DOM container div + SVG visual layer for dashed selection border and optional shadowPageShape.tsx— DOM container div + SVG visual layer with native<feDropShadow>filter
Form/mockup shapes (button, dialog, table, etc.) are unchanged.
- Added
'mockups'as a valid category toPowerUpShapeTypeDefinition. - Added
imagemockandchartmockshape type definitions toformsBuiltIn.tsx(category: 'mockups'), includingcreateDefaultfactories andrenderShapeimplementations. - Removed hardcoded
imagemock/chartmockcases fromShapeRenderer.tsx,shapeFactory.ts, andPropertiesPanel.tsx; they now go through the registry fallthrough. - All three context menus (
CanvasContextMenu,TreeNode,TreePanel) now drive their "Mockups" section from the registry; the section is hidden when the Forms powerup is inactive.
CanvasContextMenu.tsx: removed hardcodedCONTAINER_TYPES/FORM_CONTROLSarrays; "Forms" submenu (containers + form controls + mockups) now only appears when forms shapes are registered; falls back to just "Mockups" when powerup is inactive.TreeNode.tsx: same — "Containers" and "Form Controls" context menu submenus are conditionally rendered from registry.TreePanel.tsx: same — Containers and Form Controls sections in the "+" add menu are hidden when powerup is inactive.
- Added
PowerUpShapeTypeDefinitioninterface tosrc/powerups/types.tswithcreateDefault,renderShape,renderProperties, category, and behavior flags (isTextEditable,isDrillable). - Created
src/powerups/shapeRegistry.ts: a runtime registry that powerups write to on load/unload, with auseShapeRegistry()React hook for reactive updates. - Created
src/powerups/formsBuiltIn.tsx: defines all 18 form shape types (buttons, checkboxes, panels, sliders, etc.) as a "Forms" powerup. - Added "Forms" stub entry to
BUILT_IN_POWER_UPSinbuiltIns.tsxusing a dynamic import in its lifecycle hooks to avoid a circular dependency (CollapsibleSection → @store/context → reducer → registry → builtIns → formsBuiltIn). ShapeRenderer.tsx: removed hardcoded form shape cases and imports; added registry fallthrough in thedefaultcase; updatedTEXT_EDITABLE/DRILLABLEsets to check registry for form shape behaviors.Toolbar.tsx: removed hardcodedFORM_CONTROLS/CONTAINER_CONTROLSarrays; Components dropdown now reads fromuseShapeRegistry()and is hidden entirely when no forms powerup is active.store/types.ts: widenedToolModeto acceptstring & {}for powerup-registered tool modes.useCanvasPointer.ts: replaced staticTOOL_SHAPEmap withgetToolShapeType()that merges core modes with registry entries.shapeFactory.ts: added registry fallthrough increateShape().PropertiesPanel.tsx: removed all form shape cases from the switch; added registry fallthrough indefaultcase; secondary fills (thumbFill,progressFill) handled inline.tests/setup.ts: registers form shapes via dynamic import inbeforeAllto avoid circular in test environment.
- Clicking a document gradient in the Assets tree now selects it and shows an editable detail panel in the properties sheet: name, gradient preview strip, interactive stop bar (drag to reposition, click to add, drag down to delete), per-stop color picker and position input, and Delete Gradient button. Added
selectedGradientIdstate andSELECT_GRADIENTaction. - Fixed properties panel not clearing when switching from a library item to a document shape selection.
- Library fonts now run the same variable-axis metadata detection as document fonts (
useLibraryFontMetadataEnrichment); the library font properties panel shows Name, Type, and Variable Axes matching the document font panel, plus Add to Document and Delete from Library buttons. - Added
UPDATE_LIBRARY_FONTaction so detected font metadata is persisted in the library. - Consolidated library types:
LibraryGradient→GradientDef,LibraryImage→ImageAsset,LibraryFont→CustomFont(no separate library-only types). Addedidfield toCustomFont; serialization migration adds a UUID to existing documents without one. - Added
--color-selectedCSS variable (light:#dbeafe, dark:#1e3554) to complement--color-selected-hover; tree rows use--color-selectedfor selected state. NumberInputShift+arrow now increments bystep × 10, matching wheel behaviour.
Added a global, persistent Library feature — a store of reusable assets (gradients, images, Google Fonts) that lives outside any single document and survives across sessions.
New files:
src/model/library.ts—Library,LibraryGradient,LibraryImage,LibraryFonttypessrc/utils/libraryStorage.ts—loadLibrary/saveLibrarywith localStorage (web) andappDataDir(Tauri)src/components/tree/LibrarySection.tsx— tree panel section with collapsible Gradients / Images / Fonts subsections, context menus, inline rename, file pickersrc/components/tree/LibrarySection.module.csssrc/components/properties/sections/LibraryItemSection.tsx— properties panel for selected library items
Modified files:
src/store/types.ts— addedLibraryAction,AppState.library/selectedLibraryItemId/selectedLibraryItemTypesrc/store/reducer.ts— library action handlers with auto-save; initialState extendedsrc/store/context.tsx— loads library from storage on app mountsrc/components/tree/TreePanel.tsx— renders LibrarySection below Assetssrc/components/properties/PropertiesPanel.tsx— shows LibraryItemSection when library item selectedsrc/components/properties/PropertiesPanel.module.css— added.textInputand.dangerclassessrc/index.css— added--color-selectedvariable (light:#dbeafe, dark:#1e3554)src/components/tree/StyleRow.module.css,DocumentRow.module.css— use--color-selectedinstead of--color-accent-subtle
Created app-icon.svg: a calligraphic "L" in warm cream (#e8dfc8) on a deep indigo rounded square (#1c1b38). Generated all required platform icon sizes via npx tauri icon app-icon.svg, replacing all files in src-tauri/icons/.
Renamed the application from "Vibe 2D Layout" to "Limn" across all config and source files: package.json, tauri.conf.json, Cargo.toml, index.html, Toolbar.tsx (About modal), and README.md. The .vibe2d file extension and vibe2d: localStorage keys are unchanged to preserve compatibility with existing saved documents.
The X/Y/W/H inputs shown when multiple shapes are selected were raw <input type="number"> elements with no arrow key or scroll wheel support. Fixed by:
- Exporting
TFieldfromTransformSection.tsxand widening itsvaluetype tonumber | null(to handle mixed values across selected shapes) - Replacing the raw inputs in
PropertiesPanel.tsxmulti-select path withTField
All numeric inputs in the properties panel now consistently support arrow keys and scroll wheel.
src/components/properties/sections/TransformSection.tsx— exportTField, acceptvalue: number | nullsrc/components/properties/PropertiesPanel.tsx— useTFieldfor multi-select transform
Duplicate (⌘D), Group (⌘G), Ungroup (⌘⇧G), Bring Forward (⌘]), Send Backward (⌘[), and Delete now appear in the native Edit menu in Tauri. Handlers in useTauriMenu.ts read the current selection via stateRef and dispatch the same actions as the keyboard shortcuts, also firing the shortcut indicator.
src-tauri/src/lib.rs— newMenuItementries in the Edit submenusrc/hooks/useTauriMenu.ts— event listeners formenu:duplicate,menu:group,menu:ungroup,menu:bring-forward,menu:send-backward,menu:delete
- Shortcut labels now appear right-aligned in the canvas context menu (Duplicate ⌘D, Group ⌘G, Ungroup ⌘⇧G, Move Up ⌘], Move Down ⌘[, Delete ⌫)
- Web-mode File toolbar dropdown now shows shortcut hints for New ⌘N, Open ⌘O, Save ⌘S, Save As ⌘⇧S
- Tauri native menu bar already showed accelerators natively — no changes needed there
- New
ShortcutIndicatorcomponent renders a pill-shaped overlay near the bottom of the canvas when any keyboard shortcut fires, showing the key combo and action name, then fades out after ~2.5 seconds - Can be toggled on/off in Settings → Shortcuts → "Show shortcut indicator" (default: on)
- New
src/utils/shortcutDefs.ts— single source of truth for all shortcut labels;ShortcutsModalnow imports from here - New
src/utils/shortcutEvents.ts— lightweight pub/sub bus wiringuseDocumentShortcutstoShortcutIndicatorwithout touching AppState
src/utils/shortcutDefs.ts— newsrc/utils/shortcutEvents.ts— newsrc/components/layout/ShortcutIndicator.tsx— newsrc/components/layout/ShortcutIndicator.module.css— newsrc/hooks/useDocumentShortcuts.ts— emit events after each shortcutsrc/components/tree/ContextMenu.tsx—shortcut?field onContextMenuItemsrc/components/tree/ContextMenu.module.css—.shortcutstylesrc/components/canvas/CanvasContextMenu.tsx— shortcut labels on 6 itemssrc/components/toolbar/Toolbar.tsx— shortcut hints in web File menusrc/components/toolbar/Toolbar.module.css—.menuShortcutstylesrc/components/layout/AppShell.tsx— mounts<ShortcutIndicator/>src/components/layout/ShortcutsModal.tsx— imports fromshortcutDefssrc/store/types.ts—showShortcutIndicatorinUserSettingssrc/components/layout/SettingsModal.tsx— settings toggle checkbox
Drop image files (PNG, JPEG, GIF, WebP, SVG) directly onto the canvas. Works in the browser via the HTML5 File API and in Tauri via the tauri://drag-drop event + @tauri-apps/plugin-fs. Multiple files dropped at once each create their own image shape and asset centred at the drop position, scaled to fit within 400 × 400 px.
- New
src/components/canvas/useCanvasDrop.ts src/components/canvas/CanvasView.tsx— wiresonDragOver/onDrop; Tauri listener registered on mountsrc-tauri/capabilities/default.json— addedfs:allow-read-file
The Source section of the image asset property panel now shows a Format row (PNG, JPEG, GIF, WebP, SVG) for both embedded and URL images.
src/components/properties/sections/ImageAssetSection.tsx
Select an image shape with content and click Crop / Edit Crop (prop panel or right-click menu) to enter crop mode. A full-screen overlay shows 8 drag handles, a solid crop border, a dashed boundary showing the full image extent, and dark masks outside the crop region. Drag handles to adjust; click Done to apply, Reset to clear the crop, Cancel or Escape to abort.
- Applying the crop resizes the shape bounds to the selected region and composes the absolute image crop fraction so repeated crops narrow correctly into the already-cropped area.
- Locked images cannot enter crop mode.
- New
src/components/canvas/CropOverlay.tsx src/model/shapes.ts— addedImageCroptype andcrop?field toImageShapesrc/store/types.ts— addedcroppingShapeIdtoAppState; addedENTER_CROP_MODE/EXIT_CROP_MODEtoViewActionsrc/store/reducer.ts— handles new actions; addscroppingShapeId: nullto initial statesrc/components/canvas/shapes/ImageShape.tsx— renders crop via CSSposition: absolute/ percentage offsetssrc/components/canvas/CanvasView.tsx— renders<CropOverlay>when croppingsrc/components/properties/sections/ImageSection.tsx— Crop / Edit Crop buttonsrc/components/canvas/CanvasContextMenu.tsx— Crop / Edit Crop context menu item
When a linked asset has known pixel dimensions an Actual Size (W × H) button appears in the Image prop panel and as a context menu item. Both account for any active crop, so the reported and applied size reflects the cropped region, not the full image.
src/components/properties/sections/ImageSection.tsxsrc/components/properties/PropertiesPanel.tsx— passes linked asset toImageSectionsrc/components/canvas/CanvasContextMenu.tsx
Holding Shift while resizing an image shape constrains to the image's natural aspect ratio (accounting for any crop) instead of 1:1.
src/components/canvas/SelectionOverlay.tsx
vite.config.ts: Injects__APP_VERSION__(frompackage.json) and__BUILD_TIME__(ISO timestamp at build time) as Vitedefineconstants available in all frontend code.src/vite-env.d.ts: Addeddeclare constfor__APP_VERSION__and__BUILD_TIME__.src-tauri/build.rs: EmitsBUILD_TIMEenv var at Rust compile time using Hinnant's civil-date algorithm — no external crates needed.src-tauri/src/lib.rs: PassesAboutMetadata(version + build timestamp) to the native macOS About dialog.src/components/toolbar/Toolbar.tsx: Inline File menu is hidden in Tauri mode (native menu bar handles all file operations). Web mode gets an "About…" item at the bottom of the File menu that shows a modal with version and build time.
src/components/layout/StatusBar.tsx: Now showsW × H at (X, Y)next to the shape name when one or more shapes are selected, usingcomputeBoundingBoxfromSelectionOverlay.src/components/properties/sections/TransformSection.tsx: Transform panelTFieldinputs now support mouse wheel to increment/decrement, matchingNumberInputbehavior.src/hooks/useDocumentShortcuts.ts: Added Cmd+0 to reset the view.src/components/layout/ShortcutsModal.tsx: Added Cmd+0 and F2 to the shortcuts cheat sheet.src/components/properties/IconPickerDialog.tsx: Active icon now scrolls into view when the picker is opened.src/components/tree/TreeNode.tsx: F2 now starts inline rename for the selected layer, matching standard OS rename behavior.src/components/properties/inputs/NumberInput.tsx: Shift+wheel now jumps by 10× step. Also fixed macOS behavior where holding Shift converts vertical scroll to horizontal (deltaY=0, deltaX non-zero) by falling back to deltaX.src/components/properties/sections/TransformSection.tsx: Same Shift+wheel and macOS deltaX fixes applied to Transform panel inputs.
src/components/toolbar/Toolbar.tsx: Removed duplicate divider in the File menu.src/components/layout/ShortcutsModal.tsx: Added Cmd+S, Cmd+G, Cmd+Shift+G, Cmd+], and Cmd+[ to the shortcuts cheat sheet.src/components/properties/sections/TransformSection.tsx: Shift+Arrow now nudges by 10 in the Transform panel inputs, matching canvas behavior.src/hooks/useDocumentShortcuts.ts: Added Cmd+G (group), Cmd+Shift+G (ungroup), Cmd+] (bring forward), Cmd+[ (send backward) keyboard shortcuts.
src/utils/fillCSS.ts:gradientCSSnow sorts stops by position before generating CSS, so out-of-order stops (from mid-drag crossing) always render correctly.src/components/properties/sections/GradientStopBar.tsx: Added optionalonDragEndprop; fires on mouseup when a stop is released without being deleted. Lets the parent re-sort the data model after drag completes.src/components/layout/GradientEditorModal.tsx: AddedhandleDragEnd— sorts stops by position, then updatesselectedStopIdxto track the moved stop in its new sorted position. Also replaced the localpreviewCSSwithgradientCSSfrom fillCSS so list swatches benefit from the same sort.
src/components/properties/sections/GradientStopBar.tsx(new): Visual gradient bar with draggable triangular handles. Click the bar to add a stop (color interpolated from surrounding stops); click a handle to select it; drag to reposition; drag far off the bar (>40px) to delete (min 2 stops enforced).src/components/properties/sections/GradientStopBar.module.css(new): Styles for the bar and handle triangles with selected/hover states.src/components/layout/GradientEditorModal.tsx: Replaced per-stop slider rows + static preview bar with the newGradientStopBar. Stop details (color picker + position input) now appear below the bar for the selected stop. AddedinterpolateHexhelper for color blending when inserting new stops.src/components/layout/GradientEditorModal.module.css: Added.stopDetailsflex row style.src/components/properties/sections/FillSection.tsx: Gradient tab reverted to the simple preset-picker layout — stop editing lives entirely in the dialog.
src/components/properties/inputs/NumberInput.tsx: Scrolling the mouse wheel while a numeric input is focused now increments/decrements bystep(same as ArrowUp/Down). Uses a native non-passivewheelevent listener (mounted once viauseEffect) sopreventDefault()actually stops the properties panel from scrolling. AwheelStateRefkeeps the handler reading currentlocalText,step,min,max, andonChangewithout stale closures.
src/components/canvas/shapes/useEmojiCompletion.ts: Added optionalonValueChange(newValue, newCursorPos)parameter. When provided, all insertion paths (Enter/Tab keyboard, mouse click, auto-replace on closing:) call this callback instead of DOM mutation + syntheticinputevent. DOM mutation is unreliable for React controlled textareas because React may reconcile thevalueprop back before the store update commits.src/components/properties/sections/ContentSection.tsx: Passes anonValueChangecallback touseEmojiCompletionthat dispatchesCOMMIT_TEXT_EDITdirectly and usesrequestAnimationFrameto restore the cursor position after React reconciles the controlled textarea.
src/utils/emojiData.ts(new): ~300 emoji entries as[name, char]tuples covering smileys, gestures, hearts, animals, food, travel, objects, and symbols.searchEmojis(query)returns prefix matches first (sorted shortest-name-first), then contains matches.src/components/canvas/shapes/useEmojiCompletion.ts(new): Hook that detects:querypatterns in a textarea as the user types, manages completion state, handles keyboard navigation (ArrowUp/Down selects, Enter/Tab inserts, Escape closes without cancelling the edit), and auto-replaces when a closing:completes a known name (e.g.:fire:).insertEmojidirectly mutates the uncontrolled textarea value and fires a syntheticinputevent so React stays in sync.src/components/canvas/shapes/EmojiCompletionPopup.tsx(new):createPortal-based popup rendered atposition:fixedto escape canvas transforms andoverflow:hidden. Positioned below the textarea with viewport clamping and above-flip fallback. Mouse-hover updates selection;onMouseDowninserts without blurring the textarea.src/components/canvas/shapes/useTextEdit.ts: IntegratesuseEmojiCompletion; routesonKeyDownthrough emoji completion first; closes popup on edit start/end transitions.- TextShape, LabelShape, StickyNoteShape, ButtonShape: Render
<EmojiCompletionPopup>in the editing branch. src/components/properties/sections/ContentSection.tsx: Added ref +useEmojiCompletion+ popup for the property panel content textarea.tests/utils/emojiData.test.ts(new): 9 unit tests forsearchEmojis.
src/utils/textStyleCSS.ts: CSS paintstext-shadowafterbackground(includingbackground-clip:text), so an inherited shadow lands on top of the gradient. Fix:textExtraCSSnow skipstextShadowwhen a gradient is active;textGradientSpanCSSacceptstextShadowand applies it asfilter: drop-shadow(...)instead — which runs after compositing so the shadow correctly appears behind the gradient text. Also cancels the inheritedtextShadowon the span to prevent double-shadow if the parent still emits it.
src/utils/textStyleCSS.ts: AddedtextGradientKey(text)helper that returns a string derived from the gradient CSS. Used as a Reactkeyprop to force span remount when gradient changes, working around the Chrome bug wherebackground-clip: textis not re-applied when React only patches thebackgroundstyle property.src/components/canvas/shapes/TextShape.tsx,LabelShape.tsx,StickyNoteShape.tsx,PanelShape.tsx,TextFieldShape.tsx,ButtonShape.tsx: All gradient spans now usekey={textGradientKey(text)}so Chrome remounts the element and correctly re-applies the clip whenever the gradient stops or angle changes.
src/model/shapes.ts: AddedtextStrokeGradient?: GradientFill | nulltoTextStyle, alongside the existingtextGradientfield.src/utils/textStyleCSS.ts:textGradientSpanCSSnow handles both fill gradient and stroke gradient. Stroke gradient uses CSSpaint-order: stroke fill+background-clip: text+ transparent text fill to show the gradient through the stroke outline.textStrokeCSSskips solid stroke output whentextStrokeGradientis active.src/components/properties/sections/TextSection.tsx: Replaced the plainColorInputin the Color section with a two-tab panel (Color | Gradient), and replaced the stroke color row with the same two-tab pattern. Both gradient pickers reuseGradientPickerandTabbedPanelcomponents fromTabbedPanel.tsx. Width input stays outside the tabs.src/utils/exportHtml.ts:textContentHtmlnow wraps text in a gradient span for both fill gradient and stroke gradient cases, matching canvas rendering fidelity.
src/model/shapes.ts: Replaced flatStrokeStyleinterface with a discriminated unionColorStroke | GradientStroke | SketchStroke, mirroring the existingFillStylepattern. AddedstrokeColor(stroke)helper to extract a representative color from any stroke variant.src/utils/strokeStyleCSS.ts: UpdatedstrokeBorderCSSto handle all three stroke variants. Gradient strokes set a transparent CSS border (to reserve layout space) and rely on an SVG overlay for the visual.src/components/canvas/shapes/RectShape.tsx: AddedGradientStrokeSVGcomponent that renders an absolutely-positioned SVG with a<linearGradient>or<radialGradient>definition and a stroked<rect>with corner radius support — rendered whenstroke.type === 'gradient'.src/components/properties/TabbedPanel.tsx(new): ExtractedTabbedPanel,TabbedPanelTabs,TabbedPanelTab,TabbedPanelContent, andGradientPickerfromFillSection.tsxinto a shared file.src/components/properties/sections/FillSection.tsx: Updated to import tab components from the new sharedTabbedPanel.tsx.src/components/properties/sections/StrokeSection.tsx: Rewritten with three-tab structure (Color / Gradient / Sketch), matching the FillSection pattern. Color tab has dash style selector (None/Solid/Dashed/Dotted) and opacity. Gradient tab reuses document-level gradients with type and angle controls. Sketch tab has color picker.- All shape components and
exportHtml.tsupdated to usestrokeColor(stroke)helper instead of directstroke.coloraccess.
src/utils/exportHtml.ts:buildFontImportsnow looks up each used font family indoc.customFontsand callsbuildGoogleFontHref()to generate the correct@importURL — including full variable-font axis ranges. Previously, custom fonts were incorrectly excluded from the import.
src/utils/shapeFactory.ts: new text shapes now default tofill.opacity: 1instead of0. The background colour staystransparent, butopacityapplies to the entire shape element — setting it to 0 was hiding the text content as well.
src/components/tree/TreeNode.tsx: Added "Export HTML…" item to the right-click context menu on page nodes in the tree view. The item is disabled when the page has no fixed size; otherwise it exports that specific page (not necessarily the active one) directly to a.htmlfile.
src/utils/exportHtml.ts(new): Pure TypeScript HTML string generator that walks the shape tree and emits inline-styled HTML for every shape type — rect, circle, text, image, line (SVG), group, frame, panel, tabbed-panel, button, icon, label, textfield, checkbox, toggle, radio, select, slider, progress, stepper, stickynote, list, table, dialog, scrollpanel, imagemock, chartmock, pixelimage. Fills (color, gradient, sketch), strokes, box shadows, CSS transforms (rotation/scale/skew), and all text style properties — including variable font axes (font-variation-settings), text gradients, text shadows, and text stroke — are faithfully reproduced as inline CSS. Font families are auto-imported from Google Fonts. Pixel assets render as embedded SVG data URIs. The exported file is fully self-contained and downloads via Blob URL.src/hooks/useTauriMenu.ts: Addedmenu:export-htmllistener that callsexportPageAsHtml().src-tauri/src/lib.rs: Added "Export HTML..." menu item to the File menu.tests/utils/exportHtml.test.ts(new): 57 Vitest unit tests covering RectShape (fill types, corner radii, box shadow, stroke, transform), CircleShape, TextShape (all text properties including variable fonts, text gradient, text shadow, text stroke), ImageShape, LineShape (SVG output, arrows, dash), GroupShape with children, andgeneratePageHtmlstructure.
- Added
user-select: noneto.shapeinShape.module.cssso text inside shapes can't be selected during mouse drag interactions
- Added
textStrokeCSS()utility insrc/utils/textStyleCSS.ts— convertsTextStrokeStyletoWebkitTextStrokeCSS - Updated
TextShape.tsxto spread stroke CSS into the display-mode text style (not applied to textarea edit mode, where-webkit-text-strokeis unsupported) - Added collapsible Stroke section to
TextSection.tsxwith a toggle checkbox, color picker, and width number input
src/utils/tauriStorage.ts(new): Tauri-specific file helpers —tauriOpenFile(),tauriSaveFile(),tauriSaveAsFile()— using@tauri-apps/plugin-dialogand@tauri-apps/plugin-fswith dynamic imports so they never run in a browser bundle.src/store/types.ts/reducer.ts: addedcurrentFilePath: string | nulltoAppStateand aSET_FILE_PATHaction to track the open file path in Tauri mode.src/hooks/useTauriMenu.ts: replaced localStorage save/load handlers with native file I/O.menu:open→ file open dialog,menu:save→ write in-place (or Save As if no path yet),menu:save-as→ save dialog. Removedmenu:import-jsonandmenu:export-jsonlisteners.src-tauri/src/lib.rs: registeredtauri_plugin_dialogandtauri_plugin_fs; removed Import JSON and Export JSON from the native File menu.src-tauri/capabilities/default.json: addeddialog:default,fs:default,fs:allow-read-text-file,fs:allow-write-text-filepermissions.src-tauri/Cargo.toml: addedtauri-plugin-dialog = "2"andtauri-plugin-fs = "2"dependencies.src/components/toolbar/Toolbar.tsx: Import/Export JSON toolbar items hidden in Tauri mode (IS_TAURIguard); Open/Save/Save As toolbar handlers no-op in Tauri (native menu is the entry point).src/hooks/useDocumentShortcuts.ts: Cmd+S localStorage save guarded to web-only; Tauri handles it via the native menu accelerator.
GradientDefsimplified: removedgradientTypeandangle— the def now stores only a named stop collection (id,name,stops[]). Type and angle are per-shape settings in the fill picker.FillSectiongradient tab: added Type (linear/radial/conic) and Angle controls that write directly to the shape'sGradientFill. Selecting a document gradient only swaps the stops, leaving type/angle untouched.- Custom gradient swatch picker: replaced the plain
<select>for the stops dropdown with a customGradientPickercomponent that renders a live gradient swatch next to each name in both the trigger and the dropdown. - Stop delete buttons always visible: fixed
GradientEditorModalCSS — stop-row delete buttons now haveopacity: 1(previously hidden because they only revealed on.row:hoverwhich didn't apply to.stopRow). - Gradient-to-shape sync:
UPDATE_GRADIENTnow callsapplyGradientToShapesin the reducer, propagating updated stops to every shape whoseGradientFill.gradientIdmatches — same pattern as palette colour sync. Editing stops in the dialog now immediately re-renders linked shapes. - Draggable non-modal gradient editor:
GradientEditorModalis no longer modal. The backdrop overlay is removed; the panel isposition: fixedand draggable by its header (grab cursor,mousedownon header starts drag viawindowmousemove/mouseup listeners).
src/model/shapes.ts: ReplacedLinearGradient+ flatFillStylewith a proper discriminated union:ColorFill | GradientFill | SketchFill, each with atypeproperty. AddedfillColor()helper for rough.js consumers. UpdateddefaultFill().src/model/document.ts: AddedGradientDefandSketchStyleDefasset types; extendedVibeDocumentwithgradients: GradientDef[]andsketchStyles: SketchStyleDef[].src/store/types.ts: Added 6 newDocumentActionvariants (ADD/UPDATE/DELETE for gradients and sketch styles), 2 newViewActionvariants (TOGGLE_GRADIENT_MODAL, TOGGLE_SKETCH_STYLE_MODAL), and 2 newAppStatebooleans.src/store/reducer.ts: Default gradients (Sunset, Ocean, Forest, Grayscale) and sketch styles (Solid, Hatched, Cross Hatch, No Fill) added tocreateInitialDocument(). Handles all 6 new document actions. Palette sync updated to handle the union type.src/utils/fillCSS.ts: Rewritten —gradientCSS(),sketchFillCSS(), andfillBackground()handle all three fill types. Linear/radial/conic gradients and CSS hatching viarepeating-linear-gradient.src/utils/shapeFactory.ts:themeFill()returnsColorFill. All hardcoded fills includetype: 'color'.src/utils/serialization.ts: AddedmigrateFill()migration;fromJSON()migrates legacy fills on all shape fields, and adds missinggradients/sketchStylesarrays.- Canvas shape components (
IconShape,CheckboxShape,RadioShape,StepperShape,ToggleShape,SliderShape,ProgressShape,ChartMockShape,ImageMockShape): replacedfill.colorwithfillColor(fill)helper. src/components/properties/sections/FillSection.tsx: Full rewrite — color tab usesColorFill, gradient tab shows document gradient picker + "Edit Gradients…" button, sketch tab shows document sketch style picker + color input + "Edit Sketch Styles…" button. Tab switching converts fill to the appropriate type.src/components/layout/GradientEditorModal.tsx(new): Two-column portal modal (state-driven byshowGradientModal). Left: gradient list with live swatch. Right: name, type, angle, stop list with color pickers + position sliders, live preview strip.src/components/layout/SketchStyleEditorModal.tsx(new): Two-column portal modal (state-driven byshowSketchStyleModal). Left: style list with preview swatch. Right: name, fill style selector, hachure params (conditional), preview.src/components/toolbar/Toolbar.tsx: MountsGradientEditorModalandSketchStyleEditorModal.src/components/tree/GradientsSection.tsx(new): Collapsible tree panel section listing gradients with swatch previews; opens modal on click.src/components/tree/SketchStylesSection.tsx(new): Same pattern for sketch styles.src/components/tree/TreePanel.tsx: Includes both new sections below Fonts.- Tests (new):
tests/model/fillStyle.test.ts,tests/utils/fillCSS.test.ts,tests/utils/fillSerialization.test.ts,tests/store/gradients.test.ts— 24 new tests coveringfillColor(),defaultFill(),gradientCSS(),sketchFillCSS(),fillBackground(), fill migration, and all 6 gradient/sketch-style store actions.
- Added
@tauri-apps/apiand@tauri-apps/clipackages - Scaffolded
src-tauri/withtauri init(Cargo.toml, tauri.conf.json, capabilities, icons) src-tauri/src/lib.rs: native menu bar with App / File / Edit / Window menus; File menu contains all 12 actions matching the web UI (New, Open, Save, Save As, Edit Palettes, Edit Themes, Settings, Document Settings, Import JSON, Export JSON, Export PNG, Export PDF); each item emits amenu:*event to the webviewsrc/hooks/useTauriMenu.ts(new): registers Tauri event listeners that map each native menu event to the same store dispatches and utility functions used by the web UI toolbar; uses a stateRef to avoid stale closures without re-registering on every state changesrc/store/types.ts/reducer.ts: addedpendingDocumentsModalModesignal field so the Tauri hook can trigger Toolbar's DocumentsModal (Open / Save As) without reaching into local statesrc/components/toolbar/Toolbar.tsx: addeduseEffectwatchingpendingDocumentsModalModeto open the modal on demandsrc/App.tsx: thinAppInnerwrapper mountsuseTauriMenuinside the store contextvite.config.ts: addedserver: { port: 1420, strictPort: true }for Tauri's dev serversrc/vite-env.d.ts: addedWindow.__TAURI_INTERNALS__type declarationpackage.json: addedtauri:devandtauri:buildscripts.gitignore: addedsrc-tauri/target/andsrc-tauri/gen/
reformat code. added 2k LOC. :( 18383
Manually refactoring the code that Claude generated. Removing tons of duplicate code. -26 LOC.
Context menu addShape was using the generic path which left assetId: '', disabling the "Edit
Pixels" button. Now creates and attaches an empty pixel asset when inserting a pixelimage from the
context menu.
Pixel Image moved from Forms to the Shapes submenu. Added dividers between Containers, Form
Controls, and Mockups sections within the Forms submenu (required adding divider support to
ContextMenuItem).
Combined the three context menu submenus into one "Forms" submenu to reduce clutter.
Header text in TableShape was using fill.color against a stroke.color background — both could be
dark, making text invisible. Fixed to always use white for header text in both normal and hand-drawn
modes.
New shape type tabbed-panel that renders a tab bar at the top and a content area below. Users
enter a comma-separated list of tab titles as the text content, and choose which tab is visually
active (1-indexed in the UI, stored 0-indexed). All child shapes are always visible — tabs are
wireframing decoration only.
src/model/shapes.ts— AddedTabbedPanelShapeinterface and added toShapeunionsrc/store/types.ts— Added'insert-tabbed-panel'toToolModesrc/utils/shapeFactory.ts— Factory case with 3 default tabssrc/components/toolbar/Toolbar.tsx—NotebookTabsicon added to Containers submenusrc/components/canvas/shapes/TabbedPanelShape.tsx— New rendering component; supports hand-drawn mode with rough SVG tab separators and dividersrc/components/canvas/ShapeRenderer.tsx— Import andcase 'tabbed-panel'; added to TEXT_EDITABLE and DRILLABLE setssrc/store/reducer.ts—COMMIT_TEXT_EDIThandler updatestabs.contentsrc/components/properties/PropertiesPanel.tsx— Properties case with Content, Tabs (active tab), Fill, Stroke, Text, Shadow, and Panel sections
src/components/properties/sections/TextSection.tsx — each variable axis now renders a
NumberInput (for precise editing) paired with a range slider below it, matching the font-size
control pattern.
Two fixes:
src/utils/fontFeatures.ts— the broad CSS2 API request (ital,opsz,wdth,wght@0,...) was using an invalid tuple format (mixing a discrete ital value with axis ranges), causing a 400 for many fonts. Replaced with awdth,wght@25..151,100..900request (valid for all Google variable fonts) with a simplerwght@100..900fallback.src/components/properties/PropertiesPanel.tsx—activeFontwas derived from the rawshape.text.fontFamily, which is wrong when the font is inherited from a named text style. Now usesresolveTextStyle(...)to get the effective font family, matching what TextSection actually displays.
Root cause: detectVariableAxes used opentype.js to parse the font file, but modern browsers always
receive WOFF2 from Google Fonts (which opentype.js cannot parse), so resolveFontUrl always
returned null. Replaced with a CSS2 API approach:
- Fetches the font with a broad axis range request (
ital,opsz,wdth,wght@0,6..144,...); if Google Fonts rejects it (HTTP 400 for unsupported axes), falls back to a simplerwght@1..1000request - Parses the CSS response:
font-weight: X Y(range) → wght axis;font-stretch: X% Y%→ wdth;font-style: oblique Xdeg Ydeg→ slnt - Static fonts return only discrete weight values — no range match → empty axes array →
isVariable: false
Data model
src/model/document.ts—customFonts: string[]upgraded tocustomFonts: CustomFont[]; addedFontAxisandCustomFontinterfaces (name,isVariable: boolean | null,axes: FontAxis[])src/model/shapes.ts— addedfontVariationSettings?: Record<string, number>toTextStylesrc/utils/serialization.ts— migration: existingstring[]entries are converted to{ name, isVariable: null, axes: [] }on load
Store
src/store/types.ts—ADD_CUSTOM_FONTpayload changed tofont: CustomFont; addedUPDATE_CUSTOM_FONT_META,SELECT_FONTactions; addedselectedFontName: string | nulltoAppStatesrc/store/reducer.ts— updated all font cases;SELECT_FONTclears other selections;selectedFontNamecleared on any selection action;LOAD_DOCUMENTnormalizes the string→object migrationsrc/store/history.ts—UPDATE_CUSTOM_FONT_METAadded as undoable
Variable font detection
src/utils/fontFeatures.ts— addeddetectVariableAxes(fontFamily)using opentype.js fvar table (reuses existingresolveFontUrlhelper)src/hooks/useFontMetadataEnrichment.ts— new hook; watchescustomFontsforisVariable === nullentries and enriches them asynchronously viadetectVariableAxessrc/components/layout/AppShell.tsx— addeduseFontMetadataEnrichmentcall alongsideuseDynamicFonts
CSS rendering
src/utils/textStyleCSS.ts—textExtraCSSnow includesfontVariationSettings→font-variation-settingsCSS (covers all shape renderers)src/utils/textShapeCss.ts—textStyleToCssalso outputsfont-variation-settingsfor CSS export
Tree panel
src/components/tree/FontsSection.tsx— validation: fetches Google Fonts CSS2 API before dispatching, shows error if@font-faceabsent; font rows are clickable (dispatchesSELECT_FONT); "var" badge shown for variable fonts; selected row highlightedsrc/components/tree/TreePanel.tsx— passesselectedFontNametoFontsSection
Properties panel
src/components/properties/sections/FontInfoSection.tsx— new: shows font name (in its own typeface), variable/static/detecting label, read-only axes table, Remove buttonsrc/components/properties/sections/TextSection.tsx— addedactiveFont?: CustomFont | nullprop; axis sliders rendered when font is variable;fontVariationSettingsadded toSTYLE_FIELDSsrc/components/properties/PropertiesPanel.tsx—selectedFontNameguard rendersFontInfoSection; allTextSectioncalls passcustomFontNamesandactiveFont
Root cause: onClose() was unmounting CanvasContextMenu before the local setCssDialogShape
state update committed, so the dialog never rendered. Fixed by lifting dialog state to CanvasView:
CanvasContextMenu— removed localcssDialogShapestate; now acceptsonShowCssDialogprop and calls it beforeonClose();CssDialogStateinterface exported for callersCanvasView— holdscssDialogstate, passesonShowCssDialog={setCssDialog}toCanvasContextMenu, renders<TextCssDialog>whencssDialog !== null
src/utils/textShapeCss.ts—textStyleToCss(text, selector)converts aTextStyleto a CSS rule block covering font-family, font-size, font-weight, font-style, text-align, line-height, letter-spacing, text-decoration, text-transform, font-variant-caps, color (or gradient via background-clip trick), and text-shadowsrc/components/canvas/TextCssDialog.tsx— modal dialog with a read-only monospace textarea ( click to select all), a "Copy to Clipboard" button, and a "Dismiss" button; rendered via portal so it sits above everything- Added "Export CSS" context menu item (Code2 icon) for
textshapes inCanvasContextMenu; the selector is derived from the shape name
The window pointerdown capture listener in ContextMenu was calling onClose() immediately when
a click landed inside a portal-rendered sub-menu (because the portal node is outside menuRef),
unmounting the component before the click event fired and the onClick dispatch ran. Fix: use
e.composedPath() to also check whether the click landed inside any element with
data-submenu="true", and skip onClose() in that case.
- Replace instant
onMouseLeaveclose with a 300 ms debounced close in bothContextMenu.tsx( portal-based sub-menus) andToolbar.tsx(component dropdown sub-menus) cancelClose/scheduleClose(ContextMenu) andcancelSubMenuClose/scheduleSubMenuClose( Toolbar) cancel the timer whenever the cursor enters either the parent row or the sub-menu, giving the cursor time to travel across any gap- Reduced the sub-menu offset from +2 px to –4 px overlap in
ContextMenuso the parent row and sub-menu share a common hover zone with no gap
- New
PixelAssetdocument asset (src/model/pixelAsset.ts): flat RGBA pixel array,createEmptyPixelAsset,setPixel,hexToRgbahelpers VibeDocumentgainspixelAssets: PixelAsset[]; serialization migration fills it in for older documents- New
PixelImageShapeshape type (type: 'pixelimage',assetIdreference); added to shape union,shapeFactory,ShapeRenderer - Store:
ADD_PIXEL_ASSET,UPDATE_PIXEL_ASSET,DELETE_PIXEL_ASSETdocument actions ( undo-tracked);START_PIXEL_EDIT,STOP_PIXEL_EDIT,SELECT_PIXEL_ASSETview actions;editingPixelAssetIdandselectedPixelAssetIdinAppState;DELETE_PIXEL_ASSETalso removes referencing shapes PixelImageShapeComp: renders pixels ascellW×cellHrectangles on a<canvas>; checkerboard CSS background for transparent pixels; "16×16" placeholder when asset missingPixelEditorOverlay: full in-place pixel editor opened by double-clicking a pixel image shape. Pencil, line (Bresenham), and eraser tools; palette color swatches + custom color picker; grid lines; double-click outside canvas to close; each completed stroke dispatches oneUPDATE_PIXEL_ASSETfor undo- Toolbar: added "Pixel Image" (
Grid2X2icon) to the Shapes dropdown;insert-pixelimagetool mode creates asset + shape together on pointer up - Tree panel:
PixelAssetsSectionlists pixel assets under Assets with rename (double-click), usage count, and delete - Properties panel:
PixelImageSectionforpixelimageshapes (asset name, pixel size, "Edit Pixels" button); separate panel for selected pixel asset (size, usage count, "Edit Pixels" button)
- Extracted
applyTransform,shapeCorners,computeVisualBoundsfromexportPng.tsxintosrc/utils/exportBounds.tsso they can be tested independently - Added
tests/utils/exportBounds.test.tswith 18 tests covering: identity, 90°/180° rotation, scaleX/Y, skewX/Y, axis-aligned bounds, rotated bounds (including the 100×20 @ 45° case that reveals visual width ≈ 84.9px — less than 100), multi-shape spanning, scaled shapes, skewed shapes, and line-shape filtering
- Render into a padded container (200px each side) so CSS-transformed shapes (rotation/scale/skew) that visually overflow their bounding box are not clipped
- After html2canvas captures the padded canvas, crop back to the exact group dimensions using a
secondary canvas
drawImagecall
- Added
exportGroupAsPng(groupId, state)tosrc/utils/exportPng.tsx: renders the group's children into an off-screen container sized to the group's bounding box and captures it with html2canvas (transparent background) - Added "Export as PNG" context menu item (with FileImage icon) for group shapes in
CanvasContextMenu.tsx, alongside the existing Ungroup item
Snap toggle (Feature 1):
- Added
snapAlignment: booleanfield toGridSettingsinsrc/model/grid.ts(defaulttrue) - Added migration in
src/utils/serialization.tsfor older docs missing this field - Added Magnet toolbar button next to grid snap button to toggle alignment/guide snap on/off
src/components/canvas/useCanvasPointer.tsreadsgridSettings.snapAlignmentto conditionally callcomputeAlignmentSnap
NumberInput arrow keys (Feature 2):
src/components/properties/inputs/NumberInput.tsx: ArrowUp/Down keys now increment/decrement the value bystep(default 1) when the field contains a plain number (not a@variablereference)
Reset transform button (Feature 3):
src/components/properties/sections/TransformSection.tsx: Added "Reset transform" button below the grid; resets rotation to 0, scaleX/scaleY to 1, skewX/skewY to 0
Components sub-menus (Feature 4):
src/components/toolbar/Toolbar.tsx: Components dropdown now shows "Containers ›" and "Form Controls ›" items; hovering each reveals a flyout sub-menu with the respective tools
Single undo for drag (Feature 5):
- Added
MOVE_SHAPES_STARTaction (DocumentAction): records the undo anchor exactly once when a drag begins - Added
DRAG_SHAPESaction (DragAction, NOT in history): applies incremental moves without creating undo entries src/store/types.ts: AddedMOVE_SHAPES_STARTtoDocumentActionand newDragActionunion; addedDragActiontoAppActionsrc/store/history.ts: AddedMOVE_SHAPES_STARTtoDOCUMENT_ACTION_TYPESsrc/store/reducer.ts: RoutesMOVE_SHAPES_STARTthroughapplyDocumentAction(no-op); routesDRAG_SHAPEStoMOVE_SHAPESlogic without history recordingsrc/components/canvas/useCanvasPointer.ts: DispatchesMOVE_SHAPES_STARTonce when drag threshold is crossed, thenDRAG_SHAPESfor each subsequent mouse move
CollapsibleSection persistence (Feature 6):
src/components/properties/CollapsibleSection.tsx: Module-levelMap<string, boolean>stores open/closed state by section title; state is preserved when switching selected objects
Multiple box shadows:
- Changed
ShapeBase.boxShadowfromBoxShadow | nulltoBoxShadow[](array) insrc/model/shapes.ts - Updated
src/utils/shadowCSS.tsto map the array into a comma-joined CSSbox-shadowvalue - Added migration in
src/utils/serialization.ts: oldBoxShadow | nullvalues are converted to[]or[shadow]on document load - Redesigned
ShadowSectioncomponent: shows "+ Add Shadow" button, each shadow in a compact sub-panel with Color, X/Y row, Blur/Spread row, Inset checkbox, and × remove button
Gradient editor fixes:
- Wrapped gradient controls in a visually distinct sub-panel that only appears when Gradient mode is active; Solid/Gradient toggle always visible
- Added a gradient preview bar (12px tall CSS linear-gradient div) at the top of the sub-panel
- Redesigned stop rows: removed "Stop N" label, color swatch fills available width (
flex: 1), position input is explicitly60pxwide so it no longer overflows - Fixed gradient rendering bug: extracted gradient CSS from
textExtraCSSinto newtextGradientSpanCSS()insrc/utils/textStyleCSS.ts; each text-rendering shape now wraps content in an inline<span>with the gradient styles (inline elements have reliablebackground-clip: textsupport) - Updated shapes:
TextShape,LabelShape,ButtonShape,StickyNoteShape,PanelShape,TextFieldShape
- Moved Color/Gradient and Shadow to the bottom of the Text section (they are appearance effects, not core typography properties)
- Final Text section order: Style → Font → Size → Weight/Italic/SmallCaps → Alignment → Spacing → Decoration → Transform → Color → Shadow
- Reorganized Text section properties into 8 logical groups (matching Figma/Sketch conventions):
- Named Style selector
- Font Family → Font Size (identity first)
- Font Weight + Italic + Small Caps (style variants)
- Color / Gradient
- Alignment — H-align and V-align combined onto one row (saves a row)
- Spacing — Line Height and Letter Spacing side-by-side (saves a row)
- Decoration (underline/strikethrough) + Text Transform
- Shadow (moved to bottom as an effect)
- Context menu now scrolls when it's taller than the viewport (
max-height: calc(100vh - 16px)+overflow-y: auto) - Repositioning logic improved: clamps to all four viewport edges with 8px margin (previously only handled right/bottom overflow)
- Added
fontVariantCaps?: 'normal' | 'small-caps'toTextStylemodel - Applied via
fontVariant: 'small-caps'CSS intextExtraCSS - Added Small Caps toggle (ALargeSmall icon) next to italic button in TextSection
- Added
src/utils/fontFeatures.tswithdetectSmallCaps()using opentype.js:- Fetches the Google Fonts CSS link tag for the current font
- Extracts a TTF/WOFF1 URL (opentype.js cannot parse WOFF2)
- Parses GSUB feature tables to check for the
smcpOpenType feature - Returns null when detection is impossible (WOFF2 only / not a Google Font)
- Toggle shows dimmed when font is confirmed to lack native smcp; full opacity when supported or unknown
- Installed
opentype.js+@types/opentype.js
- Double-clicking any item in the tree view opens an inline name editor
- Shapes and pages: dispatches
PATCH_SHAPEwith new name on commit - Document row: dispatches
SET_DOCUMENT_METAwith new name on commit - Page folders: already supported (no change needed)
- Shapes and pages: dispatches
- Enter/Blur commits; Escape cancels; drag is disabled while editing
- Font weight dropdown now detects which weights the selected font actually supports via the CSS
Font Loading API (
document.fonts) - System fonts (not in
document.fonts) fall back to showing all 9 weights - Web fonts (Google Fonts, custom fonts) show only their registered weight variants
- Re-checks after
document.fonts.readyresolves so async-loaded fonts are handled correctly
- Added italic toggle button to Text section in PropertiesPanel (toggles
fontStylebetween'normal'and'italic') - Expanded font weight dropdown from Normal/Bold to full 9-step range: Thin (100), ExtraLight (200), Light (300), Normal (400), Medium (500), SemiBold (600), Bold (700), ExtraBold (800), Black (900)
- Reset-to-style buttons shown for both
fontWeightandfontStylewhen a named text style is active
- Reordered Transform section fields: X/Y → W/H → SX/SY → KX/KY → °
- Widened
.tlabelfrom 10px to 14px to fit 2-char labels
- Added
scaleX,scaleY,skewX,skewYoptional fields toBoundingBoxinsrc/model/transform.ts - Added
buildCSSTransform(t)utility that composes rotate/scale/skew into a single CSS transform string - Updated all 25 shape renderers to use
buildCSSTransform(transform)instead of inline rotate-only expression - Added SX (scale X %), SY (scale Y %), KX (skew X °), KY (skew Y °) inputs to
TransformSection
- Added
CollapsibleSectioncomponent (src/components/properties/CollapsibleSection.tsx) with chevron toggle anddefaultOpenprop - Added
.sectionTitleRow,.sectionChevron,.sectionChevronOpen,.sectionBodyCSS classes toPropertiesPanel.module.css - Converted all 15 standalone section components to use
<CollapsibleSection>in place of the raw<div className={styles.section}>pattern - Converted all 21 inline
<div className={styles.section}>blocks inPropertiesPanel.tsxto use<CollapsibleSection> - Removed now-unused
stylesimport from ContentSection, FillSection, PageSection, ShadowSection, StrokeSection
- Added
textGradient?: LinearGradient | nulltoTextStyleinsrc/model/shapes.tsand toTextStyleDef textExtraCSS()handlestextGradientvia CSSbackground-clip: text+WebkitTextFillColor: transparentTextSectioncolor control replaced with Solid / Gradient toggle matching the fill gradient editorFillSectionandTextSectiongradient editors now support dynamic stop count: add stops ( inserted into largest gap), remove stops (min 2), per-stop position input (%)
Stroke dash style:
- Added Solid / Dashed / Dotted selector to the Stroke section in the properties panel
- Stroke
dasharray was already in the model; now exposed in the UI - Created
src/utils/strokeStyleCSS.tswithstrokeBorderCSSanddashToBorderStyleutilities - All 17 CSS-rendered shape components updated to use
strokeBorderCSSinstead of theborder:shorthand
Per-corner border radius:
- Added
CornerRadiiinterface tosrc/model/shapes.ts - Added
cornerRadii?: CornerRadiito RectShape, ButtonShape, FrameShape, PanelShape, ScrollPanelShape - Added
cornerRadiiCSS(uniform, radii?)utility tostrokeStyleCSS.ts - All 5 relevant shape renderers updated to use
cornerRadiiCSS - Properties panel: new
CornerRadiusControlcomponent — shows a single radius input with a toggle button (⌗) to expand into 4 per-corner inputs (TL/TR/BR/BL)
- Added
BoxShadowinterface tosrc/model/shapes.tsandboxShadow?toShapeBase(applies to all shapes) - Created
src/utils/shadowCSS.tswithboxShadowCSSutility - All 25 non-line, non-page shape renderers updated to spread
boxShadowCSS(shape)onto the outer div - Created
src/components/properties/sections/ShadowSection.tsxwith enable/disable toggle, Color, X, Y, Blur, Spread, and Inset controls ShadowSectionadded to all non-line shape cases inPropertiesPanel
- Added
LinearGradientinterface andgradient?: LinearGradient | nulltoFillStyleinsrc/model/shapes.ts - Created
src/utils/fillCSS.tswithfillBackground(fill)— returns linear-gradient CSS when gradient is set, otherwise returns solid color - All 16 shape renderers that render a fill background updated to use
fillBackground(fill)instead offill.color FillSectionupdated with Solid / Gradient mode toggle; gradient mode shows: angle input, start color, end color, opacity
Added four new optional text properties to all text shapes and named text styles:
- Line Height — CSS multiplier (0.5–4); number input in Text section
- Letter Spacing — pixel offset (–10 to 50px); number input in Text section
- Text Decoration — Underline / Strikethrough / both; icon toggle buttons (Lucide icons)
- Text Transform — None / Uppercase / Lowercase / Capitalize; select dropdown
All four fields:
- Live in
TextStyleinsrc/model/shapes.tsas optional fields (no migration needed) - Are rendered via the expanded
textExtraCSS()utility insrc/utils/textStyleCSS.ts(replacestextShadowCSS) - Apply in all 13 text-rendering shape components (ButtonShape, CheckboxShape, LabelShape, ListShape, PanelShape, RadioShape, SelectShape, StepperShape, StickyNoteShape, TableShape, TextFieldShape, TextShape, ToggleShape)
- Are tracked as style overrides when a named text style is applied
- Are editable in named text style definitions (TextStyleDefSection) with optional field checkboxes
textShadowCSS is kept as a deprecated alias so nothing breaks.
Users can now type any Google Fonts family name in Document Settings → Custom Fonts and click Add (
or press Enter). The font is saved to the document, dynamically loaded via a <link> tag injection,
and immediately available in the Font dropdown across all text shapes and text style definitions.
- Fonts persist in the document JSON and are re-loaded on open
- Undo/redo supported for add/remove operations
- Font names shown in their own typeface in the font list
- Old documents without
customFontsfield migrate automatically
Files added: src/hooks/useDynamicFonts.ts
Files modified: src/model/document.ts, src/store/types.ts, src/store/history.ts,
src/store/reducer.ts, src/components/layout/AppShell.tsx,
src/components/layout/DocumentSettingsModal.tsx,
src/components/properties/sections/TextSection.tsx,
src/components/properties/sections/TextStyleDefSection.tsx,
src/components/properties/PropertiesPanel.tsx
Added import { textShadowCSS } from '@utils/textStyleCSS' and spread ...textShadowCSS(text) (or
title for PanelShape) into the display-mode text style object of every text-rendering shape
component. Textarea/input editing styles are intentionally unchanged.
Files modified:
src/components/canvas/shapes/TextShape.tsxsrc/components/canvas/shapes/LabelShape.tsxsrc/components/canvas/shapes/ButtonShape.tsxsrc/components/canvas/shapes/CheckboxShape.tsxsrc/components/canvas/shapes/RadioShape.tsxsrc/components/canvas/shapes/ToggleShape.tsxsrc/components/canvas/shapes/SelectShape.tsxsrc/components/canvas/shapes/TextFieldShape.tsxsrc/components/canvas/shapes/StickyNoteShape.tsxsrc/components/canvas/shapes/ListShape.tsxsrc/components/canvas/shapes/PanelShape.tsxsrc/components/canvas/shapes/StepperShape.tsxsrc/components/canvas/shapes/TableShape.tsx
onPointerMove's useCallback had a stale closure that didn't include state.document in its
deps, so newly-added guides were invisible to the snap computation. Fix: extract guide positions
into a pageGuidesRef that's updated on every render (outside any callback), so the snap logic
always reads fresh guide data via the ref.
Files modified: src/components/canvas/useCanvasPointer.ts
Page boundary snapping: When a fixed-size page is active, its edges and center lines are included as snap targets alongside other shapes.
User guide lines:
- Drag from the horizontal ruler (top) to create a horizontal guide line
- Drag from the vertical ruler (left) to create a vertical guide line
- Drag an existing guide line to reposition it
- Double-click a guide to delete it
- Guides persist in the document (saved per-page), are undoable/redoable, and act as snap targets when dragging shapes
Guide lines render in blue (#4d94ff) inside the canvas. Snap guide lines (pink) still render over
them during alignment snapping.
Files added: src/model/guide.ts, src/components/canvas/CanvasGuides.tsx
Files modified: src/model/shapes.ts, src/store/types.ts, src/store/reducer.ts,
src/store/history.ts, src/utils/alignmentSnap.ts, src/components/canvas/useCanvasPointer.ts,
src/components/canvas/CanvasView.tsx
When dragging a shape, the tool now shows alignment guide lines and snaps to other shapes — similar to Figma/Google Slides smart guides.
Behavior:
- While dragging, the left/right/center-X and top/bottom/center-Y edges of the dragged shape are compared against all other visible shapes on the active page
- When any pair of edges comes within ~8 screen pixels, the shape snaps to the aligned position and a pink guide line appears across the canvas
- X and Y axes snap independently
- When multiple shapes are selected and dragged together, their collective bounding box is used
- Hold Alt/Option to temporarily disable alignment snapping
- Alignment snapping takes priority over grid snap on any axis where it fires; grid snap remains active on the other axis
- Guide lines disappear on mouse release
Files added: src/utils/alignmentSnap.ts, src/components/canvas/SnapGuides.tsx
Files modified: src/components/canvas/useCanvasPointer.ts,
src/components/canvas/CanvasView.tsx
Two new wireframe placeholder shapes:
- Image Mock (
imagemock): A rectangle with a smiley face drawn inside — head circle, two dot eyes, and a curved smile. Renders in both plain SVG and hand-drawn (RoughJS) modes. Background fill and stroke are configurable. - Chart Mock (
chartmock): A generic chart with axes and either bars or a line series (5 data points). Toggle between bar and line chart in the properties panel. Bar/line color is configurable via Fill. Renders in both plain SVG and hand-drawn modes.
Both shapes appear in:
- Canvas right-click context menu → Mockups section
- Tree panel "+" dropdown → Mockups section
- Tree panel node context menu → Mockups section
Files modified: src/model/shapes.ts, src/utils/shapeFactory.ts,
src/components/canvas/ShapeRenderer.tsx, src/components/canvas/shapes/ImageMockShape.tsx (new),
src/components/canvas/shapes/ChartMockShape.tsx (new),
src/components/canvas/CanvasContextMenu.tsx, src/components/tree/TreeNode.tsx,
src/components/tree/TreePanel.tsx, src/components/properties/PropertiesPanel.tsx.
Same as the Slider tick marks feature. ProgressShape gets a ticks: number field (0 = none).
Ticks render below the bar using the bar fill color. Configurable via "Ticks" input (0–20) in the
properties panel.
SliderShapemodel: newticks: numberfield (0 = no ticks, n = number of tick marks evenly distributed across the track).SliderShape.tsx: renders tick marks below the track in both plain and hand-drawn modes. Plain mode uses small divs; hand-drawn usesroughLine.shapeFactory.ts: defaultticks: 0.- Properties panel: "Ticks" number input (0–20) in the Slider section.
useTheme now tracks a null (follow system) vs explicit override state separately.
- OS preference changes are observed live via
MediaQueryList.addEventListenerand applied immediately when no override is set. - The toggle button cycles between "override to opposite" and "clear override (return to system)" rather than always writing to localStorage.
localStoragekey"ui-theme"is only present when the user has explicitly overridden; it is removed when following system.
Added a new "Icon" shape type that displays a single lucide-react icon.
src/model/shapes.ts: NewIconShapeinterface withtransform,icon: { name },fill, andstrokeproperties. Added to theShapeunion.src/store/types.ts: Added'insert-icon'toToolMode.src/utils/shapeFactory.ts: Factory case for'icon'— 40×40px, defaults to "Star" icon with foreground color.src/components/canvas/shapes/IconShape.tsx: New component that renders the selected lucide icon centered in the shape bounds. Icon color is controlled byfill.color.src/components/canvas/ShapeRenderer.tsx: Import and dispatch for the'icon'case.src/components/canvas/useCanvasPointer.ts: Maps'insert-icon'tool mode to'icon'shape type.src/components/toolbar/Toolbar.tsx: Added "Icon" entry with Star icon to the FORM_CONTROLS dropdown.src/components/properties/sections/IconSection.tsx: New section component for picking the icon ( reuses the existing IconPickerDialog).src/components/properties/PropertiesPanel.tsx: Addedcase 'icon'with Transform, Icon, and Fill sections.src/components/tree/TreeNode.tsx: Added'icon'toFORM_CONTROL_TYPESso it appears with the label "Icon" in the tree.
Added a dark/light mode toggle to the UI.
src/index.css: Added CSS custom property tokens for all UI colors under:root(light) andhtml[data-theme="dark"](dark). Updatedhtml/body/#rootto use the new variables.src/hooks/useTheme.ts: New hook that reads/writeslocalStoragekey"ui-theme", setsdata-themeattribute on<html>, and auto-detects system preference on first visit.src/components/toolbar/Toolbar.tsx: Added Sun/Moon toggle button (right side of toolbar). Fixed 3 hardcoded inline colors on the document name input/span. ImportsuseTheme.- All 25
*.module.cssfiles: Replaced hardcoded hex color values with CSS variables. ThecontentTextareacode-editor element intentionally retains its dark background in both modes.
The Images, Variables, and Styles sections in the tree panel can now be collapsed by clicking their
header label. A › chevron rotates 90° when the section is expanded. The + add button remains
visible and functional while collapsed.
The Assets section of the tree panel now lists every image imported into the document.
Model:
- New
ImageAssettype (src/model/imageAsset.ts): id, name, src (base64 data URI or http URL), mimeType, optional width/height assetId?added toImageShapeto link shapes to their assetimages: ImageAsset[]added toVibeDocument
State/actions:
selectedAssetId: string | nullin AppState- New document actions (tracked in undo history): ADD_IMAGE_ASSET, UPDATE_IMAGE_ASSET, DELETE_IMAGE_ASSET
- UPDATE_IMAGE_ASSET propagates src/mimeType changes to all linked shapes automatically
- DELETE_IMAGE_ASSET unlinks shapes (they keep their current src)
- SELECT_IMAGE_ASSET view action; all selection actions reset
selectedAssetId
Tree panel:
AssetsSectioncomponent lists image assets with thumbnail, name, and usage count+button opens a name + URL form to add a URL-based image asset- Double-click to rename; right-click for Rename/Delete context menu
Properties panel:
- Clicking an asset row shows
ImageAssetSectionwith: editable name, source info (embedded: size in KB + pixel dimensions; URL: editable URL field), and a usage list of linked shape names
Image upload:
ImageSectionnow creates anImageAsseton first upload and links the shape to it- Re-uploading to a linked shape updates the existing asset, propagating to all shapes using it
Migration:
- Serialization migration guard:
images: []for old documents - LOAD_DOCUMENT auto-creates assets for any image shapes that have no assetId, so existing documents populate the assets panel automatically
TransformSection now supports number variable binding for X, Y, Width, and Height. Type @ in any
of those fields to trigger an autocomplete dropdown of number variables. Bound fields show
@varName with a × to clear. The rotation field does not support variable binding.
- Updated
TField(local to TransformSection) to usetype="text"with@interception, matching the NumberInput pattern - Added optional
xVar,yVar,wVar,hVarVarProps toTransformSection - All 22
TransformSectioncall sites in PropertiesPanel now pass variable binding props via the existingvp()shorthand
Named variables (number, string, boolean, color) that can be bound to shape properties. Editing a variable value re-renders all shapes using it automatically via live resolution at render time.
Data model:
src/model/variable.ts—Variableinterface,resolveVariableBindings(chains withresolveShapeTextin ShapeRenderer)variableBindings?: Record<string, string>(propPath → variableId) added toBaseShapevariables: Variable[]added toVibeDocument
State / actions:
selectedVariableId: string | nullin AppState- New document actions: ADD_VARIABLE, UPDATE_VARIABLE, DELETE_VARIABLE, REORDER_VARIABLE, BIND_VARIABLE (all tracked in undo history)
- SELECT_VARIABLE view action; all selection actions reset
selectedVariableId - DELETE_VARIABLE walks all shapes removing orphaned bindings (no baking needed — shapes fall back to stored literal values)
Input components:
NumberInput,ColorInput,ToggleInput— new optional propsvariableId?, variables?, onVariableChange?; existing call sites unchanged@in a number/color input triggers autocomplete dropdown of matching variables; bound inputs show@varName+ × clear button
New UI:
VariableRow— tree row with type icon, inline rename, context menu, value previewVariablesSection— tree section with+type-picker menu (Number/String/Boolean/Color)VariableSection— properties panel section for editing a variable's name and value
PropertiesPanel wiring:
- Early return for
selectedVariableId !== nullrenders VariableSection makeVarPropshelper builds binding props for a given shape/path/type; passed ascolorVar,widthVar,opacityVarinto FillSection/StrokeSection and directly to NumberInput/ToggleInput call sites
Serialization: migration guard if (!Array.isArray(docObj.variables)) docObj.variables = []
Two bugs in TextSection where property edits didn't behave correctly when a style was applied:
- Style connection lost on edit:
onChangewas spreading the resolved text (which has notextStyleId), so every property change was silently unlinking the shape from its style. Fixed by usingrawTextas the base in allonChangecalls. - Override not tracked when value matches raw: The PATCH_SHAPE reducer tracks overrides by
diffing old vs new raw values. If
rawText.align = 'left'and the style provides'center', clicking 'left' produced no diff → no override added → style's value kept winning. Fixed by adding anapplyChangehelper inTextSectionthat explicitly adds the changed field totextStyleOverrideswhenever a style is set, regardless of value equality.
- Named text styles: Users can create document-level
TextStyleDefobjects in the Styles section of the tree panel. Each style is a named collection of optional text properties (font family, size, weight, style, color, align, verticalAlign). - Built-in styles: Three default styles are created with every new document — Title (32px bold), Subtitle (20px 600-weight), Paragraph (14px normal). Old documents auto-migrate with the defaults.
- Style assignment: Any shape with text shows a "Style" selector at the top of the Text section in the properties panel. Selecting a style applies its properties live; shapes re-render immediately when the style is edited.
- Per-field overrides: After applying a style, individual text properties can still be
overridden. Modified fields show a ↺ reset button to restore the style's value. Override tracking
is automatic in the reducer when
PATCH_SHAPEchanges text fields on a styled shape. - Style editor: Clicking a style in the Styles tree section shows it in the properties panel. Each field has a checkbox to include/exclude it from the style. Changes apply live — no save button needed.
- Delete bakes values: Deleting a style bakes its resolved values into all shapes that referenced it, then disconnects them.
- Data model: Added
TextStyleDef,TextStyleField,TEXT_STYLE_FIELDS,BUILT_IN_TEXT_STYLES,resolveTextStyle,resolveShapeTextin newsrc/model/textStyle.ts. AddedtextStyleId?andtextStyleOverrides?toTextStyleinshapes.ts. AddedtextStyles: TextStyleDef[]toVibeDocument. - State: Added
selectedStyleId: string | nulltoAppState. New document actions:ADD_TEXT_STYLE,UPDATE_TEXT_STYLE,DELETE_TEXT_STYLE,REORDER_TEXT_STYLE,APPLY_TEXT_STYLE,CLEAR_TEXT_OVERRIDE. New view action:SELECT_STYLE. - Canvas:
ShapeRenderercallsresolveShapeTextbefore passing shapes to sub-renderers, so all text renders with resolved style values without needing to store resolved values in shape data. - New files:
src/model/textStyle.ts,StyleRow.tsx/css,StylesSection.tsx,TextStyleDefSection.tsx - Modified:
TextSectionupdated with new prop signature (style selector, override reset buttons, font family selector); all 14 call sites inPropertiesPanel.tsxupdated.
- Document row: A "Document" item at the very top of the tree panel. Click it to select the document and see its properties in the right panel (document name, grid settings, active theme).
- Page Folders: New organizational folder type for pages. Folders have no canvas presence — just
a name and an ordered list of pages they contain. Features:
- Create via the
+menu → "Folder" - Inline rename (double-click or context menu → Rename)
- Collapse/expand with chevron button
- Context menu: Add Page to Folder, Rename, Move Up/Down, Delete Folder (keep pages), Delete Folder and Pages
- Drag a page node onto a folder to assign it to that folder
- Create via the
- Section headers: Static "Assets", "Variables", "Styles" sections below pages (empty for now, placeholders for future content)
- Data model: Added
PageFolderinterface andpageFolders: PageFolder[]toVibeDocument. Helper functionsfindFolderForPageandgetUnfiledPageIdsindocument.ts. Old documents auto-migrate withpageFolders: []. - State: Added
documentSelected: booleantoAppState. New actions:SELECT_DOCUMENT,SET_FOLDER_COLLAPSED(view, not undoable);ADD_PAGE_FOLDER,DELETE_PAGE_FOLDER,RENAME_PAGE_FOLDER,ASSIGN_PAGES_TO_FOLDER,REMOVE_PAGES_FROM_FOLDER,REORDER_PAGE_FOLDER(document, tracked in undo history). - New files:
DocumentRow.tsx/css,PageFolderRow.tsx/css,SectionHeader.tsx/css,DocumentSection.tsx
- New utility
src/utils/paletteImport.tswith parsers for.hex/.txtfiles, GIMP.gplfiles, Coolors.co URLs, and a Lospec.com JSON API fetcher - Added "Import" section to the bottom of the palette list in the Palette Editor modal with:
- URL input: paste a
lospec.com/palette-list/<slug>orcoolors.co/<hex>-<hex>-…URL and click Import - File upload: pick a
.hex,.txt, or.gplfile from disk - Error display for bad URLs, failed fetches, or invalid files
- URL input: paste a
- Imported palette is automatically selected after import
- 33 new unit tests covering all parser functions and the fetch helper
- Fixed palette editor dialog height jumping when switching between palettes — set fixed
height: 500pxon the modal
- Fixed: grid not visible until something moved — SVG grid had
width: 100%; height: 100%on a zero-size canvas div; changed to explicitleft: -10000, top: -10000, width: 20000, height: 20000so it paints immediately on mount - Fixed: turning off grid snap still snapped shapes until zoom changed —
snapEnabled/gridSizewere missing fromonPointerMove'suseCallbackdependency array, causing stale closure - Fixed: drag-move snapped shapes to offset grid positions — was snapping absolute cursor position then subtracting unsnapped initial position; now snaps total displacement from drag start so movement is always in clean grid-size increments
- Added
GridSettingsmodel (src/model/grid.ts) withsize,style('lines' | 'dots' | 'none'), andsnapEnabledfields; default is 10px lines, snap off - Added
gridSettings: GridSettingstoVibeDocument; old documents load with defaults via fallback - Added optional
gridSettings?: Partial<GridSettings>per-page override toPageShape - New
UPDATE_GRID_SETTINGSdocument action andTOGGLE_DOCUMENT_SETTINGS_MODALview action CanvasGridcomponent renders an SVG pattern grid (lines or dots) inside the canvas transform div; inherits zoom/pan scaling- Grid snapping during shape drag: pointer position snapped to grid before computing move delta
- Grid snapping during shape insertion (drag-insert and click-insert): origin, size snapped to grid
- Grid snapping during resize (
SelectionOverlay): x, y, width, height snapped beforeSET_TRANSFORM - Arrow key nudge uses
gridSizeas step distance when snap is enabled (instead of 1 or 10px) - Toolbar: grid toggle button (Grid icon) highlights when snap is on; File menu has "Document Settings..." entry
DocumentSettingsModal: controls for snap enabled, grid size (1–200 px), and grid stylePropertiesPanel: page shape now shows a "Grid Override" section to set per-page grid settingssrc/utils/snapping.ts:snapToGrid(value, size)andgetEffectiveGridSettings(pageId, shapes, docSettings)utilities- 11 new unit tests for
snapToGridandgetEffectiveGridSettings(14 test files, 136 tests total)
- Fixed: double-clicking a nested group while already drilled into a parent group now correctly enters the inner group's drill mode
- Fixed: moving a shape inside an inner group was incorrectly moving the outer group instead; hit-test bubble-up now exempts all containers in the drill stack, not just the innermost one
- Drill mode now supports arbitrary nesting depth using a stack instead of a single
drilledInContainerId - Double-clicking a nested group while already drilled in pushes the inner group onto the stack
- Pressing Escape pops one level at a time, returning to the parent group rather than jumping to the top level
- The breadcrumb label shows the full drill path with
›separators (e.g. "Editing: Outer Group › Inner Group") - Outer drill levels remain visible with a faded orange border; the innermost active level shows a solid orange border
- Group shape: A transparent container that wraps multiple shapes into a logical unit. Bounds are auto-computed as the union of all children's bounding boxes.
- Create group: Select 2+ shapes → right-click → "Group". The group is placed at the same tree level as the selected shapes and all selected shapes become its children.
- Ungroup: Right-click a group → "Ungroup" to unwrap children back to the group's parent at their original canvas positions.
- Drill mode: Double-click a group to enter drill mode and interact with individual children. Escape exits drill mode.
- Hit testing: Clicking a child of an undrilled group selects/moves the group instead of the child.
- Group bounds recomputation: Group bounds automatically update when children are moved or resized while drilled in.
- Empty group: Add an empty group from the Layers panel "+" button under Containers → Group, then drag shapes into it.
- Drag-to-reparent: Shapes dragged onto a group in the canvas or tree will be reparented into it.
- Nested groups: Groups can be nested indefinitely; drill mode scopes to the innermost drilled group.
- Delete group: Deletes the group and all its children.
- Table: Grid shape where each line of text is a row and commas separate columns. The first row is always the header (bold, filled with stroke color). Double-click to edit raw CSV-style text; Cmd/Ctrl+Enter commits. Available in Components > Form Controls. Supports hand-drawn rendering.
- Sticky Note: A yellow note shape with a folded top-right corner. Supports editable text ( double-click). Available in Components > Containers. Works in both clean and hand-drawn themes.
- List: A multi-item list control. Text content is newline-separated items. The selected row is
highlighted with a light blue background.
selectedIndex(-1 = none) is editable in the Properties panel. Double-click to edit items. Available in Components > Form Controls. - Scroll Panel: A titled panel with a decorative scrollbar on the right side.
scrollPosition( 0–1) controls the thumb position and is editable in the Properties panel. Title is double-click editable. Available in Components > Containers.
All three shapes support hand-drawn (rough) rendering and integrate with undo/redo, selection, and the canvas context menu.
- Shift+click now removes items from selection: Shift+clicking an already-selected shape on the
canvas now deselects it. Previously,
draggingIdswas filtered correctly but no dispatch was made to update the selection state, so the item stayed selected.
- Configurable base URL: Added
baseoption tovite.config.tsusingprocess.env.VITE_BASE_PATH(defaults to/). SetVITE_BASE_PATH=/your-path/at build time to deploy to a subdirectory. Installed@types/nodeto supportprocess.envin the Vite config.
- TextField and Select shapes now respect handDrawn theme: Both components were missing the
handDrawnprop and always rendered with RoughJS. Added plain CSS rendering (border, border-radius, background) when the active theme hashandDrawn: false. - Dialog title font now follows active theme:
DialogShapemodel gainedtitleFontFamilyandtitleColorfields. New dialogs are created with the active theme's font and foreground color. Existing dialogs without those fields fall back to the active theme font passed throughShapeRenderer(themeFontFamilyprop), so switching themes updates them without requiring a manual reset. - Dialog text color separated from border color: Title text, Cancel label, and OK label now use
titleColor(theme foreground) rather thanstroke.color, matching how the Panel shape separates text from border colors. "Reset to theme" also updatestitleColor. - Inter web font loaded: Added Inter (weights 400/500/600) to the Google Fonts request in
index.htmlso the Plain Light and Plain Dark themes render with the correct font rather than falling back to the system font. - Theme editor duplicate button: Built-in themes now show an explicit "Duplicate to customize" button in the read-only notice. The sidebar "Add theme" button also changes to "Duplicate" (with a copy icon) when a built-in theme is selected.
- Document themes: Added a theming system with three built-in themes — Hand Drawn, Plain Light, and Plain Dark.
- Theme model (
src/model/theme.ts):Themeinterface defines foreground, background, border color/width/radius, hand-drawn toggle, and font family/size.getActiveTheme()helper reads active theme from document. - Theme Editor (File → "Edit Themes..."): Left sidebar lists themes (built-ins are locked/read-only with a lock icon; custom themes are deletable). Right panel lets you edit all theme properties. "Set as active theme" applies the theme to new shapes; "Apply to all shapes" resets all existing shapes in the document to the theme's values.
- New shapes use active theme: All shape creation paths (canvas draw, context menu, tree panel,
toolbar) use
getActiveTheme(doc)to initialize colors, fonts, border styles, and corner radii. - Reset to theme: A "Reset to theme (…)" button in the Properties Panel resets the selected shape(s) to the active theme's values (fill color, stroke color/width, corner radius, font family/size, text color). Does not affect content (text, images, etc.).
- handDrawn toggle: Each of the 11 rough-rendered shape components (Button, Panel, Dialog,
Checkbox, Radio, Slider, Toggle, Frame, Label, Progress, Stepper) now supports a
handDrawn: booleanprop — when false, renders with plain CSS (border, border-radius, background) instead of RoughJS SVG paths. The active theme'shandDrawnsetting is applied document-wide; individual shapes can override it viashape.handDrawn. - Document migration: Existing documents that don't have
themes/activeThemeIdfields are automatically migrated to the built-in themes on load.
- Export document as PDF: Added "Export PDF..." to the File menu. Renders all fixed-size pages
in document order, one per PDF page, and downloads as
<document-name>.pdf. Each page uses its own dimensions. Pages without a fixed size are skipped.
- Export page as PNG: Added "Export PNG..." to the File menu. Renders the active page off-screen
at 1:1 scale (using html2canvas) and downloads it as
<document-name>.png. Requires the page to have a fixed size set; shows a helpful message if the page uses infinite canvas mode.
- Settings dialog: Added a Settings modal (File → Settings...) with configurable zoom speeds. Pinch zoom speed (trackpad gesture) and scroll wheel zoom step (mouse wheel) can each be adjusted via sliders, with a "Reset to defaults" button. Settings are stored in app state.
- Smarter pinch detection:
usePanZoomnow distinguishes pinch gestures (deltaMode === 0) from mouse wheel clicks (deltaMode === 1) and applies separate configurable multipliers to each.
- Drill-in container editing: Double-clicking a frame, panel, or dialog on the canvas enters a focused editing mode for that container. While drilled in, all canvas interactions (hit-testing, drag-marquee selection, shape movement) are scoped exclusively to the container's children.
- Visual feedback: An orange border highlights the active container and a small " Editing: [name]" label appears at the top of the canvas while in drill mode.
- Exit options: Double-click outside the container or press Escape to return to normal page-level editing.
- Auto-exit on page change: Switching the active page automatically clears drill mode.
- Added
drilledInContainerId: string | nulltoAppState(view-only, non-undoable). - Added
ENTER_DRILL_MODEandEXIT_DRILL_MODEViewActionvariants; handled in reducer alongsideSET_ACTIVE_PAGEreset. useCanvasPointer.ts:hitTestShapesand marquee selection both scope to the drilled container'sTreeNode.childrenwhendrilledInContainerIdis set.onDoubleClickroutes to drill-in vs. text-edit based on shape type and current drill state.useDocumentShortcuts.ts: Escape priority chain is now text-edit → drill-exit → deselect.
- Reparent with position compensation: Moving a shape to a different parent (via the layer tree or by dragging on the canvas) now preserves its visual position. The shape's local coordinates are recalculated so it appears at the same canvas location after the parent changes.
- Canvas drag-to-reparent: Dragging a shape on the canvas so its center lands inside a frame or panel automatically reparents it into that container. Dragging it out of all containers reparents it back to the active page. Position is compensated in both cases.
- Tree panel reparent fix: Drag-and-drop reordering in the layer panel now adjusts local coordinates when the parent changes, so the shape stays visually in place.
- Added
getContentOrigin(parentId, shapes, parentMap)tosrc/utils/geometry.ts— returns the canvas-space content origin of a given parent shape (used to compute new local coords when reparenting). - Extended
REPARENT_SHAPEaction with optionalx?: number; y?: numberfields; reducer applies them atomically with the tree move (single undo step). useCanvasPointer.ts: on pointer-up after a drag, checks whether each dragged shape has moved into or out of a frame/panel and dispatchesREPARENT_SHAPEwith adjusted coordinates if needed.
- Color palettes: Documents now include named color palettes (multiple palettes supported). A default "Colors" palette ships with 14 swatches (black, white, grays, blue, green, red, yellow, orange, purple, brown, teal, pink).
- Palette-linked colors: Every color field (fill, stroke, text color, track/thumb fills, page
background) can be linked to a palette swatch via
paletteColorId. Editing a palette color instantly updates all linked shapes on the canvas. - Swatches in color pickers: Every
ColorInputshows a row of circular swatches from all palettes. Click a swatch to link the color; using the system picker or hex field sets a raw color and unlinks any palette reference. - Palette editor: File → Edit Palettes opens a two-column modal to add/rename/delete palettes
and their colors. Editing a color dispatches
UPDATE_PALETTE_COLORwhich propagates to all linked shapes. - Document migration: v1 documents (no
palettesfield) are automatically migrated to v2 with the default palette on load. Document version is now 2. - Undo/redo: All palette actions (
ADD/DELETE/RENAME_PALETTE,ADD/DELETE/UPDATE_PALETTE_COLOR) are fully undoable.
- New
tests/store/palette.test.ts— 11 tests covering all palette actions, shape color propagation, non-linked shape immunity, name-only updates, and undo.
- Multi-selection properties: When 2+ shapes are selected the Properties panel now shows Transform (x/y/w/h with mixed-value placeholder), Fill, and Stroke sections in addition to Visible/Locked toggles. Changing a field applies to all selected shapes.
- Shape alignment: New
ALIGN_SHAPESdocument action (undoable) aligns selected shapes in 8 modes: left, center-h, right, top, middle-v, bottom, match-width, match-height. Uses canvas-space coordinate math viacomputeAlignedTransforms()insrc/utils/alignment.ts. - Multi-select context menu: Right-clicking when 2+ shapes are selected (and the target is already in the selection) preserves the multi-selection and shows a dedicated menu: Duplicate, 8 alignment actions, Delete.
- Bug fix:
ALIGN_SHAPESandDUPLICATE_SHAPESwere not tracked inDOCUMENT_ACTION_TYPES, so undo/redo did not work for them. Both are now registered.
- New
tests/utils/alignment.test.ts: covers all 8 alignment types plus line-shape exclusion and empty-ids edge case. - New
tests/store/alignment.test.ts: testsALIGN_SHAPESviaappReducerand undo viahistoryReducer. - Extended
tests/store/reducer.test.ts: multi-shapeDELETE_SHAPESandDUPLICATE_SHAPEStest cases added.
- Full lucide icon picker for Button icon: Replaced the inline 35-icon grid with a dialog that lists all ~1000 lucide-react icons. Type to search by name; active icon highlighted; click to select.
- New
src/utils/allLucideIcons.tsenumerates every exported lucide icon at runtime. - New
src/components/properties/IconPickerDialog.tsx— searchable 8-column grid modal. getButtonIconnow resolves any lucide icon name (not just the original 35).- Bundle size increases from ~316KB to ~1.1MB uncompressed (~226KB gzip) due to including all icons.
- Bug fix: Initial implementation filtered lucide exports by
typeof === 'function', which excluded all icons because lucide wraps them withReact.forwardRef()(returns an object, not a function). Fixed by using lucide-react's built-iniconsnamed export instead.
- Text alignment icon buttons: Replaced the Align and V-Align dropdowns in the Text properties
section with icon button groups. Align uses
AlignLeft/Center/Right; V-Align usesAlignVerticalJustifyStart/Center/End. Active value highlighted in blue. - Added
.iconBtnGroup,.iconBtn,.iconBtnActiveCSS classes toinputs.module.cssfor reuse.
- Label vertical alignment:
LabelShapewas ignoringtext.verticalAlign— its display div usedalignItems: center(hardcoded) instead offlexDirection: column+justifyContent. Now matchesTextShapebehaviour.
- Extracted
useTextEdithook (src/components/canvas/shapes/useTextEdit.ts) containing theuseRef/useEffectedit-state logic and textarea event handlers previously duplicated across 6 shapes. - Extracted
vAlignToJustifyhelper into the same file. TextShape,LabelShape,ButtonShape,CheckboxShape,RadioShape,ToggleShapeall useuseTextEdit— no more copy-pasted boilerplate.
- localStorage document persistence: Documents can now be saved to and loaded from browser
localStorage. The Load/Save toolbar buttons are replaced by a "File" dropdown menu containing:
- Open... — shows a list of previously saved documents; click any to load it
- Save — saves the current document (overwrites if previously saved, otherwise prompts via Save As)
- Save As... — save with a new name or overwrite an existing document
- Import JSON... / Export JSON... — existing file-based import/export, unchanged
- Document name is displayed in the toolbar to show which document is active.
- New files:
src/utils/localStorageDB.ts,src/components/layout/DocumentsModal.tsx/.module.css AppStategainsdocumentIdanddocumentNamefields; newSET_DOCUMENT_METAaction.- New document: File menu includes a "New" item that creates a blank document without reloading the browser.
- Inline rename: Clicking the document name in the toolbar makes it editable in-place (Enter or blur to confirm, Escape to cancel).
- Toolbar reorganisation: File menu and document name moved to the far left; spacer pushes drawing tools to the centre; help button anchored to the far right; separator added between Pan and Shapes tools.
- Selection handles offset on new pages:
getAbsoluteTransformandgetParentContentOriginwere adding the page's owntransform.x/yas a coordinate offset for all child shapes. Since shapes are stored in absolute canvas coordinates (not page-relative), the page parent is now skipped when walking the transform chain. Was invisible on Page 1 (x:0, y:0) but caused a visible offset on any page created with a non-zero position. - New page default position:
createShape('page')now defaults tox:0, y:0instead of the genericx:50, y:50.
- Ruler numbers legible: Increased RULER_SIZE from 20 → 28px; vertical labels now use
ctx.measureTextfor proper centering instead of a fixed char-width estimate; labels drawn in the non-tick area of each ruler. - Page button now activates new page: After inserting a page shape from the toolbar,
SET_ACTIVE_PAGEis now dispatched so the canvas immediately switches to the new empty page.
- Select control text editing: Double-click to edit the selected value directly on the canvas (
inline
<input>). Commit with Enter or Cmd+Enter, cancel with Escape. - Text style properties on all text controls: Added TextSection (font size, weight, family, alignment, color) to textfield, select, stepper, checkbox, toggle, and radio in the Properties panel.
- Checkbox/Toggle/Radio now use TextStyle: Model updated to replace
label: stringwithtext: TextStyle, giving full typography control. Renderers updated to usetext.fontFamily/fontSize/fontWeight/color. - Canvas ruler numbers fixed: Ruler canvases now dynamically match their CSS rendered size ( ×devicePixelRatio for sharpness), so labels render at full size instead of being scaled down from a 4000px buffer. Font increased to 11px; minimum tick spacing increased to 12px screen pixels.
- Toolbar dropdowns no longer render behind canvas: Added
position: relativeand increased z-index to 100 on the toolbar container, ensuring dropdowns always appear above canvas content. - Page button in toolbar now works: Inserting a page shape now uses
parentId: null(root level) instead of the current active page, so pages appear at the document root.
- Canvas ruler: 20px horizontal and vertical rulers positioned in screen space, origin (0 label + blue line) aligned to the active page's top-left corner. Ticks adapt their interval based on zoom level; origin marker moves correctly with pan/zoom.
- Titled Panel / plain Panel split: Existing "Panel" shape is now labelled "Titled Panel"
everywhere (type remains
'panel'for document compatibility). A new "Panel" (type: 'frame') is a simple container with no title bar — rough rect outline with children nested inside. - Dialog shape: New
'dialog'type with a rough title bar, a scrollable body area for child shapes, a rough footer divider, and two rough-rect buttons (Cancel / OK) with configurable labels. - Cmd+drag to duplicate-and-move: Holding Cmd while dragging a shape creates a duplicate at the original position and drags the clone. Pre-generates clone IDs so the drag is immediately transferred to the new shape.
- Lucide icons in context menus: All unicode glyph icons (
⧉,↑↓⬆⬇,👁🚫🔒🔓,✕,📄) replaced with lucide-react components.ContextMenuItem.iconwidened fromstringtoReact.ReactNode. - Shapes dropdown in toolbar: Rect, Circle, and Line moved into a single dropdown button (same pattern as Form Controls). Shows the icon of the last-used shape tool.
- Components dropdown in toolbar: Renamed from "Form Controls"; now has a "Containers" section (
Titled Panel, Panel, Dialog) and a "Form Controls" section. Help
?button added to the right side of the toolbar. - 4 new form controls: Radio Button (
'radio'), Select/Dropdown ('select'), Progress Bar ('progress'), Number Stepper ('stepper') — all rendered with rough.js hand-drawn style. - Tree view auto-switches active page: Clicking any non-page shape in the layer tree that belongs to a different page now automatically switches the active page.
- Help modal (
?): Press?or click the?toolbar button to open a keyboard + mouse shortcuts reference modal. Escape or clicking the overlay closes it.
findAncestorPagehelper added todocument.ts- All ruler, pan, zoom, and resize-handle coordinate conversions updated to account for the 20px ruler offset
DUPLICATE_SHAPESaction accepts an optionalrootIdsarray for pre-seeded clone IDs (used by Cmd+drag)AppState.showShortcutsModal+TOGGLE_SHORTCUTS_MODALview action added
- Cmd+Enter exits text editing: Pressing Cmd+Enter (or Ctrl+Enter) while editing text in any shape commits the edit and exits text editing mode. Applies to all 7 editable shapes: Text, Button, Panel, Label, TextField, Checkbox, Toggle.
- Duplicate action: Added
DUPLICATE_SHAPESdocument action that deep-clones a shape subtree with new IDs, offsets the root clone by (10, 10) in local space, and inserts it after the original in the tree. Accessible via Cmd+D keyboard shortcut, canvas right-click context menu, and tree node right-click context menu. - Zoom 300%/400%: Added 300% and 400% presets to the zoom dropdown and zoom in/out step sequence.
- Status bar: Added a 24px status bar at the bottom of the screen. Left corner has a button to collapse/expand the layer panel (‹/›), right corner for the properties panel. Center displays the name of the currently selected shape, or "N shapes selected" for multi-selection.
- Subshape positioned at mouse cursor: When right-clicking a shape to add a subshape, the new shape's position is now converted from absolute canvas coordinates to parent-local coordinates, so it appears under the cursor rather than at the raw canvas position.
- Button icon support: Buttons can now display a Lucide icon alongside their label. In the
Properties panel, a new "Icon" section shows a 6-column grid of 36 common icons (arrows, chevrons,
UI actions, etc.) to pick from. Left/Right radio buttons control which side the icon appears on; "
None" clears the icon. The icon scales with the button's font size (
fontSize × 1.1), matches the button's text color, and uses astrokeWidthof 1.5 for a lighter hand-drawn look. TheButtonShapemodel gains anicon: { name: string; side: 'left' | 'right' } | nullfield ( defaults tonullfor new buttons).
- Caveat handwritten font for form controls:
Added Caveat (Google Fonts) to
index.html. All form control shapes default toCaveat, cursive— button text, panel title, label, textfield value/placeholder display, checkbox label, and toggle label all render in the hand-drawn font, complementing the RoughJS sketchy outlines.
- 4 new form control shapes with RoughJS rendering:
Label— text label with a subtle rough underline. Double-click to edit text.Text Field— rough rect with placeholder text (shown in gray when value is empty). Double-click to edit displayed value.Checkbox— rough 16×16 tick box with a rough checkmark when checked, and a label to the right. Double-click to edit label.Toggle— rough pill track with a sliding rough circle thumb (moves left/right based oncheckedstate), label to the right. Double-click to edit label.
- Form Controls dropdown in toolbar: Replaced the three individual button/panel/slider toolbar buttons with a single "Form Controls" dropdown. Shows the icon of the currently active form control; clicking opens a menu with all 7 controls (Button, Panel, Slider, Label, Text Field, Checkbox, Toggle).
- Form Controls section in all context menus: Both the canvas right-click menu and the tree node
right-click menu now have separate "Shapes" and "Form Controls" sections when adding shapes. Same
split applied to the Layers panel
+add menu. - Properties panel: Added property sections for all 4 new shapes (transform, content, text style for label; placeholder, fill, stroke for textfield; checked toggle, fill, stroke for checkbox/toggle).
- Space+drag to pan: Holding Space while dragging on the canvas pans the view regardless of the
active tool. The cursor changes to
grabwhile Space is held. Space key is captured onkeydownwhen the canvas container is focused to prevent browser scroll.
- Zoom control in toolbar: Replaced the static zoom label with a
−/ dropdown /+control. The dropdown offers 25%, 50%, 75%, 100%, 150%, 200% presets; if the current zoom is outside that list (e.g. from pinch/scroll) it shows the actual percentage as an extra option. The−/+buttons step through the same presets. All zoom changes useZOOM_TOcentered on the viewport so the canvas center stays fixed.
- Compact transform panel: X/Y/W/H/° fields redesigned with a tight grid layout. Each field is a
bordered pill containing a small label (
X,Y,W,H,°) left-aligned and the number input right-aligned, matching the label to its field visually. X+Y share one row, W+H share the next, ° sits alone on the third row at half-width. Replaced genericNumberInputwrappers with a localTFieldcomponent for full layout control.
- Text editing commit on click-outside: Clicking outside a text textarea was not committing
changes (text reverted). Root cause:
pointerdownfires on the canvas beforeblurfires on the textarea, soDESELECT_ALLclearededitingTextIdfirst, causing the textarea to unmount beforeonBlurcould run. Fixed by watchingisEditingtransitioningtrue → falsein auseEffectinstead of relying ononBlur. AcancelReftracks whether Escape was pressed so the effect knows whether to commit or discard. Escape reverts. Clicking anywhere outside commits. Enter inserts a newline (multi-line). Applied toTextShape,ButtonShape, andPanelShape. - Unit tests: Added
tests/store/textEditCommitOnDeselect.test.tswith 7 tests covering:DESELECT_ALLclearseditingTextId,COMMIT_TEXT_EDITafterDESELECT_ALLstill saves content, cancel path (STOP_TEXT_EDITwithout commit) for all three shape types, and full commit sequences for button and panel.
- Multi-line text with alignment for all text shapes:
TextShape,ButtonShape, andPanelShapetitle now support multi-line text (white-space: pre-wrap,word-break: break-word) and correctly apply both horizontal (text-align) and vertical alignment. Display uses aflexDirection: columncontainer withjustifyContentfor vertical positioning and an innerdivwithtextAlignfor horizontal — the inner div haswidth: 100%so alignment applies across the full width.ButtonShapeandPanelShapetitle were also changed from<input>to<textarea>for multi-line editing. Vertical alignment is reflected in theTextStyle.verticalAlignfield already present in the model.
- Resize handles broken for nested shapes:
startBboxinResizeHandleis in canvas space ( fromgetAbsoluteTransform), butSET_TRANSFORMstores coordinates in parent-local space. AddedgetParentContentOrigintogeometry.tswhich returns the canvas-space origin of a shape's parent content area. The resize handler now subtracts this origin before dispatchingSET_TRANSFORM, converting canvas-space back to local coordinates.
- Marquee (rubber-band) selection: Clicking and dragging on the empty canvas background draws a
selection rectangle. On release, all shapes whose absolute bounding boxes intersect the rectangle
are selected. Shift+drag adds to the existing selection without deselecting first. The marquee is
rendered as a thin blue overlay in screen space; hit testing converts the rectangle to canvas
space and uses
getAbsoluteTransformto correctly handle nested shapes.
- Shift-constrained square resize: Holding Shift while dragging any resize handle constrains width and height to be equal (square). For corner handles the opposite corner stays fixed. For edge handles the shape grows symmetrically around the perpendicular axis.
- Tree view drag-and-drop reparenting: Each tree row is now draggable. Hovering the top 25% of a
row shows a "before" indicator (blue top border), the bottom 25% shows "after" (blue bottom
border), and the middle shows "into" (blue background highlight). Dropping dispatches
REPARENT_SHAPEwith the correctnewParentIdandindex, including same-parent index adjustment (when dragging within the same parent, the target's index shifts after removal). AddedparentIdandnodeIndexprops toTreeNodeCompto carry the positional context needed for the index calculation.
- Nested shape click selection and selection overlay: Shapes inside panels (or other containers)
couldn't be clicked to select in the canvas, and the selection overlay was drawn at the wrong
position. Root cause: both
hitTestShapesandSelectionOverlaywere treating shapetransform.x/yas canvas-absolute coordinates, but for nested shapes those are parent-relative. Fixed by addingbuildParentMapandgetAbsoluteTransformhelpers togeometry.tsthat walk the tree to compute absolute canvas-space positions (including the panel title-bar Y offset for panel children). BothuseCanvasPointerandSelectionOverlaynow use these helpers.
- Canvas context menu delete not working: Clicking a menu item in the canvas context menu (a
React portal) was bubbling
pointerdown/pointerup/clickthrough the React component tree into the canvas container'sonPointerDownhandler, which calledsetPointerCaptureand dispatchedDESELECT_ALL, preventing delete from completing and the menu from closing. Fixed by addingstopPropagationforonPointerDown,onPointerUp,onClick, andonContextMenuon theContextMenudiv — applies to both canvas and tree context menus. - Unit test: Added
tests/store/canvasContextMenu.test.tswith 5 tests covering the delete action sequence (shape removed from map, removed from tree, selection cleared, other shapes unaffected, full action sequence).
- Canvas context menu: Right-clicking anywhere on the canvas now shows a context menu.
Right-clicking a shape selects it and shows: Add Child Shape submenu, Bring to Front/Send to
Back/Move Up/Move Down, Hide/Show, Lock/Unlock, Delete. Right-clicking empty canvas shows an Add
Shape submenu (all types inserted at the cursor position, added to the active page). Implemented
in
CanvasContextMenu.tsx;useCanvasPointerexposesonContextMenu/contextMenu/closeContextMenu. - Stable rough.js seeds:
seedFromIdhelper derives a deterministic seed from each shape's UUID so hand-drawn paths don't jitter on re-render when shapes are moved. Added toroughPaths.ts; used in ButtonShape, PanelShape, and SliderShape.
- Hand-drawn UI components via rough.js:
ButtonShape,PanelShape, andSliderShapenow render using the rough.js generator API (roughRect,roughCircle,roughLine) producing sketchy/hand-drawn SVG paths. Each component renders an absolute SVG overlay withRoughSvgPathsfor the background, plus HTML overlay for text/children. PanelShape includes a rough divider line below the title bar. SliderShape renders a rough rect track and rough circle thumb positioned byvalue. - Added
src/utils/roughPaths.ts(generator utilities) andsrc/utils/RoughSvgPaths.tsx(SVG path component).
- Context menu on tree nodes: Right-click any tree item to get a contextual menu. Pages show " Set as Active Page" + "Add Shape" submenu (all shape types). Non-page shapes show "Add Child Shape" submenu + Move Up/Down/To Front/To Back + Hide/Lock + Delete.
- Light mode theme: All panels, toolbar, canvas, and inputs converted from dark to light.
- Wider properties panel: Default 300px, resizable.
- Resizable sidebars: Drag handles between panels allow resizing both the left tree sidebar and right properties panel (min 150px, max 500px).
- Locked shapes: Locked shapes cannot be moved or have properties changed via the reducer (
MOVE_SHAPES,SET_TRANSFORM,PATCH_SHAPEall bail out for locked shapes, except visibility/lock toggles). Properties panel shows a yellow banner and dims/disables the property sections while still showing values. - Selection overlay pan offset fix:
SelectionOverlaywas rendering inside the CSS-transformed canvas div but then re-applyingpanX/panY/zoom, doubling the offset. Fixed to use canvas-space coordinates directly; handle sizes divided byzoomto stay visually constant. - Line rendering fix: SVG path coordinates were in world space but the SVG was positioned at
(minX, minY). Path points now subtractminX/minYto be in SVG-local space. - Line selection fix: Hit testing was skipping all lines. Added
pointNearLinehit test with a tolerance scaled by1/zoom. - Lucide-react icons: Replaced emoji/unicode icons in toolbar and tree panel with lucide-react icons.
- Reparenting via context menu: "Move into" shape nesting is available via the context menu "Add Child Shape" flow (creates shape as child of target node).
- Double-click text editing broken:
setPointerCaptureon the canvas container retargets all derived mouse events (includingdblclick) to itself, soonDoubleClickon shape divs never fired. Fixed by addingonDoubleClickdirectly to the container inuseCanvasPointer— it hit-tests the position and dispatchesSTART_TEXT_EDITfor text-bearing shapes (text, button, panel). - Text content not editable from properties panel:
TextSectiononly exposed style properties ( color, font size, alignment). Added aContentSectioncomponent with a textarea that dispatchesCOMMIT_TEXT_EDITon change. Added to the properties panel fortext,button, andpanelshape types. - Added 10 unit tests covering
START_TEXT_EDIT,STOP_TEXT_EDIT,COMMIT_TEXT_EDIT(for all three shape types, undoability, no-ops), andPATCH_SHAPEfor text style properties.
Initial implementation of Vibe 2D Layout.
shapes.ts: Discriminated union of all shape types (rect, circle, line, text, image, page, button, panel, slider) with shared style interfaces (FillStyle, StrokeStyle, TextStyle)document.ts: VibeDocument with flat normalized shape map + separate TreeNode topology; tree helpers (findNode, removeNode, insertNode, getAllIds)transform.ts: BoundingBox, Point, Anchor typesconnector.ts: ConnectorEndpoint (free | attached) and ConnectorRoute
types.ts: AppState, all AppAction discriminated union (DocumentAction, SelectionAction, ViewAction, HistoryAction)reducer.ts: Pure reducer handling all actions; screen↔canvas coordinate helpershistory.ts: Undo/redo via snapshot ring-buffer (max 100); only DocumentActions create history entriescontext.tsx: Two separate React contexts (state + dispatch) to avoid unnecessary re-rendersselectors.ts: Derived state helpers
geometry.ts: anchorPoint, pointInBox, pointNearLine, unionBoxes, distance, rotatePointconnectors.ts: resolveEndpoint, buildConnectorPath (straight/orthogonal/curved), arrowMarkerPathserialization.ts: toJSON/fromJSON with validation, downloadJSON, uploadJSONshapeFactory.ts: createShape factory with sensible defaults per typeidgen.ts: crypto.randomUUID-based ID generation
- Three-column app shell (sidebar 220px | canvas flex | properties 260px) with dark theme
- Canvas: Pan/zoom, shape rendering (HTML div-based), selection overlay with 8 resize handles, connector line rendering (SVG), inline text editing
- Toolbar: Tool mode buttons, undo/redo, zoom display, save/load
- Tree panel: Recursive tree view, expand/collapse, visibility/lock/delete per node, add shape menu
- Properties panel: Per-shape type sections (Transform, Fill, Stroke, Text, Image, Connector, Page)
useCanvasPointer: Pointer state machine for select/pan/insert tool modesusePanZoom: Wheel event handler for zoom (Ctrl+wheel) and panuseDocumentShortcuts: Keyboard shortcuts (undo, redo, delete, arrows, escape, select-all)
- 64 unit tests across 6 test files covering document tree operations, geometry, connector routing, serialization, reducer actions, and undo/redo history
- Converted all inline
<div className={styles.section}>blocks inPropertiesPanel.tsxto use<CollapsibleSection>(21 sections total) - Removed unused
stylesimport from ContentSection, FillSection, PageSection, ShadowSection, StrokeSection
- Added
scaleX,scaleY,skewX,skewYoptional fields toBoundingBoxinsrc/model/transform.ts - Added
buildCSSTransform(t)utility that composes rotate/scale/skew into a CSS transform string - Updated all 25 shape renderers to use
buildCSSTransform(transform)instead of inline rotate-only expression - Added SX (scale X %), SY (scale Y %), KX (skew X °), KY (skew Y °) inputs to
TransformSection
- Reordered Transform section fields: X/Y, W/H, SX/SY, KX/KY, °
- Widened
.tlabelfrom 10px to 14px to fit 2-char labels (SX, SY, KX, KY)
- Fix: shapes being dragged on a secondary page could be silently reparented to containers on another page
- Root cause:
findDropTargetinuseCanvasPointer.tswalked all pages' shape trees; since pages default to canvas origin (0,0), page 1 containers matched drop targets for shapes on page 2 - Fix: scope
findDropTargetto active page's children only, via newactivePageIdparameter