spectoflow 0.22.0 → 0.22.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.22.0",
3
+ "version": "0.22.2",
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",
@@ -7,6 +7,7 @@ function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('
7
7
  let P = null, openTaskId = null;
8
8
  let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
9
9
  let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
10
+ let sideHidden = (()=>{ try{ return localStorage.getItem('spf-side-hidden')==='1'; }catch{ return false; } })(); // right sidebar (Journal/Specs/Running) — mainly to give Kanban's own-width columns more room
10
11
  let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
11
12
  let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
12
13
  let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
@@ -164,7 +165,7 @@ function render(){
164
165
  if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
165
166
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
166
167
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
167
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles();
168
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden();
168
169
  renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
169
170
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
170
171
  applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
@@ -1540,7 +1541,22 @@ openTaskId = taskFromPath(); // deep-link straight to a task drawer
1540
1541
  function applyActiveTab(){
1541
1542
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
1542
1543
  $$('.panel').forEach(p=> p.classList.toggle('is-active', p.dataset.panel===activeTab));
1543
- }
1544
+ fitTabs();
1545
+ }
1546
+ // Horizontal top-nav designs (Console's vertical rail and Orbit's hidden/radial nav manage their own
1547
+ // layout independently) can run out of room as tabs are added over time — a single px breakpoint
1548
+ // tuned for whatever tab count existed back then goes stale the moment a tab is added or removed
1549
+ // (exactly what happened when the Files tab pushed the row from 10 to 11 items: some tabs, including
1550
+ // Personalize, silently overflowed with no visible way to reach them). Measure the row's REAL
1551
+ // overflow instead of guessing from viewport width alone, so it's correct at any tab count.
1552
+ function fitTabs(){
1553
+ const tabsEl=$('#tabs'); if(!tabsEl) return;
1554
+ const design=document.documentElement.getAttribute('data-design');
1555
+ if(design==='console'||design==='orbit'){ tabsEl.classList.remove('tabs-compact'); return; }
1556
+ tabsEl.classList.remove('tabs-compact'); // measure at full (labelled) size first
1557
+ if(tabsEl.scrollWidth>tabsEl.clientWidth+1) tabsEl.classList.add('tabs-compact');
1558
+ }
1559
+ window.addEventListener('resize', (()=>{ let t=null; return ()=>{ clearTimeout(t); t=setTimeout(fitTabs,120); }; })());
1544
1560
  $$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=> navigateTab(tab.dataset.tab)));
1545
1561
  // mobile hamburger — toggles the tab dropdown (body.nav-open); closes on tab pick / outside / Esc
1546
1562
  const navToggle=$('#navToggle');
@@ -1568,6 +1584,16 @@ $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.s
1568
1584
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
1569
1585
  // board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
1570
1586
  $$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; try{ localStorage.setItem('spf-board-view',boardView); }catch{} renderBoard(); }));
1587
+ // right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
1588
+ // persisted per viewer like every other layout preference here
1589
+ function applySideHidden(){
1590
+ const panel=$('.panel[data-panel="board"]'); const btn=$('#sideToggle');
1591
+ if(panel) panel.classList.toggle('side-hidden', sideHidden);
1592
+ if(btn){ btn.setAttribute('aria-pressed', String(sideHidden)); btn.title=t(sideHidden?'board.showSidebar':'board.hideSidebar'); }
1593
+ }
1594
+ const sideToggleBtn=$('#sideToggle');
1595
+ if(sideToggleBtn) sideToggleBtn.addEventListener('click', ()=>{ sideHidden=!sideHidden; try{ localStorage.setItem('spf-side-hidden', sideHidden?'1':'0'); }catch{} applySideHidden(); });
1596
+ applySideHidden();
1571
1597
  // expand / collapse all phases (List view) — keeps a big board compact by default
1572
1598
  const phaseToggleAllBtn=$('#phaseToggleAll');
