openpencil/openspec/changes/mobile-layout/tasks.md
Anton Soldatov 168c25c189
feat: mobile layout, PWA support (#27) (#27)
- Mobile-responsive editor with bottom drawer, ribbon nav, HUD overlay
- Touch support: single-finger tools, two-finger pinch-zoom
- PWA: manifest, service worker, installability
- Extract LayerTree from LayersPanel, shared utils (colorToCSS, initials, toolIcons)
- Constants moved to src/constants.ts, clipboard state to editor store
- reka-ui Popover/DropdownMenu in MobileHud

Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-03-05 19:51:51 +03:00

5.2 KiB

1. Store & State

  • 1.1 Add mobile state to editor store (src/stores/editor.ts): activeRibbonTab: 'panels' | 'code' | 'ai' | null (default null), panelMode: 'layers' | 'design' (default 'design'), mobileDrawerSnap: 0 | 'half' | 'full' | 'closed' (default 0), actionToast: string | null (default null)
  • 1.2 Fix showUI initialization: change from matchMedia(...) to true

2. Extract LayerTree Component

  • 2.1 Create src/components/LayerTree.vue — extract tree logic from LayersPanel: reka-ui TreeRoot/TreeItem, drag reorder, expand/collapse, context menu, visibility icon. Import order: reka-ui → vue → @vueuse → icons → @/ → relative
  • 2.2 Refactor src/components/LayersPanel.vue to use <LayerTree />

3. MobileRibbon Component

  • 3.1 Create src/components/MobileRibbon.vue — fixed bar at viewport bottom (fixed bottom-0 z-40), outside the drawer. Layers/Design as separate buttons (icon + label when active), Code/AI as icon-only buttons on right. 44px touch targets. env(safe-area-inset-bottom) padding. All tabs have select-none outline-none transition-colors. No hover: states — mobile only uses state-based styles
  • 3.2 Swipe gestures on ribbon: swipe up opens drawer (half/full), swipe down closes. SWIPE_THRESHOLD = 30, SWIPE_MAX_DURATION = 500
  • 3.3 Tab tap toggles: tap active tab while drawer open → close drawer + reset activeRibbonTab to null

4. MobileDrawer Component

  • 4.1 Create src/components/MobileDrawer.vue — custom drawer using CSS translateY + transition (NOT vaul-vue). Snap states: closed, half (3/7 viewport), full (viewport - 44px ribbon - 94px HUD). renderSnap ref with nextTick() + requestAnimationFrame() for smooth open animation
  • 4.2 Drag gestures on entire drawer body (not just handle). 50px threshold. Swipe down: full→half→closed. Swipe up: closed→half→full. Drag handle with select-none
  • 4.3 Drawer header: compact PagesPanel (horizontal scrollable) with border-b
  • 4.4 Panel content via v-show: LayerTree, DesignPanel, CodePanel, ChatPanel. @touchstart.stop @touchmove.stop on content area to prevent canvas interaction

5. MobileHud Component

  • 5.1 Create src/components/MobileHud.vue — absolute overlay with pointer-events-none root, pointer-events-auto on interactive groups. @touchstart.stop on root
  • 5.2 Top-left: Undo/redo buttons (size-8 rounded-full border border-border shadow-md select-none) + active tool indicator (border-accent/30, animated icon change via Transition mode="out-in")
  • 5.3 Top-center: Online badge with peers dropdown (simple div toggle, not reka-ui Popover) + action toast (toast-fade transition, 800ms duration from ACTION_TOAST_DURATION)
  • 5.4 Top-right: Share button + burger menu (onClickOutside for dismiss). Menu items: New, Open, Save, Export, Zoom to fit. Each with Lucide icon. border-none bg-transparent outline-none select-none

6. EditorView Responsive Layout

  • 6.1 Add isMobile = useMediaQuery('(max-width: 767px)') in EditorView. Conditional: desktop SplitterGroup vs mobile layout
  • 6.2 Mobile layout: EditorCanvas + MobileHud (collab props forwarded) + Toolbar + MobileRibbon + MobileDrawer
  • 6.3 Wire @tab-change on MobileRibbon: auto-open drawer to half if collapsed

7. Keyboard Shortcut: ⌘J on mobile

  • 7.1 In use-keyboard.ts, detect isMobile. On ⌘J: mobile toggles activeRibbonTab between current and 'ai' (opens drawer if collapsed); desktop unchanged

8. Toolbar Mobile Adaptation

  • 8.1 3 categories: Category 0 (drawing tools), Category 1 (Copy/Paste/Cut/Duplicate/Delete), Category 2 (Front/Back/Group/Ungroup/Lock). Arrow navigation buttons
  • 8.2 Animated container width: active category relative (sets width), others absolute left-2 top-1/2 -translate-y-1/2 with opacity transition. Width via scrollWidth measurement + transition: width 250ms ease
  • 8.3 Internal clipboard: mobileCopy(), mobileCut(), mobilePaste() using in-memory buffer
  • 8.4 Action toast: onActionTap() sets store.state.actionToast for ACTION_TOAST_DURATION (800ms)
  • 8.5 All buttons: select-none border-none, rounded-[8px] container, rounded-[6px] buttons, h-11 container with size-8 buttons

9. Touch & Canvas

  • 9.1 use-canvas-input.ts: syntheticMouse() helper creates MouseEvent from Touch. Single finger on non-HAND tool → delegate to mouse handlers. Two-finger → pinch-zoom (existing)
  • 9.2 use-canvas.ts: hide rulers on mobile via !isMobile check

10. Code Style Alignment

  • 10.1 Import ordering: reka-ui → vue → icons → @vueuse → @/ → relative → type imports
  • 10.2 No semicolons (codebase convention)
  • 10.3 select-none on all tappable elements
  • 10.4 outline-none on focusable elements (MobileRibbon tabs, menu items)
  • 10.5 Magic numbers extracted: ACTION_TOAST_DURATION, SWIPE_MAX_DURATION, SWIPE_THRESHOLD, RIBBON_H, HALF_FRAC, HUD_TOP
  • 10.6 No hover: on mobile-only elements — only active: for touch feedback
  • 10.7 border-none bg-transparent on menu/action buttons

11. Verification

  • 11.1 Desktop layout unchanged at ≥768px
  • 11.2 bun run check passes (0 errors)