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.
- package/bin/spectoflow.js +88 -153
- package/lib/hub-server.js +245 -0
- package/lib/init.js +116 -0
- package/lib/registry.js +101 -0
- package/package.json +1 -1
- package/templates/dashboard/handlers.js +241 -0
- package/templates/dashboard/public/app.js +47 -30
- package/templates/dashboard/public/hub.html +69 -0
- package/templates/dashboard/public/hub.js +134 -0
- package/templates/dashboard/public/index.html +1 -0
- package/templates/dashboard/public/styles.css +64 -2
- package/templates/dashboard/server.js +13 -226
|
@@ -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=
|
|
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=
|
|
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(
|
|
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">×</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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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)}">×</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">← 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
|
-
|
|
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); }
|