spectoflow 0.22.4 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -15,6 +15,23 @@ let attnFilter = 'open'; // attention tab filter — cl
15
15
  // apply the persisted design skin as early as possible (before the first paint of app-driven DOM)
16
16
  (function(){ try{ const d=localStorage.getItem('spf-design'); if(d) document.documentElement.setAttribute('data-design',d); }catch{} })();
17
17
 
18
+ // The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
19
+ // hub-server's legacy-route redirect (sub-project 3) guarantees a bookmark without this prefix never
20
+ // reaches this file directly; it 302s to a /p/<id>/... URL first. Null when served by the older
21
+ // single-project templates/dashboard/server.js (no prefix at all) — every helper below no-ops in
22
+ // that case, preserving today's exact single-project behavior.
23
+ const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
24
+ // Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
25
+ // attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
26
+ // one" (&p=, e.g. '/api/agentfile?path=...').
27
+ function withProject(url) { if (!PROJECT_ID) return url; return url + (url.includes('?') ? '&' : '?') + 'p=' + encodeURIComponent(PROJECT_ID); }
28
+ // Prefixes an app-internal path (e.g. '/board', '/custom/x') with /p/<id> for history.pushState/
29
+ // replaceState — every page navigation this file performs stays within the current project.
30
+ function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : rest; }
31
+ // location.pathname's segments with a leading /p/<id> stripped, if present — the single place that
32
+ // strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
33
+ function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
34
+
18
35
  const $ = (s,r=document)=>r.querySelector(s);
19
36
  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
20
37
  const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
@@ -22,7 +39,7 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
22
39
  const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
23
40
 
24
41
  async function load(){
25
- const r = await fetch('/api/project'); P = await r.json(); render();
42
+ const r = await fetch(withProject('/api/project')); P = await r.json(); render();
26
43
  if(openTaskId) openDrawer(openTaskId,true);
27
44
  }
28
45
  // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
