From 5f45014226c132b487710793bdc4da0555469bca Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 12:06:44 -0700 Subject: [PATCH] =?UTF-8?q?feat(rtime):=20unified=20Canvas=20view=20?= =?UTF-8?q?=E2=80=94=20merge=20Pool=20+=20Weave=20with=20pan/zoom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 3-tab layout (Pool/Weave/Collaborate) with 2-tab Canvas + Collaborate. Canvas tab has collapsible left pool panel with orbs alongside infinite SVG canvas with pan/zoom (ctrl+wheel zoom, wheel pan, space+drag, touch pinch). - Long-press orb in pool → drag onto canvas to create commitment node - Drop on matching task port auto-creates wire connection - Gold glow highlights unfulfilled task ports matching dragged skill - "Frame as Tasks" button on solver results creates task nodes with dashed intent frame on canvas - Add intentFrameId to Task schema for solver-to-task linkage - Zoom controls overlay (+/−/reset) - Light/dark theme support for all new elements Co-Authored-By: Claude Opus 4.6 --- modules/rtime/components/folk-timebank-app.ts | 796 ++++++++++++++---- modules/rtime/mod.ts | 3 +- modules/rtime/schemas.ts | 1 + 3 files changed, 636 insertions(+), 164 deletions(-) diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts index 38603f9..eb7a4b5 100644 --- a/modules/rtime/components/folk-timebank-app.ts +++ b/modules/rtime/components/folk-timebank-app.ts @@ -245,9 +245,9 @@ function svgText(txt: string, x: number, y: number, size: number, color: string, class FolkTimebankApp extends HTMLElement { private shadow: ShadowRoot; private space = 'demo'; - private currentView: 'pool' | 'weave' | 'collaborate' = 'pool'; + private currentView: 'canvas' | 'collaborate' = 'canvas'; - // Pool state + // Pool panel state private canvas!: HTMLCanvasElement; private ctx!: CanvasRenderingContext2D; private orbs: Orb[] = []; @@ -263,8 +263,25 @@ class FolkTimebankApp extends HTMLElement { private dpr = 1; private poolPointerId: number | null = null; private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null; + private poolPanelCollapsed = false; - // Weave state + // Pan/zoom state + private panX = 0; + private panY = 0; + private scale = 1; + private canvasGroup!: SVGGElement; + private isPanning = false; + private panStart = { x: 0, y: 0, panX: 0, panY: 0 }; + private spaceHeld = false; + private intentFramesLayer!: SVGGElement; + + // Orb drag-to-canvas state + private draggingOrb: Orb | null = null; + private draggingSkill: string | null = null; + private orbDragGhost: HTMLElement | null = null; + private orbDragTimer: ReturnType | null = null; + + // SVG canvas state private svgEl!: SVGSVGElement; private nodesLayer!: SVGGElement; private connectionsLayer!: SVGGElement; @@ -306,18 +323,22 @@ class FolkTimebankApp extends HTMLElement { attributeChangedCallback(name: string, _old: string, val: string) { if (name === 'space') this.space = val; - if (name === 'view' && (val === 'pool' || val === 'weave' || val === 'collaborate')) this.currentView = val; + if (name === 'view' && (val === 'canvas' || val === 'collaborate')) this.currentView = val; } connectedCallback() { this.space = this.getAttribute('space') || 'demo'; - this.currentView = (this.getAttribute('view') as any) || 'pool'; + const rawView = this.getAttribute('view'); + // Map legacy view names + if (rawView === 'pool' || rawView === 'weave' || rawView === 'canvas') this.currentView = 'canvas'; + else if (rawView === 'collaborate') this.currentView = 'collaborate'; + else this.currentView = 'canvas'; this.dpr = window.devicePixelRatio || 1; this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark'; this.render(); this.applyTheme(); - this.setupPool(); - this.setupWeave(); + this.setupPoolPanel(); + this.setupCanvas(); this.setupCollaborate(); this.fetchData(); } @@ -399,9 +420,10 @@ class FolkTimebankApp extends HTMLElement { // Auto-place first task if canvas is empty if (this.weaveNodes.length === 0 && this.tasks.length > 0) { - const svgRect = this.svgEl?.getBoundingClientRect(); - const x = svgRect ? svgRect.width * 0.55 : 400; - const y = svgRect ? svgRect.height * 0.2 : 80; + const wrap = this.shadow.getElementById('canvasWrap'); + const wrapRect = wrap?.getBoundingClientRect(); + const x = wrapRect ? wrapRect.width * 0.4 : 400; + const y = wrapRect ? wrapRect.height * 0.2 : 80; this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y)); this.renderAll(); this.rebuildSidebar(); @@ -412,8 +434,7 @@ class FolkTimebankApp extends HTMLElement { this.shadow.innerHTML = `
-
Commitment Pool
-
Weaving Dashboard
+
Canvas
Collaborate
@@ -424,26 +445,30 @@ class FolkTimebankApp extends HTMLElement {
-
- -
-
-
-
+
+ +
+
+ Commitments +
-
-
-
-
- -
- `; @@ -1945,6 +2267,108 @@ class FolkTimebankApp extends HTMLElement { } catch { /* ignore */ } }); }); + + // Frame as Tasks buttons + container.querySelectorAll('.solver-frame-btn').forEach(btn => { + btn.addEventListener('click', async () => { + const resultId = (btn as HTMLElement).dataset.resultId; + const result = this.solverResults.find(r => r.id === resultId); + if (!result) return; + await this.frameResultAsTasks(result); + }); + }); + } + + /** Create tasks from a solver result and lay them out on the canvas with a frame. */ + private async frameResultAsTasks(result: any) { + const skills = result.skills || []; + const skillPairs = skills.length > 0 ? skills : ['facilitation']; + + // Group skills into task(s) — one task per 2 skills, or one per unique group + const taskDefs: { name: string; needs: Record }[] = []; + if (skillPairs.length <= 2) { + taskDefs.push({ + name: `Collaboration: ${skillPairs.map((s: string) => SKILL_LABELS[s] || s).join(' + ')}`, + needs: Object.fromEntries(skillPairs.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])), + }); + } else { + for (let i = 0; i < skillPairs.length; i += 2) { + const group = skillPairs.slice(i, i + 2); + taskDefs.push({ + name: `${group.map((s: string) => SKILL_LABELS[s] || s).join(' + ')} Work`, + needs: Object.fromEntries(group.map((s: string) => [s, Math.ceil((result.totalHours || 4) / skillPairs.length)])), + }); + } + } + + // POST tasks to server and collect created tasks + const createdTasks: TaskData[] = []; + for (const def of taskDefs) { + try { + const resp = await fetch(`${this.getApiBase()}/api/tasks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, + body: JSON.stringify({ name: def.name, description: `From solver result ${result.id}`, needs: def.needs, intentFrameId: result.id }), + }); + if (resp.ok) { + const task = await resp.json(); + createdTasks.push({ ...task, fulfilled: {} }); + this.tasks.push(task); + } + } catch { /* offline */ } + } + + if (createdTasks.length === 0) return; + + // Auto-layout: horizontal row on canvas + const wrap = this.shadow.getElementById('canvasWrap'); + const wrapRect = wrap?.getBoundingClientRect(); + const startX = wrapRect ? wrapRect.width * 0.15 : 100; + const startY = wrapRect ? wrapRect.height * 0.3 : 150; + const gap = TASK_W + 40; + + createdTasks.forEach((t, i) => { + const x = startX + i * gap; + const y = startY; + this.weaveNodes.push(this.mkTaskNode(t, x, y)); + }); + + // Draw dashed frame around the group in intentFramesLayer + const padding = 30; + const frameX = startX - padding; + const frameY = startY - padding - 20; + const frameW = createdTasks.length * gap - 40 + TASK_W + padding; + const frameH = 200 + padding * 2; + + const frame = ns('rect'); + frame.setAttribute('x', String(frameX)); + frame.setAttribute('y', String(frameY)); + frame.setAttribute('width', String(frameW)); + frame.setAttribute('height', String(frameH)); + frame.setAttribute('rx', '12'); + frame.setAttribute('fill', 'none'); + frame.setAttribute('stroke', '#fbbf2466'); + frame.setAttribute('stroke-width', '2'); + frame.setAttribute('stroke-dasharray', '8 4'); + frame.setAttribute('class', 'intent-frame'); + this.intentFramesLayer.appendChild(frame); + + // Frame label + const label = svgText( + `Solver: ${(result.skills || []).map((s: string) => SKILL_LABELS[s] || s).join(', ')}`, + frameX + 12, frameY + 16, 11, '#fbbf24', '500' + ); + label.setAttribute('class', 'intent-frame-label'); + this.intentFramesLayer.appendChild(label); + + this.renderAll(); + this.rebuildSidebar(); + + // Switch to canvas view + this.currentView = 'canvas'; + this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === 'canvas')); + this.shadow.getElementById('canvas-view')!.style.display = 'flex'; + this.shadow.getElementById('collaborate-view')!.style.display = 'none'; } private renderSkillPrices() { @@ -2019,16 +2443,59 @@ const CSS_TEXT = ` .main { flex: 1; position: relative; overflow: hidden; } -#pool-view { width: 100%; height: 100%; position: absolute; top: 0; left: 0; } -#pool-canvas { width: 100%; height: 100%; display: block; cursor: default; touch-action: none; } +/* Unified canvas view: pool panel + SVG canvas side by side */ +#canvas-view { + display: flex; + width: 100%; height: 100%; + position: absolute; top: 0; left: 0; + overflow: hidden; +} + +/* Pool panel (left side) */ +.pool-panel { + width: 260px; + flex-shrink: 0; + background: #1e293b; + border-right: 1px solid #334155; + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + transition: width 0.2s ease; +} +.pool-panel.collapsed { width: 40px; } +.pool-panel.collapsed #pool-canvas, +.pool-panel.collapsed .pool-detail, +.pool-panel.collapsed .pool-panel-sidebar, +.pool-panel.collapsed .add-btn { display: none; } +.pool-panel-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + font-size: 0.82rem; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.04em; + border-bottom: 1px solid #334155; + flex-shrink: 0; +} +.pool-panel-header button { + background: none; border: 1px solid #475569; border-radius: 0.25rem; + color: #94a3b8; font-size: 0.85rem; cursor: pointer; padding: 0.1rem 0.4rem; + line-height: 1; transition: border-color 0.15s; +} +.pool-panel-header button:hover { border-color: #8b5cf6; color: #e2e8f0; } +#pool-canvas { flex: 1; min-height: 0; display: block; cursor: default; touch-action: none; } .pool-detail { position: absolute; background: #1e293b; border-radius: 0.75rem; box-shadow: 0 8px 32px rgba(0,0,0,0.4); - padding: 1.25rem; - min-width: 220px; + padding: 1rem; + min-width: 200px; pointer-events: none; opacity: 0; transform: scale(0.9) translateY(8px); @@ -2036,108 +2503,90 @@ const CSS_TEXT = ` z-index: 50; } .pool-detail.visible { opacity: 1; transform: scale(1) translateY(0); pointer-events: auto; } -.pool-detail-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; } +.pool-detail-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; } .pool-detail-dot { width: 10px; height: 10px; border-radius: 50%; } -.pool-detail-name { font-weight: 600; font-size: 0.95rem; color: #f1f5f9; } -.pool-detail-skill { font-size: 0.82rem; color: #94a3b8; margin-bottom: 0.3rem; } -.pool-detail-hours { font-size: 0.85rem; font-weight: 600; color: #8b5cf6; } -.pool-detail-desc { font-size: 0.8rem; color: #94a3b8; margin-top: 0.35rem; line-height: 1.4; } +.pool-detail-name { font-weight: 600; font-size: 0.9rem; color: #f1f5f9; } +.pool-detail-skill { font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.2rem; } +.pool-detail-hours { font-size: 0.82rem; font-weight: 600; color: #8b5cf6; } +.pool-detail-desc { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; line-height: 1.4; } + +.pool-panel-sidebar { flex-shrink: 0; overflow-y: auto; max-height: 200px; border-top: 1px solid #334155; } .add-btn { - position: absolute; - bottom: 1.5rem; - right: 1.5rem; - padding: 0.65rem 1.25rem; + padding: 0.5rem 0.75rem; + margin: 0.5rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none; border-radius: 0.5rem; - font-size: 0.9rem; + font-size: 0.82rem; font-weight: 500; cursor: pointer; box-shadow: 0 4px 16px rgba(139,92,246,0.3); transition: all 0.2s; - z-index: 20; + flex-shrink: 0; } .add-btn:hover { transform: translateY(-1px); box-shadow: 0 6px 20px rgba(139,92,246,0.4); } -/* Weaving */ -#weave-view { - width: 100%; height: 100%; - position: absolute; top: 0; left: 0; - flex-direction: row; -} - -.sidebar { - width: 260px; - background: #1e293b; - border-right: 1px solid #334155; - display: flex; - flex-direction: column; - flex-shrink: 0; - overflow: hidden; -} -.sidebar-header { - padding: 0.75rem 1rem; - font-size: 0.85rem; - font-weight: 600; - color: #94a3b8; - text-transform: uppercase; - letter-spacing: 0.04em; - border-bottom: 1px solid #334155; -} -.sidebar-items { flex: 1; overflow-y: auto; padding: 0.5rem; } -.sidebar-item { - display: flex; - align-items: center; - gap: 0.6rem; - padding: 0.6rem 0.75rem; - border-radius: 0.5rem; - cursor: grab; - transition: background 0.15s; - margin-bottom: 0.25rem; - touch-action: none; -} -.sidebar-item:hover { background: #334155; } -.sidebar-item.used { opacity: 0.35; pointer-events: none; } -.sidebar-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .sidebar-item-info { flex: 1; min-width: 0; } .sidebar-item-name { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .sidebar-item-meta { font-size: 0.72rem; color: #64748b; } .sidebar-section { - padding: 0.75rem 1rem; - font-size: 0.78rem; + padding: 0.5rem 0.75rem; + font-size: 0.72rem; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.04em; - border-top: 1px solid #334155; - margin-top: 0.25rem; } .sidebar-task { display: flex; align-items: center; - gap: 0.6rem; - padding: 0.6rem 0.75rem; - border-radius: 0.5rem; + gap: 0.5rem; + padding: 0.45rem 0.65rem; + border-radius: 0.375rem; cursor: grab; transition: background 0.15s; - margin: 0 0.5rem 0.25rem; + margin: 0 0.25rem 0.2rem; touch-action: none; } .sidebar-task:hover { background: #334155; } .sidebar-task-icon { - width: 24px; height: 24px; - border-radius: 0.375rem; + width: 22px; height: 22px; + border-radius: 0.25rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); display: flex; align-items: center; justify-content: center; - color: #fff; font-size: 0.7rem; flex-shrink: 0; + color: #fff; font-size: 0.65rem; flex-shrink: 0; } -.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; } +/* SVG canvas area */ +.canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; touch-action: none; } #weave-svg { width: 100%; height: 100%; display: block; touch-action: none; } +/* Zoom controls */ +.zoom-controls { + position: absolute; + bottom: 1rem; + right: 1rem; + z-index: 50; + display: flex; + flex-direction: column; + gap: 0.25rem; +} +.zoom-controls button { + width: 32px; height: 32px; + border-radius: 0.375rem; + border: 1px solid #475569; + background: #1e293b; + color: #e2e8f0; + font-size: 1rem; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: all 0.15s; +} +.zoom-controls button:hover { border-color: #8b5cf6; background: #334155; } + .node-group { cursor: grab; } .node-group:active { cursor: grabbing; } .node-rect { fill: #1e293b; stroke: #334155; stroke-width: 1; transition: stroke 0.15s; } @@ -2149,6 +2598,11 @@ const CSS_TEXT = ` .temp-connection { fill: none; stroke: #8b5cf6; stroke-width: 2; stroke-dasharray: 6 4; pointer-events: none; opacity: 0.6; } .task-node .node-rect { stroke: #8b5cf6; stroke-width: 1.5; } .task-node.ready .node-rect { stroke: #10b981; stroke-width: 2; } +.task-node.skill-match .node-rect { stroke: #fbbf24; stroke-width: 2.5; filter: url(#glowGold); } + +/* Intent frames */ +.intent-frame { pointer-events: none; } +.intent-frame-label { pointer-events: none; } .exec-btn-rect { cursor: pointer; transition: opacity 0.15s; } .exec-btn-rect:hover { opacity: 0.85; } @@ -2555,6 +3009,18 @@ const CSS_TEXT = ` background: #334155; color: #94a3b8; } +.solver-frame-btn { + background: linear-gradient(135deg, #f59e0b, #fbbf24); + color: #1e293b; + padding: 0.3rem 0.75rem; + border: none; + border-radius: 0.375rem; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} +.solver-frame-btn:hover { opacity: 0.85; } /* Skill prices */ .collab-prices-section h3 { @@ -2598,13 +3064,18 @@ const CSS_TEXT = ` :host([data-theme="light"]) .stats-bar { background: linear-gradient(135deg, #f0ecff 0%, #fdf2f8 100%); border-bottom-color: #e2e8f0; } :host([data-theme="light"]) .stat { color: #64748b; } :host([data-theme="light"]) .stat-value { color: #1e293b; } -:host([data-theme="light"]) .sidebar { background: #fff; border-right-color: #e2e8f0; } -:host([data-theme="light"]) .sidebar-header { color: #64748b; border-bottom-color: #e2e8f0; } -:host([data-theme="light"]) .sidebar-item:hover { background: #f1f5f9; } +:host([data-theme="light"]) .pool-panel { background: #fff; border-right-color: #e2e8f0; } +:host([data-theme="light"]) .pool-panel-header { color: #64748b; border-bottom-color: #e2e8f0; } +:host([data-theme="light"]) .pool-panel-header button { border-color: #e2e8f0; color: #64748b; } +:host([data-theme="light"]) .sidebar-task:hover { background: #f1f5f9; } :host([data-theme="light"]) .sidebar-item-name { color: #1e293b; } :host([data-theme="light"]) .canvas-wrap { background: #f8fafc; } :host([data-theme="light"]) .node-rect { fill: #fff; stroke: #e2e8f0; } :host([data-theme="light"]) .theme-toggle { border-color: #e2e8f0; color: #64748b; } +:host([data-theme="light"]) .zoom-controls button { background: #fff; border-color: #e2e8f0; color: #1e293b; } +:host([data-theme="light"]) .zoom-controls button:hover { border-color: #8b5cf6; background: #f1f5f9; } +:host([data-theme="light"]) .pool-detail { background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12); } +:host([data-theme="light"]) .pool-detail-name { color: #1e293b; } /* Hex hover stroke */ .hex-hover-stroke { transition: stroke-width 0.15s; } @@ -2665,12 +3136,13 @@ const CSS_TEXT = ` .exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; } @media (max-width: 768px) { - .sidebar { width: 200px; } + .pool-panel { width: 200px; } .exec-panel { width: 95vw; } .task-edit-panel { width: 95vw; } } @media (max-width: 640px) { - .sidebar { width: 180px; } + .pool-panel { width: 180px; } + .pool-panel.collapsed { width: 36px; } } `; diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts index 018b9c6..1da8973 100644 --- a/modules/rtime/mod.ts +++ b/modules/rtime/mod.ts @@ -467,8 +467,7 @@ export const timeModule: RSpaceModule = { }, ], outputPaths: [ - { path: "commitments", name: "Commitments", icon: "🧺", description: "Community hour pledges" }, - { path: "weave", name: "Weave", icon: "🧶", description: "Task weaving dashboard" }, + { path: "canvas", name: "Canvas", icon: "🧺", description: "Unified commitment pool & task weaving canvas" }, { path: "collaborate", name: "Collaborate", icon: "🤝", description: "Intent-routed collaboration matching" }, ], onboardingActions: [ diff --git a/modules/rtime/schemas.ts b/modules/rtime/schemas.ts index 79d6237..30b756d 100644 --- a/modules/rtime/schemas.ts +++ b/modules/rtime/schemas.ts @@ -52,6 +52,7 @@ export interface Task { needs: Record; // skill → hours needed links: { label: string; url: string }[]; notes: string; + intentFrameId?: string; // links task to solver result that spawned it } export interface Connection {