spectoflow 0.13.2 → 0.13.4
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/README.md +3 -2
- package/package.json +1 -1
- package/templates/dashboard/public/app.js +43 -10
- package/templates/dashboard/public/designs.js +1 -1
- package/templates/dashboard/public/index.html +4 -0
- package/templates/dashboard/public/styles.css +43 -1
- package/templates/dashboard/server.js +7 -2
package/README.md
CHANGED
|
@@ -131,8 +131,9 @@ full-height group-chat panel), and **Info** (a project-at-a-glance summary). URL
|
|
|
131
131
|
client-side.
|
|
132
132
|
|
|
133
133
|
**Designs & theme.** The dashboard ships **switchable designs** (Settings → *Dashboard design*):
|
|
134
|
-
**Control Room** (violet), **Obsidian Ops** (near-black lime/cyan, mono),
|
|
135
|
-
(glassmorphism aurora)
|
|
134
|
+
**Control Room** (violet), **Obsidian Ops** (near-black lime/cyan, mono), **Neon Command**
|
|
135
|
+
(glassmorphism aurora), and **Mission Control** (indigo control panel). Each works in light and dark
|
|
136
|
+
(the moon toggle). A design is a `data-design`
|
|
136
137
|
skin — a scoped CSS token block plus a one-line entry in `dashboard/public/designs.js`, so adding one
|
|
137
138
|
is trivial. Fonts are **self-hosted** (`dashboard/public/fonts/*.woff2`), keeping the dashboard fully
|
|
138
139
|
offline and dependency-free. Your choice persists per viewer (localStorage) and as the project
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectoflow",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.4",
|
|
4
4
|
"description": "Agent-agnostic spec-driven development framework + real-time local control plane. Markdown artifacts, intent router, workflow-by-scope.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"spec-driven-development",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To validate', to_analyze:'To analyze', done:'Done', blocked:'Blocked' };
|
|
3
3
|
let P = null, openTaskId = null;
|
|
4
4
|
let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
|
|
5
|
+
let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
|
|
5
6
|
let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
|
|
6
7
|
let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
|
|
7
8
|
let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
|
|
@@ -315,6 +316,10 @@ function renderBoard(){
|
|
|
315
316
|
running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
|
|
316
317
|
|
|
317
318
|
const board=$('#board'); board.innerHTML='';
|
|
319
|
+
updateBoardViewToggle();
|
|
320
|
+
board.classList.toggle('is-kanban', boardView==='kanban');
|
|
321
|
+
if(!tasks.length){ board.append(emptyState()); return; }
|
|
322
|
+
if(boardView==='kanban'){ renderKanban(board, tasks); return; } // columns by status
|
|
318
323
|
let shown=0;
|
|
319
324
|
(P.plans||[]).forEach(pl=> pl.phases.forEach(ph=>{
|
|
320
325
|
const filtered=ph.tasks.filter(taskMatches);
|
|
@@ -322,9 +327,28 @@ function renderBoard(){
|
|
|
322
327
|
if(!filtered.length) return; // hide phases with zero matching tasks
|
|
323
328
|
board.append(renderPhase(ph,pl.file,filtered));
|
|
324
329
|
}));
|
|
325
|
-
if(!
|
|
326
|
-
else if(!shown) board.append(noMatchState());
|
|
330
|
+
if(!shown) board.append(noMatchState());
|
|
327
331
|
}
|
|
332
|
+
// Kanban view — one column per status, filtered by the text search (columns already are the statuses).
|
|
333
|
+
function renderKanban(board, tasks){
|
|
334
|
+
const q=filter.q.trim().toLowerCase();
|
|
335
|
+
const match=(t)=> !q || (t.title+' '+t.id).toLowerCase().includes(q);
|
|
336
|
+
const cols=el('div','kanban');
|
|
337
|
+
Object.keys(STATUS).forEach(st=>{
|
|
338
|
+
const colTasks=tasks.filter(t=> t.status===st && match(t));
|
|
339
|
+
const col=el('div','kanban-col');
|
|
340
|
+
const head=el('div','kanban-col-head');
|
|
341
|
+
const dot=el('span','kanban-dot'); dot.style.background='var(--s-'+st+')';
|
|
342
|
+
head.append(dot, el('span','kanban-col-title',STATUS[st]||st), el('span','kanban-col-count',String(colTasks.length)));
|
|
343
|
+
col.append(head);
|
|
344
|
+
const body=el('div','kanban-col-body');
|
|
345
|
+
if(!colTasks.length) body.append(el('div','kanban-empty','—'));
|
|
346
|
+
colTasks.forEach(t=> body.append(renderTask(t)));
|
|
347
|
+
col.append(body); cols.append(col);
|
|
348
|
+
});
|
|
349
|
+
board.append(cols);
|
|
350
|
+
}
|
|
351
|
+
function updateBoardViewToggle(){ $$('#boardViewToggle .vt-btn').forEach(b=> b.classList.toggle('active', b.dataset.view===boardView)); }
|
|
328
352
|
function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
|
|
329
353
|
function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.'; return d; }
|
|
330
354
|
function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='No tasks match this filter.'; return d; }
|
|
@@ -559,13 +583,20 @@ function renderWfPop(){
|
|
|
559
583
|
}
|
|
560
584
|
function positionWfPop(anchorEl, pop){
|
|
561
585
|
const r=anchorEl.getBoundingClientRect();
|
|
586
|
+
const vpH=window.innerHeight, m=12, edge=10;
|
|
587
|
+
const below=vpH - r.bottom - m - edge; // room below the step
|
|
588
|
+
const above=r.top - m - edge; // room above the step
|
|
589
|
+
// Prefer below; flip above only when below is cramped and above has more room. Then cap the
|
|
590
|
+
// popover's height to the space on the chosen side so it always fits the viewport and scrolls
|
|
591
|
+
// internally (the enable/disable button stays reachable via the sticky footer).
|
|
592
|
+
const useAbove = below < 240 && above > below;
|
|
562
593
|
pop.style.visibility='hidden'; pop.hidden=false; pop.classList.remove('above');
|
|
563
|
-
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
if(
|
|
567
|
-
|
|
568
|
-
pop.
|
|
594
|
+
pop.style.maxHeight=Math.max(160, (useAbove?above:below))+'px';
|
|
595
|
+
const pw=pop.offsetWidth;
|
|
596
|
+
const left=Math.max(edge, Math.min(r.left + r.width/2 - pw/2, window.innerWidth-pw-edge));
|
|
597
|
+
if(useAbove){ pop.style.top=(r.top - m - pop.offsetHeight)+'px'; pop.classList.add('above'); }
|
|
598
|
+
else { pop.style.top=(r.bottom + m)+'px'; }
|
|
599
|
+
pop.style.left=left+'px';
|
|
569
600
|
pop.style.setProperty('--caret-x', ((r.left + r.width/2) - left)+'px');
|
|
570
601
|
pop.style.visibility='';
|
|
571
602
|
}
|
|
@@ -904,10 +935,10 @@ const navToggle=$('#navToggle');
|
|
|
904
935
|
if(navToggle) navToggle.addEventListener('click',e=>{ e.stopPropagation(); const open=!document.body.classList.contains('nav-open'); document.body.classList.toggle('nav-open',open); navToggle.setAttribute('aria-expanded',String(open)); });
|
|
905
936
|
document.addEventListener('click',e=>{ if(!document.body.classList.contains('nav-open')) return; if(e.target.closest('#tabs')||e.target.closest('#navToggle')) return; closeNav(); });
|
|
906
937
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeNav(); });
|
|
907
|
-
// workflow step popover — close on outside click /
|
|
938
|
+
// workflow step popover — close on outside click / Esc / resize. (No scroll-to-close: a capture
|
|
939
|
+
// scroll listener also fires on the popover's own internal scroll, which would slam it shut.)
|
|
908
940
|
document.addEventListener('click',e=>{ if(wfPopStep && !e.target.closest('#wfPop') && !e.target.closest('.wf-step2')) closeWfPop(); });
|
|
909
941
|
document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeWfPop(); });
|
|
910
|
-
window.addEventListener('scroll',()=>{ if(wfPopStep) closeWfPop(); },true);
|
|
911
942
|
window.addEventListener('resize',()=>{ if(wfPopStep) closeWfPop(); });
|
|
912
943
|
// keep the URL and the path in sync when the user uses the browser back/forward buttons
|
|
913
944
|
window.addEventListener('popstate',()=>{
|
|
@@ -920,6 +951,8 @@ applyActiveTab(); // sync to the resolved tab before the first render
|
|
|
920
951
|
// filters (status chips + search) — client-side only, does not write anything
|
|
921
952
|
$$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
|
|
922
953
|
$('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
|
|
954
|
+
// board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
|
|
955
|
+
$$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; try{ localStorage.setItem('spf-board-view',boardView); }catch{} renderBoard(); }));
|
|
923
956
|
// backlog: independent filters + sortable column headers — client-side only (reset to page 1 on change)
|
|
924
957
|
$$('#backlogStatusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ backlogFilter.status=b.dataset.status; backlogPage=1; renderBacklog(); }));
|
|
925
958
|
$('#backlogSearch').addEventListener('input', e=>{ backlogFilter.q=e.target.value; backlogPage=1; renderBacklog(); });
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
{ id: 'control-room', name: 'Control Room', desc: 'Dark violet engineering control room — the original.' },
|
|
24
24
|
{ id: 'obsidian', name: 'Obsidian Ops', desc: 'Near-black mission-control — electric lime + cyan, mono type, Linear/Vercel precision.' },
|
|
25
25
|
{ id: 'neon-command', name: 'Neon Command', desc: 'Glassmorphism — aurora violet + cyan, Space Grotesk display, control-room ambiance.' },
|
|
26
|
-
|
|
26
|
+
{ id: 'mission', name: 'Mission Control', desc: 'Indigo control panel on solid slate — clean, flat, status-coloured.' },
|
|
27
27
|
];
|
|
28
28
|
if (typeof module !== 'undefined' && module.exports) module.exports = DESIGNS;
|
|
29
29
|
else root.DESIGNS = DESIGNS;
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
<button class="fchip" data-status="done">Done</button>
|
|
62
62
|
<button class="fchip" data-status="blocked">Blocked</button>
|
|
63
63
|
</div>
|
|
64
|
+
<div class="view-toggle" id="boardViewToggle" role="group" aria-label="Board view">
|
|
65
|
+
<button class="vt-btn active" data-view="list" title="Grouped by phase"><span class="tab-ico" data-icon="backlog"></span><span>List</span></button>
|
|
66
|
+
<button class="vt-btn" data-view="kanban" title="Columns by status"><span class="tab-ico" data-icon="board"></span><span>Kanban</span></button>
|
|
67
|
+
</div>
|
|
64
68
|
<input type="search" id="search" class="search" placeholder="Filter tasks…" autocomplete="off" />
|
|
65
69
|
</div>
|
|
66
70
|
<div class="board" id="board"></div>
|
|
@@ -657,8 +657,10 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
|
|
|
657
657
|
.wf-detail-skill b { color:var(--ink); font-weight:600; }
|
|
658
658
|
|
|
659
659
|
/* ---- Workflow: click-popover, connector arrows, mobile reflow (v0.13.2) ---- */
|
|
660
|
-
.wf-pop { position:fixed; z-index:40; width:min(360px,92vw); background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:16px 18px; }
|
|
660
|
+
.wf-pop { position:fixed; z-index:40; width:min(360px,92vw); max-height:min(72vh,540px); overflow-y:auto; overscroll-behavior:contain; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); box-shadow:var(--shadow); padding:16px 18px; }
|
|
661
661
|
.wf-pop[hidden] { display:none; }
|
|
662
|
+
/* keep the enable/disable button visible even when the details scroll (sticky footer) */
|
|
663
|
+
.wf-pop-actions { position:sticky; bottom:0; margin:15px -18px 0; padding:12px 18px; background:var(--surface); border-top:1px solid var(--line); }
|
|
662
664
|
.wf-pop::before { content:""; position:absolute; top:-8px; left:var(--caret-x,50%); transform:translateX(-50%); border-left:8px solid transparent; border-right:8px solid transparent; border-bottom:8px solid var(--surface); filter:drop-shadow(0 -1px 0 var(--line)); }
|
|
663
665
|
.wf-pop.above::before { top:auto; bottom:-8px; border-bottom:0; border-top:8px solid var(--surface); filter:drop-shadow(0 1px 0 var(--line)); }
|
|
664
666
|
.wf-pop-actions { margin-top:15px; display:flex; }
|
|
@@ -773,3 +775,43 @@ body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1)
|
|
|
773
775
|
--signal:#6d3bff; --cool:#0891b2; --on-accent:#ffffff;
|
|
774
776
|
--s-done:#0f9d63; --s-to_validate:#0891b2; --s-in_progress:#c2801e; --s-blocked:#d15168;
|
|
775
777
|
}
|
|
778
|
+
|
|
779
|
+
/* --- Mission Control: indigo control panel on solid slate (reproduces the init-dashboard skill) --- */
|
|
780
|
+
:root[data-design="mission"] {
|
|
781
|
+
--bg:#1b1e24; --surface:#262a33; --surface-2:#2d323d; --line:#353b46;
|
|
782
|
+
--ink:#e9ebf0; --muted:#9aa2af; --faint:#6b7280;
|
|
783
|
+
--signal:#5b6cff; --cool:#22d3ee; --on-accent:#ffffff;
|
|
784
|
+
--s-todo:#6b7280; --s-in_progress:#f59e0b; --s-to_validate:#ec4899; --s-to_analyze:#a855f7; --s-done:#22c55e; --s-blocked:#ef4444;
|
|
785
|
+
--radius:14px; --shadow:0 10px 30px rgba(0,0,0,.4);
|
|
786
|
+
}
|
|
787
|
+
/* signature touches: a filled active tab pill + solid-indigo progress bars */
|
|
788
|
+
:root[data-design="mission"] .tab.is-active { background:var(--signal); color:var(--on-accent); box-shadow:none; }
|
|
789
|
+
:root[data-design="mission"] .progress-meter-fill,
|
|
790
|
+
:root[data-design="mission"] .phase-bar-fill,
|
|
791
|
+
:root[data-design="mission"] .bar-fill { background:var(--signal); }
|
|
792
|
+
:root[data-design="mission"] .fchip.active[data-status="all"] { background:var(--signal); }
|
|
793
|
+
:root[data-design="mission"][data-theme="light"] {
|
|
794
|
+
--bg:#e4e7ee; --surface:#fbfcfe; --surface-2:#eef1f6; --line:#cfd5e0;
|
|
795
|
+
--ink:#1a1f2b; --muted:#565f70; --faint:#8792a6;
|
|
796
|
+
--signal:#5b6cff; --cool:#0891b2; --on-accent:#ffffff;
|
|
797
|
+
--s-todo:#64748b; --s-in_progress:#d97706; --s-to_validate:#db2777; --s-to_analyze:#9333ea; --s-done:#16a34a; --s-blocked:#dc2626;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/* ---- Board view toggle (List / Kanban) + Kanban view (v0.13.4) ---- */
|
|
801
|
+
.view-toggle { display:inline-flex; gap:2px; padding:2px; border:1px solid var(--line); border-radius:999px; background:var(--surface); flex-shrink:0; }
|
|
802
|
+
.vt-btn { display:inline-flex; align-items:center; gap:6px; font:inherit; font-size:12px; padding:5px 12px; border:0; border-radius:999px; background:transparent; color:var(--muted); cursor:pointer; }
|
|
803
|
+
.vt-btn .tab-ico { width:14px; height:14px; }
|
|
804
|
+
.vt-btn:hover { color:var(--ink); }
|
|
805
|
+
.vt-btn.active { background:var(--signal); color:var(--on-accent); font-weight:600; }
|
|
806
|
+
/* the status chips are redundant in Kanban (columns already are the statuses) */
|
|
807
|
+
.main:has(.board.is-kanban) #statusChips { display:none; }
|
|
808
|
+
|
|
809
|
+
.kanban { display:grid; grid-auto-flow:column; grid-auto-columns:minmax(228px,1fr); gap:12px; overflow-x:auto; padding:4px 2px 14px; align-items:start; }
|
|
810
|
+
.kanban-col { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); display:flex; flex-direction:column; min-height:84px; }
|
|
811
|
+
.kanban-col-head { display:flex; align-items:center; gap:8px; padding:11px 13px; border-bottom:1px solid var(--line); border-radius:var(--radius) var(--radius) 0 0; }
|
|
812
|
+
.kanban-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
|
813
|
+
.kanban-col-title { font-size:11.5px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--ink); }
|
|
814
|
+
.kanban-col-count { margin-left:auto; font-family:var(--mono); font-size:11px; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:0 7px; }
|
|
815
|
+
.kanban-col-body { display:flex; flex-direction:column; gap:8px; padding:10px; }
|
|
816
|
+
.kanban-empty { color:var(--faint); font-size:12px; text-align:center; padding:10px 0; }
|
|
817
|
+
.kanban .task { width:auto; }
|
|
@@ -203,17 +203,22 @@ const server = http.createServer(async (req,res)=>{
|
|
|
203
203
|
let file=p==='/'?'/index.html':p;
|
|
204
204
|
const full=path.join(PUBLIC,path.normalize(file).replace(/^(\.\.[/\\])+/,''));
|
|
205
205
|
if(!full.startsWith(PUBLIC)){ res.writeHead(403); return res.end('Forbidden'); }
|
|
206
|
+
// Local tool: always serve the freshest asset — never let the browser cache a stale app.js/css.
|
|
207
|
+
const noCache = { 'Cache-Control': 'no-store, must-revalidate' };
|
|
206
208
|
fs.readFile(full,(err,data)=>{
|
|
207
209
|
if(err){
|
|
208
210
|
if(req.method==='GET' && !path.extname(p) && !p.startsWith('/api/')){
|
|
209
211
|
return fs.readFile(path.join(PUBLIC,'index.html'),(e2,d2)=>{
|
|
210
212
|
if(e2){ res.writeHead(404); return res.end('Not found'); }
|
|
211
|
-
res.writeHead(200,{'Content-Type':MIME['.html']}); res.end(d2);
|
|
213
|
+
res.writeHead(200,Object.assign({'Content-Type':MIME['.html']},noCache)); res.end(d2);
|
|
212
214
|
});
|
|
213
215
|
}
|
|
214
216
|
res.writeHead(404); return res.end('Not found');
|
|
215
217
|
}
|
|
216
|
-
|
|
218
|
+
const ext=path.extname(full);
|
|
219
|
+
// fonts are content-hashed by name and safe to cache long-term; everything else is no-store
|
|
220
|
+
const headers = ext==='.woff2'||ext==='.woff' ? { 'Cache-Control':'public, max-age=604800' } : noCache;
|
|
221
|
+
res.writeHead(200,Object.assign({'Content-Type':MIME[ext]||'application/octet-stream'},headers)); res.end(data);
|
|
217
222
|
});
|
|
218
223
|
}catch(e){ sendJSON(res,500,{error:String(e&&e.message||e)}); }
|
|
219
224
|
});
|