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) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-04 02:04:52 +00:00 committed by Jeff Emmett
parent cc12f7a936
commit 3a3b83c807
1 changed files with 232 additions and 2 deletions

View File

@ -675,6 +675,136 @@ const STYLES = `
.gc-detail__delete:hover { .gc-detail__delete:hover {
background: #7f1d1d; 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 ── // ── Component ──
@ -692,6 +822,10 @@ export class FolkGovCircuit extends HTMLElement {
private canvasPanY = 0; private canvasPanY = 0;
private showGrid = true; private showGrid = true;
// Sidebar state
private paletteOpen = false;
private detailOpen = false;
// Interaction // Interaction
private isPanning = false; private isPanning = false;
private panStartX = 0; private panStartX = 0;
@ -704,9 +838,15 @@ export class FolkGovCircuit extends HTMLElement {
private dragNodeStartX = 0; private dragNodeStartX = 0;
private dragNodeStartY = 0; private dragNodeStartY = 0;
// Touch — pinch-to-zoom
private activeTouches: Map<number, { x: number; y: number }> = new Map();
private pinchStartDist = 0;
private pinchStartZoom = 1;
private pinchMidX = 0;
private pinchMidY = 0;
// Selection & detail panel // Selection & detail panel
private selectedNodeId: string | null = null; private selectedNodeId: string | null = null;
private detailOpen = false;
// Wiring // Wiring
private wiringActive = false; private wiringActive = false;
@ -719,6 +859,9 @@ export class FolkGovCircuit extends HTMLElement {
private _boundPointerMove: ((e: PointerEvent) => void) | null = null; private _boundPointerMove: ((e: PointerEvent) => void) | null = null;
private _boundPointerUp: ((e: PointerEvent) => void) | null = null; private _boundPointerUp: ((e: PointerEvent) => void) | null = null;
private _boundKeyDown: ((e: KeyboardEvent) => 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() { constructor() {
super(); super();
@ -735,6 +878,9 @@ export class FolkGovCircuit extends HTMLElement {
if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove); if (this._boundPointerMove) document.removeEventListener('pointermove', this._boundPointerMove);
if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp); if (this._boundPointerUp) document.removeEventListener('pointerup', this._boundPointerUp);
if (this._boundKeyDown) document.removeEventListener('keydown', this._boundKeyDown); 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 ── // ── Data init ──
@ -811,6 +957,7 @@ export class FolkGovCircuit extends HTMLElement {
<div class="gc-root"> <div class="gc-root">
<div class="gc-toolbar"> <div class="gc-toolbar">
<div class="gc-toolbar__title"> <div class="gc-toolbar__title">
<button class="gc-sidebar-toggle ${this.paletteOpen ? 'active' : ''}" id="btn-palette-toggle" title="Toggle palette">&#9776;</button>
<span>\u25A3 Governance Circuits</span> <span>\u25A3 Governance Circuits</span>
</div> </div>
<div class="gc-toolbar__actions"> <div class="gc-toolbar__actions">
@ -822,7 +969,7 @@ export class FolkGovCircuit extends HTMLElement {
</div> </div>
<div class="gc-canvas-area"> <div class="gc-canvas-area">
<div class="gc-palette" id="palette"> <div class="gc-palette ${this.paletteOpen ? 'mobile-open' : 'collapsed'}" id="palette">
<div class="gc-palette__title">Node Types</div> <div class="gc-palette__title">Node Types</div>
${GOV_NODE_CATALOG.map(n => ` ${GOV_NODE_CATALOG.map(n => `
<div class="gc-palette__card" data-node-type="${n.type}" draggable="true"> <div class="gc-palette__card" data-node-type="${n.type}" draggable="true">
@ -1354,6 +1501,18 @@ export class FolkGovCircuit extends HTMLElement {
this.addNode(type, x - NODE_WIDTH / 2, y - NODE_HEIGHT / 2); 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 pointer events
svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e)); svg.addEventListener('pointerdown', (e: PointerEvent) => this.handlePointerDown(e));
@ -1365,6 +1524,14 @@ export class FolkGovCircuit extends HTMLElement {
document.addEventListener('pointerup', this._boundPointerUp); document.addEventListener('pointerup', this._boundPointerUp);
document.addEventListener('keydown', this._boundKeyDown); 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 // Detail panel
this.attachDetailListeners(); 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) { private handleKeyDown(e: KeyboardEvent) {
const tag = (e.target as Element)?.tagName; const tag = (e.target as Element)?.tagName;
const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'; const isEditing = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';