spectoflow 0.13.0 → 0.13.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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 G. MOMO
3
+ Copyright (c) 2026 Georges MOMO
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -23,8 +23,27 @@ ceremonial command to start.
23
23
  ## Install
24
24
 
25
25
  ```bash
26
- npm install -g spectoflow # (once published)
27
- spectoflow init /path/to/project # or: node bin/spectoflow.js init .
26
+ # npm (recommended)
27
+ npm install -g spectoflow
28
+ spectoflow init /path/to/project
29
+
30
+ # or use it straight from a clone, no global install
31
+ git clone https://github.com/georgesmomo/spectoflow
32
+ node spectoflow/bin/spectoflow.js init /path/to/project
33
+ ```
34
+
35
+ Every command works both ways — `spectoflow <cmd>` when installed globally, or `node
36
+ /path/to/spectoflow/bin/spectoflow.js <cmd>` from a clone.
37
+
38
+ ### CLI
39
+
40
+ ```
41
+ spectoflow init [dir] [--agent=claude,codex] scaffold a project (auto-detects installed agents)
42
+ spectoflow update [--dry-run] refresh framework files to this kit version
43
+ spectoflow dashboard [--port=NNNN] run the local control plane (default 4319)
44
+ spectoflow status progress + whether the dashboard is running
45
+ spectoflow --version (-v) print the version
46
+ spectoflow --help (-h) show help
28
47
  ```
29
48
 
30
49
  `init` scaffolds:
@@ -90,20 +109,34 @@ agent never clobber each other.
90
109
  ## Dashboard (real-time)
91
110
 
92
111
  ```bash
93
- node .spectoflow/dashboard/server.js # → http://localhost:4319
112
+ spectoflow dashboard # → http://localhost:4319 (or --port=NNNN)
113
+ # manual (no global install): node .spectoflow/dashboard/server.js
94
114
  ```
95
115
 
96
- Zero dependencies, updates live via SSE + file watching. Seven tabs behind a dense icon-tab header
97
- (brand · subtitle · a slim global-progress meter · a sync dot · a **Run** quick-action): **Board**
98
- (the control-room Overview KPI cards, a status donut, a **scope-vs-delivered area curve**, a
116
+ `spectoflow dashboard` is the simple way it prints the URL and won't double-start (it detects a
117
+ dashboard already running on the port). `spectoflow status` tells you whether one is up. Running the
118
+ `server.js` directly still works for a manual/embedded setup. Zero dependencies, updates live via SSE +
119
+ file watching. Eight tabs behind a dense icon-tab header (brand logo · subtitle · a slim
120
+ global-progress meter · a sync dot · a **Run** quick-action · a **settings** gear): **Board** (the
121
+ control-room Overview — KPI cards, a status donut, a **scope-vs-delivered area curve**, a
99
122
  workflow-at-a-glance strip, per-phase progress bars, filter chips + search — plus the phase board),
