From ba7a5733b8b30c0e9f02ae78f1e144588956ab3e Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 4 Apr 2026 02:04:52 +0000 Subject: [PATCH] fix(rgov): mobile-friendly canvas with collapsible sidebars and touch support - Collapsible palette sidebar (hamburger toggle, hidden by default on mobile) - Pinch-to-zoom and two-finger pan for touch/pen - Larger touch targets for ports and zoom controls - Responsive text sizing and compact toolbar on small screens - Detail panel goes full-width on very small screens - touch-action: none on SVG to prevent browser gesture conflicts Co-Authored-By: Claude Opus 4.6 (1M context) --- modules/rgov/components/folk-gov-circuit.ts | 234 +++++++++++++++++++- 1 file changed, 232 insertions(+), 2 deletions(-) diff --git a/modules/rgov/components/folk-gov-circuit.ts b/modules/rgov/components/folk-gov-circuit.ts index 924c185..b8bae48 100644 --- a/modules/rgov/components/folk-gov-circuit.ts +++ b/modules/rgov/components/folk-gov-circuit.ts @@ -675,6 +675,136 @@ const STYLES = ` .gc-detail__delete:hover { background: #7f1d1d; } + +/* ── Collapsible sidebar toggle buttons ── */ + +.gc-sidebar-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 1px solid #334155; + border-radius: 6px; + background: #1e293b; + color: #94a3b8; + font-size: 16px; + cursor: pointer; + flex-shrink: 0; +} + +.gc-sidebar-toggle:hover { + background: #334155; + color: #e2e8f0; +} + +.gc-sidebar-toggle.active { + background: #334155; + color: #38bdf8; + border-color: #38bdf8; +} + +/* ── Palette collapsed state ── */ + +.gc-palette { + transition: width 0.2s, padding 0.2s, opacity 0.15s; +} + +.gc-palette.collapsed { + width: 0; + padding: 0; + overflow: hidden; + border-right: none; + opacity: 0; + pointer-events: none; +} + +/* ── Touch & pen support ── */ + +.gc-canvas svg { + touch-action: none; + -webkit-user-select: none; + user-select: none; +} + +/* ── Mobile responsive ── */ + +@media (max-width: 768px) { + .gc-toolbar { + padding: 6px 10px; + gap: 6px; + } + .gc-toolbar__title { + font-size: 13px; + gap: 6px; + } + .gc-btn { + padding: 4px 8px; + font-size: 11px; + } + .gc-palette { + width: 0; + padding: 0; + overflow: hidden; + border-right: none; + opacity: 0; + pointer-events: none; + } + .gc-palette.mobile-open { + width: 160px; + padding: 10px 6px; + overflow-y: auto; + border-right: 1px solid #334155; + opacity: 1; + pointer-events: auto; + position: absolute; + left: 0; + top: 0; + bottom: 0; + z-index: 10; + background: #1e293b; + } + .gc-detail.open { + width: 240px; + position: absolute; + right: 0; + top: 0; + bottom: 0; + z-index: 10; + background: #1e293b; + } + .gc-zoom-controls { + bottom: 10px; + } + .gc-zoom-btn { + width: 36px; + height: 36px; + } + .gc-zoom-level { + font-size: 12px; + min-width: 44px; + } + /* Larger port hit targets for touch */ + .gc-port-hit { + r: 18; + } + .gc-palette__card { + padding: 8px 8px; + font-size: 13px; + } +} + +@media (max-width: 480px) { + .gc-toolbar__title span:not(:first-child) { + display: none; + } + .gc-detail.open { + width: 100%; + } + .gc-palette.mobile-open { + width: 180px; + } +} `; // ── Component ── @@ -692,6 +822,10 @@ export class FolkGovCircuit extends HTMLElement { private canvasPanY = 0; private showGrid = true; + // Sidebar state + private paletteOpen = false; + private detailOpen = false; + // Interaction private isPanning = false; private panStartX = 0; @@ -704,9 +838,15 @@ export class FolkGovCircuit extends HTMLElement { private dragNodeStartX = 0; private dragNodeStartY = 0; + // Touch — pinch-to-zoom + private activeTouches: Map = new Map(); + private pinchStartDist = 0; + private pinchStartZoom = 1; + private pinchMidX = 0; + private pinchMidY = 0; + // Selection & detail panel private selectedNodeId: string | null = null; - private detailOpen = false; // Wiring private wiringActive = false; @@ -719,6 +859,9 @@ export class FolkGovCircuit extends HTMLElement { private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null; private _boundKeyDown: ((e: KeyboardEvent) => void) | null = null; + private _boundTouchStart: ((e: TouchEvent) => void) | null = null; + private _boundTouchMove: ((e: TouchEvent) => void) | null = null; + private _boundTouchEnd: ((e: TouchEvent) => void) | null = null; constructor() { super(); @@ -735,6 +878,9 @@ export class FolkGovCircuit extends HTMLElement { if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); + if (this._boundTouchStart) document.removeEventListener('touchstart', this._boundTouchStart); + if (this._boundTouchMove) document.removeEventListener('touchmove', this._boundTouchMove); + if (this._boundTouchEnd) document.removeEventListener('touchend', this._boundTouchEnd); } // ── Data init ── @@ -811,6 +957,7 @@ export class FolkGovCircuit extends HTMLElement {
+ \u25A3 Governance Circuits
@@ -822,7 +969,7 @@ export class FolkGovCircuit extends HTMLElement {
-
+
Node Types
${GOV_NODE_CATALOG.map(n => `
@@ -1354,6 +1501,18 @@ export class FolkGovCircuit extends HTMLElement { this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); }); + // Palette toggle + this.shadow.getElementById('btn-palette-toggle')?.addEventListener('click', () => { + this.paletteOpen = !this.paletteOpen; + const pal = this.shadow.getElementById('palette'); + const btn = this.shadow.getElementById('btn-palette-toggle'); + if (pal) { + pal.classList.toggle('collapsed', !this.paletteOpen); + pal.classList.toggle('mobile-open', this.paletteOpen); + } + if (btn) btn.classList.toggle('active', this.paletteOpen); + }); + // SVG pointer events svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); @@ -1365,6 +1524,14 @@ export class FolkGovCircuit extends HTMLElement { document.addEventListener('pointerup', this._boundPointerUp); document.addEventListener('keydown', this._boundKeyDown); + // Touch: pinch-to-zoom + two-finger pan + this._boundTouchStart = (e: TouchEvent) => this.handleTouchStart(e); + this._boundTouchMove = (e: TouchEvent) => this.handleTouchMove(e); + this._boundTouchEnd = (e: TouchEvent) => this.handleTouchEnd(e); + svg.addEventListener('touchstart', this._boundTouchStart, { passive: false }); + svg.addEventListener('touchmove', this._boundTouchMove, { passive: false }); + svg.addEventListener('touchend', this._boundTouchEnd, { passive: false }); + // Detail panel this.attachDetailListeners(); } @@ -1526,6 +1693,69 @@ export class FolkGovCircuit extends HTMLElement { } } + // ── Touch handlers (pinch-to-zoom, two-finger pan) ── + + private handleTouchStart(e: TouchEvent) { + for (let i = 0; i < e.changedTouches.length; i++) { + const t = e.changedTouches[i]; + this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY }); + } + if (this.activeTouches.size === 2) { + e.preventDefault(); + const pts = [...this.activeTouches.values()]; + this.pinchStartDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + this.pinchStartZoom = this.canvasZoom; + this.pinchMidX = (pts[0].x + pts[1].x) / 2; + this.pinchMidY = (pts[0].y + pts[1].y) / 2; + this.panStartPanX = this.canvasPanX; + this.panStartPanY = this.canvasPanY; + } + } + + private handleTouchMove(e: TouchEvent) { + for (let i = 0; i < e.changedTouches.length; i++) { + const t = e.changedTouches[i]; + this.activeTouches.set(t.identifier, { x: t.clientX, y: t.clientY }); + } + if (this.activeTouches.size === 2) { + e.preventDefault(); + const pts = [...this.activeTouches.values()]; + const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); + const midX = (pts[0].x + pts[1].x) / 2; + const midY = (pts[0].y + pts[1].y) / 2; + + // Zoom + const svg = this.shadow.getElementById('gc-svg') as SVGSVGElement | null; + if (svg) { + const rect = svg.getBoundingClientRect(); + const scale = dist / this.pinchStartDist; + const newZoom = Math.max(0.1, Math.min(4, this.pinchStartZoom * scale)); + const cx = this.pinchMidX - rect.left; + const cy = this.pinchMidY - rect.top; + this.canvasPanX = cx - (cx - this.panStartPanX) * (newZoom / this.pinchStartZoom); + this.canvasPanY = cy - (cy - this.panStartPanY) * (newZoom / this.pinchStartZoom); + this.canvasZoom = newZoom; + + // Pan offset from midpoint movement + this.canvasPanX += (midX - this.pinchMidX); + this.canvasPanY += (midY - this.pinchMidY); + this.pinchMidX = midX; + this.pinchMidY = midY; + + this.updateCanvasTransform(); + } + } + } + + private handleTouchEnd(e: TouchEvent) { + for (let i = 0; i < e.changedTouches.length; i++) { + this.activeTouches.delete(e.changedTouches[i].identifier); + } + if (this.activeTouches.size < 2) { + this.pinchStartDist = 0; + } + } + private handleKeyDown(e: KeyboardEvent) { const tag = (e.target as Element)?.tagName; const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';