/** * folk-timebank-app — Timebank commitment pool & weaving dashboard. * * Port of hcc-mem-staging/html/index.html into a shadow-DOM web component. * Two views: "pool" (canvas orbs) and "weave" (SVG node editor). */ // ── Constants ── const SKILL_COLORS: Record = { facilitation: '#8b5cf6', design: '#ec4899', tech: '#3b82f6', outreach: '#10b981', logistics: '#f59e0b', }; const SKILL_LABELS: Record = { facilitation: 'Facilitation', design: 'Design', tech: 'Tech', outreach: 'Outreach', logistics: 'Logistics', }; const EXEC_STEPS: Record = { default: [ { title: 'Set Up Space', desc: 'Configure the physical or virtual venue', detail: 'venue', icon: '1' }, { title: 'Communications Hub', desc: 'Create group chat and notification channels', detail: 'comms', icon: '2' }, { title: 'Shared Notes & Docs', desc: 'Set up collaborative agenda and resource links', detail: 'notes', icon: '3' }, { title: 'Prep from Input', desc: 'Upload transcripts or notes to auto-generate briefs', detail: 'prep', icon: '4' }, { title: 'Launch & Coordinate', desc: 'Confirm roles, send notifications, begin execution', detail: 'launch', icon: '5' }, ], }; const NODE_W = 90, NODE_H = 104; const TASK_W = 220, TASK_H_BASE = 52, TASK_ROW = 26; const EXEC_BTN_H = 28; const PORT_R = 5.5; const HEX_R = 52; interface Commitment { id: string; memberName: string; hours: number; skill: string; desc: string; } interface TaskData { id: string; name: string; description: string; needs: Record; links: { label: string; url: string }[]; notes: string; fulfilled?: Record; } interface WeaveNode { id: string; type: 'commitment' | 'task'; x: number; y: number; w: number; h: number; hexR?: number; baseH?: number; data: any; } interface Wire { from: string; to: string; skill: string; } // ── Orb class ── class Orb { c: Commitment; baseRadius: number; radius: number; x: number; y: number; vx: number; vy: number; hoverT = 0; phase: number; opacity = 0; color: string; statusRing: 'offer' | 'need' | 'matched' | null = null; // intent status ring color constructor(c: Commitment, basketCX: number, basketCY: number, basketR: number, x?: number, y?: number) { this.c = c; this.baseRadius = 18 + c.hours * 9; this.radius = this.baseRadius; if (x != null && y != null) { this.x = x; this.y = y; } else { const a = Math.random() * Math.PI * 2; const r = Math.random() * (basketR - this.baseRadius - 10); this.x = basketCX + Math.cos(a) * r; this.y = basketCY + Math.sin(a) * r; } this.vx = (Math.random() - 0.5) * 0.4; this.vy = (Math.random() - 0.5) * 0.4; this.phase = Math.random() * Math.PI * 2; this.color = SKILL_COLORS[c.skill] || '#8b5cf6'; } update(hoveredOrb: Orb | null, basketCX: number, basketCY: number, basketR: number) { this.phase += 0.008; this.vx += Math.sin(this.phase) * 0.003; this.vy += Math.cos(this.phase * 0.73 + 1) * 0.003; this.vx *= 0.996; this.vy *= 0.996; this.x += this.vx; this.y += this.vy; const dx = this.x - basketCX, dy = this.y - basketCY; const dist = Math.sqrt(dx * dx + dy * dy); const maxDist = basketR - this.radius - 3; if (dist > maxDist && dist > 0.1) { const nx = dx / dist, ny = dy / dist; this.x = basketCX + nx * maxDist; this.y = basketCY + ny * maxDist; const dot = this.vx * nx + this.vy * ny; this.vx -= 1.3 * dot * nx; this.vy -= 1.3 * dot * ny; this.vx *= 0.6; this.vy *= 0.6; } const isH = hoveredOrb === this; this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12; this.radius = this.baseRadius + this.hoverT * 5; if (this.opacity < 1) this.opacity = Math.min(1, this.opacity + 0.025); } draw(ctx: CanvasRenderingContext2D) { if (this.opacity < 0.01) return; ctx.save(); ctx.globalAlpha = this.opacity; if (this.hoverT > 0.05) { ctx.shadowColor = this.color; ctx.shadowBlur = 24 * this.hoverT; } ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fillStyle = this.color + '15'; ctx.fill(); ctx.beginPath(); ctx.arc(this.x, this.y, this.radius * 0.82, 0, Math.PI * 2); const g = ctx.createRadialGradient( this.x - this.radius * 0.15, this.y - this.radius * 0.15, 0, this.x, this.y, this.radius * 0.82 ); g.addColorStop(0, this.color + 'dd'); g.addColorStop(1, this.color); ctx.fillStyle = g; ctx.fill(); ctx.shadowBlur = 0; ctx.strokeStyle = this.color; ctx.lineWidth = 1.5; ctx.stroke(); // Status ring (green=offer, blue=need, gold=matched) if (this.statusRing) { const ringColor = this.statusRing === 'offer' ? '#10b981' : this.statusRing === 'need' ? '#3b82f6' : '#fbbf24'; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius + 4, 0, Math.PI * 2); ctx.strokeStyle = ringColor; ctx.lineWidth = 2.5; ctx.setLineDash(this.statusRing === 'matched' ? [] : [6, 4]); ctx.stroke(); ctx.setLineDash([]); } if (this.hoverT > 0.05) { ctx.globalAlpha = this.opacity * 0.08 * this.hoverT; ctx.beginPath(); ctx.ellipse(this.x, this.y + this.radius + 4, this.radius * 0.6, 4, 0, 0, Math.PI * 2); ctx.fillStyle = '#000'; ctx.fill(); ctx.globalAlpha = this.opacity; } ctx.fillStyle = '#fff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; const fs = Math.max(10, this.radius * 0.3); ctx.font = '600 ' + fs + 'px -apple-system, sans-serif'; ctx.fillText(this.c.memberName.split(' ')[0], this.x, this.y - fs * 0.35); ctx.font = '500 ' + (fs * 0.78) + 'px -apple-system, sans-serif'; ctx.fillStyle = '#ffffffcc'; ctx.fillText(this.c.hours + 'hr \u00b7 ' + (SKILL_LABELS[this.c.skill] || this.c.skill), this.x, this.y + fs * 0.55); ctx.restore(); } contains(px: number, py: number) { const dx = px - this.x, dy = py - this.y; return dx * dx + dy * dy < this.radius * this.radius; } } // ── Ripple class ── class Ripple { x: number; y: number; r = 8; o = 0.5; color: string; constructor(x: number, y: number, color: string) { this.x = x; this.y = y; this.color = color; } update() { this.r += 1.5; this.o -= 0.008; return this.o > 0; } draw(ctx: CanvasRenderingContext2D) { ctx.save(); ctx.globalAlpha = this.o; ctx.strokeStyle = this.color; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(this.x, this.y, this.r, 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } } // ── Hex helpers ── function hexPoints(cx: number, cy: number, r: number): [number, number][] { return [0, 1, 2, 3, 4, 5].map(i => { const angle = -Math.PI / 2 + i * Math.PI / 3; return [cx + r * Math.cos(angle), cy + r * Math.sin(angle)] as [number, number]; }); } function hexPolygonStr(cx: number, cy: number, r: number) { return hexPoints(cx, cy, r).map(p => p[0] + ',' + p[1]).join(' '); } function bezier(x1: number, y1: number, x2: number, y2: number) { const d = Math.max(40, Math.abs(x2 - x1) * 0.5); return 'M' + x1 + ',' + y1 + ' C' + (x1 + d) + ',' + y1 + ' ' + (x2 - d) + ',' + y2 + ' ' + x2 + ',' + y2; } function ns(tag: string) { return document.createElementNS('http://www.w3.org/2000/svg', tag); } function svgText(txt: string, x: number, y: number, size: number, color: string, weight?: string, anchor?: string) { const t = ns('text'); t.setAttribute('x', String(x)); t.setAttribute('y', String(y)); t.setAttribute('fill', color); t.setAttribute('font-size', String(size)); t.setAttribute('font-weight', weight || '400'); t.setAttribute('font-family', '-apple-system, BlinkMacSystemFont, sans-serif'); if (anchor) t.setAttribute('text-anchor', anchor); t.textContent = txt; return t; } // ── Main component ── class FolkTimebankApp extends HTMLElement { private shadow: ShadowRoot; private space = 'demo'; private currentView: 'pool' | 'weave' | 'collaborate' = 'pool'; // Pool state private canvas!: HTMLCanvasElement; private ctx!: CanvasRenderingContext2D; private orbs: Orb[] = []; private ripples: Ripple[] = []; private animFrame = 0; private poolW = 0; private poolH = 0; private basketCX = 0; private basketCY = 0; private basketR = 0; private hoveredOrb: Orb | null = null; private selectedOrb: Orb | null = null; private dpr = 1; private poolPointerId: number | null = null; private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null; // Weave state private svgEl!: SVGSVGElement; private nodesLayer!: SVGGElement; private connectionsLayer!: SVGGElement; private tempConn!: SVGPathElement; private weaveNodes: WeaveNode[] = []; private connections: Wire[] = []; private dragNode: WeaveNode | null = null; private dragOff = { x: 0, y: 0 }; private connecting: { nodeId: string; portType: string; skill: string | null } | null = null; private svgActivePointerId: number | null = null; private svgDblTapTime = 0; private svgDblTapPos: { x: number; y: number } | null = null; private sidebarDragData: { type: string; id: string } | null = null; private sidebarGhost: HTMLElement | null = null; // Data private commitments: Commitment[] = []; private tasks: TaskData[] = []; // Collaborate state private intents: any[] = []; private solverResults: any[] = []; private skillPrices: Record = {}; private memberIntentStatus: Map = new Map(); // Exec state private execStepStates: Record> = {}; constructor() { super(); this.shadow = this.attachShadow({ mode: 'open' }); } static get observedAttributes() { return ['space', 'view']; } 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; } connectedCallback() { this.space = this.getAttribute('space') || 'demo'; this.currentView = (this.getAttribute('view') as any) || 'pool'; this.dpr = window.devicePixelRatio || 1; this.render(); this.setupPool(); this.setupWeave(); this.setupCollaborate(); this.fetchData(); } /** Derive API base from the current pathname — works for both subdomain and path routing. */ private getApiBase(): string { const path = window.location.pathname; const match = path.match(/^(\/[^/]+)?\/rtime/); return match ? match[0] : '/rtime'; } /** Auth headers from encryptid session token. */ private authHeaders(): Record { const token = localStorage.getItem('encryptid-token'); return token ? { Authorization: `Bearer ${token}` } : {}; } disconnectedCallback() { if (this.animFrame) cancelAnimationFrame(this.animFrame); } private async fetchData() { const base = this.getApiBase(); try { const [cResp, tResp] = await Promise.all([ fetch(`${base}/api/commitments`), fetch(`${base}/api/tasks`), ]); if (cResp.ok) { const cData = await cResp.json(); this.commitments = cData.commitments || []; } if (tResp.ok) { const tData = await tResp.json(); this.tasks = tData.tasks || []; } } catch { // Offline — use empty state } this.buildOrbs(); this.updateStats(); this.rebuildSidebar(); } private render() { this.shadow.innerHTML = `
Commitment Pool
Weaving Dashboard
Collaborate
0 hours available
0 contributors

