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 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), and **Neon Command**
135
- (glassmorphism aurora). Each works in light and dark (the moon toggle). A design is a `data-design`
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.2",
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(!tasks.length) board.append(emptyState());
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
- const pw=pop.offsetWidth, ph=pop.offsetHeight;
564
- const left=Math.max(10, Math.min(r.left + r.width/2 - pw/2, window.innerWidth-pw-10));
565
- let top=r.bottom + 12, above=false;
566
- if(top + ph > window.innerHeight-10 && r.top - ph - 12 > 10){ top=r.top - ph - 12; above=true; }
567
- pop.style.left=left+'px'; pop.style.top=top+'px';
568
- pop.classList.toggle('above',above);
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 / scroll / resize / Esc
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
- // one more from the design set coming: nord…
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
- res.writeHead(200,{'Content-Type':MIME[path.extname(full)]||'application/octet-stream'}); res.end(data);
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
  });