@@ -30,7 +47,7 @@ async function load(){
30
47
  let loadTimer=null;
31
48
  function scheduleLoad(){ clearTimeout(loadTimer); loadTimer=setTimeout(load,180); }
32
49
  function connect(){
33
- const es = new EventSource('/api/events');
50
+ const es = new EventSource(withProject('/api/events'));
34
51
  es.onopen = ()=>{ $('#sync').classList.remove('offline'); $('#syncLabel').textContent='live'; };
35
52
  es.onmessage = (ev)=>{
36
53
  let m; try{ m=JSON.parse(ev.data); }catch{ return; }
@@ -123,32 +140,32 @@ async function doRun(promptEl,agentEl){
123
140
  promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
124
141
  const prompt=promptEl.value.trim(); if(!prompt) return;
125
142
  const agent=agentEl.value;
126
- await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
143
+ await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
127
144
  promptEl.value=''; // the prompt renders as a bubble from the message log
128
145
  }
129
146
  async function doOrchestrate(promptEl){
130
147
  if(isChatBusy()) return;
131
148
  promptEl=promptEl||$('#runPrompt');
132
149
  const prompt=promptEl.value.trim(); if(!prompt) return;
133
- await fetch('/api/orchestrate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
150
+ await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
134
151
  promptEl.value='';
135
152
  }
136
- async function approve(decision){ await fetch('/api/orchestrate/approve',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
153
+ async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
137
154
  // ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
138
155
  // floating widget stays "quick access", full controls live where there's room to read them) ----
139
156
  async function summarizeChat(agentEl){
140
157
  if(isChatBusy()) return;
141
158
  const agent=(agentEl||$('#tabRunAgent'))?.value;
142
159
  flash();
143
- await fetch('/api/chat/summarize',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
160
+ await fetch(withProject('/api/chat/summarize'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent})});
144
161
  }
145
162
  async function clearChat(){
146
163
  flash();
147
- await fetch('/api/chat/clear',{method:'POST'});
164
+ await fetch(withProject('/api/chat/clear'),{method:'POST'});
148
165
  }
149
- async function patchTask(id,patch){ flash(); await fetch('/api/task/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
150
- async function addComment(id,text,action){ flash(); await fetch('/api/task/'+encodeURIComponent(id)+'/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
151
- async function toggleStep(name){ flash(); await fetch('/api/workflow/toggle',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
166
+ async function patchTask(id,patch){ flash(); await fetch(withProject('/api/task/'+encodeURIComponent(id)),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
167
+ async function addComment(id,text,action){ flash(); await fetch(withProject('/api/task/'+encodeURIComponent(id)+'/comment'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text,action})}); }
168
+ async function toggleStep(name){ flash(); await fetch(withProject('/api/workflow/toggle'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name})}); }
152
169
  function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel').textContent='writing…'; setTimeout(()=>{ s.classList.remove('saving'); $('#syncLabel').textContent='live'; },800); }
153
170
 
154
171
  // ---- "agent is running" state — no visible feedback used to exist between clicking Send/
@@ -731,7 +748,7 @@ function editAttn(it,txtNode){
731
748
  ta.addEventListener('blur',save);
732
749
  ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
733
750
  }
734
- async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
751
+ async function addAttn(text){ flash(); await fetch(withProject('/api/attention'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
735
752
 
736
753
  // ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
737
754
  function openBacklogAddForm(){
@@ -756,7 +773,7 @@ async function submitBacklogAdd(){
756
773
  const owner=($('#blAddOwner').value||'').trim();
757
774
  const level=$('#blAddLevel').value;
758
775
  flash();
759
- const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
776
+ const r=await fetch(withProject('/api/task'),{method:'POST',headers:{'Content-Type':'application/json'},
760
777
  body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
761
778
  if(!r.ok){
762
779
  const j=await r.json().catch(()=>({}));
@@ -765,9 +782,9 @@ async function submitBacklogAdd(){
765
782
  }
766
783
  closeBacklogAddForm();
767
784
  }
768
- async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
769
- async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
770
- async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
785
+ async function patchAttn(id,patch){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
786
+ async function deleteAttn(id){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)),{method:'DELETE'}); }
787
+ async function promoteAttn(id){ flash(); await fetch(withProject('/api/attention/'+encodeURIComponent(id)+'/promote'),{method:'POST'}); }
771
788
 
772
789
  // ---- Settings tab + topbar quick-switch: change autonomy mode + output language (writes config.json) ----
773
790
  // Mode/language can be changed from two places — the Settings tab (#setMode/#setLang) and the
@@ -826,13 +843,13 @@ function showAgentError(msg){
826
843
  async function saveAgent(id){
827
844
  if(!id) return;
828
845
  flash();
829
- const r=await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
846
+ const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
830
847
  if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
831
848
  }
832
849
  // ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
833
850
  function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
834
851
  function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
835
- async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
852
+ async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
836
853
 
837
854
  function renderSettings(){
838
855
  const c=(P&&P.config)||{};
@@ -864,7 +881,7 @@ function renderSettings(){
864
881
  async function saveSettings(){
865
882
  flash();
866
883
  const mode=($('#setMode')||$('#topMode')).value, language=($('#setLang')||$('#topLang')).value;
867
- await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
884
+ await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
868
885
  const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
869
886
  }
870
887
 
@@ -1032,7 +1049,7 @@ function renderCustomize(){
1032
1049
  async function czSubmit(kind,description,agent){
1033
1050
  const cfg=CZ_KINDS.find((c)=>c.kind===kind);
1034
1051
  const prompt=description?cfg.promptAdd(description):cfg.promptAuto;
1035
- await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
1052
+ await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
1036
1053
  const root=$('#czRoot'); if(root) root.dataset.open='';
1037
1054
  navigateTab('chat');
1038
1055
  }
@@ -1046,17 +1063,17 @@ const ROUTES=['board','requests','attention','backlog','workflow','team','files'
1046
1063
  // under that name still land on the Personalize tab instead of a blank panel.
1047
1064
  function normalizeTab(t){ return t==='settings'?'personalize':t; }
1048
1065
  function tabFromPath(){
1049
- const s=location.pathname.split('/').filter(Boolean);
1066
+ const s=pathSegments();
1050
1067
  if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
1051
1068
  const t=normalizeTab(s[0]);
1052
1069
  return ROUTES.includes(t)?t:null;
1053
1070
  }
1054
- function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1071
+ function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1055
1072
  function navigateTab(tabId,push){
1056
1073
  activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
1057
1074
  if(push!==false){
1058
1075
  const isCustom=tabId.indexOf('custom:')===0;
1059
- history.pushState(null,'', isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId);
1076
+ history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
1060
1077
  }
1061
1078
  applyActiveTab();
1062
1079
  closeNav(); // a tab pick closes the mobile menu
@@ -1163,7 +1180,7 @@ async function openFileDrawer(kind,obj){
1163
1180
  sec.append(body); b.append(sec);
1164
1181
  $('#drawer').setAttribute('aria-hidden','false');
1165
1182
  try{
1166
- const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
1183
+ const r=await fetch(withProject('/api/agentfile?path='+encodeURIComponent(rel)));
1167
1184
  const data=await r.json().catch(()=>({}));
1168
1185
  if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||t('drawer.loadError'))); return; }
1169
1186
  body.innerHTML=mdLite(data.content||'');
@@ -1325,7 +1342,7 @@ let filesSelectedDir=''; // '' = project root — the folder + File/+ Folder cre
1325
1342
  const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
1326
1343
  async function loadFilesTree(){
1327
1344
  try{
1328
- const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
1345
+ const r=await fetch(withProject('/api/files/tree')); const d=await r.json().catch(()=>({}));
1329
1346
  filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
1330
1347
  }catch{ filesTreeData=filesTreeData||[]; }
1331
1348
  renderFilesTree();
@@ -1488,7 +1505,7 @@ async function openFilesFile(relPath){
1488
1505
  const box=$('#filesContent'); box.innerHTML='';
1489
1506
  box.append(el('div','files-empty',t('drawer.loading')));
1490
1507
  let data;
1491
- try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
1508
+ try{ const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:relPath}))); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
1492
1509
  catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
1493
1510
  box.innerHTML='';
1494
1511
  const bar=el('div','files-toolbar-row');
@@ -1515,7 +1532,7 @@ function filesDiscardBtn(relPath){
1515
1532
  }
1516
1533
  async function filesSave(relPath,content,actions){
1517
1534
  try{
1518
- const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1535
+ const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1519
1536
  const d=await r.json().catch(()=>({}));
1520
1537
  if(!r.ok) throw new Error(d.error||'error');
1521
1538
  filesOpenDirty=false; filesSavedTip(actions);
@@ -1610,7 +1627,7 @@ async function submitFilesCreate(){
1610
1627
  const kind=filesCreateKind;
1611
1628
  const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
1612
1629
  const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
1613
- const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1630
+ const r=await fetch(withProject(endpoint),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1614
1631
  const d=await r.json().catch(()=>({}));
1615
1632
  if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
1616
1633
  closeFilesCreateForm();
@@ -1623,7 +1640,7 @@ function openDrawer(id,keep){
1623
1640
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
1624
1641
  const task=allTasks().find(x=>x.id===id); if(!task) return;
1625
1642
  openTaskId=id;
1626
- if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
1643
+ if(!keep && taskFromPath()!==id) history.pushState(null,'',projectPath('/'+activeTab+'/'+encodeURIComponent(id)));
1627
1644
  const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
1628
1645
  b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
1629
1646
  b.append(el('div','d-title',task.title));
@@ -1657,7 +1674,7 @@ function openDrawer(id,keep){
1657
1674
  $('#drawer').setAttribute('aria-hidden','false');
1658
1675
  if(keep) $('.drawer-panel').scrollTop=prev;
1659
1676
  }
1660
- function closeDrawer(){ if(taskFromPath()) history.pushState(null,'','/'+activeTab); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
1677
+ function closeDrawer(){ if(taskFromPath()) history.pushState(null,'',projectPath('/'+activeTab)); openTaskId=null; $('#drawer').setAttribute('aria-hidden','true'); }
1661
1678
  const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).trim()||'#888';
1662
1679
 
1663
1680
  // tabs — activeTab is the single source of truth (persisted), so a click sets it and applies it,
@@ -1707,7 +1724,7 @@ const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('clic
1707
1724
  applyActiveTab(); // sync to the resolved tab before the first render
1708
1725
  // an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
1709
1726
  // route once resolved, so the visible URL matches the "Personalize" tab it landed on.
1710
- if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
1727
+ if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
1711
1728
  // filters (status chips + search) — client-side only, does not write anything
1712
1729
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1713
1730
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
@@ -0,0 +1,69 @@
1
+ <!doctype html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>spectoflow · projects</title>
7
+ <link rel="icon" type="image/png" href="/logo-dark.png" />
8
+ <link rel="icon" type="image/png" media="(prefers-color-scheme: dark)" href="/logo-white.png" />
9
+ <link rel="stylesheet" href="/styles.css" />
10
+ </head>
11
+ <body class="hub-body">
12
+ <header class="hub-header">
13
+ <div class="hub-brand">
14
+ <img class="brand-logo-img is-dark" src="/logo-white.png" alt="" />
15
+ <img class="brand-logo-img is-light" src="/logo-dark.png" alt="" />
16
+ <span class="hub-brand-name">spectoflow</span>
17
+ </div>
18
+ <button class="hub-theme-toggle" id="hubThemeToggle" aria-label="Toggle theme" title="Toggle theme">◐</button>
19
+ </header>
20
+
21
+ <main class="hub-main">
22
+ <div class="hub-titlebar">
23
+ <h1>Your projects</h1>
24
+ <button class="hub-add-btn" id="hubAddBtn">+ Add project</button>
25
+ </div>
26
+
27
+ <div id="hubEmpty" class="hub-empty" hidden>
28
+ <p class="hub-empty-title">No projects yet</p>
29
+ <p class="hub-empty-sub">Add your first project to get started — point spectoflow at any folder on your computer.</p>
30
+ <button class="hub-add-btn hub-add-btn-lg" id="hubAddBtnEmpty">+ Add your first project</button>
31
+ </div>
32
+
33
+ <div id="hubGrid" class="hub-grid"></div>
34
+ </main>
35
+
36
+ <div id="hubModal" class="hub-modal" hidden>
37
+ <div class="hub-modal-card">
38
+ <div class="hub-modal-head">
39
+ <h2>Add a project</h2>
40
+ <button class="hub-modal-close" id="hubModalClose" aria-label="Close">&times;</button>
41
+ </div>
42
+ <div class="hub-modal-tabs">
43
+ <button class="hub-modal-tab is-active" data-mode="browse">Browse</button>
44
+ <button class="hub-modal-tab" data-mode="paste">Paste a path</button>
45
+ </div>
46
+
47
+ <div id="hubBrowsePane" class="hub-modal-pane">
48
+ <div class="hub-browse-crumb" id="hubBrowseCrumb"></div>
49
+ <div class="hub-browse-list" id="hubBrowseList"></div>
50
+ <div class="hub-browse-footer">
51
+ <span class="hub-browse-current" id="hubBrowseCurrent"></span>
52
+ <button class="hub-add-btn" id="hubBrowseUse">Use this folder</button>
53
+ </div>
54
+ </div>
55
+
56
+ <div id="hubPastePane" class="hub-modal-pane" hidden>
57
+ <label class="hub-paste-label" for="hubPasteInput">Folder path</label>
58
+ <input class="hub-paste-input" id="hubPasteInput" type="text" placeholder="e.g. C:\Users\you\Projects\my-app" autocomplete="off" spellcheck="false" />
59
+ <button class="hub-add-btn" id="hubPasteUse">Use this path</button>
60
+ </div>
61
+
62
+ <p class="hub-modal-error" id="hubModalError" hidden></p>
63
+ <p class="hub-modal-status" id="hubModalStatus" hidden></p>
64
+ </div>
65
+ </div>
66
+
67
+ <script src="/hub.js"></script>
68
+ </body>
69
+ </html>
@@ -0,0 +1,134 @@
1
+ 'use strict';
2
+ (function () {
3
+ const s = localStorage.getItem('spf-theme');
4
+ if (s) document.documentElement.setAttribute('data-theme', s);
5
+ })();
6
+ (function () {
7
+ const grid = document.getElementById('hubGrid');
8
+ const empty = document.getElementById('hubEmpty');
9
+ const modal = document.getElementById('hubModal');
10
+ const modalError = document.getElementById('hubModalError');
11
+ const modalStatus = document.getElementById('hubModalStatus');
12
+ const browsePane = document.getElementById('hubBrowsePane');
13
+ const pastePane = document.getElementById('hubPastePane');
14
+ const browseCrumb = document.getElementById('hubBrowseCrumb');
15
+ const browseList = document.getElementById('hubBrowseList');
16
+ const browseCurrent = document.getElementById('hubBrowseCurrent');
17
+ let browsePath = null; // null = show starting points (home dir / drives)
18
+
19
+ function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
20
+ function timeAgo(iso) {
21
+ if (!iso) return '';
22
+ const ms = Date.now() - new Date(iso).getTime();
23
+ const m = Math.floor(ms / 60000);
24
+ if (m < 1) return 'just now';
25
+ if (m < 60) return m + 'm ago';
26
+ const h = Math.floor(m / 60);
27
+ if (h < 24) return h + 'h ago';
28
+ return Math.floor(h / 24) + 'd ago';
29
+ }
30
+
31
+ async function loadProjects() {
32
+ const r = await fetch('/api/hub/projects');
33
+ const data = await r.json();
34
+ const rows = data.projects || [];
35
+ empty.hidden = rows.length > 0;
36
+ grid.innerHTML = rows.map((p) => {
37
+ const pct = p.stats && p.stats.total ? Math.round(100 * p.stats.done / p.stats.total) : null;
38
+ return `<div class="hub-card" data-id="${p.id}">
39
+ <a class="hub-card-open" href="/p/${p.id}/board">
40
+ <div class="hub-card-name">${esc(p.name)}</div>
41
+ <div class="hub-card-path">${esc(p.path)}</div>
42
+ ${pct !== null ? `<div class="hub-card-progress"><div class="hub-card-progress-fill" style="width:${pct}%"></div></div><div class="hub-card-pct">${pct}% · ${p.stats.done}/${p.stats.total} tasks</div>` : ''}
43
+ <div class="hub-card-meta">Opened ${esc(timeAgo(p.lastOpened))}</div>
44
+ </a>
45
+ <button class="hub-card-remove" data-remove="${p.id}" title="Remove from this list" aria-label="Remove ${esc(p.name)}">&times;</button>
46
+ </div>`;
47
+ }).join('');
48
+ }
49
+
50
+ // No native confirm() — it blocks the tab (and this codebase never uses it, see D46). A second
51
+ // click within 3s on the same remove button confirms; the button flips to a checkmark meanwhile.
52
+ let pendingRemoveId = null;
53
+ function confirmRemove(id) {
54
+ if (pendingRemoveId === id) { pendingRemoveId = null; return true; }
55
+ pendingRemoveId = id;
56
+ const btn = grid.querySelector('[data-remove="' + id + '"]');
57
+ if (btn) {
58
+ const orig = btn.textContent;
59
+ btn.textContent = '✓'; btn.title = 'Click again to confirm';
60
+ setTimeout(() => { if (btn.textContent === '✓') btn.textContent = orig; }, 3000);
61
+ }
62
+ return false;
63
+ }
64
+ grid.addEventListener('click', async (e) => {
65
+ const btn = e.target.closest('[data-remove]');
66
+ if (!btn) return;
67
+ e.preventDefault();
68
+ const id = btn.getAttribute('data-remove');
69
+ if (!confirmRemove(id)) return;
70
+ await fetch('/api/hub/projects/' + encodeURIComponent(id), { method: 'DELETE' });
71
+ loadProjects();
72
+ });
73
+
74
+ function openModal() { modal.hidden = false; modalError.hidden = true; modalStatus.hidden = true; browsePath = null; loadBrowse(); }
75
+ function closeModal() { modal.hidden = true; }
76
+ document.getElementById('hubAddBtn').addEventListener('click', openModal);
77
+ document.getElementById('hubAddBtnEmpty').addEventListener('click', openModal);
78
+ document.getElementById('hubModalClose').addEventListener('click', closeModal);
79
+ modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });
80
+
81
+ document.querySelectorAll('.hub-modal-tab').forEach((tab) => {
82
+ tab.addEventListener('click', () => {
83
+ document.querySelectorAll('.hub-modal-tab').forEach((t) => t.classList.remove('is-active'));
84
+ tab.classList.add('is-active');
85
+ const mode = tab.getAttribute('data-mode');
86
+ browsePane.hidden = mode !== 'browse';
87
+ pastePane.hidden = mode !== 'paste';
88
+ });
89
+ });
90
+
91
+ async function loadBrowse() {
92
+ const q = browsePath ? ('?path=' + encodeURIComponent(browsePath)) : '';
93
+ const r = await fetch('/api/hub/browse' + q);
94
+ const data = await r.json();
95
+ if (data.error) { browseList.innerHTML = '<p class="hub-browse-empty">' + esc(data.error) + '</p>'; return; }
96
+ browsePath = data.current || null;
97
+ browseCurrent.textContent = browsePath || 'Choose a starting point';
98
+ // A drive root (D:\) or similar has no OS-level parent (data.parent is null) but the picker must
99
+ // never dead-end there — "Up" from any real folder always goes somewhere: its real parent, or
100
+ // back to the starting points list if there isn't one. Only hide it once we're AT that list.
101
+ const showUp = browsePath !== null;
102
+ browseCrumb.innerHTML = showUp ? `<button class="hub-crumb-up" id="hubCrumbUp">&larr; Up</button>` : '';
103
+ const up = document.getElementById('hubCrumbUp');
104
+ if (up) up.addEventListener('click', () => { browsePath = data.parent || null; loadBrowse(); });
105
+ browseList.innerHTML = (data.entries || []).map((e) =>
106
+ `<button class="hub-browse-item" data-path="${esc(e.path)}">${esc(e.name)}</button>`
107
+ ).join('') || '<p class="hub-browse-empty">No sub-folders here.</p>';
108
+ browseList.querySelectorAll('[data-path]').forEach((el) => {
109
+ el.addEventListener('click', () => { browsePath = el.getAttribute('data-path'); loadBrowse(); });
110
+ });
111
+ }
112
+
113
+ async function submitPath(p) {
114
+ modalError.hidden = true; modalStatus.hidden = false; modalStatus.textContent = 'Adding…';
115
+ const r = await fetch('/api/hub/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: p }) });
116
+ const data = await r.json();
117
+ if (!r.ok) { modalStatus.hidden = true; modalError.hidden = false; modalError.textContent = data.error || 'Could not add that folder.'; return; }
118
+ location.href = '/p/' + data.entry.id + '/board';
119
+ }
120
+ document.getElementById('hubBrowseUse').addEventListener('click', () => { if (browsePath) submitPath(browsePath); });
121
+ document.getElementById('hubPasteUse').addEventListener('click', () => {
122
+ const v = document.getElementById('hubPasteInput').value.trim();
123
+ if (v) submitPath(v);
124
+ });
125
+
126
+ document.getElementById('hubThemeToggle').addEventListener('click', () => {
127
+ const cur = document.documentElement.getAttribute('data-theme');
128
+ const next = cur === 'dark' ? 'light' : 'dark';
129
+ document.documentElement.setAttribute('data-theme', next);
130
+ localStorage.setItem('spf-theme', next);
131
+ });
132
+
133
+ loadProjects();
134
+ })();
@@ -18,6 +18,7 @@
18
18
  <img class="brand-logo-img is-dark" src="/logo-white.png" alt="spectoflow" />
19
19
  <img class="brand-logo-img is-light" src="/logo-dark.png" alt="spectoflow" />
20
20
  </a>
21
+ <a class="hub-back-link" href="/" title="Back to your projects" aria-label="Back to your projects">⌂</a>
21
22
  <div class="brand-text">
22
23
  <div class="brand-line">
23
24
  <span class="brand-name">spectoflow</span>
@@ -338,9 +338,12 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
338
338
  line up; the backdrop never scrolls on its own (overflow:hidden), its scroll position is just
339
339
  copied from the textarea on every scroll event. */
340
340
  .files-code-wrap { position:relative; flex:1; min-height:0; }
341
- .files-code-backdrop { position:absolute; inset:0; margin:0; overflow:hidden; pointer-events:none; white-space:pre-wrap; word-break:break-word; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; color:var(--ink); background:transparent; }
341
+ /* Ligatures (calt/liga e.g. "===", "!==", "=>" fused into one glyph, common in Cascadia Code and
342
+ JetBrains Mono) must be off on BOTH layers: this overlay depends on the backdrop and the textarea
343
+ laying out every character at an identical pixel width, and a fused ligature glyph breaks that. */
344
+ .files-code-backdrop { position:absolute; inset:0; margin:0; overflow:hidden; pointer-events:none; white-space:pre-wrap; word-break:break-word; padding:16px 20px; font-family:var(--mono); font-size:12.5px; line-height:1.6; color:var(--ink); background:transparent; font-variant-ligatures:none; font-feature-settings:"liga" 0,"calt" 0; }
342
345
  .files-code-backdrop code { font:inherit; background:none; }
343
- .files-code-wrap .files-code-input { position:absolute; inset:0; background:transparent; color:transparent; caret-color:var(--ink); white-space:pre-wrap; word-break:break-word; }
346
+ .files-code-wrap .files-code-input { position:absolute; inset:0; background:transparent; color:transparent; caret-color:var(--ink); white-space:pre-wrap; word-break:break-word; font-variant-ligatures:none; font-feature-settings:"liga" 0,"calt" 0; }
344
347
  .hl-comment { color:var(--faint); font-style:italic; }
345
348
  .hl-string { color:var(--s-done); }
346
349
  .hl-number { color:var(--cool); }
@@ -965,3 +968,62 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
965
968
 
966
969
  /* Phase progress: cap the list so a big project (many phases) doesn't dominate the overview */
967
970
  .bars-block.scroll-cap { max-height:340px; overflow-y:auto; padding-right:6px; }
971
+
972
+ /* ---- Hub landing page (multi-project) — reuses the same tokens as the per-project dashboard,
973
+ deliberately simpler: no per-design skins, no tabs, just a calm project picker. ---- */
974
+ .hub-body { min-height:100%; display:flex; flex-direction:column; }
975
+ .hub-header { display:flex; align-items:center; justify-content:space-between; padding:16px 24px; border-bottom:1px solid var(--line); }
976
+ .hub-brand { display:flex; align-items:center; gap:9px; font-weight:700; }
977
+ .hub-brand .brand-logo-img { width:22px; height:22px; }
978
+ .hub-brand-name { font-size:15px; }
979
+ .hub-theme-toggle { width:32px; height:32px; border-radius:8px; border:1px solid var(--line); background:var(--surface); color:var(--ink); cursor:pointer; font-size:14px; }
980
+ .hub-main { flex:1; max-width:1080px; width:100%; margin:0 auto; padding:32px 24px 60px; }
981
+ .hub-titlebar { display:flex; align-items:center; justify-content:space-between; gap:16px; margin-bottom:24px; flex-wrap:wrap; }
982
+ .hub-titlebar h1 { font-size:24px; font-weight:700; margin:0; }
983
+ .hub-add-btn { font-family:var(--sans); font-size:13.5px; font-weight:600; padding:9px 16px; border-radius:9px; border:1px solid transparent; background:var(--signal); color:var(--on-accent); cursor:pointer; transition:filter .15s; }
984
+ .hub-add-btn:hover { filter:brightness(1.08); }
985
+ .hub-add-btn-lg { padding:12px 22px; font-size:14.5px; margin-top:14px; }
986
+ .hub-empty { text-align:center; padding:60px 20px; border:1px dashed var(--line); border-radius:var(--radius); }
987
+ .hub-empty-title { font-size:18px; font-weight:700; margin:0 0 8px; }
988
+ .hub-empty-sub { color:var(--muted); font-size:13.5px; max-width:420px; margin:0 auto; }
989
+ .hub-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
990
+ .hub-card { position:relative; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); transition:border-color .15s,transform .15s; }
991
+ .hub-card:hover { border-color:var(--signal); transform:translateY(-1px); }
992
+ .hub-card-open { display:block; padding:16px; text-decoration:none; color:inherit; }
993
+ .hub-card-name { font-size:15px; font-weight:700; color:var(--ink); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
994
+ .hub-card-path { font-family:var(--mono); font-size:10.5px; color:var(--faint); margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
995
+ .hub-card-progress { height:5px; border-radius:999px; background:var(--surface-2); margin-top:12px; overflow:hidden; }
996
+ .hub-card-progress-fill { height:100%; background:var(--s-done); border-radius:999px; }
997
+ .hub-card-pct { font-family:var(--mono); font-size:10.5px; color:var(--muted); margin-top:6px; }
998
+ .hub-card-meta { font-size:11px; color:var(--faint); margin-top:10px; }
999
+ .hub-card-remove { position:absolute; top:8px; right:8px; width:22px; height:22px; border-radius:999px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); cursor:pointer; font-size:13px; line-height:1; }
1000
+ .hub-card-remove:hover { color:var(--s-blocked); border-color:var(--s-blocked); }
1001
+
1002
+ .hub-modal { position:fixed; inset:0; background:rgba(0,0,0,.5); display:flex; align-items:center; justify-content:center; z-index:20; padding:20px; }
1003
+ .hub-modal[hidden] { display:none; }
1004
+ .hub-modal-card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); width:100%; max-width:460px; max-height:86vh; display:flex; flex-direction:column; padding:20px; }
1005
+ .hub-modal-head { display:flex; align-items:center; justify-content:space-between; margin-bottom:14px; }
1006
+ .hub-modal-head h2 { font-size:16px; margin:0; }
1007
+ .hub-modal-close { width:28px; height:28px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); cursor:pointer; font-size:16px; line-height:1; }
1008
+ .hub-modal-tabs { display:flex; gap:6px; margin-bottom:14px; }
1009
+ .hub-modal-tab { flex:1; font-family:var(--sans); font-size:12.5px; font-weight:600; padding:7px 10px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--muted); cursor:pointer; }
1010
+ .hub-modal-tab.is-active { background:var(--signal); border-color:var(--signal); color:var(--on-accent); }
1011
+ .hub-modal-pane { display:flex; flex-direction:column; gap:10px; min-height:0; }
1012
+ .hub-modal-pane[hidden] { display:none; }
1013
+ .hub-browse-crumb { min-height:20px; }
1014
+ .hub-crumb-up { font-family:var(--mono); font-size:11.5px; color:var(--cool); background:none; border:0; cursor:pointer; padding:0; }
1015
+ .hub-browse-list { display:flex; flex-direction:column; gap:4px; max-height:220px; overflow-y:auto; border:1px solid var(--line); border-radius:9px; padding:6px; }
1016
+ .hub-browse-item { text-align:left; font-family:var(--sans); font-size:13px; padding:7px 9px; border-radius:6px; border:0; background:none; color:var(--ink); cursor:pointer; }
1017
+ .hub-browse-item:hover { background:var(--surface-2); }
1018
+ .hub-browse-empty { color:var(--faint); font-size:12.5px; padding:6px 2px; margin:0; }
1019
+ .hub-browse-footer { display:flex; align-items:center; gap:10px; justify-content:space-between; }
1020
+ .hub-browse-current { font-family:var(--mono); font-size:10.5px; color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; flex:1; }
1021
+ .hub-paste-label { font-size:12px; color:var(--muted); }
1022
+ .hub-paste-input { font-family:var(--mono); font-size:13px; padding:9px 11px; border-radius:8px; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); }
1023
+ .hub-modal-error { color:var(--s-blocked); font-size:12.5px; margin:4px 0 0; }
1024
+ .hub-modal-status { color:var(--muted); font-size:12.5px; margin:4px 0 0; }
1025
+
1026
+ /* "back to hub" — plain link next to the brand logo on the per-project dashboard; a full navigation
1027
+ (not SPA), since leaving to the hub means leaving this project's dashboard entirely. */
1028
+ .hub-back-link { display:flex; align-items:center; justify-content:center; width:26px; height:26px; border-radius:7px; color:var(--muted); text-decoration:none; font-size:15px; flex-shrink:0; transition:background .15s,color .15s; }
1029
+ .hub-back-link:hover { background:var(--surface-2); color:var(--ink); }