100
- **Requests** (tasks awaiting you — `to_validate`/`to_analyze`), **Backlog** (a flat sortable/filterable
101
- table of every task across every plan), **Workflow** (the pipeline as a diagram click a step to
102
- enable/disable it, which edits `workflow.md`), **Agents & Skills** (enriched cards capability,
103
- standards, uses/inputs/outputsthat open a full-body markdown drawer), **Chat** (a full-height
104
- group-chat panel), and **Info** (a project-at-a-glance summary). Charts are zero-dep, hand-rolled SVG
105
- in `dashboard/public/charts.js` (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and
106
- every aggregate is computed client-side.
123
+ **Requests** (tasks awaiting you — `to_validate`/`to_analyze`), **Attention** (points the agent raised
124
+ via a `::spectoflow attention msg=…` sentinel or that you notededit/resolve/delete, or **validate
125
+ task**), **Backlog** (a flat sortable/filterable, paginated table of every task, defaulting to open
126
+ work), **Workflow** (the pipeline as step cards click one to enable/disable it, which edits
127
+ `workflow.md`), **Agents & Skills** (enriched cards that open a full-body markdown drawer), **Chat** (a
128
+ full-height group-chat panel), and **Info** (a project-at-a-glance summary). URLs are real routes
129
+ (`/board`, `/backlog/T-012`, …). Charts are zero-dep, hand-rolled SVG in `dashboard/public/charts.js`
130
+ (donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and every aggregate is computed
131
+ client-side.
132
+
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`
136
+ skin — a scoped CSS token block plus a one-line entry in `dashboard/public/designs.js`, so adding one
137
+ is trivial. Fonts are **self-hosted** (`dashboard/public/fonts/*.woff2`), keeping the dashboard fully
138
+ offline and dependency-free. Your choice persists per viewer (localStorage) and as the project
139
+ default (`config.design`).
107
140
 
108
141
  A floating **💬 chat widget** (bottom-right, redesigned) and the **Chat** tab render the same
109
142
  `runtime.messages` log via a shared `renderChatLog()`, so they never drift. A running agent identifies
@@ -125,9 +158,14 @@ INVEST, Playwright E2E, Conventional Commits, and more — not generic one-liner
125
158
 
126
159
  ## Language
127
160
 
128
- `.spectoflow/config.json` → `language` (default `en`, incl. code comments). Switchable.
161
+ `.spectoflow/config.json` → `language` (default `en`, incl. code comments). Switchable from the CLI
162
+ (`config.json`) or the dashboard's **settings** gear (mode + language).
129
163
 
130
164
  ## Studied, not copied
131
165
 
132
166
  Structure informed by spec-kit, OpenSpec (markdown + per-agent adapters), and BMAD (agent-personas).
133
- spectoflow keeps the good ideas, removes the ceremony, and adds a real-time control plane. MIT.
167
+ spectoflow keeps the good ideas, removes the ceremony, and adds a real-time control plane.
168
+
169
+ ## License & author
170
+
171
+ MIT © 2026 [Georges MOMO](https://github.com/georgesmomo).
package/bin/spectoflow.js CHANGED
@@ -16,6 +16,11 @@ const VERSION = require('../package.json').version;
16
16
  const argv = process.argv.slice(2);
17
17
  const cmd = argv[0] || 'help';
18
18
 
19
+ // Tiny ANSI colouriser — no dependency; disabled when not a TTY or NO_COLOR is set.
20
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
21
+ const paint = (code) => (s) => (useColor ? `\x1b[${code}m${s}\x1b[0m` : String(s));
22
+ const c = { g: paint('32'), cy: paint('36'), b: paint('34'), y: paint('33'), dim: paint('2'), bold: paint('1'), amber: paint('38;5;179') };
23
+
19
24
  // ---- dashboard port + running-state probe ------------------------------------
20
25
  // Precedence: --port=NNNN > SPECTOFLOW_PORT env > 4319 (matches templates/dashboard/server.js).
21
26
  function resolvePort(args) {
@@ -156,18 +161,27 @@ function update() {
156
161
  const r = require('../lib/update').runUpdate({ projectRoot: root, templatesDir: TPL, version: VERSION, dryRun });
157
162
 
158
163
  const from = r.fromVersion || 'unknown';
159
- console.log(`spectoflow update ${from} ${r.toVersion}${dryRun ? ' (dry-run)' : ''}`);
160
- const line = (label, list) => list.length && console.log(` ${label.padEnd(10)} ${String(list.length).padStart(2)} ${list.join(', ')}`);
161
- line('refreshed', r.refreshed);
162
- line('created', r.created);
163
- line('adopted', r.adopted);
164
- line('.new', r.newSidecar);
165
- if (r.unchanged.length) console.log(` ${'unchanged'.padEnd(10)} ${String(r.unchanged.length).padStart(2)}`);
166
- console.log(` ${'preserved'.padEnd(10)} config.json, workflow.md, specs/, plans/, your custom agents & skills`);
167
- if (r.newSidecar.length) {
168
- console.log(`\n${r.newSidecar.length} file(s) you edited have a new version alongside as *.new — compare and merge by hand.`);
169
- }
170
- if (dryRun) console.log('\n(dry-run nothing was written)');
164
+ const changed = r.refreshed.length + r.created.length + r.adopted.length + r.newSidecar.length;
165
+ const row = (sym, label, list, painter, note) => {
166
+ if (!list.length) return;
167
+ const n = c.dim(String(list.length).padStart(2));
168
+ const detail = note ? c.dim(note) : c.dim(list.slice(0, 6).join(', ') + (list.length > 6 ? ` +${list.length - 6} more` : ''));
169
+ console.log(` ${sym} ${painter(label.padEnd(9))} ${n} ${detail}`);
170
+ };
171
+ console.log('');
172
+ console.log(` ${c.bold('spectoflow update')} ${c.dim(from)} ${c.amber('→')} ${c.bold(r.toVersion)}${dryRun ? c.dim(' (dry-run)') : ''}`);
173
+ console.log('');
174
+ row(c.g('✓'), 'refreshed', r.refreshed, c.g);
175
+ row(c.cy('+'), 'created', r.created, c.cy);
176
+ row(c.b('~'), 'adopted', r.adopted, c.b);
177
+ row(c.y('!'), '.new', r.newSidecar, c.y, 'you edited these — new version saved as *.new, merge by hand');
178
+ if (r.unchanged.length) console.log(` ${c.dim('·')} ${c.dim('unchanged'.padEnd(9))} ${c.dim(String(r.unchanged.length).padStart(2))}`);
179
+ console.log(` ${c.dim('=')} ${c.dim('preserved'.padEnd(9))} ${c.dim('config.json · workflow.md · specs/ · plans/ · your custom agents & skills')}`);
180
+ console.log('');
181
+ if (dryRun) console.log(` ${c.dim('(dry-run — nothing was written)')}`);
182
+ else console.log(` ${changed ? c.g('✓ Done') : c.dim('Already up to date')}${changed ? c.dim(` · ${changed} file(s) changed`) : ''}`);
183
+ if (r.newSidecar.length && !dryRun) console.log(` ${c.y('→')} ${c.dim(`${r.newSidecar.length} *.new file(s) to review and merge`)}`);
184
+ console.log('');
171
185
  }
172
186
 
173
187
  // THE launch command — prints the URL clearly and won't crash on EADDRINUSE: it probes first
@@ -204,11 +218,26 @@ async function status() {
204
218
  console.log(`dashboard: ${running ? `running → http://localhost:${port}` : 'not running'}`);
205
219
  }
206
220
 
207
- const help = () => console.log(`spectoflow commands:
208
- init [dir] [--agent=claude,codex] install into a project
209
- update [--dry-run] refresh framework files to this kit version
210
- dashboard [--port=NNNN] run the local control plane (default 4319, or $SPECTOFLOW_PORT)
211
- status print progress`);
221
+ function version() { console.log(`spectoflow v${VERSION}`); }
222
+
223
+ const help = () => console.log(`${c.bold('spectoflow')} ${c.amber('v' + VERSION)} — agent-agnostic spec-driven development
224
+
225
+ ${c.dim('Usage:')} spectoflow <command> [options]
226
+
227
+ ${c.bold('Commands:')}
228
+ ${c.g('init')} [dir] [--agent=claude,codex] scaffold a project (auto-detects installed agents)
229
+ ${c.g('update')} [--dry-run] refresh framework files to this kit version
230
+ ${c.g('dashboard')} [--port=NNNN] run the local control plane (default 4319, or $SPECTOFLOW_PORT)
231
+ ${c.g('status')} print progress + whether the dashboard is running
232
+
233
+ ${c.bold('Options:')}
234
+ -v, --version print the version
235
+ -h, --help show this help
236
+
237
+ ${c.dim('Docs:')} https://github.com/georgesmomo/spectoflow`);
212
238
 
213
- const fns = { init, update, dashboard, status, help };
214
- fns[cmd] ? fns[cmd]() : help();
239
+ const fns = { init, update, dashboard, status, help, version };
240
+ if (['-v', '-V', '--version', 'version'].includes(cmd)) version();
241
+ else if (['-h', '--help'].includes(cmd)) help();
242
+ else if (fns[cmd]) fns[cmd]();
243
+ else help();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spectoflow",
3
- "version": "0.13.0",
3
+ "version": "0.13.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",
@@ -6,6 +6,8 @@ let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-
6
6
  let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
7
7
  let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
8
8
  let attnFilter = 'open'; // attention tab filter — client-side only
9
+ // apply the persisted design skin as early as possible (before the first paint of app-driven DOM)
10
+ (function(){ try{ const d=localStorage.getItem('spf-design'); if(d) document.documentElement.setAttribute('data-design',d); }catch{} })();
9
11
 
10
12
  const $ = (s,r=document)=>r.querySelector(s);
11
13
  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
@@ -115,9 +117,7 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
115
117
  function render(){
116
118
  const c = P.config||{};
117
119
  $('#projectName').textContent = c.projectType || 'project';
118
- $('#agentChip').textContent = c.agent||'claude';
119
- $('#langChip').textContent = c.language||'en';
120
- $('#modeChip').textContent = c.mode||'semi';
120
+ const bv=$('#brandVer'); if(bv){ if(P.version){ bv.textContent='v'+P.version; bv.hidden=false; } else { bv.hidden=true; } }
121
121
  $('#brandSub').textContent = (c.mode||'semi') + ' · ' + (c.language||'en');
122
122
  // agent select — widget (#runAgent) and Chat tab (#tabRunAgent) each get their own populated
123
123
  // <select>, since an id can't be shared by two elements; same option list, same source of truth.
@@ -133,7 +133,7 @@ function render(){
133
133
  if(meter) meter.title=`Global progress: ${s.pct}% (${s.done}/${s.total} tasks)`;
134
134
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
135
135
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
136
- renderSidebar(); renderRequests(); renderAttention(); renderInfo();
136
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderSettings();
137
137
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
138
138
  }
139
139
 
@@ -202,8 +202,9 @@ function countUp(node){
202
202
  requestAnimationFrame(tick);
203
203
  }
204
204
 
205
- function kpiCard(label,visual,sub){
205
+ function kpiCard(label,visual,sub,accent){
206
206
  const c=el('div','kpi');
207
+ if(accent){ c.style.borderLeftColor=accent; c.classList.add('has-accent'); }
207
208
  c.append(el('div','kpi-label',label));
208
209
  const body=el('div','kpi-body'); body.append(visual); c.append(body);
209
210
  if(sub) c.append(el('div','kpi-sub',sub));
@@ -228,15 +229,15 @@ function renderOverview(){
228
229
 
229
230
  // KPI row
230
231
  const kpis=el('div','kpi-row');
231
- kpis.append(kpiCard('Global progress', ring(s.pct,72), `${s.done}/${s.total} tasks`));
232
- kpis.append(kpiCard('In progress', numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), 'tasks'));
233
- kpis.append(kpiCard('To validate', numBlock(s.byStatus.to_validate||0,'var(--s-to_validate)'), 'awaiting review'));
232
+ kpis.append(kpiCard('Global progress', ring(s.pct,72), `${s.done}/${s.total} tasks`, cssv('--s-done')));
233
+ kpis.append(kpiCard('In progress', numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), 'tasks', cssv('--s-in_progress')));
234
+ kpis.append(kpiCard('To validate', numBlock(s.byStatus.to_validate||0,'var(--s-to_validate)'), 'awaiting review', cssv('--s-to_validate')));
234
235
  const r=s.running||{};
235
236
  let runVal='—', runSub='no runs yet';
236
237
  if(r.agents>0){ runVal=`${r.agents} running`; runSub='agents active'; }
237
238
  else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub='orchestration'; }
238
239
  else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub='last: '+(r.lastRun.tool||'—'); }
239
- kpis.append(kpiCard('Running', numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub));
240
+ kpis.append(kpiCard('Running', numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub, cssv('--signal')));
240
241
  box.append(kpis);
241
242
 
242
243
  // Status donut + legend
@@ -469,40 +470,104 @@ function renderTask(t){
469
470
  return c;
470
471
  }
471
472
 
473
+ // Plain-language explanation of what each workflow step does, resolved from the step name — so the
474
+ // detail zone teaches the user what the pipeline is, not just that a step exists.
475
+ function wfDesc(s){
476
+ const n=String(s.name||'').toLowerCase();
477
+ if(/brainstorm|idea|intake/.test(n)) return 'Explore the request before committing to it — clarify the goal, constraints and success criteria, and shape a raw idea into something worth building.';
478
+ if(/analy/.test(n)) return 'Break the idea down: study the existing code and requirements, surface risks and ambiguities, and pin down clear, testable acceptance criteria.';
479
+ if(/spec/.test(n)) return 'Write the specification — the versioned, plain-markdown source of truth for what to build and why, before any code is written.';
480
+ if(/plan/.test(n)) return 'Turn the spec into an ordered plan of small, checkbox tasks — each one testable and executable on its own by a developer or an agent.';
481
+ if(/develop|implement|\bcode\b/.test(n)) return 'Implement the plan task by task, writing the code (and the tests that pin it down) to satisfy each checkbox.';
482
+ if(/integration/.test(n)) return 'Check that the pieces work together — modules, services and data paths across component boundaries, not just in isolation.';
483
+ if(/end.?to.?end|e2e/.test(n)) return 'Exercise the whole product the way a real user would, through the actual UI and end-to-end flows.';
484
+ if(/unit/.test(n)||/\btest/.test(n)) return 'Verify each unit in isolation — fast, focused tests that lock in behaviour and catch regressions early.';
485
+ if(/review/.test(n)) return 'A final quality pass — correctness, security and standards — before the work is considered done.';
486
+ if(/deploy|ship|release|publish/.test(n)) return 'Release the delivered work — ship it to its target environment.';
487
+ return 'A step in the delivery pipeline.';
488
+ }
489
+ let wfPopStep=null; // name of the step whose click-popover is open (null = closed)
490
+
491
+ // Workflow — a horizontal pipeline of representative icons with directional arrows. Clicking a step
492
+ // opens a floating popover (tooltip-style, anchored to the step) with what it does, its
493
+ // capability/agent/skill, and the enable/disable button. On narrow screens the pipeline reflows into
494
+ // a centered wrap (no horizontal scroll).
472
495
  function renderWorkflow(){
473
496
  const box=$('#wfDiagram'); box.innerHTML='';
474
497
  const steps=P.workflow||[];
475
- if(!steps.length){ box.append(el('div','empty','No workflow defined.')); return; }
498
+ if(!steps.length){ box.append(el('div','empty','No workflow defined.')); closeWfPop(); return; }
476
499
  const enabledCount=steps.filter(s=>s.enabled).length;
477
500
  const legend=el('div','wf-legend');
478
- legend.append(el('span','wf-legend-txt',`${enabledCount}/${steps.length} steps enabled`));
501
+ legend.append(el('span','wf-legend-dot'));
502
+ legend.append(el('span','wf-legend-txt',`${enabledCount} of ${steps.length} steps enabled — click a step to see what it does`));
479
503
  box.append(legend);
480
- const track=el('div','wf-track');
504
+ const pipe=el('div','wf-pipeline');
481
505
  steps.forEach((s,i)=>{
482
- const node=el('div','wf-card'+(s.enabled?'':' off')); node.tabIndex=0; node.setAttribute('role','button');
483
- node.setAttribute('aria-pressed',String(!!s.enabled));
484
- const head=el('div','wf-card-head');
485
- head.append(el('span','wf-num',String(i+1)));
486
- head.append(el('span','wf-card-name',s.name));
487
- if(s.optional) head.append(el('span','wf-opt','optional'));
488
- node.append(head);
489
- if(s.cap||s.skill){
490
- const meta=el('div','wf-card-meta');
491
- if(s.cap) meta.append(el('span','wf-cap',s.cap));
492
- if(s.skill) meta.append(el('span','wf-skill',s.skill));
493
- node.append(meta);
494
- }
495
- const toggle=el('div','wf-toggle');
496
- toggle.append(el('span','wf-toggle-dot'));
497
- toggle.append(el('span','wf-toggle-label',s.enabled?'enabled':'disabled'));
498
- node.append(toggle);
499
- const act=()=>toggleStep(s.name);
500
- node.addEventListener('click',act);
501
- node.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); act(); } });
502
- track.append(node);
503
- if(i<steps.length-1) track.append(el('div','wf-conn'+(s.enabled&&steps[i+1].enabled?'':' off')));
506
+ const step=el('div','wf-step2'+(s.enabled?' on':' off')+(s.name===wfPopStep?' is-selected':'')); step.style.setProperty('--i',i); step.dataset.idx=i;
507
+ step.tabIndex=0; step.setAttribute('role','button'); step.setAttribute('aria-expanded',String(s.name===wfPopStep));
508
+ step.title=s.name;
509
+ const circle=el('div','wf-circle');
510
+ circle.innerHTML=(typeof ICON!=='undefined'&&ICON.wf)?ICON.wf(s.name):'';
511
+ step.append(circle);
512
+ const cap=el('div','wf-caption'); cap.append(document.createTextNode(s.name));
513
+ if(s.optional) cap.append(el('span','wf-opt2','optional'));
514
+ step.append(cap);
515
+ const sel=(e)=>{ if(e) e.stopPropagation(); if(wfPopStep===s.name) closeWfPop(); else openWfPop(s.name); };
516
+ step.addEventListener('click',sel);
517
+ step.addEventListener('keydown',e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); sel(e); } });
518
+ pipe.append(step);
519
+ if(i<steps.length-1) pipe.append(el('div','wf-link'+(s.enabled&&steps[i+1].enabled?' on':'')));
504
520
  });
505
- box.append(track);
521
+ box.append(pipe);
522
+ if(wfPopStep) renderWfPop(); // re-anchor / refresh an open popover after a live re-render
523
+ }
524
+ function wfDetailRow(k,v){ const r=el('div','wf-detail-row'); r.append(el('span','wf-detail-k',k), el('span','wf-detail-v',v)); return r; }
525
+ function wfPopFill(pop, s, idx){
526
+ const skill=(P.skills||[]).find(x=>x.name===s.skill);
527
+ const agent=(P.agents||[]).find(a=>a.capability===s.cap);
528
+ pop.innerHTML='';
529
+ const head=el('div','wf-detail-head');
530
+ head.append(el('span','wf-detail-num',String(idx+1)));
531
+ head.append(el('span','wf-detail-name',s.name));
532
+ head.append(el('span','wf-detail-status '+(s.enabled?'on':'off'), s.enabled?'enabled':'disabled'));
533
+ if(s.optional) head.append(el('span','wf-opt','optional'));
534
+ pop.append(head);
535
+ pop.append(el('p','wf-detail-desc', wfDesc(s)));
536
+ const grid=el('div','wf-detail-grid');
537
+ grid.append(wfDetailRow('Capability', s.cap||'—'));
538
+ grid.append(wfDetailRow('Handled by', agent?(agent.title||agent.name):'—'));
539
+ grid.append(wfDetailRow('Skill', s.skill||'—'));
540
+ if(skill&&skill.standard) grid.append(wfDetailRow('Standard', skill.standard));
541
+ if(skill&&(skill.inputs||skill.outputs)) grid.append(wfDetailRow('Flow', (skill.inputs||'—')+' → '+(skill.outputs||'—')));
542
+ pop.append(grid);
543
+ if(skill&&skill.description){ const sk=el('div','wf-detail-skill'); sk.append(el('b',null,'The “'+skill.name+'” skill — ')); sk.append(document.createTextNode(skill.description)); pop.append(sk); }
544
+ const btn=el('button','btn '+(s.enabled?'':'primary')+' wf-detail-btn', s.enabled?'Disable step':'Enable step');
545
+ btn.addEventListener('click',(e)=>{ e.stopPropagation(); toggleStep(s.name); });
546
+ const actions=el('div','wf-pop-actions'); actions.append(btn); pop.append(actions);
547
+ }
548
+ function openWfPop(name){ wfPopStep=name; renderWfPop(); }
549
+ function closeWfPop(){ wfPopStep=null; const p=$('#wfPop'); if(p) p.hidden=true; $$('#wfDiagram .wf-step2.is-selected').forEach(x=>x.classList.remove('is-selected')); }
550
+ function renderWfPop(){
551
+ const p=$('#wfPop'); if(!p) return;
552
+ const steps=P.workflow||[]; const idx=steps.findIndex(s=>s.name===wfPopStep);
553
+ if(idx<0){ closeWfPop(); return; }
554
+ const anchor=$('#wfDiagram .wf-step2[data-idx="'+idx+'"]');
555
+ if(!anchor){ p.hidden=true; return; }
556
+ $$('#wfDiagram .wf-step2.is-selected').forEach(x=>x.classList.remove('is-selected')); anchor.classList.add('is-selected');
557
+ wfPopFill(p, steps[idx], idx);
558
+ positionWfPop(anchor.querySelector('.wf-circle')||anchor, p);
559
+ }
560
+ function positionWfPop(anchorEl, pop){
561
+ const r=anchorEl.getBoundingClientRect();
562
+ 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);
569
+ pop.style.setProperty('--caret-x', ((r.left + r.width/2) - left)+'px');
570
+ pop.style.visibility='';
506
571
  }
507
572
 
508
573
  // ---- Attention tab: agent/user-raised points; validate → real task ----------
@@ -552,29 +617,59 @@ async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encod
552
617
  async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
553
618
  async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
554
619
 
555
- // ---- Settings popover: change autonomy mode + output language (writes config.json) ----
556
- function openSettings(open){
557
- const pop=$('#settingsPop'); if(!pop) return;
558
- if(open){ const c=P&&P.config||{}; if($('#setMode')) $('#setMode').value=c.mode||'semi'; setLangSelect(c.language||'en'); }
559
- pop.hidden=!open;
560
- }
620
+ // ---- Settings tab: change autonomy mode + output language (writes config.json) ----
561
621
  function setLangSelect(lang){
562
622
  const sel=$('#setLang'); if(!sel) return;
563
623
  if(![...sel.options].some(o=>o.value===lang)){ const o=document.createElement('option'); o.value=lang; o.textContent=lang; sel.append(o); }
564
624
  sel.value=lang;
565
625
  }
566
- async function saveSettings(){ flash(); const mode=$('#setMode').value, language=$('#setLang').value;
567
- await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})}); }
626
+ // ---- design skins (data-design) switchable, persisted per viewer + as the project default ----
627
+ function currentDesign(){ return document.documentElement.getAttribute('data-design')||'control-room'; }
628
+ function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
629
+ 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{} }
630
+
631
+ function renderSettings(){
632
+ const c=(P&&P.config)||{};
633
+ if($('#setMode')) $('#setMode').value=c.mode||'semi';
634
+ setLangSelect(c.language||'en');
635
+ // design switcher — options from the DESIGNS registry (designs.js)
636
+ const dsel=$('#setDesign');
637
+ if(dsel){
638
+ const designs=(typeof DESIGNS!=='undefined')?DESIGNS:[{id:'control-room',name:'Control Room'}];
639
+ if(dsel.options.length!==designs.length){ dsel.innerHTML=''; designs.forEach(d=>{ const o=document.createElement('option'); o.value=d.id; o.textContent=d.name; if(d.desc) o.title=d.desc; dsel.append(o); }); }
640
+ // reconcile: a per-viewer choice (localStorage) wins; else the project default (config.design)
641
+ let active; try{ active=localStorage.getItem('spf-design'); }catch{}
642
+ if(!active && c.design) active=c.design;
643
+ if(active && active!==currentDesign() && designs.some(d=>d.id===active)) document.documentElement.setAttribute('data-design',active);
644
+ dsel.value=currentDesign();
645
+ }
646
+ const box=$('#settingsReadonly');
647
+ if(box){
648
+ box.innerHTML='';
649
+ const rows=[['Active agent',c.agent||'—'],['Project type',c.projectType||'—'],
650
+ ['Plans folder',c.plansDir||'plans'],['Specs folder',c.specsDir||'specs'],
651
+ ['Framework version', P&&P.version?('v'+P.version):'—']];
652
+ rows.forEach(([k,v])=>{ const r=el('div','settings-ro-row'); r.append(el('span','settings-ro-k',k), el('span','settings-ro-v',String(v))); box.append(r); });
653
+ }
654
+ const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
655
+ }
656
+ async function saveSettings(){
657
+ flash(); const mode=$('#setMode').value, language=$('#setLang').value;
658
+ await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
659
+ const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
660
+ }
568
661
 
569
662
  // ---- client-side routing: /<tab>[/<taskId>] via the History API ------------
570
- const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info'];
663
+ const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','settings'];
571
664
  function tabFromPath(){ const s=location.pathname.split('/').filter(Boolean); return ROUTES.includes(s[0])?s[0]:null; }
572
665
  function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
573
666
  function navigateTab(t,push){
574
667
  activeTab=t; try{ localStorage.setItem('spf-tab',t); }catch{}
575
668
  if(push!==false) history.pushState(null,'','/'+t);
576
669
  applyActiveTab();
670
+ closeNav(); // a tab pick closes the mobile menu
577
671
  }
672
+ function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
578
673
 
579
674
  // chip row: a small uppercase kicker label followed by one chip per item — used for
580
675
  // agent standards/uses and the skill standard.
@@ -804,6 +899,16 @@ function applyActiveTab(){
804
899
  $$('.panel').forEach(p=> p.classList.toggle('is-active', p.dataset.panel===activeTab));
805
900
  }
806
901
  $$('#tabs .tab').forEach(tab=> tab.addEventListener('click',()=> navigateTab(tab.dataset.tab)));
902
+ // mobile hamburger — toggles the tab dropdown (body.nav-open); closes on tab pick / outside / Esc
903
+ const navToggle=$('#navToggle');
904
+ 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
+ document.addEventListener('click',e=>{ if(!document.body.classList.contains('nav-open')) return; if(e.target.closest('#tabs')||e.target.closest('#navToggle')) return; closeNav(); });
906
+ document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeNav(); });
907
+ // workflow step popover — close on outside click / scroll / resize / Esc
908
+ document.addEventListener('click',e=>{ if(wfPopStep && !e.target.closest('#wfPop') && !e.target.closest('.wf-step2')) closeWfPop(); });
909
+ document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeWfPop(); });
910
+ window.addEventListener('scroll',()=>{ if(wfPopStep) closeWfPop(); },true);
911
+ window.addEventListener('resize',()=>{ if(wfPopStep) closeWfPop(); });
807
912
  // keep the URL and the path in sync when the user uses the browser back/forward buttons
808
913
  window.addEventListener('popstate',()=>{
809
914
  activeTab = tabFromPath() || 'board'; applyActiveTab();
@@ -828,11 +933,12 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
828
933
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
829
934
  $('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
830
935
  $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
831
- // settings popover
832
- $('#settingsBtn').addEventListener('click',e=>{ e.stopPropagation(); openSettings($('#settingsPop').hidden); });
936
+ // settings — the footer link opens the Settings tab; selects save on change
937
+ const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
938
+ const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
833
939
  $('#setMode').addEventListener('change',saveSettings);
834
940
  $('#setLang').addEventListener('change',saveSettings);
835
- document.addEventListener('click',e=>{ const pop=$('#settingsPop'); if(!pop||pop.hidden) return; if(!pop.contains(e.target) && !e.target.closest('#settingsBtn')) openSettings(false); });
941
+ const setDesignSel=$('#setDesign'); if(setDesignSel) setDesignSel.addEventListener('change',()=>saveDesign(setDesignSel.value));
836
942
  // theme
837
943
  (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);
838
944
  $('#themeToggle').addEventListener('click',()=>{ const c=document.documentElement.getAttribute('data-theme'); const n=c==='dark'?'light':'dark'; document.documentElement.setAttribute('data-theme',n); localStorage.setItem('spf-theme',n); }); })();
@@ -0,0 +1,30 @@
1
+ 'use strict';
2
+ /*
3
+ * Dashboard design registry. Each entry is a selectable visual "skin" applied via the
4
+ * `data-design="<id>"` attribute on <html>. The switcher in Settings is built from this list.
5
+ *
6
+ * TO ADD A DESIGN (3 easy steps, no other code changes needed):
7
+ * 1. Append an entry here: { id, name, desc }. The id must be a slug (kebab-case).
8
+ * 2. In styles.css, add a scoped block that overrides the design tokens (and, if the design
9
+ * needs it, component rules) under that id:
10
+ * :root[data-design="<id>"] { --bg:…; --surface:…; --signal:…; … }
11
+ * :root[data-design="<id>"][data-theme="light"] { … light-mode overrides … }
12
+ * :root[data-design="<id>"] .kpi { … optional component tweaks … }
13
+ * Everything is token-driven, so most designs are just a palette override.
14
+ * 3. That's it — the design shows up in Settings → Dashboard design and can be switched live.
15
+ *
16
+ * The active design is persisted per viewer (localStorage 'spf-design') and, when changed, also
17
+ * written to config.json (config.design) as the project default. Fonts stay system-only (the
18
+ * dashboard is zero-dependency and offline-capable), so designs express themselves through colour,
19
+ * surfaces, radius, shadow and spacing rather than web fonts.
20
+ */
21
+ (function (root) {
22
+ const DESIGNS = [
23
+ { id: 'control-room', name: 'Control Room', desc: 'Dark violet engineering control room — the original.' },
24
+ { id: 'obsidian', name: 'Obsidian Ops', desc: 'Near-black mission-control — electric lime + cyan, mono type, Linear/Vercel precision.' },
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…
27
+ ];
28
+ if (typeof module !== 'undefined' && module.exports) module.exports = DESIGNS;
29
+ else root.DESIGNS = DESIGNS;
30
+ })(typeof window !== 'undefined' ? window : globalThis);
@@ -23,8 +23,39 @@
23
23
  run: wrap('<path d="M5.4 3.6v10.8l9-5.4z" fill="currentColor" stroke="none"/>'),
24
24
  // flag — points needing attention
25
25
  attention: wrap('<line x1="4.5" y1="2.4" x2="4.5" y2="15.6"/><path d="M4.5 3.4h8.2l-1.7 2.6 1.7 2.6H4.5z"/>'),
26
- // gear — settings
27
- settings: wrap('<circle cx="9" cy="9" r="2.3"/><path d="M9 1.8v2M9 14.2v2M16.2 9h-2M3.8 9h-2M14.1 3.9l-1.4 1.4M5.3 12.7l-1.4 1.4M14.1 14.1l-1.4-1.4M5.3 5.3 3.9 3.9"/>'),
26
+ // gear — settings (proper cog with teeth)
27
+ settings: wrap('<circle cx="9" cy="9" r="2.4"/><path d="M9 1.6v2.2M9 14.2v2.2M16.4 9h-2.2M3.8 9H1.6M14.2 3.8l-1.55 1.55M5.35 12.65 3.8 14.2M14.2 14.2l-1.55-1.55M5.35 5.35 3.8 3.8"/>'),
28
+ // crescent moon — theme toggle
29
+ theme: wrap('<path d="M14.6 10.8A5.6 5.6 0 0 1 7.2 3.4 5.7 5.7 0 1 0 14.6 10.8z"/>'),
30
+ };
31
+
32
+ // Representative icons for workflow pipeline steps, resolved from the step name.
33
+ const WF = {
34
+ brainstorm: wrap('<path d="M6.4 12.2a4.3 4.3 0 1 1 5.2 0c-.5.4-.8.9-.9 1.5H7.3c-.1-.6-.4-1.1-.9-1.5z"/><line x1="7.2" y1="15.4" x2="10.8" y2="15.4"/>'),
35
+ analysis: wrap('<circle cx="7.8" cy="7.8" r="4"/><line x1="10.8" y1="10.8" x2="15" y2="15"/>'),
36
+ spec: wrap('<path d="M5 2.5h5l3 3v10H5z"/><path d="M10 2.5v3h3"/><line x1="6.8" y1="9" x2="11" y2="9"/><line x1="6.8" y1="11.5" x2="11" y2="11.5"/>'),
37
+ plan: wrap('<rect x="4" y="3.4" width="10" height="12" rx="1.4"/><path d="M6.8 3.4V3a1.1 1.1 0 0 1 1.1-1.1h2.2A1.1 1.1 0 0 1 11.2 3v.4"/><path d="M6.4 8.2 7.3 9 9 7"/><line x1="10.4" y1="8" x2="11.7" y2="8"/><line x1="6.4" y1="11.6" x2="11.7" y2="11.6"/>'),
38
+ develop: wrap('<path d="M6.4 5.6 3 9l3.4 3.4"/><path d="M11.6 5.6 15 9l-3.4 3.4"/>'),
39
+ unit: wrap('<circle cx="9" cy="9" r="6.2"/><path d="M6.2 9.2 8 11l3.9-4.1"/>'),
40
+ integration:wrap('<path d="M7.2 12.1 5.9 13.4a2.4 2.4 0 0 1-3.3-3.3L4 8.7"/><path d="M10.8 5.9 12.1 4.6a2.4 2.4 0 0 1 3.3 3.3L14 9.3"/><line x1="7.4" y1="10.6" x2="10.6" y2="7.4"/>'),
41
+ e2e: wrap('<rect x="2.8" y="3.6" width="12.4" height="8.2" rx="1.2"/><line x1="7" y1="15" x2="11" y2="15"/><line x1="9" y1="11.8" x2="9" y2="15"/><line x1="2.8" y1="6.4" x2="15.2" y2="6.4"/>'),
42
+ review: wrap('<path d="M2.4 9S5 4.6 9 4.6 15.6 9 15.6 9 13 13.4 9 13.4 2.4 9 2.4 9z"/><circle cx="9" cy="9" r="2.1"/>'),
43
+ deploy: wrap('<path d="M15.6 2.4 8.2 9.2"/><path d="M15.6 2.4 11.1 15.6 8.2 9.2 2.4 6.6z"/>'),
44
+ fallback: wrap('<circle cx="9" cy="9" r="3.4"/>'),
45
+ };
46
+ ICON.wf = function (name) {
47
+ const n = String(name || '').toLowerCase();
48
+ if (/brainstorm|idea/.test(n)) return WF.brainstorm;
49
+ if (/analy/.test(n)) return WF.analysis;
50
+ if (/spec/.test(n)) return WF.spec;
51
+ if (/plan/.test(n)) return WF.plan;
52
+ if (/develop|implement|\bcode\b|build/.test(n)) return WF.develop;
53
+ if (/integration/.test(n)) return WF.integration;
54
+ if (/end.?to.?end|e2e/.test(n)) return WF.e2e;
55
+ if (/unit/.test(n) || /\btest/.test(n)) return WF.unit;
56
+ if (/review/.test(n)) return WF.review;
57
+ if (/deploy|ship|release|publish/.test(n)) return WF.deploy;
58
+ return WF.fallback;
28
59
  };
29
60
 
30
61
  if (typeof module !== 'undefined' && module.exports) module.exports = ICON;