feat(rtime): port 14 missing hcc-mem-staging features to rTime module

- Connection & exec state persistence (POST /api/connections, PUT exec-state)
- Exec step detail forms for all 5 types (venue, comms, notes, prep, launch)
- Step state machine fix: click to expand/collapse, action button to complete
- Task editor links field with dynamic add/remove and server persistence
- Cyclos-aware launch handler with fallback to demo celebration
- Fix dead EXEC_STEPS[taskId] lookup, auto-place first task on empty canvas
- DID display truncation for unreadable DIDs in intent routes
- Dark/light theme toggle with localStorage persistence
- Hex hover stroke, commitment description in hex nodes, edit pencil on tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-02 15:08:43 -07:00
parent 5b4300db77
commit 87bafc9d74
2 changed files with 429 additions and 24 deletions

View File

@ -292,6 +292,10 @@ class FolkTimebankApp extends HTMLElement {
// Exec state
private execStepStates: Record<string, Record<number, string>> = {};
private _restoredConnections: { fromCommitmentId: string; toTaskId: string; skill: string }[] = [];
private _currentExecTaskId: string | null = null;
private _cyclosMembers: { id: string; name: string; balance: number }[] = [];
private _theme: 'dark' | 'light' = 'dark';
constructor() {
super();
@ -309,7 +313,9 @@ class FolkTimebankApp extends HTMLElement {
this.space = this.getAttribute('space') || 'demo';
this.currentView = (this.getAttribute('view') as any) || 'pool';
this.dpr = window.devicePixelRatio || 1;
this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark';
this.render();
this.applyTheme();
this.setupPool();
this.setupWeave();
this.setupCollaborate();
@ -329,6 +335,16 @@ class FolkTimebankApp extends HTMLElement {
return token ? { Authorization: `Bearer ${token}` } : {};
}
private applyTheme() {
if (this._theme === 'light') {
this.setAttribute('data-theme', 'light');
} else {
this.removeAttribute('data-theme');
}
const btn = this.shadow.getElementById('themeToggle');
if (btn) btn.textContent = this._theme === 'dark' ? '☀' : '☾';
}
disconnectedCallback() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
}
@ -347,13 +363,49 @@ class FolkTimebankApp extends HTMLElement {
if (tResp.ok) {
const tData = await tResp.json();
this.tasks = tData.tasks || [];
// Restore connections
this._restoredConnections = (tData.connections || []).map((cn: any) => ({
fromCommitmentId: cn.fromCommitmentId,
toTaskId: cn.toTaskId,
skill: cn.skill,
}));
// Restore exec states
for (const es of (tData.execStates || [])) {
if (es.taskId && es.steps) {
this.execStepStates[es.taskId] = {};
for (const [k, v] of Object.entries(es.steps)) {
this.execStepStates[es.taskId][Number(k)] = v as string;
}
}
}
}
} catch {
// Offline — use empty state
}
// Fetch Cyclos members (silent failure → demo mode)
try {
const mResp = await fetch(`${base}/api/cyclos/members`);
if (mResp.ok) {
const mData = await mResp.json();
this._cyclosMembers = (mData.members || []).map((m: any) => ({ id: m.id, name: m.name, balance: m.balance || 0 }));
}
} catch { /* Cyclos not configured — demo mode */ }
this.buildOrbs();
this.updateStats();
this.rebuildSidebar();
this.applyRestoredConnections();
// 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;
this.weaveNodes.push(this.mkTaskNode(this.tasks[0], x - TASK_W / 2, y));
this.renderAll();
this.rebuildSidebar();
}
}
private render() {
@ -363,6 +415,7 @@ class FolkTimebankApp extends HTMLElement {
<div class="tab active" data-view="pool">Commitment Pool</div>
<div class="tab" data-view="weave">Weaving Dashboard</div>
<div class="tab" data-view="collaborate">Collaborate</div>
<button class="theme-toggle" id="themeToggle" title="Toggle light/dark theme"></button>
</div>
<div class="stats-bar">
<div class="stat"><span class="stat-value" id="statHours">0</span>&nbsp;hours available</div>
@ -538,6 +591,10 @@ class FolkTimebankApp extends HTMLElement {
<div class="task-edit-field"><label>Task Name</label><input type="text" id="taskEditName"></div>
<div class="task-edit-field"><label>Description</label><textarea id="taskEditDesc"></textarea></div>
<div class="task-edit-field"><label>Notes</label><textarea id="taskEditNotes"></textarea></div>
<div class="task-edit-field"><label>Links</label>
<div id="taskEditLinksContainer"></div>
<button class="task-edit-add-link" id="taskEditAddLink">+ Add Link</button>
</div>
</div>
<div class="task-edit-footer">
<button class="modal-cancel" id="taskEditCancel">Cancel</button>
@ -573,6 +630,13 @@ class FolkTimebankApp extends HTMLElement {
});
});
// Theme toggle
this.shadow.getElementById('themeToggle')!.addEventListener('click', () => {
this._theme = this._theme === 'dark' ? 'light' : 'dark';
localStorage.setItem('rtime-theme', this._theme);
this.applyTheme();
});
// Add commitment modal
const modal = this.shadow.getElementById('modalOverlay')!;
this.shadow.getElementById('addBtn')!.addEventListener('click', () => {
@ -598,16 +662,7 @@ class FolkTimebankApp extends HTMLElement {
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);
});
this.shadow.getElementById('execLaunch')!.addEventListener('click', () => this.handleExecLaunch());
// Task editor
this.shadow.getElementById('taskEditCancel')!.addEventListener('click', () => this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible'));
@ -615,6 +670,11 @@ class FolkTimebankApp extends HTMLElement {
if (e.target === this.shadow.getElementById('taskEditOverlay')) this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
});
this.shadow.getElementById('taskEditSave')!.addEventListener('click', () => this.saveTaskEdit());
this.shadow.getElementById('taskEditAddLink')!.addEventListener('click', () => {
const current = this.getTaskEditLinks();
current.push({ label: '', url: '' });
this.renderTaskEditLinks(current);
});
// Resize
const resizeObserver = new ResizeObserver(() => { if (this.currentView === 'pool') this.resizePoolCanvas(); });
@ -716,7 +776,11 @@ class FolkTimebankApp extends HTMLElement {
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');
if (this._theme === 'light') {
bg.addColorStop(0, '#f8f7ff'); bg.addColorStop(1, '#fdf2f8');
} else {
bg.addColorStop(0, '#0f172a'); bg.addColorStop(1, '#1a1033');
}
this.ctx.fillStyle = bg; this.ctx.fillRect(0, 0, this.poolW, this.poolH);
this.drawBasket();
@ -949,6 +1013,15 @@ class FolkTimebankApp extends HTMLElement {
hex.setAttribute('filter', 'url(#nodeShadow)');
g.appendChild(hex);
const hexHover = ns('polygon');
hexHover.setAttribute('points', hexPolygonStr(cx, cy, hr));
hexHover.setAttribute('fill', 'none');
hexHover.setAttribute('stroke', col);
hexHover.setAttribute('stroke-width', '0');
hexHover.setAttribute('class', 'hex-hover-stroke');
hexHover.style.pointerEvents = 'none';
g.appendChild(hexHover);
const clipId = 'hexClip-' + node.id;
const clipPath = ns('clipPath');
clipPath.setAttribute('id', clipId);
@ -968,6 +1041,10 @@ class FolkTimebankApp extends HTMLElement {
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'));
if (c.desc) {
const descText = c.desc.length > 18 ? c.desc.slice(0, 17) + '\u2026' : c.desc;
g.appendChild(svgText(descText, cx, cy + 32, 9, '#64748b', '400', 'middle'));
}
const pts = hexPoints(cx, cy, hr);
const portHit = ns('circle');
@ -1011,6 +1088,9 @@ class FolkTimebankApp extends HTMLElement {
g.appendChild(hm);
g.appendChild(svgText(t.name, 12, 18, 12, '#fff', '600'));
const editPencil = svgText('\u270E', node.w - 10, 18, 11, '#ffffff66', '400', 'middle');
editPencil.style.pointerEvents = 'none';
g.appendChild(editPencil);
g.appendChild(svgText(ready ? 'Ready!' : Math.round(progress * 100) + '%', node.w - 24, 18, 10, '#ffffffcc', '500', 'end'));
const pbW = node.w - 24;
@ -1170,6 +1250,7 @@ class FolkTimebankApp extends HTMLElement {
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;
this.persistConnection(fromId, toId, skill);
const nowReady = this.isTaskReady(tNode);
this.renderAll();
this.rebuildSidebar();
@ -1278,11 +1359,55 @@ class FolkTimebankApp extends HTMLElement {
document.addEventListener('pointercancel', cancelHandler);
}
// ── Connection/exec persistence ──
private applyRestoredConnections() {
if (this._restoredConnections.length === 0) return;
for (const rc of this._restoredConnections) {
const fromNodeId = 'cn-' + rc.fromCommitmentId;
const toNodeId = rc.toTaskId;
const fNode = this.weaveNodes.find(n => n.id === fromNodeId);
const tNode = this.weaveNodes.find(n => n.id === toNodeId);
if (!fNode || !tNode) continue;
if (this.connections.find(c => c.from === fromNodeId && c.to === toNodeId)) continue;
this.connections.push({ from: fromNodeId, to: toNodeId, skill: rc.skill });
if (!tNode.data.fulfilled) tNode.data.fulfilled = {};
tNode.data.fulfilled[rc.skill] = (tNode.data.fulfilled[rc.skill] || 0) + (fNode.data.hours || 0);
}
this._restoredConnections = [];
this.renderAll();
this.rebuildSidebar();
}
private async persistConnection(from: string, to: string, skill: string) {
const fromCommitmentId = from.replace(/^cn-/, '');
try {
await fetch(`${this.getApiBase()}/api/connections`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({ fromCommitmentId, toTaskId: to, skill }),
});
} catch { /* offline */ }
}
private async persistExecState(taskId: string) {
const states = this.execStepStates[taskId];
if (!states) return;
try {
await fetch(`${this.getApiBase()}/api/tasks/${taskId}/exec-state`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({ steps: states }),
});
} catch { /* offline */ }
}
// ── Exec panel ──
private openExecPanel(taskId: string) {
const node = this.weaveNodes.find(n => n.id === taskId);
if (!node || node.type !== 'task') return;
this._currentExecTaskId = taskId;
const t = node.data as TaskData;
this.shadow.getElementById('execTitle')!.innerHTML = t.name + ' <span class="exec-panel-badge">All Commitments Woven</span>';
@ -1293,7 +1418,7 @@ class FolkTimebankApp extends HTMLElement {
return cn ? cn.data.memberName : '';
}).filter(Boolean);
const steps = EXEC_STEPS[taskId] || EXEC_STEPS.default;
const steps = EXEC_STEPS.default;
if (!this.execStepStates[taskId]) {
this.execStepStates[taskId] = {};
steps.forEach((_, i) => { this.execStepStates[taskId][i] = 'pending'; });
@ -1304,6 +1429,91 @@ class FolkTimebankApp extends HTMLElement {
this.shadow.getElementById('execOverlay')!.classList.add('visible');
}
private renderStepDetail(type: string, members: string[], taskId: string, stepIdx: number): string {
const memberOpts = members.map(m => `<option value="${m}">${m}</option>`).join('');
switch (type) {
case 'venue':
return `<div class="exec-step-detail">
<div class="exec-step-field"><label>Space Type</label>
<select><option>In-person venue</option><option>Virtual room</option><option>Hybrid</option></select></div>
<div class="exec-step-field"><label>Location</label><input type="text" placeholder="Address or meeting URL"></div>
<div class="exec-step-field"><label>Date &amp; Time</label><input type="datetime-local"></div>
<div class="exec-step-field"><label>Space Lead</label><select><option value="">Select</option>${memberOpts}</select></div>
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Confirm Setup</button>
</div>`;
case 'comms':
return `<div class="exec-step-detail">
<div class="exec-step-field"><label>Channels</label>
<div class="exec-step-checklist">
<label><input type="checkbox" checked> Group Chat</label>
<label><input type="checkbox"> Email Thread</label>
<label><input type="checkbox"> Signal Group</label>
</div></div>
<div class="exec-step-field"><label>Invite List</label>
<div class="exec-step-invite-list">${members.map(m => `<span class="invite-tag">${m}</span>`).join('')}</div></div>
<div class="exec-step-field"><label>Comms Lead</label><select><option value="">Select</option>${memberOpts}</select></div>
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Set Up Channels</button>
</div>`;
case 'notes':
return `<div class="exec-step-detail">
<div class="exec-step-field"><label>Document Type</label>
<select><option>Shared Agenda</option><option>Meeting Notes</option><option>Project Brief</option></select></div>
<div class="exec-step-field"><label>Initial Sections</label>
<div class="exec-step-checklist">
<label><input type="checkbox" checked> Objectives</label>
<label><input type="checkbox" checked> Agenda</label>
<label><input type="checkbox"> Resources</label>
<label><input type="checkbox"> Action Items</label>
</div></div>
<div class="exec-step-field"><label>Drive Link</label><input type="url" placeholder="https://…"></div>
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Create Document</button>
</div>`;
case 'prep':
return `<div class="exec-step-detail">
<div class="exec-step-field"><label>Transcript / Notes</label><textarea rows="3" placeholder="Paste transcript or notes…"></textarea></div>
<div class="exec-step-field"><label>Processing Mode</label>
<select><option>Summarize</option><option>Extract Action Items</option><option>Generate Brief</option></select></div>
<button class="exec-step-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Process Input</button>
</div>`;
case 'launch':
return `<div class="exec-step-detail">
<div class="exec-step-field"><label>Pre-Launch Checklist</label>
<div class="exec-step-checklist">
<label><input type="checkbox"> All roles confirmed</label>
<label><input type="checkbox"> Venue / link confirmed</label>
<label><input type="checkbox"> Comms channels active</label>
<label><input type="checkbox"> Shared docs ready</label>
<label><input type="checkbox"> Notifications sent</label>
</div></div>
<button class="exec-step-action launch-action" data-complete-step="${stepIdx}" data-task-id="${taskId}">Confirm Launch</button>
</div>`;
default:
return '';
}
}
private completeExecStep(taskId: string, stepIdx: number) {
const states = this.execStepStates[taskId];
if (!states || states[stepIdx] === 'done') return;
states[stepIdx] = 'done';
// Auto-advance to next pending step
const steps = EXEC_STEPS.default;
let nextPending = -1;
for (let j = 0; j < steps.length; j++) {
if (states[j] !== 'done') { nextPending = j; break; }
}
if (nextPending >= 0) {
Object.keys(states).forEach(k => { if (states[Number(k)] === 'active') states[Number(k)] = 'pending'; });
states[nextPending] = 'active';
}
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);
this.renderExecSteps(taskId, steps, members);
this.persistExecState(taskId);
}
private renderExecSteps(taskId: string, steps: typeof EXEC_STEPS['default'], members: string[]) {
const container = this.shadow.getElementById('execSteps')!;
container.innerHTML = '';
@ -1321,38 +1531,104 @@ class FolkTimebankApp extends HTMLElement {
<div class="exec-step-content">
<div class="exec-step-title">${step.title}</div>
<div class="exec-step-desc">${step.desc}</div>
${state === 'active' ? this.renderStepDetail(step.detail, members, taskId, i) : ''}
</div>
`;
div.addEventListener('click', () => {
div.addEventListener('click', (e: MouseEvent) => {
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON') return;
if ((e.target as HTMLElement).closest('[data-complete-step]')) return;
if (states[i] === 'done') return;
if (states[i] === 'active') {
states[i] = 'done';
states[i] = 'pending';
} 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);
this.persistExecState(taskId);
});
container.appendChild(div);
});
// Bind action buttons via event delegation
container.querySelectorAll('[data-complete-step]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt((btn as HTMLElement).dataset.completeStep!);
this.completeExecStep(taskId, idx);
});
});
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;
}
// ── Launch handler ──
private async handleExecLaunch() {
const btn = this.shadow.getElementById('execLaunch') as HTMLButtonElement;
const taskId = this._currentExecTaskId;
// Check if connected commitments have cyclosMemberId
const connectedCommitments = taskId
? this.connections.filter(c => c.to === taskId).map(c => {
const cn = this.weaveNodes.find(n => n.id === c.from);
return cn?.data;
}).filter(Boolean)
: [];
const cyclosCommitments = connectedCommitments.filter(c => {
const member = this._cyclosMembers.find(m => m.name === c.memberName);
return member?.id;
});
if (cyclosCommitments.length === 0 || this._cyclosMembers.length === 0) {
// Demo mode — celebration
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);
return;
}
// Cyclos mode
btn.disabled = true;
btn.textContent = 'Processing…';
let successes = 0, failures = 0;
for (const c of cyclosCommitments) {
const member = this._cyclosMembers.find(m => m.name === c.memberName);
if (!member) { failures++; continue; }
try {
const resp = await fetch(`${this.getApiBase()}/api/cyclos/commitments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({ fromUserId: member.id, amount: c.hours, description: `${c.skill}: ${c.desc || 'commitment'}` }),
});
if (resp.ok) successes++; else failures++;
} catch { failures++; }
}
if (failures > 0 && successes === 0) {
btn.textContent = `Failed (${failures} errors)`;
btn.style.background = '#ef4444';
} else {
btn.textContent = failures > 0 ? `Launched (${successes} ok, ${failures} failed)` : 'Launched!';
btn.style.background = 'linear-gradient(135deg, #8b5cf6, #ec4899)';
}
setTimeout(() => {
this.shadow.getElementById('execOverlay')!.classList.remove('visible');
btn.textContent = 'Launch Project'; btn.style.background = ''; btn.disabled = false;
}, 2000);
}
// ── Task editor ──
private editingTaskNode: WeaveNode | null = null;
@ -1364,15 +1640,56 @@ class FolkTimebankApp extends HTMLElement {
(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.renderTaskEditLinks(t.links || []);
this.shadow.getElementById('taskEditOverlay')!.classList.add('visible');
}
private renderTaskEditLinks(links: { label: string; url: string }[]) {
const container = this.shadow.getElementById('taskEditLinksContainer')!;
container.innerHTML = '';
links.forEach((link, i) => {
const row = document.createElement('div');
row.className = 'task-edit-link-row';
row.innerHTML = `<input type="text" class="link-label" placeholder="Label" value="${link.label || ''}">
<input type="url" class="link-url" placeholder="https://…" value="${link.url || ''}">
<button class="task-edit-link-rm" data-link-idx="${i}">&times;</button>`;
container.appendChild(row);
});
container.querySelectorAll('.task-edit-link-rm').forEach(btn => {
btn.addEventListener('click', () => {
const idx = parseInt((btn as HTMLElement).dataset.linkIdx!);
const current = this.getTaskEditLinks();
current.splice(idx, 1);
this.renderTaskEditLinks(current);
});
});
}
private getTaskEditLinks(): { label: string; url: string }[] {
const container = this.shadow.getElementById('taskEditLinksContainer')!;
const rows = container.querySelectorAll('.task-edit-link-row');
const links: { label: string; url: string }[] = [];
rows.forEach(row => {
const label = (row.querySelector('.link-label') as HTMLInputElement).value.trim();
const url = (row.querySelector('.link-url') as HTMLInputElement).value.trim();
if (label || url) links.push({ label, url });
});
return links;
}
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();
t.links = this.getTaskEditLinks();
// Persist to server
fetch(`${this.getApiBase()}/api/tasks/${t.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', ...this.authHeaders() },
body: JSON.stringify({ name: t.name, description: t.description, notes: t.notes, links: t.links }),
}).catch(() => {});
this.renderAll();
this.shadow.getElementById('taskEditOverlay')!.classList.remove('visible');
this.editingTaskNode = null;
@ -2264,6 +2581,89 @@ const CSS_TEXT = `
.price-skill { font-size: 0.78rem; color: #94a3b8; }
.price-value { font-size: 0.82rem; font-weight: 600; color: #f1f5f9; margin-left: 0.25rem; }
/* Theme toggle */
.theme-toggle {
margin-left: auto; padding: 0.4rem 0.75rem;
background: transparent; border: 1px solid #334155; border-radius: 0.375rem;
color: #94a3b8; font-size: 1rem; cursor: pointer; transition: all 0.15s;
}
.theme-toggle:hover { border-color: #8b5cf6; color: #e2e8f0; }
/* Light theme overrides */
:host([data-theme="light"]) { background: #f8fafc; color: #1e293b; }
:host([data-theme="light"]) .tab-bar { background: #fff; border-bottom-color: #e2e8f0; }
:host([data-theme="light"]) .tab { color: #94a3b8; }
:host([data-theme="light"]) .tab:hover { color: #1e293b; }
:host([data-theme="light"]) .tab.active { color: #8b5cf6; }
: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"]) .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; }
/* Hex hover stroke */
.hex-hover-stroke { transition: stroke-width 0.15s; }
.node-group:hover .hex-hover-stroke { stroke-width: 2; }
/* Task editor links */
.task-edit-link-row {
display: flex; gap: 0.4rem; margin-bottom: 0.4rem; align-items: center;
}
.task-edit-link-row input { flex: 1; }
.task-edit-link-row .link-label { max-width: 120px; }
.task-edit-link-rm {
width: 28px; height: 28px; border: 1px solid #475569; border-radius: 0.25rem;
background: #0f172a; color: #ef4444; font-size: 1rem; cursor: pointer;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.task-edit-link-rm:hover { background: #1e293b; }
.task-edit-add-link {
padding: 0.35rem 0.75rem; border: 1px dashed #475569; border-radius: 0.375rem;
background: transparent; color: #8b5cf6; font-size: 0.78rem; font-weight: 500;
cursor: pointer; margin-top: 0.35rem; transition: border-color 0.15s;
}
.task-edit-add-link:hover { border-color: #8b5cf6; }
/* Exec step detail forms */
.exec-step-detail {
margin-top: 0.75rem;
padding: 0.75rem;
background: #0f172a;
border-radius: 0.5rem;
border: 1px solid #334155;
}
.exec-step-field { margin-bottom: 0.6rem; }
.exec-step-field label { display: block; font-size: 0.75rem; font-weight: 500; color: #94a3b8; margin-bottom: 0.2rem; }
.exec-step-field input, .exec-step-field select, .exec-step-field textarea {
width: 100%; padding: 0.4rem 0.6rem; border: 1px solid #475569; border-radius: 0.375rem;
font-size: 0.82rem; background: #1e293b; color: #e2e8f0; outline: none; font-family: inherit;
}
.exec-step-field input:focus, .exec-step-field select:focus, .exec-step-field textarea:focus { border-color: #8b5cf6; }
.exec-step-field textarea { resize: vertical; min-height: 50px; }
.exec-step-action {
margin-top: 0.5rem; padding: 0.45rem 1rem;
background: linear-gradient(135deg, #8b5cf6, #ec4899); color: #fff; border: none;
border-radius: 0.375rem; font-size: 0.8rem; font-weight: 600; cursor: pointer;
transition: opacity 0.15s; width: 100%;
}
.exec-step-action:hover { opacity: 0.85; }
.exec-step-action.launch-action { background: linear-gradient(135deg, #10b981, #059669); }
.exec-step-invite-list { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.25rem; }
.invite-tag {
font-size: 0.72rem; padding: 0.15rem 0.5rem; border-radius: 1rem;
background: rgba(139,92,246,0.15); color: #a78bfa;
}
.exec-step-checklist { display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.25rem; }
.exec-step-checklist label {
display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; color: #e2e8f0; cursor: pointer;
}
.exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; }
@media (max-width: 768px) {
.sidebar { width: 200px; }
.exec-panel { width: 95vw; }

View File

@ -100,12 +100,17 @@ export function createIntentRoutes(getSyncServer: () => SyncServer | null): Hono
return doc;
}
function truncateDid(did: string): string {
if (!did || did.length < 20) return did;
return did.slice(0, 12) + '…' + did.slice(-6);
}
async function requireAuth(headers: Headers): Promise<{ did: string; username: string } | null> {
const token = extractToken(headers);
if (!token) return null;
try {
const claims = await verifyToken(token);
return { did: claims.sub, username: (claims as any).username || claims.sub };
return { did: claims.sub, username: (claims as any).username || truncateDid(claims.sub) };
} catch {
return null;
}