1573
1599
  if(phaseToggleAllBtn) phaseToggleAllBtn.addEventListener('click', ()=>{
@@ -199,12 +199,14 @@ html[data-design="orbit"] .ob-item .ob-bd {
199
199
  position:absolute; top:2px; right:2px; min-width:15px; height:15px; padding:0 4px; border-radius:8px;
200
200
  background:var(--s-blocked); color:#fff; font:700 9px/15px var(--mono); text-align:center;
201
201
  }
202
- @keyframes ob-orbit { to{ opacity:1; transform:rotate(var(--a)) translate(98px) rotate(calc(-1*var(--a))) scale(1); } }
202
+ /* --ob-r is set inline on .ob-dial (computed per open, from the actual tab count) and inherits down
203
+ to every .ob-item — the 98px/72px fallbacks only cover a stylesheet loaded before orbit.js runs. */
204
+ @keyframes ob-orbit { to{ opacity:1; transform:rotate(var(--a)) translate(var(--ob-r,98px)) rotate(calc(-1*var(--a))) scale(1); } }
203
205
  @media (max-width:900px) {
204
206
  html[data-design="orbit"] .ob-item { width:46px; height:46px; margin:-23px 0 0 -23px; }
205
207
  html[data-design="orbit"] .ob-item .ob-lb { display:none; }
206
208
  html[data-design="orbit"] .ob-item .ob-ico { width:16px; height:16px; }
207
- @keyframes ob-orbit { to{ opacity:1; transform:rotate(var(--a)) translate(72px) rotate(calc(-1*var(--a))) scale(1); } }
209
+ @keyframes ob-orbit { to{ opacity:1; transform:rotate(var(--a)) translate(var(--ob-r,72px)) rotate(calc(-1*var(--a))) scale(1); } }
208
210
  }
209
211
 
210
212
  @media (prefers-reduced-motion: reduce) {
@@ -220,7 +222,7 @@ html[data-design="orbit"] .ob-item .ob-bd {
220
222
  html[data-design="orbit"] body.booting .card {
221
223
  animation:none !important;
222
224
  }
223
- html[data-design="orbit"] .ob-item { opacity:1; transform:rotate(var(--a)) translate(98px) rotate(calc(-1*var(--a))); }
225
+ html[data-design="orbit"] .ob-item { opacity:1; transform:rotate(var(--a)) translate(var(--ob-r,98px)) rotate(calc(-1*var(--a))); }
224
226
  }
225
227
 
226
228
  /* chat FAB — amber brand accent (the teal is the dial's); readable on both sets */
@@ -71,11 +71,31 @@
71
71
  }
72
72
  }
73
73
 
74
+ // Items sit on a ring of radius R; at n evenly-spaced items the straight-line gap between two
75
+ // adjacent item centers is a chord of length 2R·sin(π/n). Below ~9 items the tuned default radius
76
+ // already clears the item's own diameter with room to spare; past that, more tabs (built-in ones
77
+ // like this session's new Files tab, or future custom dashboards) would pack the same fixed ring
78
+ // tighter and tighter until items visibly overlap. Grow the radius instead — solved for the chord
79
+ // to equal the item diameter plus a minimum gap — so the ring always has room for however many
80
+ // tabs exist, never a fixed count tuned for whatever the tab bar happened to hold at the time.
81
+ function ringRadius(n, itemDiameter, minGap) {
82
+ if (n <= 1) return itemDiameter; // a lone item has no neighbor to clear
83
+ var needed = (itemDiameter + minGap) / (2 * Math.sin(Math.PI / n));
84
+ return Math.max(itemDiameter, needed);
85
+ }
86
+
74
87
  /* ---- radial overlay ---- */
75
88
  function buildOverlay() {
76
89
  if (ovEl) return;
77
90
  var list = tabs(), n = list.length || 1;
78
91
  var idx = activeIndex(list);
92
+ var isCompact = window.matchMedia('(max-width:900px)').matches;
93
+ var itemDiameter = isCompact ? 46 : 58;
94
+ var baseRadius = isCompact ? 72 : 98; // the tuned default for <=9 items — never shrink below it
95
+ // the gap accounts for the label text under each icon, which can run wider than the icon
96
+ // circle itself ("Agents & Skills" is the long pole) — a gap sized only to the circle left
97
+ // adjacent labels touching even though the circles themselves were visibly clear.
98
+ var radius = Math.max(baseRadius, ringRadius(n, itemDiameter, 22));
79
99
  var items = list.map(function (tab, i) {
80
100
  var angle = -90 + i * (360 / n);
81
101
  var ico = tab.querySelector('.tab-ico');
@@ -91,7 +111,7 @@
91
111
  ovEl = document.createElement('div');
92
112
  ovEl.className = 'ob-ov';
93
113
  ovEl.innerHTML =
94
- '<div class="ob-dial" role="dialog" aria-label="spectoflow navigation">' +
114
+ '<div class="ob-dial" role="dialog" aria-label="spectoflow navigation" style="--ob-r:' + radius + 'px">' +
95
115
  '<svg class="ob-ring" viewBox="0 0 284 284" aria-hidden="true">' +
96
116
  '<circle class="ob-track" cx="142" cy="142" r="128" stroke-dasharray="176 25" stroke-dashoffset="-12"></circle>' +
97
117
  '<circle class="ob-prog" cx="142" cy="142" r="128" stroke-dasharray="0 804.2"></circle>' +
@@ -34,7 +34,7 @@ en: {
34
34
  'topbar.lang.title':'Output language — change from here or in Personalize',
35
35
  'topbar.agent.title':'Active agent — change from here or in Personalize',
36
36
  'topbar.agent.none':'No agent found',
37
- 'board.filterPlaceholder':'Filter tasks…','board.expandAll':'Expand all','board.collapseAll':'Collapse all',
37
+ 'board.filterPlaceholder':'Filter tasks…','board.expandAll':'Expand all','board.collapseAll':'Collapse all','board.hideSidebar':'Hide sidebar','board.showSidebar':'Show sidebar',
38
38
  'board.expandAllTitle':'Expand or collapse all phases','board.viewList':'List','board.viewKanban':'Kanban',
39
39
  'board.viewList.title':'Grouped by phase','board.viewKanban.title':'Columns by status',
40
40
  'board.noTasksMatch':'No tasks match this filter.',
@@ -146,7 +146,7 @@ fr: {
146
146
  'topbar.lang.title':'Langue de sortie — modifiable ici ou dans Personnalisation',
147
147
  'topbar.agent.title':'Agent actif — modifiable ici ou dans Personnalisation',
148
148
  'topbar.agent.none':'Aucun agent trouvé',
149
- 'board.filterPlaceholder':'Filtrer les tâches…','board.expandAll':'Tout déplier','board.collapseAll':'Tout replier',
149
+ 'board.filterPlaceholder':'Filtrer les tâches…','board.expandAll':'Tout déplier','board.collapseAll':'Tout replier','board.hideSidebar':'Masquer la barre latérale','board.showSidebar':'Afficher la barre latérale',
150
150
  'board.expandAllTitle':'Déplier ou replier toutes les phases','board.viewList':'Liste','board.viewKanban':'Kanban',
151
151
  'board.viewList.title':'Regroupé par phase','board.viewKanban.title':'Colonnes par statut',
152
152
  'board.noTasksMatch':'Aucune tâche ne correspond à ce filtre.',
@@ -258,7 +258,7 @@ es: {
258
258
  'topbar.lang.title':'Idioma de salida — se cambia aquí o en Personalizar',
259
259
  'topbar.agent.title':'Agente activo — se cambia aquí o en Personalizar',
260
260
  'topbar.agent.none':'No se encontró ningún agente',
261
- 'board.filterPlaceholder':'Filtrar tareas…','board.expandAll':'Expandir todo','board.collapseAll':'Colapsar todo',
261
+ 'board.filterPlaceholder':'Filtrar tareas…','board.expandAll':'Expandir todo','board.collapseAll':'Colapsar todo','board.hideSidebar':'Ocultar barra lateral','board.showSidebar':'Mostrar barra lateral',
262
262
  'board.expandAllTitle':'Expandir o colapsar todas las fases','board.viewList':'Lista','board.viewKanban':'Kanban',
263
263
  'board.viewList.title':'Agrupado por fase','board.viewKanban.title':'Columnas por estado',
264
264
  'board.noTasksMatch':'Ninguna tarea coincide con este filtro.',
@@ -370,7 +370,7 @@ de: {
370
370
  'topbar.lang.title':'Ausgabesprache — hier oder in Personalisieren änderbar',
371
371
  'topbar.agent.title':'Aktiver Agent — hier oder in Personalisieren änderbar',
372
372
  'topbar.agent.none':'Kein Agent gefunden',
373
- 'board.filterPlaceholder':'Aufgaben filtern…','board.expandAll':'Alle ausklappen','board.collapseAll':'Alle einklappen',
373
+ 'board.filterPlaceholder':'Aufgaben filtern…','board.expandAll':'Alle ausklappen','board.collapseAll':'Alle einklappen','board.hideSidebar':'Seitenleiste ausblenden','board.showSidebar':'Seitenleiste einblenden',
374
374
  'board.expandAllTitle':'Alle Phasen ein- oder ausklappen','board.viewList':'Liste','board.viewKanban':'Kanban',
375
375
  'board.viewList.title':'Nach Phase gruppiert','board.viewKanban.title':'Spalten nach Status',
376
376
  'board.noTasksMatch':'Keine Aufgabe entspricht diesem Filter.',
@@ -482,7 +482,7 @@ pt: {
482
482
  'topbar.lang.title':'Idioma de saída — altere aqui ou em Personalizar',
483
483
  'topbar.agent.title':'Agente ativo — altere aqui ou em Personalizar',
484
484
  'topbar.agent.none':'Nenhum agente encontrado',
485
- 'board.filterPlaceholder':'Filtrar tarefas…','board.expandAll':'Expandir tudo','board.collapseAll':'Recolher tudo',
485
+ 'board.filterPlaceholder':'Filtrar tarefas…','board.expandAll':'Expandir tudo','board.collapseAll':'Recolher tudo','board.hideSidebar':'Ocultar barra lateral','board.showSidebar':'Mostrar barra lateral',
486
486
  'board.expandAllTitle':'Expandir ou recolher todas as fases','board.viewList':'Lista','board.viewKanban':'Kanban',
487
487
  'board.viewList.title':'Agrupado por fase','board.viewKanban.title':'Colunas por estado',
488
488
  'board.noTasksMatch':'Nenhuma tarefa corresponde a este filtro.',
@@ -594,7 +594,7 @@ it: {
594
594
  'topbar.lang.title':'Lingua di output — modificabile qui o in Personalizza',
595
595
  'topbar.agent.title':'Agente attivo — modificabile qui o in Personalizza',
596
596
  'topbar.agent.none':'Nessun agente trovato',
597
- 'board.filterPlaceholder':'Filtra attività…','board.expandAll':'Espandi tutto','board.collapseAll':'Comprimi tutto',
597
+ 'board.filterPlaceholder':'Filtra attività…','board.expandAll':'Espandi tutto','board.collapseAll':'Comprimi tutto','board.hideSidebar':'Nascondi barra laterale','board.showSidebar':'Mostra barra laterale',
598
598
  'board.expandAllTitle':'Espandi o comprimi tutte le fasi','board.viewList':'Elenco','board.viewKanban':'Kanban',
599
599
  'board.viewList.title':'Raggruppato per fase','board.viewKanban.title':'Colonne per stato',
600
600
  'board.noTasksMatch':'Nessuna attività corrisponde a questo filtro.',
@@ -89,6 +89,9 @@
89
89
  </div>
90
90
  <button class="mini-btn" id="phaseToggleAll" data-i18n-title="board.expandAllTitle" data-i18n="board.expandAll" title="Expand or collapse all phases">Expand all</button>
91
91
  <input type="search" id="search" class="search" data-i18n-ph="board.filterPlaceholder" placeholder="Filter tasks…" autocomplete="off" />
92
+ <button class="mini-btn" id="sideToggle" type="button" data-i18n-title="board.hideSidebar" title="Hide sidebar">
93
+ <svg viewBox="0 0 18 18" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="2" y="3" width="14" height="12" rx="2"/><line x1="11.5" y1="3" x2="11.5" y2="15"/></svg>
94
+ </button>
92
95
  </div>
93
96
  <div class="board" id="board"></div>
94
97
  </div>
@@ -73,9 +73,14 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
73
73
  .panel-sub { color:var(--muted); font-size:12.5px; margin:0 0 16px; }
74
74
  .count { font-family:var(--mono); font-size:10px; color:var(--muted); border:1px solid var(--line); border-radius:999px; padding:1px 6px; }
75
75
 
76
- /* main column + right sidebar */
76
+ /* main column + right sidebar — hideable (mainly to give Kanban's own-width columns more room
77
+ instead of forcing horizontal scroll on top of the sidebar already eating 300px) */
77
78
  .main { min-width:0; display:flex; flex-direction:column; }
78
79
  .side { border-left:1px solid var(--line); padding:18px 16px; display:flex; flex-direction:column; gap:22px; }
80
+ .panel[data-panel="board"].side-hidden { grid-template-columns:1fr; }
81
+ .panel[data-panel="board"].side-hidden .side { display:none; }
82
+ #sideToggle { display:inline-flex; align-items:center; justify-content:center; padding:5px 9px; }
83
+ #sideToggle[aria-pressed="true"] { color:var(--signal); border-color:var(--signal); }
79
84
  .rail-title { font-size:11px; text-transform:uppercase; letter-spacing:.1em; color:var(--faint); margin:0 0 8px; font-weight:700; display:flex; gap:8px; align-items:center; }
80
85
  .flatlist { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:5px; font-size:12.5px; }
81
86
  .flatlist li { background:var(--surface); border:1px solid var(--line); border-radius:7px; padding:6px 9px; color:var(--muted); }
@@ -389,9 +394,13 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
389
394
  .btn.primary { background:var(--signal); color:var(--on-accent); border-color:transparent; font-weight:600; }
390
395
  .empty { color:var(--faint); font-style:italic; font-size:12.5px; }
391
396
  :focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
392
- /* Below ~1180px the 8 labelled tabs no longer fit alongside brand + right cluster,
393
- so tabs go icon-only (and scroll horizontally inside their own strip if still tight). */
394
- @media (max-width:1180px){ .tabs .tab{ padding:7px 8px; } .tab-label{ display:none; } }
397
+ /* A fixed px breakpoint here would go stale the moment a tab is added or removed (exactly what
398
+ happened when the Files tab pushed the row from 10 to 11 items) — fitTabs() in app.js measures
399
+ the tab row's REAL overflow instead and adds this class, so it's correct at any tab count and any
400
+ viewport width, not just the count it happened to be tuned against once. Narrow-viewport padding
401
+ still tightens up below ~1180px regardless — smaller tabs simply need less to overflow. */
402
+ @media (max-width:1180px){ .tabs .tab{ padding:7px 8px; } }
403
+ .tabs-compact .tab-label { display:none; }
395
404
  @media (max-width:900px){
396
405
  .panel[data-panel="board"].is-active { grid-template-columns:1fr; }
397
406
  .side { border-left:0; border-top:1px solid var(--line); flex-direction:row; flex-wrap:wrap; gap:16px 28px; }