Project Execution

All commitments woven — prepare for launch

Edit Task

`; } // ── Pool setup ── private setupPool() { this.canvas = this.shadow.getElementById('pool-canvas') as HTMLCanvasElement; this.ctx = this.canvas.getContext('2d')!; // Tab switching this.shadow.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { const view = (tab as HTMLElement).dataset.view as 'pool' | 'weave' | 'collaborate'; if (view === this.currentView) return; this.currentView = view; this.shadow.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', (t as HTMLElement).dataset.view === view)); const poolView = this.shadow.getElementById('pool-view')!; const weaveView = this.shadow.getElementById('weave-view')!; const collabView = this.shadow.getElementById('collaborate-view')!; poolView.style.display = view === 'pool' ? 'block' : 'none'; weaveView.style.display = view === 'weave' ? 'flex' : 'none'; collabView.style.display = view === 'collaborate' ? 'flex' : 'none'; if (view === 'pool') this.resizePoolCanvas(); if (view === 'weave') this.rebuildSidebar(); if (view === 'collaborate') this.refreshCollaborate(); }); }); // Add commitment modal const modal = this.shadow.getElementById('modalOverlay')!; this.shadow.getElementById('addBtn')!.addEventListener('click', () => { modal.classList.add('visible'); (this.shadow.getElementById('modalName') as HTMLInputElement).value = ''; (this.shadow.getElementById('modalHours') as HTMLInputElement).value = '2'; (this.shadow.getElementById('modalDesc') as HTMLInputElement).value = ''; (this.shadow.getElementById('modalName') as HTMLInputElement).focus(); }); this.shadow.getElementById('modalCancel')!.addEventListener('click', () => modal.classList.remove('visible')); modal.addEventListener('click', (e) => { if (e.target === modal) modal.classList.remove('visible'); }); this.shadow.getElementById('modalSubmit')!.addEventListener('click', () => this.submitCommitment()); // Pool pointer events this.canvas.addEventListener('pointerdown', (e) => this.onPoolPointerDown(e)); this.canvas.addEventListener('pointermove', (e) => this.onPoolPointerMove(e)); this.canvas.addEventListener('pointerup', (e) => this.onPoolPointerUp(e)); this.canvas.addEventListener('pointercancel', () => { this.poolPointerId = null; this.poolPointerStart = null; }); this.canvas.addEventListener('pointerleave', (e) => { if (e.pointerType === 'mouse') { this.hoveredOrb = null; this.hideDetail(); } }); // Exec panel this.shadow.getElementById('execClose')!.addEventListener('click', () => this.shadow.getElementById('execOverlay')!.classList.remove('visible')); this.shadow.getElementById('execOverlay')!.addEventListener('click', (e) => { if (e.target === this.shadow.getElementById('execOverlay')) this.shadow.getElementById('execOverlay')!.classList.remove('visible'); }); this.shadow.getElementById('execLaunch')!.addEventListener('click', () => { const btn = this.shadow.getElementById('execLaunch') as HTMLButtonElement; btn.textContent = 'Launched!'; btn.style.background = 'linear-gradient(135deg, #8b5cf6, #ec4899)'; setTimeout(() => { this.shadow.getElementById('execOverlay')!.classList.remove('visible'); btn.textContent = 'Launch Project'; btn.style.background = ''; }, 1500); }); // Task editor this.shadow.getElementById('taskEditCancel')!.addEventListener('click', () => this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible')); this.shadow.getElementById('taskEditOverlay')!.addEventListener('click', (e) => { if (e.target === this.shadow.getElementById('taskEditOverlay')) this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'); }); this.shadow.getElementById('taskEditSave')!.addEventListener('click', () => this.saveTaskEdit()); // Resize const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'pool') this.resizePoolCanvas(); }); resizeObserver.observe(this); this.resizePoolCanvas(); this.poolFrame(); } private resizePoolCanvas() { const rect = this.canvas.parentElement!.getBoundingClientRect(); this.poolW = rect.width; this.poolH = rect.height; this.canvas.width = this.poolW * this.dpr; this.canvas.height = this.poolH * this.dpr; this.canvas.style.width = this.poolW + 'px'; this.canvas.style.height = this.poolH + 'px'; this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); this.basketCX = this.poolW / 2; this.basketCY = this.poolH / 2 + 10; this.basketR = Math.min(this.poolW, this.poolH) * 0.42; } private buildOrbs() { this.orbs = this.commitments.map(c => { const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR); orb.statusRing = this.memberIntentStatus.get(c.memberName) || null; return orb; }); } private resolveCollisions() { for (let i = 0; i < this.orbs.length; i++) { for (let j = i + 1; j < this.orbs.length; j++) { const a = this.orbs[i], b = this.orbs[j]; const dx = b.x - a.x, dy = b.y - a.y; const dist = Math.sqrt(dx * dx + dy * dy); const min = a.radius + b.radius + 4; if (dist < min && dist > 0.1) { const nx = dx / dist, ny = dy / dist; const ov = (min - dist) * 0.5; a.x -= nx * ov * 0.5; a.y -= ny * ov * 0.5; b.x += nx * ov * 0.5; b.y += ny * ov * 0.5; a.vx -= nx * 0.04; a.vy -= ny * 0.04; b.vx += nx * 0.04; b.vy += ny * 0.04; } } } } private drawBasket() { const ctx = this.ctx; const ig = ctx.createRadialGradient(this.basketCX, this.basketCY - this.basketR * 0.2, 0, this.basketCX, this.basketCY, this.basketR); ig.addColorStop(0, 'rgba(139,92,246,0.06)'); ig.addColorStop(0.7, 'rgba(139,92,246,0.1)'); ig.addColorStop(1, 'rgba(236,72,153,0.12)'); ctx.beginPath(); ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2); ctx.fillStyle = ig; ctx.fill(); ctx.beginPath(); ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(139,92,246,0.25)'; ctx.lineWidth = 6; ctx.stroke(); ctx.beginPath(); ctx.arc(this.basketCX, this.basketCY, this.basketR, 0, Math.PI * 2); ctx.strokeStyle = 'rgba(139,92,246,0.4)'; ctx.lineWidth = 2; ctx.stroke(); // Woven texture ctx.strokeStyle = 'rgba(139,92,246,0.2)'; ctx.lineWidth = 1.5; const segs = 48; for (let i = 0; i < segs; i++) { const a1 = (i / segs) * Math.PI * 2; const a2 = ((i + 0.5) / segs) * Math.PI * 2; const r1 = this.basketR - 3, r2 = this.basketR + 3; ctx.beginPath(); ctx.moveTo(this.basketCX + Math.cos(a1) * r1, this.basketCY + Math.sin(a1) * r1); ctx.quadraticCurveTo( this.basketCX + Math.cos((a1 + a2) / 2) * r2, this.basketCY + Math.sin((a1 + a2) / 2) * r2, this.basketCX + Math.cos(a2) * r1, this.basketCY + Math.sin(a2) * r1 ); ctx.stroke(); } ctx.fillStyle = '#a78bfacc'; ctx.font = '600 13px -apple-system, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom'; ctx.fillText('COMMITMENT BASKET', this.basketCX, this.basketCY - this.basketR - 14); } private poolFrame = () => { this.ctx.clearRect(0, 0, this.poolW, this.poolH); const bg = this.ctx.createLinearGradient(0, 0, 0, this.poolH); bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1a1033'); this.ctx.fillStyle = bg; this.ctx.fillRect(0, 0, this.poolW, this.poolH); this.drawBasket(); this.ripples = this.ripples.filter(r => r.update()); this.ripples.forEach(r => r.draw(this.ctx)); this.orbs.forEach(o => o.update(this.hoveredOrb, this.basketCX, this.basketCY, this.basketR)); this.resolveCollisions(); this.orbs.forEach(o => o.draw(this.ctx)); this.animFrame = requestAnimationFrame(this.poolFrame); }; // Pool pointer handlers private onPoolPointerDown(e: PointerEvent) { if (this.poolPointerId !== null) return; this.poolPointerId = e.pointerId; this.canvas.setPointerCapture(e.pointerId); const r = this.canvas.getBoundingClientRect(); this.poolPointerStart = { x: e.clientX, y: e.clientY, cx: e.clientX - r.left, cy: e.clientY - r.top }; } private onPoolPointerMove(e: PointerEvent) { const r = this.canvas.getBoundingClientRect(); const mx = e.clientX - r.left, my = e.clientY - r.top; this.hoveredOrb = null; for (let i = this.orbs.length - 1; i >= 0; i--) { if (this.orbs[i].contains(mx, my)) { this.hoveredOrb = this.orbs[i]; break; } } this.canvas.style.cursor = this.hoveredOrb ? 'pointer' : 'default'; if (this.poolPointerStart) { const dx = e.clientX - this.poolPointerStart.x, dy = e.clientY - this.poolPointerStart.y; if (dx * dx + dy * dy > 100) this.poolPointerStart = null; } } private onPoolPointerUp(e: PointerEvent) { if (e.pointerId !== this.poolPointerId) return; this.poolPointerId = null; if (!this.poolPointerStart) return; const r = this.canvas.getBoundingClientRect(); const px = e.clientX - r.left, py = e.clientY - r.top; let clicked: Orb | null = null; for (let i = this.orbs.length - 1; i >= 0; i--) { if (this.orbs[i].contains(px, py)) { clicked = this.orbs[i]; break; } } if (clicked) { this.selectedOrb = this.selectedOrb === clicked ? null : clicked; if (this.selectedOrb) this.showDetail(this.selectedOrb, e.clientX, e.clientY); else this.hideDetail(); } else { this.selectedOrb = null; this.hideDetail(); } this.poolPointerStart = null; } private showDetail(orb: Orb, cx: number, cy: number) { const el = this.shadow.getElementById('poolDetail')!; const c = orb.c; (this.shadow.getElementById('detailDot') as HTMLElement).style.background = SKILL_COLORS[c.skill] || '#8b5cf6'; this.shadow.getElementById('detailName')!.textContent = c.memberName; this.shadow.getElementById('detailSkill')!.textContent = SKILL_LABELS[c.skill] || c.skill; this.shadow.getElementById('detailHours')!.textContent = c.hours + ' hour' + (c.hours !== 1 ? 's' : '') + ' pledged'; this.shadow.getElementById('detailDesc')!.textContent = c.desc; const mr = this.shadow.querySelector('.main')!.getBoundingClientRect(); let left = cx - mr.left + 15, top = cy - mr.top - 30; if (left + 240 > mr.width) left = cx - mr.left - 250; if (top + 150 > mr.height) top = mr.height - 160; if (top < 10) top = 10; el.style.left = left + 'px'; el.style.top = top + 'px'; el.classList.add('visible'); } private hideDetail() { this.shadow.getElementById('poolDetail')?.classList.remove('visible'); } // ── Submit commitment ── private async submitCommitment() { const name = (this.shadow.getElementById('modalName') as HTMLInputElement).value.trim(); const skill = (this.shadow.getElementById('modalSkill') as HTMLSelectElement).value; const hours = Math.max(1, Math.min(10, parseInt((this.shadow.getElementById('modalHours') as HTMLInputElement).value) || 2)); const desc = (this.shadow.getElementById('modalDesc') as HTMLInputElement).value.trim() || (SKILL_LABELS[skill] || skill) + ' contribution'; if (!name) { (this.shadow.getElementById('modalName') as HTMLInputElement).focus(); return; } const c: Commitment = { id: 'local-' + Date.now(), memberName: name, skill, hours, desc }; // Try server-side persist try { const resp = await fetch(`${this.getApiBase()}/api/commitments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ memberName: name, skill, hours, desc }), }); if (resp.ok) { const saved = await resp.json(); c.id = saved.id; } } catch { /* offline — keep local id */ } this.commitments.push(c); const orb = new Orb(c, this.basketCX, this.basketCY, this.basketR, this.basketCX + (Math.random() - 0.5) * 40, this.basketCY - this.basketR - 30); orb.vy = 2.5; this.orbs.push(orb); setTimeout(() => { this.ripples.push(new Ripple(this.basketCX, this.basketCY, SKILL_COLORS[skill] || '#8b5cf6')); this.ripples.push(new Ripple(this.basketCX - 15, this.basketCY + 10, SKILL_COLORS[skill] || '#8b5cf6')); }, 400); this.updateStats(); this.shadow.getElementById('modalOverlay')!.classList.remove('visible'); } // ── Stats bar ── private updateStats() { const total = this.commitments.reduce((s, c) => s + c.hours, 0); this.shadow.getElementById('statHours')!.textContent = String(total); this.shadow.getElementById('statContributors')!.textContent = String(this.commitments.length); const bySkill: Record = {}; this.commitments.forEach(c => { bySkill[c.skill] = (bySkill[c.skill] || 0) + c.hours; }); const bar = this.shadow.getElementById('skillBar')!; const legend = this.shadow.getElementById('skillLegend')!; bar.innerHTML = ''; legend.innerHTML = ''; if (total === 0) return; for (const [skill, hrs] of Object.entries(bySkill)) { const seg = document.createElement('div'); seg.className = 'skill-bar-segment'; seg.style.width = ((hrs as number) / total * 100) + '%'; seg.style.background = SKILL_COLORS[skill] || '#888'; bar.appendChild(seg); const item = document.createElement('div'); item.className = 'skill-legend-item'; item.innerHTML = '
' + (SKILL_LABELS[skill] || skill) + ' ' + hrs + 'h'; legend.appendChild(item); } } // ── Weave setup ── private setupWeave() { this.svgEl = this.shadow.getElementById('weave-svg') as any; this.nodesLayer = this.shadow.getElementById('nodesLayer') as any; this.connectionsLayer = this.shadow.getElementById('connectionsLayer') as any; this.tempConn = this.shadow.getElementById('tempConnection') as any; this.svgEl.addEventListener('pointerdown', (e: PointerEvent) => this.onSvgPointerDown(e)); this.svgEl.addEventListener('pointermove', (e: PointerEvent) => this.onSvgPointerMove(e)); this.svgEl.addEventListener('pointerup', (e: PointerEvent) => this.onSvgPointerUp(e)); this.svgEl.addEventListener('pointercancel', () => { this.svgActivePointerId = null; this.connecting = null; this.dragNode = null; this.tempConn.style.display = 'none'; }); this.svgEl.addEventListener('dblclick', (e) => { const ng = (e.target as Element).closest('.node-group') as SVGElement; if (!ng) return; const taskNode = this.weaveNodes.find(n => n.id === ng.dataset.id && n.type === 'task'); if (taskNode) this.openTaskEditor(taskNode); }); // Drop zone const wrap = this.shadow.getElementById('canvasWrap')!; wrap.addEventListener('dragover', (e) => { e.preventDefault(); }); } private svgPt(cx: number, cy: number) { const p = this.svgEl.createSVGPoint(); p.x = cx; p.y = cy; return p.matrixTransform(this.svgEl.getScreenCTM()!.inverse()); } private mkCommitNode(c: Commitment, x: number, y: number): WeaveNode { return { id: 'cn-' + c.id, type: 'commitment', x, y, w: NODE_W, h: NODE_H, hexR: HEX_R, data: c }; } private mkTaskNode(t: TaskData, x: number, y: number): WeaveNode { const n = Object.keys(t.needs).length; const baseH = TASK_H_BASE + n * TASK_ROW + 12; return { id: t.id, type: 'task', x, y, w: TASK_W, h: baseH, baseH, data: { ...t, fulfilled: {} } }; } private isTaskReady(node: WeaveNode): boolean { if (node.type !== 'task') return false; const t = node.data; for (const skill of Object.keys(t.needs)) { if ((t.fulfilled?.[skill] || 0) < t.needs[skill]) return false; } return true; } private renderConnections() { this.connectionsLayer.innerHTML = ''; this.connections.forEach(conn => { const from = this.weaveNodes.find(n => n.id === conn.from); const to = this.weaveNodes.find(n => n.id === conn.to); if (!from || !to) return; let x1: number, y1: number; if (from.type === 'commitment') { const cx = from.x + from.w / 2, cy = from.y + from.h / 2; const pts = hexPoints(cx, cy, from.hexR || HEX_R); x1 = pts[1][0]; y1 = pts[1][1]; } else { x1 = from.x + from.w; y1 = from.y + from.h / 2; } let x2 = to.x, y2 = to.y + to.h / 2; if (to.type === 'task') { const skills = Object.keys(to.data.needs); const idx = skills.indexOf(conn.skill); if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2; } const p = ns('path'); p.setAttribute('d', bezier(x1, y1, x2, y2)); p.setAttribute('class', 'connection-line active'); this.connectionsLayer.appendChild(p); }); } private renderNode(node: WeaveNode): SVGGElement { const g = ns('g') as SVGGElement; g.setAttribute('data-id', node.id); g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')'); if (node.type === 'commitment') { const c = node.data as Commitment; const col = SKILL_COLORS[c.skill] || '#8b5cf6'; const cx = node.w / 2, cy = node.h / 2; const hr = node.hexR || HEX_R; g.setAttribute('class', 'node-group'); const hex = ns('polygon'); hex.setAttribute('points', hexPolygonStr(cx, cy, hr)); hex.setAttribute('fill', '#1e293b'); hex.setAttribute('stroke', '#334155'); hex.setAttribute('stroke-width', '1'); hex.setAttribute('filter', 'url(#nodeShadow)'); g.appendChild(hex); const clipId = 'hexClip-' + node.id; const clipPath = ns('clipPath'); clipPath.setAttribute('id', clipId); const clipRect = ns('rect'); clipRect.setAttribute('x', String(cx - hr)); clipRect.setAttribute('y', String(cy - hr)); clipRect.setAttribute('width', String(hr * 2)); clipRect.setAttribute('height', String(hr * 0.75)); clipPath.appendChild(clipRect); g.appendChild(clipPath); const headerFill = ns('polygon'); headerFill.setAttribute('points', hexPolygonStr(cx, cy, hr)); headerFill.setAttribute('fill', col); headerFill.setAttribute('clip-path', 'url(#' + clipId + ')'); g.appendChild(headerFill); const nameText = c.memberName.length > 12 ? c.memberName.slice(0, 11) + '\u2026' : c.memberName; g.appendChild(svgText(nameText, cx, cy - hr * 0.35, 11, '#fff', '600', 'middle')); g.appendChild(svgText(c.hours + 'hr', cx, cy + 4, 13, '#f1f5f9', '700', 'middle')); g.appendChild(svgText(SKILL_LABELS[c.skill] || c.skill, cx, cy + 18, 10, '#94a3b8', '400', 'middle')); const pts = hexPoints(cx, cy, hr); const portHit = ns('circle'); portHit.setAttribute('cx', String(pts[1][0])); portHit.setAttribute('cy', String(pts[1][1])); portHit.setAttribute('r', '18'); portHit.setAttribute('fill', 'transparent'); portHit.setAttribute('class', 'port'); portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'output'); g.appendChild(portHit); const port = ns('circle'); port.setAttribute('cx', String(pts[1][0])); port.setAttribute('cy', String(pts[1][1])); port.setAttribute('r', String(PORT_R)); port.setAttribute('fill', col); port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2'); port.style.pointerEvents = 'none'; g.appendChild(port); } else if (node.type === 'task') { const t = node.data as TaskData; const skills = Object.keys(t.needs); const totalNeeded = Object.values(t.needs).reduce((a, b) => a + b, 0); const totalFulfilled = Object.values(t.fulfilled || {}).reduce((a, b) => a + b, 0); const progress = totalNeeded > 0 ? Math.min(1, totalFulfilled / totalNeeded) : 0; const ready = this.isTaskReady(node); node.h = node.baseH! + (ready ? EXEC_BTN_H + 8 : 0); g.setAttribute('class', 'node-group task-node' + (ready ? ' ready' : '')); const rect = ns('rect'); rect.setAttribute('class', 'node-rect'); rect.setAttribute('width', String(node.w)); rect.setAttribute('height', String(node.h)); rect.setAttribute('rx', '8'); rect.setAttribute('ry', '8'); rect.setAttribute('filter', ready ? 'url(#glowGreen)' : 'url(#nodeShadow)'); g.appendChild(rect); const hCol = ready ? '#10b981' : '#8b5cf6'; const h = ns('rect'); h.setAttribute('width', String(node.w)); h.setAttribute('height', '28'); h.setAttribute('fill', hCol); h.setAttribute('rx', '8'); h.setAttribute('ry', '8'); g.appendChild(h); const hm = ns('rect'); hm.setAttribute('width', String(node.w)); hm.setAttribute('height', '10'); hm.setAttribute('y', '20'); hm.setAttribute('fill', hCol); g.appendChild(hm); g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600')); g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', node.w - 24, 18, 10, '#ffffffcc', '500', 'end')); const pbW = node.w - 24; const pbBg = ns('rect'); pbBg.setAttribute('x', '12'); pbBg.setAttribute('y', '36'); pbBg.setAttribute('width', String(pbW)); pbBg.setAttribute('height', '4'); pbBg.setAttribute('rx', '2'); pbBg.setAttribute('fill', '#334155'); g.appendChild(pbBg); const pbF = ns('rect'); pbF.setAttribute('x', '12'); pbF.setAttribute('y', '36'); pbF.setAttribute('width', String(progress * pbW)); pbF.setAttribute('height', '4'); pbF.setAttribute('rx', '2'); pbF.setAttribute('fill', hCol); g.appendChild(pbF); skills.forEach((skill, i) => { const ry = TASK_H_BASE + i * TASK_ROW; const needed = t.needs[skill]; const ful = (t.fulfilled || {})[skill] || 0; const done = ful >= needed; const portHit = ns('circle'); portHit.setAttribute('cx', '0'); portHit.setAttribute('cy', String(ry + TASK_ROW / 2)); portHit.setAttribute('r', '18'); portHit.setAttribute('fill', 'transparent'); portHit.setAttribute('class', 'port'); portHit.setAttribute('data-node', node.id); portHit.setAttribute('data-port', 'input'); portHit.setAttribute('data-skill', skill); g.appendChild(portHit); const port = ns('circle'); port.setAttribute('cx', '0'); port.setAttribute('cy', String(ry + TASK_ROW / 2)); port.setAttribute('r', String(PORT_R)); port.setAttribute('fill', done ? '#10b981' : (SKILL_COLORS[skill] || '#888')); port.setAttribute('stroke', '#fff'); port.setAttribute('stroke-width', '2'); port.style.pointerEvents = 'none'; g.appendChild(port); const dot = ns('circle'); dot.setAttribute('cx', '20'); dot.setAttribute('cy', String(ry + TASK_ROW / 2)); dot.setAttribute('r', '4'); dot.setAttribute('fill', SKILL_COLORS[skill] || '#888'); g.appendChild(dot); const lbl = (SKILL_LABELS[skill] || skill) + ': ' + ful + '/' + needed + 'hr'; g.appendChild(svgText(lbl, 30, ry + TASK_ROW / 2 + 4, 11, done ? '#10b981' : '#94a3b8', done ? '600' : '400')); if (done) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle')); }); if (ready) { const btnY = node.baseH! + 4; const btnR = ns('rect'); btnR.setAttribute('x', '12'); btnR.setAttribute('y', String(btnY)); btnR.setAttribute('width', String(node.w - 24)); btnR.setAttribute('height', String(EXEC_BTN_H)); btnR.setAttribute('rx', '6'); btnR.setAttribute('ry', '6'); btnR.setAttribute('fill', '#10b981'); btnR.setAttribute('class', 'exec-btn-rect'); btnR.setAttribute('data-task-id', node.id); g.appendChild(btnR); const btnT = svgText('Execute Project \u2192', node.w / 2, btnY + EXEC_BTN_H / 2 + 4, 12, '#fff', '600', 'middle'); btnT.style.pointerEvents = 'none'; g.appendChild(btnT); } } return g; } private renderAll() { this.nodesLayer.innerHTML = ''; this.weaveNodes.forEach(n => this.nodesLayer.appendChild(this.renderNode(n))); this.renderConnections(); } // SVG pointer events private onSvgPointerDown(e: PointerEvent) { if (this.svgActivePointerId !== null) return; this.svgActivePointerId = e.pointerId; this.svgEl.setPointerCapture(e.pointerId); if ((e.target as Element).classList.contains('exec-btn-rect')) { const taskId = (e.target as HTMLElement).getAttribute('data-task-id'); if (taskId) this.openExecPanel(taskId); return; } const port = (e.target as Element).closest('.port') as SVGElement; if (port) { e.preventDefault(); this.connecting = { nodeId: port.getAttribute('data-node')!, portType: port.getAttribute('data-port')!, skill: port.getAttribute('data-skill') }; this.tempConn.style.display = 'block'; return; } const ng = (e.target as Element).closest('.node-group') as SVGElement; if (ng) { const id = ng.dataset.id!; this.dragNode = this.weaveNodes.find(n => n.id === id) || null; if (this.dragNode) { const pt = this.svgPt(e.clientX, e.clientY); this.dragOff.x = pt.x - this.dragNode.x; this.dragOff.y = pt.y - this.dragNode.y; } // Double-tap detection const now = Date.now(); if (this.svgDblTapPos && now - this.svgDblTapTime < 300) { const dx = e.clientX - this.svgDblTapPos.x, dy = e.clientY - this.svgDblTapPos.y; if (dx * dx + dy * dy < 225) { const taskNode = this.weaveNodes.find(n => n.id === id && n.type === 'task'); if (taskNode) { this.openTaskEditor(taskNode); this.dragNode = null; } } } this.svgDblTapTime = now; this.svgDblTapPos = { x: e.clientX, y: e.clientY }; } } private onSvgPointerMove(e: PointerEvent) { if (e.pointerId !== this.svgActivePointerId) return; const pt = this.svgPt(e.clientX, e.clientY); if (this.connecting) { const fn = this.weaveNodes.find(n => n.id === this.connecting!.nodeId); if (!fn) return; if (this.connecting.portType === 'input') { const skills = fn.type === 'task' ? Object.keys(fn.data.needs) : []; const idx = skills.indexOf(this.connecting.skill!); const x1 = fn.x; const y1 = idx >= 0 ? fn.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2 : fn.y + fn.h / 2; this.tempConn.setAttribute('d', bezier(pt.x, pt.y, x1, y1)); } else { const outX = fn.x + fn.w, outY = fn.y + fn.h / 2; this.tempConn.setAttribute('d', bezier(outX, outY, pt.x, pt.y)); } return; } if (this.dragNode) { this.dragNode.x = pt.x - this.dragOff.x; this.dragNode.y = pt.y - this.dragOff.y; this.renderAll(); } } private onSvgPointerUp(e: PointerEvent) { if (e.pointerId !== this.svgActivePointerId) return; this.svgActivePointerId = null; if (this.connecting) { const port = (e.target as Element).closest('.port') as SVGElement; if (port) { const tId = port.getAttribute('data-node')!; const tType = port.getAttribute('data-port')!; const tSkill = port.getAttribute('data-skill'); let fromId: string | undefined, toId: string | undefined, skill: string | undefined; if (this.connecting.portType === 'output' && tType === 'input' && tId !== this.connecting.nodeId) { fromId = this.connecting.nodeId; toId = tId; skill = tSkill || undefined; } else if (this.connecting.portType === 'input' && tType === 'output' && tId !== this.connecting.nodeId) { fromId = tId; toId = this.connecting.nodeId; skill = this.connecting.skill || undefined; } if (fromId && toId && skill) { const fNode = this.weaveNodes.find(n => n.id === fromId); const tNode = this.weaveNodes.find(n => n.id === toId); if (fNode && tNode && fNode.type === 'commitment' && tNode.type === 'task' && fNode.data.skill === skill) { if (!this.connections.find(c => c.from === fromId && c.to === toId)) { this.connections.push({ from: fromId, to: toId, skill }); if (!tNode.data.fulfilled) tNode.data.fulfilled = {}; tNode.data.fulfilled[skill] = (tNode.data.fulfilled[skill] || 0) + fNode.data.hours; const nowReady = this.isTaskReady(tNode); this.renderAll(); this.rebuildSidebar(); if (nowReady) setTimeout(() => this.openExecPanel(tNode.id), 600); } } } } this.connecting = null; this.tempConn.style.display = 'none'; this.tempConn.setAttribute('d', ''); return; } this.dragNode = null; } // ── Sidebar ── private rebuildSidebar() { const cont = this.shadow.getElementById('sidebarItems'); if (!cont) return; cont.innerHTML = ''; const used = new Set(this.weaveNodes.filter(n => n.type === 'commitment').map(n => n.data.id)); this.commitments.forEach(c => { const el = document.createElement('div'); el.className = 'sidebar-item' + (used.has(c.id) ? ' used' : ''); el.innerHTML = '' + ''; if (!used.has(c.id)) { el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'commitment', id: c.id }, c.memberName)); } cont.appendChild(el); }); const tc = this.shadow.getElementById('sidebarTasks'); if (!tc) return; tc.innerHTML = ''; const usedT = new Set(this.weaveNodes.filter(n => n.type === 'task').map(n => n.id)); this.tasks.forEach(t => { if (usedT.has(t.id)) return; const el = document.createElement('div'); el.className = 'sidebar-task'; const needs = Object.entries(t.needs).map(([s, h]) => h + 'hr ' + (SKILL_LABELS[s] || s)).join(', '); el.innerHTML = '' + ''; el.addEventListener('pointerdown', (e) => this.startSidebarDrag(e, { type: 'task', id: t.id }, t.name)); tc.appendChild(el); }); } private startSidebarDrag(e: PointerEvent, data: { type: string; id: string }, label: string) { e.preventDefault(); this.sidebarDragData = data; this.sidebarGhost = document.createElement('div'); this.sidebarGhost.style.cssText = 'position:fixed;padding:0.5rem 0.85rem;background:#1e293b;border:2px solid #8b5cf6;border-radius:0.5rem;font-size:0.8rem;font-weight:600;color:#e2e8f0;pointer-events:none;z-index:9999;box-shadow:0 4px 16px rgba(139,92,246,0.25);white-space:nowrap;'; this.sidebarGhost.textContent = label; this.sidebarGhost.style.left = e.clientX + 'px'; this.sidebarGhost.style.top = (e.clientY - 20) + 'px'; document.body.appendChild(this.sidebarGhost); const moveHandler = (ev: PointerEvent) => { if (!this.sidebarGhost) return; this.sidebarGhost.style.left = ev.clientX + 'px'; this.sidebarGhost.style.top = (ev.clientY - 20) + 'px'; }; const upHandler = (ev: PointerEvent) => { document.removeEventListener('pointermove', moveHandler); document.removeEventListener('pointerup', upHandler); document.removeEventListener('pointercancel', cancelHandler); if (this.sidebarGhost) { this.sidebarGhost.remove(); this.sidebarGhost = null; } if (!this.sidebarDragData) return; const wrap = this.shadow.getElementById('canvasWrap'); if (!wrap) return; const wr = wrap.getBoundingClientRect(); if (ev.clientX >= wr.left && ev.clientX <= wr.right && ev.clientY >= wr.top && ev.clientY <= wr.bottom) { const pt = this.svgPt(ev.clientX, ev.clientY); if (this.sidebarDragData.type === 'commitment') { const c = this.commitments.find(x => x.id === this.sidebarDragData!.id); if (c && !this.weaveNodes.find(n => n.id === 'cn-' + c.id)) { this.weaveNodes.push(this.mkCommitNode(c, pt.x - NODE_W / 2, pt.y - NODE_H / 2)); } } else if (this.sidebarDragData.type === 'task') { const t = this.tasks.find(x => x.id === this.sidebarDragData!.id); if (t && !this.weaveNodes.find(n => n.id === t.id)) { this.weaveNodes.push(this.mkTaskNode(t, pt.x - TASK_W / 2, pt.y - 40)); } } this.renderAll(); this.rebuildSidebar(); } this.sidebarDragData = null; }; const cancelHandler = () => { document.removeEventListener('pointermove', moveHandler); document.removeEventListener('pointerup', upHandler); document.removeEventListener('pointercancel', cancelHandler); if (this.sidebarGhost) { this.sidebarGhost.remove(); this.sidebarGhost = null; } this.sidebarDragData = null; }; document.addEventListener('pointermove', moveHandler); document.addEventListener('pointerup', upHandler); document.addEventListener('pointercancel', cancelHandler); } // ── Exec panel ── private openExecPanel(taskId: string) { const node = this.weaveNodes.find(n => n.id === taskId); if (!node || node.type !== 'task') return; const t = node.data as TaskData; this.shadow.getElementById('execTitle')!.innerHTML = t.name + ' All Commitments Woven'; this.shadow.getElementById('execSubtitle')!.textContent = 'Set up the project for execution'; const members = this.connections.filter(c => c.to === taskId).map(c => { const cn = this.weaveNodes.find(n => n.id === c.from); return cn ? cn.data.memberName : ''; }).filter(Boolean); const steps = EXEC_STEPS[taskId] || EXEC_STEPS.default; if (!this.execStepStates[taskId]) { this.execStepStates[taskId] = {}; steps.forEach((_, i) => { this.execStepStates[taskId][i] = 'pending'; }); this.execStepStates[taskId][0] = 'active'; } this.renderExecSteps(taskId, steps, members); this.shadow.getElementById('execOverlay')!.classList.add('visible'); } private renderExecSteps(taskId: string, steps: typeof EXEC_STEPS['default'], members: string[]) { const container = this.shadow.getElementById('execSteps')!; container.innerHTML = ''; const states = this.execStepStates[taskId]; let doneCount = 0; steps.forEach((step, i) => { const state = states[i] || 'pending'; if (state === 'done') doneCount++; const div = document.createElement('div'); div.className = 'exec-step ' + state; div.innerHTML = `
${state === 'done' ? '\u2713' : step.icon}
${step.title}
${step.desc}
`; div.addEventListener('click', () => { if (states[i] === 'done') return; if (states[i] === 'active') { states[i] = 'done'; } else { Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; }); states[i] = 'active'; } // Auto-advance let nextActive = -1; for (let j = 0; j < steps.length; j++) { if (states[j] !== 'done') { nextActive = j; break; } } if (nextActive >= 0 && states[nextActive] !== 'active') { Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; }); states[nextActive] = 'active'; } this.renderExecSteps(taskId, steps, members); }); container.appendChild(div); }); const pct = steps.length > 0 ? (doneCount / steps.length * 100) : 0; (this.shadow.getElementById('execProgressFill') as HTMLElement).style.width = pct + '%'; this.shadow.getElementById('execProgressText')!.textContent = doneCount + '/' + steps.length; (this.shadow.getElementById('execLaunch') as HTMLButtonElement).disabled = doneCount < steps.length; } // ── Task editor ── private editingTaskNode: WeaveNode | null = null; private openTaskEditor(node: WeaveNode) { this.editingTaskNode = node; const t = node.data as TaskData; this.shadow.getElementById('taskEditTitle')!.textContent = 'Edit: ' + t.name; (this.shadow.getElementById('taskEditName') as HTMLInputElement).value = t.name; (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value = t.description || ''; (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value = t.notes || ''; this.shadow.getElementById('taskEditOverlay')!.classList.add('visible'); } private saveTaskEdit() { if (!this.editingTaskNode) return; const t = this.editingTaskNode.data as TaskData; t.name = (this.shadow.getElementById('taskEditName') as HTMLInputElement).value.trim() || t.name; t.description = (this.shadow.getElementById('taskEditDesc') as HTMLTextAreaElement).value.trim(); t.notes = (this.shadow.getElementById('taskEditNotes') as HTMLTextAreaElement).value.trim(); this.renderAll(); this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'); this.editingTaskNode = null; } /* ── Guided Tour ── */ private _tour: import('../../../shared/tour-engine').TourEngine | null = null; private async _initTour() { if (this._tour) return; const { TourEngine } = await import('../../../shared/tour-engine'); this._tour = new TourEngine(this.shadow as unknown as ShadowRoot, [ { target: '.tab-bar', title: 'Pool & Weave Views', message: 'Switch between the Commitment Pool (visual orbs) and the Weaving Dashboard (SVG node editor).' }, { target: '#pool-canvas', title: 'Commitment Pool', message: 'Each floating orb represents a time commitment — sized by hours, colored by skill category.' }, { target: '#addBtn', title: 'Add a Commitment', message: 'Pledge your hours with a skill category. Your commitment joins the pool for others to see.', advanceOnClick: true }, { target: '.stats-bar', title: 'Community Stats', message: 'See total hours available and how many contributors are in the pool at a glance.' }, ], 'rtime_tour_done', () => this.shadow.querySelector('.main') as HTMLElement); } async startTour() { await this._initTour(); this._tour?.start(); } // ── Collaborate setup ── private setupCollaborate() { // Create Intent modal const intentModal = this.shadow.getElementById('intentModalOverlay')!; const createBtn = this.shadow.getElementById('createIntentBtn'); const solverBtn = this.shadow.getElementById('runSolverBtn'); createBtn?.addEventListener('click', () => { intentModal.classList.add('visible'); (this.shadow.getElementById('intentDesc') as HTMLInputElement).value = ''; (this.shadow.getElementById('intentHours') as HTMLInputElement).value = '2'; this.updateIntentCost(); }); this.shadow.getElementById('intentCancel')?.addEventListener('click', () => { intentModal.classList.remove('visible'); }); // Type toggle this.shadow.querySelectorAll('.intent-type-btn').forEach(btn => { btn.addEventListener('click', () => { this.shadow.querySelectorAll('.intent-type-btn').forEach(b => b.classList.remove('active')); btn.classList.add('active'); this.updateIntentCost(); }); }); // Cost update on skill/hours change this.shadow.getElementById('intentSkill')?.addEventListener('change', () => this.updateIntentCost()); this.shadow.getElementById('intentHours')?.addEventListener('input', () => this.updateIntentCost()); // Submit intent this.shadow.getElementById('intentSubmit')?.addEventListener('click', () => this.submitIntent()); // Run solver solverBtn?.addEventListener('click', () => this.triggerSolver()); } private updateIntentCost() { const typeBtn = this.shadow.querySelector('.intent-type-btn.active') as HTMLElement; const type = typeBtn?.dataset.type || 'offer'; const costDiv = this.shadow.getElementById('intentCost')!; const costVal = this.shadow.getElementById('intentCostValue')!; if (type === 'need') { const skill = (this.shadow.getElementById('intentSkill') as HTMLSelectElement).value; const hours = parseInt((this.shadow.getElementById('intentHours') as HTMLInputElement).value) || 1; const price = this.skillPrices[skill] || 100; costVal.textContent = String(price * hours); costDiv.style.display = 'block'; } else { costDiv.style.display = 'none'; } } private async submitIntent() { const typeBtn = this.shadow.querySelector('.intent-type-btn.active') as HTMLElement; const type = typeBtn?.dataset.type || 'offer'; const skill = (this.shadow.getElementById('intentSkill') as HTMLSelectElement).value; const hours = parseInt((this.shadow.getElementById('intentHours') as HTMLInputElement).value) || 1; const description = (this.shadow.getElementById('intentDesc') as HTMLInputElement).value; try { const resp = await fetch(`${this.getApiBase()}/api/intent`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authHeaders() }, body: JSON.stringify({ type, skill, hours, description }), }); if (!resp.ok) { const err = await resp.json(); alert(err.error || 'Failed to create intent'); return; } this.shadow.getElementById('intentModalOverlay')!.classList.remove('visible'); this.refreshCollaborate(); } catch { alert('Failed to create intent'); } } private async triggerSolver() { try { await fetch(`${this.getApiBase()}/api/solver/run`, { method: 'POST', headers: this.authHeaders() }); this.refreshCollaborate(); } catch { // ignore } } private async refreshCollaborate() { const base = this.getApiBase(); try { const [iResp, sResp, cResp] = await Promise.all([ fetch(`${base}/api/intents`), fetch(`${base}/api/solver-results`), fetch(`${base}/api/skill-curves`), ]); if (iResp.ok) { const data = await iResp.json(); this.intents = data.intents || []; } if (sResp.ok) { const data = await sResp.json(); this.solverResults = data.results || []; } if (cResp.ok) { const data = await cResp.json(); this.skillPrices = data.prices || {}; } } catch { // offline } // Build member intent status map for pool view status rings this.memberIntentStatus.clear(); for (const intent of this.intents) { if (intent.status !== 'active' && intent.status !== 'matched') continue; const existing = this.memberIntentStatus.get(intent.memberName); if (intent.status === 'matched') { this.memberIntentStatus.set(intent.memberName, 'matched'); } else if (!existing) { this.memberIntentStatus.set(intent.memberName, intent.type); } } this.renderIntentsList(); this.renderSolverResults(); this.renderSkillPrices(); } private renderIntentsList() { const container = this.shadow.getElementById('collabIntents'); if (!container) return; const activeIntents = this.intents.filter(i => i.status === 'active' || i.status === 'matched'); if (activeIntents.length === 0) { container.innerHTML = '
No active intents yet. Create an offer or need to get started.
'; return; } container.innerHTML = activeIntents.map(intent => { const color = SKILL_COLORS[intent.skill] || '#8b5cf6'; const typeLabel = intent.type === 'offer' ? 'OFFER' : 'NEED'; const typeClass = intent.type === 'offer' ? 'intent-offer' : 'intent-need'; return `
${typeLabel} ${SKILL_LABELS[intent.skill] || intent.skill} ${intent.hours}h ${intent.status}
${intent.memberName}
${intent.description || 'No description'}
`; }).join(''); } private renderSolverResults() { const container = this.shadow.getElementById('collabResults'); if (!container) return; if (this.solverResults.length === 0) { container.innerHTML = '
No solver results yet. Create intents and run the solver to find matches.
'; return; } container.innerHTML = this.solverResults.map(result => { const scorePercent = Math.round(result.score * 100); const skills = (result.skills || []).map((s: string) => { const color = SKILL_COLORS[s] || '#8b5cf6'; return `${SKILL_LABELS[s] || s}`; }).join(''); const acceptCount = Object.values(result.acceptances || {}).filter(Boolean).length; const totalMembers = (result.members || []).length; return `
${scorePercent}
${skills}
${result.totalHours}h total · ${totalMembers} members · ${acceptCount}/${totalMembers} accepted
${(result.members || []).map((m: string) => { const accepted = result.acceptances?.[m]; const icon = accepted ? '✓' : '○'; const cls = accepted ? 'accepted' : 'pending'; return `${icon} ${m.split(':').pop()}`; }).join('')}
`; }).join(''); // Bind accept/reject buttons container.querySelectorAll('.solver-accept-btn').forEach(btn => { btn.addEventListener('click', async () => { const resultId = (btn as HTMLElement).dataset.resultId; try { await fetch(`${this.getApiBase()}/api/solver-results/${resultId}/accept`, { method: 'POST', headers: this.authHeaders() }); this.refreshCollaborate(); } catch { /* ignore */ } }); }); container.querySelectorAll('.solver-reject-btn').forEach(btn => { btn.addEventListener('click', async () => { const resultId = (btn as HTMLElement).dataset.resultId; try { await fetch(`${this.getApiBase()}/api/solver-results/${resultId}/reject`, { method: 'POST', headers: this.authHeaders() }); this.refreshCollaborate(); } catch { /* ignore */ } }); }); } private renderSkillPrices() { const container = this.shadow.getElementById('collabPrices'); if (!container) return; const skills = ['facilitation', 'design', 'tech', 'outreach', 'logistics']; container.innerHTML = skills.map(skill => { const color = SKILL_COLORS[skill] || '#8b5cf6'; const price = this.skillPrices[skill] || 100; return `
${SKILL_LABELS[skill] || skill} ${price} tok/h
`; }).join(''); } } // ── CSS ── const CSS_TEXT = ` :host { display: flex; flex-direction: column; height: 100%; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; color: #e2e8f0; background: #0f172a; line-height: 1.6; overflow: hidden; } .tab-bar { display: flex; background: #1e293b; border-bottom: 1px solid #334155; flex-shrink: 0; } .tab { padding: 0.65rem 1.5rem; font-size: 0.9rem; font-weight: 500; color: #64748b; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.2s; user-select: none; } .tab:hover { color: #e2e8f0; } .tab.active { color: #8b5cf6; border-bottom-color: #8b5cf6; } .stats-bar { display: flex; align-items: center; gap: 1.5rem; padding: 0.6rem 1.5rem; background: linear-gradient(135deg, #1e1b4b 0%, #2d1b3d 100%); border-bottom: 1px solid #334155; flex-shrink: 0; flex-wrap: wrap; } .stat { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: #94a3b8; } .stat-value { font-weight: 700; font-size: 1rem; color: #f1f5f9; } .skill-bar { display: flex; height: 6px; border-radius: 3px; overflow: hidden; flex: 1; min-width: 150px; max-width: 300px; } .skill-bar-segment { transition: width 0.5s ease; } .skill-legend { display: flex; gap: 0.75rem; flex-wrap: wrap; } .skill-legend-item { display: flex; align-items: center; gap: 0.3rem; font-size: 0.75rem; color: #94a3b8; } .skill-legend-dot { width: 8px; height: 8px; border-radius: 50%; } .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; } .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; pointer-events: none; opacity: 0; transform: scale(0.9) translateY(8px); transition: opacity 0.2s, transform 0.2s; 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-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; } .add-btn { position: absolute; bottom: 1.5rem; right: 1.5rem; padding: 0.65rem 1.25rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 500; cursor: pointer; box-shadow: 0 4px 16px rgba(139,92,246,0.3); transition: all 0.2s; z-index: 20; } .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; 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; cursor: grab; transition: background 0.15s; margin: 0 0.5rem 0.25rem; touch-action: none; } .sidebar-task:hover { background: #334155; } .sidebar-task-icon { width: 24px; height: 24px; border-radius: 0.375rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 0.7rem; flex-shrink: 0; } .canvas-wrap { flex: 1; position: relative; overflow: hidden; background: #0f172a; } #weave-svg { width: 100%; height: 100%; display: block; touch-action: none; } .node-group { cursor: grab; } .node-group:active { cursor: grabbing; } .node-rect { fill: #1e293b; stroke: #334155; stroke-width: 1; transition: stroke 0.15s; } .node-group:hover .node-rect { stroke: #8b5cf6; } .port { cursor: crosshair; transition: r 0.15s; touch-action: none; } .port:hover { r: 7; } .connection-line { fill: none; stroke: #475569; stroke-width: 2; pointer-events: none; } .connection-line.active { stroke: #8b5cf6; stroke-width: 2.5; } .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; } .exec-btn-rect { cursor: pointer; transition: opacity 0.15s; } .exec-btn-rect:hover { opacity: 0.85; } /* Modals & overlays */ .modal-overlay, .exec-overlay, .task-edit-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.7); z-index: 200; display: none; align-items: center; justify-content: center; } .modal-overlay.visible, .exec-overlay.visible, .task-edit-overlay.visible { display: flex; } .modal { background: #1e293b; border-radius: 1rem; padding: 1.75rem; width: 380px; max-width: 90vw; box-shadow: 0 16px 64px rgba(0,0,0,0.5); } .modal h3 { font-size: 1.1rem; font-weight: 700; color: #f1f5f9; margin-bottom: 1rem; } .modal-field { margin-bottom: 0.85rem; } .modal-field label { display: block; font-size: 0.8rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.25rem; } .modal-field input, .modal-field select { width: 100%; padding: 0.55rem 0.75rem; border: 1px solid #475569; border-radius: 0.375rem; font-size: 0.9rem; outline: none; transition: border-color 0.15s; background: #0f172a; color: #e2e8f0; } .modal-field input:focus, .modal-field select:focus { border-color: #8b5cf6; } .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1.25rem; } .modal-cancel { padding: 0.5rem 1rem; border: 1px solid #334155; border-radius: 0.375rem; background: #1e293b; color: #94a3b8; font-size: 0.85rem; cursor: pointer; } .modal-submit { padding: 0.5rem 1.25rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none; border-radius: 0.375rem; font-size: 0.85rem; font-weight: 500; cursor: pointer; } /* Exec panel */ .exec-overlay { z-index: 300; } .exec-panel { background: #1e293b; border-radius: 1rem; width: 520px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 16px 64px rgba(0,0,0,0.5); } .exec-panel-header { padding: 1.5rem 1.75rem 1rem; border-bottom: 1px solid #334155; } .exec-panel-header h2 { font-size: 1.15rem; font-weight: 700; color: #f1f5f9; margin-bottom: 0.2rem; } .exec-panel-header p { font-size: 0.85rem; color: #64748b; } .exec-panel-badge { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 1rem; background: rgba(16,185,129,0.15); color: #10b981; font-size: 0.75rem; font-weight: 600; margin-left: 0.5rem; vertical-align: middle; } .exec-steps { padding: 0.75rem 1.75rem 1.5rem; } .exec-step { display: flex; gap: 1rem; padding: 1rem 0.5rem; border-bottom: 1px solid #1e293b; align-items: flex-start; cursor: pointer; transition: background 0.1s; border-radius: 0.5rem; } .exec-step:hover { background: #0f172a; } .exec-step:last-child { border-bottom: none; } .exec-step.active .exec-step-num { background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; } .exec-step.done .exec-step-num { background: #10b981; color: #fff; } .exec-step-num { width: 32px; height: 32px; min-width: 32px; border-radius: 50%; background: #334155; color: #64748b; display: flex; align-items: center; justify-content: center; font-size: 0.82rem; font-weight: 600; transition: all 0.2s; } .exec-step-content { flex: 1; } .exec-step-title { font-size: 0.92rem; font-weight: 600; color: #f1f5f9; margin-bottom: 0.15rem; } .exec-step.done .exec-step-title { color: #10b981; } .exec-step-desc { font-size: 0.8rem; color: #64748b; line-height: 1.4; } .exec-panel-footer { padding: 1rem 1.75rem; border-top: 1px solid #334155; display: flex; justify-content: space-between; align-items: center; } .exec-close { padding: 0.45rem 1rem; border: 1px solid #334155; border-radius: 0.375rem; background: #1e293b; color: #94a3b8; font-size: 0.85rem; cursor: pointer; } .exec-launch { padding: 0.5rem 1.5rem; background: linear-gradient(135deg, #10b981, #059669); color: #fff; border: none; border-radius: 0.375rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; } .exec-launch:disabled { opacity: 0.5; cursor: not-allowed; } .exec-progress { display: flex; gap: 0.35rem; align-items: center; font-size: 0.78rem; color: #64748b; } .exec-progress-bar { width: 80px; height: 4px; background: #334155; border-radius: 2px; overflow: hidden; } .exec-progress-fill { height: 100%; background: linear-gradient(90deg, #8b5cf6, #10b981); border-radius: 2px; transition: width 0.3s; } /* Task editor */ .task-edit-overlay { z-index: 250; } .task-edit-panel { background: #1e293b; border-radius: 1rem; width: 480px; max-width: 95vw; max-height: 90vh; overflow-y: auto; box-shadow: 0 16px 64px rgba(0,0,0,0.5); } .task-edit-header { padding: 1.25rem 1.5rem 0.75rem; border-bottom: 1px solid #334155; } .task-edit-header h3 { font-size: 1.05rem; font-weight: 700; color: #f1f5f9; } .task-edit-body { padding: 1rem 1.5rem; } .task-edit-field { margin-bottom: 0.85rem; } .task-edit-field label { display: block; font-size: 0.78rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.25rem; } .task-edit-field input, .task-edit-field textarea { width: 100%; padding: 0.5rem 0.7rem; border: 1px solid #475569; border-radius: 0.375rem; font-size: 0.85rem; font-family: inherit; outline: none; background: #0f172a; color: #e2e8f0; } .task-edit-field input:focus, .task-edit-field textarea:focus { border-color: #8b5cf6; } .task-edit-field textarea { resize: vertical; min-height: 70px; } .task-edit-footer { padding: 0.75rem 1.5rem; border-top: 1px solid #334155; display: flex; justify-content: flex-end; gap: 0.5rem; } /* Collaborate view */ #collaborate-view { width: 100%; height: 100%; position: absolute; top: 0; left: 0; flex-direction: column; overflow-y: auto; padding: 1.25rem; gap: 1rem; } .collab-panel { max-width: 800px; margin: 0 auto; width: 100%; display: flex; flex-direction: column; gap: 1.5rem; } .collab-section { background: #1e293b; border-radius: 0.75rem; padding: 1rem 1.25rem; border: 1px solid #334155; } .collab-section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; } .collab-section-header h3 { font-size: 0.95rem; font-weight: 600; color: #f1f5f9; margin: 0; } .collab-create-btn, .collab-solver-btn { padding: 0.35rem 0.85rem; background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none; border-radius: 0.375rem; font-size: 0.78rem; font-weight: 500; cursor: pointer; transition: opacity 0.15s; } .collab-create-btn:hover, .collab-solver-btn:hover { opacity: 0.85; } .collab-solver-btn { background: linear-gradient(135deg, #3b82f6, #06b6d4); } .collab-empty { color: #64748b; font-size: 0.82rem; text-align: center; padding: 1.5rem 0; } .collab-intents, .collab-results { display: flex; flex-direction: column; gap: 0.5rem; } /* Intent cards */ .intent-card { background: #0f172a; border-radius: 0.5rem; padding: 0.75rem 1rem; border: 1px solid #334155; transition: border-color 0.15s; } .intent-card:hover { border-color: #475569; } .intent-card-header { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.4rem; flex-wrap: wrap; } .intent-type-badge { font-size: 0.68rem; font-weight: 700; padding: 0.1rem 0.5rem; border-radius: 1rem; letter-spacing: 0.04em; } .intent-type-badge.intent-offer { background: rgba(16,185,129,0.15); color: #10b981; } .intent-type-badge.intent-need { background: rgba(59,130,246,0.15); color: #3b82f6; } .intent-skill-badge { font-size: 0.72rem; font-weight: 500; padding: 0.1rem 0.5rem; border-radius: 1rem; } .intent-hours { font-size: 0.78rem; font-weight: 600; color: #f1f5f9; margin-left: auto; } .intent-status-badge { font-size: 0.68rem; padding: 0.1rem 0.4rem; border-radius: 1rem; background: #334155; color: #94a3b8; } .intent-status-active { background: rgba(16,185,129,0.15); color: #10b981; } .intent-status-matched { background: rgba(251,191,36,0.15); color: #fbbf24; } .intent-status-settled { background: rgba(139,92,246,0.15); color: #8b5cf6; } .intent-member { font-size: 0.82rem; font-weight: 600; color: #e2e8f0; } .intent-desc { font-size: 0.78rem; color: #64748b; line-height: 1.4; } /* Intent type toggle */ .intent-type-toggle { display: flex; gap: 0; border: 1px solid #475569; border-radius: 0.375rem; overflow: hidden; } .intent-type-btn { flex: 1; padding: 0.45rem 0.75rem; background: #0f172a; color: #94a3b8; border: none; font-size: 0.85rem; font-weight: 500; cursor: pointer; transition: all 0.15s; } .intent-type-btn.active { background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; } .intent-cost { font-size: 0.82rem; color: #94a3b8; padding: 0.5rem 0; } .intent-cost strong { color: #fbbf24; } /* Solver result cards */ .solver-result-card { background: #0f172a; border-radius: 0.5rem; padding: 0.75rem 1rem; border: 1px solid #334155; } .solver-result-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.5rem; } .solver-score { flex-shrink: 0; } .solver-score-ring { width: 40px; height: 40px; border-radius: 50%; background: conic-gradient(#8b5cf6 var(--score, 0%), #334155 var(--score, 0%)); display: flex; align-items: center; justify-content: center; } .solver-score-ring span { background: #0f172a; width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 0.72rem; font-weight: 700; color: #f1f5f9; } .solver-result-info { flex: 1; } .solver-skills { display: flex; gap: 0.35rem; flex-wrap: wrap; } .solver-meta { font-size: 0.75rem; color: #64748b; margin-top: 0.25rem; } .solver-result-members { display: flex; flex-wrap: wrap; gap: 0.35rem; margin-bottom: 0.5rem; } .solver-member { font-size: 0.72rem; padding: 0.1rem 0.4rem; border-radius: 0.25rem; background: #1e293b; color: #94a3b8; } .solver-member.accepted { background: rgba(16,185,129,0.15); color: #10b981; } .solver-result-actions { display: flex; gap: 0.5rem; justify-content: flex-end; } .solver-accept-btn, .solver-reject-btn { padding: 0.3rem 0.75rem; border: none; border-radius: 0.375rem; font-size: 0.78rem; font-weight: 500; cursor: pointer; } .solver-accept-btn { background: linear-gradient(135deg, #10b981, #059669); color: #fff; } .solver-reject-btn { background: #334155; color: #94a3b8; } /* Skill prices */ .collab-prices-section h3 { font-size: 0.95rem; font-weight: 600; color: #f1f5f9; margin: 0 0 0.75rem; } .collab-prices { display: flex; gap: 0.75rem; flex-wrap: wrap; } .price-card { display: flex; align-items: center; gap: 0.4rem; padding: 0.35rem 0.7rem; background: #0f172a; border-radius: 0.375rem; border: 1px solid #334155; } .price-dot { width: 8px; height: 8px; border-radius: 50%; } .price-skill { font-size: 0.78rem; color: #94a3b8; } .price-value { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; margin-left: 0.25rem; } @media (max-width: 768px) { .sidebar { width: 200px; } .exec-panel { width: 95vw; } .task-edit-panel { width: 95vw; } } @media (max-width: 640px) { .sidebar { width: 180px; } } `; customElements.define('folk-timebank-app', FolkTimebankApp);