spectoflow 0.13.1 → 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/README.md +8 -0
- package/package.json +1 -1
- package/templates/dashboard/public/app.js +153 -47
- package/templates/dashboard/public/designs.js +30 -0
- package/templates/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
- package/templates/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
- package/templates/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
- package/templates/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
- package/templates/dashboard/public/fonts/sora-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/sora-600.woff2 +0 -0
- package/templates/dashboard/public/fonts/sora-700.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
- package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
- package/templates/dashboard/public/icons.js +33 -2
- package/templates/dashboard/public/index.html +63 -30
- package/templates/dashboard/public/styles.css +293 -26
- package/templates/dashboard/server.js +15 -2
package/README.md
CHANGED
|
@@ -130,6 +130,14 @@ full-height group-chat panel), and **Info** (a project-at-a-glance summary). URL
|
|
|
130
130
|
(donut/area/bars/ring, animated, `prefers-reduced-motion`-aware), and every aggregate is computed
|
|
131
131
|
client-side.
|
|
132
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`).
|
|
140
|
+
|
|
133
141
|
A floating **💬 chat widget** (bottom-right, redesigned) and the **Chat** tab render the same
|
|
134
142
|
`runtime.messages` log via a shared `renderChatLog()`, so they never drift. A running agent identifies
|
|
135
143
|
itself by printing `::spectoflow role=… kind=… msg=…` sentinels, which become labelled messages
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spectoflow",
|
|
3
|
-
"version": "0.13.
|
|
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
|
-
|
|
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-
|
|
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
|
|
504
|
+
const pipe=el('div','wf-pipeline');
|
|
481
505
|
steps.forEach((s,i)=>{
|
|
482
|
-
const
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if(s.cap
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
567
|
-
|
|
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
|
|
832
|
-
|
|
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
|
-
|
|
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);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
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;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en" data-theme="dark">
|
|
2
|
+
<html lang="en" data-theme="dark" data-design="control-room">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
<body>
|
|
11
11
|
<header class="topbar">
|
|
12
12
|
<div class="brand">
|
|
13
|
+
<button class="nav-toggle" id="navToggle" aria-label="Toggle menu" aria-expanded="false"><span></span><span></span><span></span></button>
|
|
13
14
|
<a class="brand-logo" href="/board" data-route="board" aria-label="spectoflow home">
|
|
14
15
|
<img class="brand-logo-img is-dark" src="logo-white.png" alt="spectoflow" />
|
|
15
16
|
<img class="brand-logo-img is-light" src="logo-dark.png" alt="spectoflow" />
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
<div class="brand-text">
|
|
18
19
|
<div class="brand-line">
|
|
19
20
|
<span class="brand-name">spectoflow</span>
|
|
21
|
+
<span class="brand-ver" id="brandVer" hidden></span>
|
|
20
22
|
<span class="brand-sep">/</span>
|
|
21
23
|
<span class="brand-project" id="projectName">—</span>
|
|
22
24
|
</div>
|
|
@@ -35,38 +37,12 @@
|
|
|
35
37
|
<button class="tab" data-tab="team"><span class="tab-ico" data-icon="agents"></span><span class="tab-label">Agents & Skills</span></button>
|
|
36
38
|
<button class="tab" data-tab="chat"><span class="tab-ico" data-icon="chat"></span><span class="tab-label">Chat</span></button>
|
|
37
39
|
<button class="tab" data-tab="info"><span class="tab-ico" data-icon="info"></span><span class="tab-label">Info</span></button>
|
|
40
|
+
<button class="tab" data-tab="settings"><span class="tab-ico" data-icon="settings"></span><span class="tab-label">Settings</span></button>
|
|
38
41
|
</nav>
|
|
39
42
|
<div class="top-right">
|
|
40
|
-
<span class="meta-chip" id="agentChip" title="Active agent">claude</span>
|
|
41
|
-
<span class="meta-chip" id="langChip" title="Output language">en</span>
|
|
42
|
-
<span class="mode-chip" id="modeChip" title="Autonomy mode">semi</span>
|
|
43
43
|
<span class="sync" id="sync"><span class="sync-dot"></span><span id="syncLabel">live</span></span>
|
|
44
44
|
<button class="btn primary run-btn" id="runQuickBtn" title="Open the run chat"><span class="tab-ico" data-icon="run"></span><span>Run</span></button>
|
|
45
|
-
<button class="
|
|
46
|
-
<button class="theme-toggle" id="themeToggle" aria-label="Toggle theme"><span class="theme-ico"></span></button>
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
<!-- settings popover (mode + language) -->
|
|
50
|
-
<div class="settings-pop" id="settingsPop" hidden role="dialog" aria-label="Settings">
|
|
51
|
-
<div class="settings-head">Settings</div>
|
|
52
|
-
<label class="settings-row"><span>Autonomy mode</span>
|
|
53
|
-
<select id="setMode">
|
|
54
|
-
<option value="autopilot">autopilot</option>
|
|
55
|
-
<option value="semi">semi</option>
|
|
56
|
-
<option value="manual">manual</option>
|
|
57
|
-
</select>
|
|
58
|
-
</label>
|
|
59
|
-
<label class="settings-row"><span>Output language</span>
|
|
60
|
-
<select id="setLang">
|
|
61
|
-
<option value="en">en · English</option>
|
|
62
|
-
<option value="fr">fr · Français</option>
|
|
63
|
-
<option value="es">es · Español</option>
|
|
64
|
-
<option value="de">de · Deutsch</option>
|
|
65
|
-
<option value="pt">pt · Português</option>
|
|
66
|
-
<option value="it">it · Italiano</option>
|
|
67
|
-
</select>
|
|
68
|
-
</label>
|
|
69
|
-
<p class="settings-note">Writes <code>.spectoflow/config.json</code> — the agent picks it up on its next run.</p>
|
|
45
|
+
<button class="theme-toggle" id="themeToggle" aria-label="Toggle light / dark theme" title="Toggle light / dark"><span class="tab-ico" data-icon="theme"></span></button>
|
|
70
46
|
</div>
|
|
71
47
|
</header>
|
|
72
48
|
|
|
@@ -174,7 +150,7 @@
|
|
|
174
150
|
<section class="panel" data-panel="workflow">
|
|
175
151
|
<div class="wf-wrap">
|
|
176
152
|
<h2 class="panel-title">Active workflow</h2>
|
|
177
|
-
<p class="panel-sub">Click a step to
|
|
153
|
+
<p class="panel-sub">The pipeline your requests flow through — from idea to shipped, spec-driven. <strong>Click a step</strong> to see what it does and turn it on or off (this edits <code>.spectoflow/workflow.md</code>). Enabled steps run in order; the orchestrator gates each one by your <em>mode</em> and <em>policy</em>. Optional steps stay off until you switch them on.</p>
|
|
178
154
|
<div class="wf-diagram" id="wfDiagram"></div>
|
|
179
155
|
</div>
|
|
180
156
|
</section>
|
|
@@ -224,8 +200,61 @@
|
|
|
224
200
|
<div class="info-grid" id="infoGrid"></div>
|
|
225
201
|
</div>
|
|
226
202
|
</section>
|
|
203
|
+
|
|
204
|
+
<!-- SETTINGS (change autonomy mode + output language → config.json) -->
|
|
205
|
+
<section class="panel" data-panel="settings">
|
|
206
|
+
<div class="settings-wrap">
|
|
207
|
+
<h2 class="panel-title">Settings</h2>
|
|
208
|
+
<p class="panel-sub">Change how spectoflow runs. Writes <code>.spectoflow/config.json</code> — the agent picks it up on its next run.</p>
|
|
209
|
+
<div class="settings-card">
|
|
210
|
+
<label class="settings-field">
|
|
211
|
+
<span class="settings-field-label">Dashboard design</span>
|
|
212
|
+
<select id="setDesign" class="settings-select"></select>
|
|
213
|
+
</label>
|
|
214
|
+
<label class="settings-field">
|
|
215
|
+
<span class="settings-field-label">Autonomy mode</span>
|
|
216
|
+
<select id="setMode" class="settings-select">
|
|
217
|
+
<option value="autopilot">autopilot — run freely, ask only on policy gates</option>
|
|
218
|
+
<option value="semi">semi — autopilot + policy approvals</option>
|
|
219
|
+
<option value="manual">manual — approve every step</option>
|
|
220
|
+
</select>
|
|
221
|
+
</label>
|
|
222
|
+
<label class="settings-field">
|
|
223
|
+
<span class="settings-field-label">Output language</span>
|
|
224
|
+
<select id="setLang" class="settings-select">
|
|
225
|
+
<option value="en">en · English</option>
|
|
226
|
+
<option value="fr">fr · Français</option>
|
|
227
|
+
<option value="es">es · Español</option>
|
|
228
|
+
<option value="de">de · Deutsch</option>
|
|
229
|
+
<option value="pt">pt · Português</option>
|
|
230
|
+
<option value="it">it · Italiano</option>
|
|
231
|
+
</select>
|
|
232
|
+
</label>
|
|
233
|
+
<div class="settings-saved" id="settingsSaved" hidden>✓ saved</div>
|
|
234
|
+
</div>
|
|
235
|
+
<div class="settings-readonly" id="settingsReadonly"></div>
|
|
236
|
+
</div>
|
|
237
|
+
</section>
|
|
227
238
|
</main>
|
|
228
239
|
|
|
240
|
+
<footer class="app-footer">
|
|
241
|
+
<div class="footer-left">
|
|
242
|
+
<a class="footer-logo" href="/board" data-route="board" aria-label="spectoflow">
|
|
243
|
+
<img class="brand-logo-img is-dark" src="logo-white.png" alt="spectoflow" />
|
|
244
|
+
<img class="brand-logo-img is-light" src="logo-dark.png" alt="spectoflow" />
|
|
245
|
+
</a>
|
|
246
|
+
<span class="footer-name">spectoflow</span>
|
|
247
|
+
<span class="footer-ver" id="footerVer"></span>
|
|
248
|
+
<span class="footer-sep">·</span>
|
|
249
|
+
<span class="footer-lic">MIT License</span>
|
|
250
|
+
</div>
|
|
251
|
+
<div class="footer-right">
|
|
252
|
+
<a href="https://github.com/georgesmomo/spectoflow" target="_blank" rel="noopener">GitHub</a>
|
|
253
|
+
<a href="https://www.npmjs.com/package/spectoflow" target="_blank" rel="noopener">npm</a>
|
|
254
|
+
<button class="footer-link" id="footerSettings">Settings</button>
|
|
255
|
+
</div>
|
|
256
|
+
</footer>
|
|
257
|
+
|
|
229
258
|
<!-- CHAT WIDGET (floating agent runner; entry point to the group-chat) -->
|
|
230
259
|
<button class="chat-fab" id="chatFab" aria-label="Open run chat" title="Run an agent">💬</button>
|
|
231
260
|
<div class="chat" id="chat" aria-hidden="true" role="dialog" aria-label="Run an agent">
|
|
@@ -257,6 +286,9 @@
|
|
|
257
286
|
<!-- floating chart tooltip layer — positioned on mousemove over [data-tip] hit targets -->
|
|
258
287
|
<div id="tooltip" class="tooltip" hidden></div>
|
|
259
288
|
|
|
289
|
+
<!-- workflow step popover — anchored to the clicked step, holds its details + toggle -->
|
|
290
|
+
<div id="wfPop" class="wf-pop" hidden></div>
|
|
291
|
+
|
|
260
292
|
<!-- shared gradient used by SpectoCharts.ring's foreground arc (stroke="url(#grad)") -->
|
|
261
293
|
<svg width="0" height="0" style="position:absolute" aria-hidden="true">
|
|
262
294
|
<defs>
|
|
@@ -270,6 +302,7 @@
|
|
|
270
302
|
<script src="stats.js"></script>
|
|
271
303
|
<script src="charts.js"></script>
|
|
272
304
|
<script src="icons.js"></script>
|
|
305
|
+
<script src="designs.js"></script>
|
|
273
306
|
<script src="app.js"></script>
|
|
274
307
|
</body>
|
|
275
308
|
</html>
|
|
@@ -1,23 +1,25 @@
|
|
|
1
|
-
/* spectoflow control plane. Direction:
|
|
2
|
-
|
|
1
|
+
/* spectoflow control plane. Direction: a calm, dense control room.
|
|
2
|
+
Signature: a violet "signal" for what is live/active; green for delivered; teal secondary.
|
|
3
|
+
The amber brand logo is the one deliberate warm accent. Mono carries ids and statuses. */
|
|
3
4
|
:root {
|
|
4
|
-
--bg: #
|
|
5
|
-
--ink: #
|
|
6
|
-
--signal: #
|
|
7
|
-
--s-todo: #
|
|
8
|
-
--s-to_analyze: #ab8cd9; --s-done: #
|
|
9
|
-
--radius: 14px; --shadow: 0
|
|
5
|
+
--bg: #0f1116; --surface: #171922; --surface-2: #1e2130; --line: #2a2e3d;
|
|
6
|
+
--ink: #e9edf4; --muted: #9aa3b5; --faint: #6a7289;
|
|
7
|
+
--signal: #7c5cff; --cool: #4bb3a7; --on-accent: #ffffff;
|
|
8
|
+
--s-todo: #7d8695; --s-in_progress: #e6a54b; --s-to_validate: #4bb3a7;
|
|
9
|
+
--s-to_analyze: #ab8cd9; --s-done: #4caf72; --s-blocked: #e0788a;
|
|
10
|
+
--radius: 14px; --shadow: 0 12px 34px rgba(0,0,0,.42);
|
|
10
11
|
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
|
11
12
|
--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
13
|
+
--display: var(--sans); /* headings / big numbers — designs may swap in a display face */
|
|
12
14
|
}
|
|
13
|
-
[data-theme="light"] { --bg:#
|
|
15
|
+
[data-theme="light"] { --bg:#f4f5f9; --surface:#ffffff; --surface-2:#eef0f6; --line:#dde0ea; --ink:#1b1e28; --muted:#5c6478; --faint:#8b93a6; --signal:#6d54e0; --cool:#2f8e82; --on-accent:#ffffff; --s-todo:#6b7280; --s-in_progress:#c17d20; --s-to_validate:#2f8e82; --s-to_analyze:#8a6bb0; --s-done:#3f9f63; --s-blocked:#c85a6e; }
|
|
14
16
|
* { box-sizing: border-box; }
|
|
15
17
|
html,body { margin:0; height:100%; }
|
|
16
|
-
body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; }
|
|
18
|
+
body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-size:14px; line-height:1.5; -webkit-font-smoothing:antialiased; overflow-x:hidden; }
|
|
17
19
|
@media (prefers-reduced-motion: reduce){ *{ animation:none!important; transition:none!important; } }
|
|
18
20
|
|
|
19
21
|
/* topbar — dense engineering-control-room header: brand+meter (left) / icon tabs (centre) / status+run (right) */
|
|
20
|
-
.topbar { display:grid; grid-template-columns:1fr auto
|
|
22
|
+
.topbar { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; column-gap:14px; min-height:60px; padding:8px 16px; border-bottom:1px solid var(--line);
|
|
21
23
|
background:color-mix(in srgb,var(--surface) 92%,transparent); position:sticky; top:0; z-index:6; backdrop-filter:blur(8px); }
|
|
22
24
|
.brand { justify-self:start; display:flex; align-items:center; gap:11px; font-weight:600; min-width:0; }
|
|
23
25
|
.brand-mark { width:16px; height:16px; border-radius:4px; background:conic-gradient(from 210deg,var(--signal),var(--cool),var(--signal)); box-shadow:0 0 0 1px var(--line) inset; flex-shrink:0; }
|
|
@@ -27,13 +29,14 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
27
29
|
.brand-sub { font-family:var(--mono); font-size:9.5px; text-transform:uppercase; letter-spacing:.08em; color:var(--faint); }
|
|
28
30
|
.progress-meter { width:76px; height:5px; border-radius:999px; background:var(--surface-2); overflow:hidden; flex-shrink:0; margin-left:4px; }
|
|
29
31
|
.progress-meter-fill { height:100%; width:0%; background:linear-gradient(90deg,var(--cool),var(--signal)); border-radius:999px; transition:width .5s ease; }
|
|
30
|
-
.tabs { justify-self:center; display:flex; gap:2px; }
|
|
32
|
+
.tabs { justify-self:center; display:flex; gap:2px; min-width:0; max-width:100%; overflow-x:auto; overflow-y:hidden; scrollbar-width:none; }
|
|
33
|
+
.tabs::-webkit-scrollbar { display:none; }
|
|
31
34
|
.tab { display:flex; align-items:center; gap:6px; font:inherit; font-size:12.5px; padding:7px 11px; border:0; background:transparent; color:var(--muted); cursor:pointer; border-radius:7px; white-space:nowrap; }
|
|
32
35
|
.tab-ico { width:16px; height:16px; flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; }
|
|
33
36
|
.tab-ico svg { width:100%; height:100%; display:block; }
|
|
34
37
|
.tab:hover { color:var(--ink); background:var(--surface-2); }
|
|
35
38
|
.tab.is-active { color:var(--ink); background:var(--surface-2); box-shadow:inset 0 -2px 0 var(--signal); }
|
|
36
|
-
.top-right { justify-self:end; display:flex; align-items:center; gap:9px; }
|
|
39
|
+
.top-right { justify-self:end; display:flex; align-items:center; gap:9px; min-width:0; }
|
|
37
40
|
.meta-chip,.mode-chip { font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:.06em; padding:3px 8px; border:1px solid var(--line); border-radius:999px; color:var(--muted); }
|
|
38
41
|
.mode-chip { color:var(--signal); border-color:color-mix(in srgb,var(--signal) 40%,var(--line)); }
|
|
39
42
|
.sync { display:flex; align-items:center; gap:6px; font-size:12px; color:var(--muted); }
|
|
@@ -137,7 +140,7 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
137
140
|
.chip-list { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
|
|
138
141
|
.fchip { font-family:var(--mono); font-size:11px; text-transform:uppercase; letter-spacing:.05em; padding:5px 11px; border-radius:999px; border:1px solid var(--line); background:var(--surface); color:var(--muted); cursor:pointer; transition:all .15s; }
|
|
139
142
|
.fchip:hover { color:var(--ink); border-color:var(--cool); }
|
|
140
|
-
.fchip.active { color
|
|
143
|
+
.fchip.active { color:var(--on-accent); border-color:transparent; font-weight:700; background:var(--muted); }
|
|
141
144
|
.fchip[data-status="all"].active { background:var(--signal); }
|
|
142
145
|
.fchip[data-status="todo"].active { background:var(--s-todo); }
|
|
143
146
|
.fchip[data-status="in_progress"].active { background:var(--s-in_progress); }
|
|
@@ -255,7 +258,8 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
255
258
|
|
|
256
259
|
/* workflow diagram */
|
|
257
260
|
.wf-wrap,.team-wrap { padding:26px 28px 60px; max-width:1000px; }
|
|
258
|
-
.wf-
|
|
261
|
+
.wf-wrap { max-width:1340px; } /* wider so the horizontal pipeline fits its 9 steps on one line */
|
|
262
|
+
.wf-diagram { display:block; margin-top:8px; }
|
|
259
263
|
.wf-step { display:flex; align-items:center; }
|
|
260
264
|
.wf-node { display:flex; align-items:center; gap:8px; padding:11px 15px; border:1px solid var(--line); border-radius:var(--radius); background:var(--surface); cursor:pointer; transition:border-color .18s,transform .15s,box-shadow .15s; user-select:none; }
|
|
261
265
|
.wf-node:hover { border-color:var(--cool); transform:translateY(-1px); box-shadow:var(--shadow); }
|
|
@@ -311,27 +315,39 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
311
315
|
[data-theme="light"] .md-body pre code { color:var(--ink); }
|
|
312
316
|
.md-body strong { font-weight:700; }
|
|
313
317
|
.status-btn { font-family:var(--mono); font-size:11px; padding:4px 9px; border:1px solid var(--line); border-radius:999px; background:var(--surface-2); color:var(--muted); cursor:pointer; }
|
|
314
|
-
.status-btn.active { color
|
|
318
|
+
.status-btn.active { color:var(--on-accent); border-color:transparent; font-weight:600; }
|
|
315
319
|
.comments { display:flex; flex-direction:column; gap:8px; }
|
|
316
320
|
.comment { background:var(--surface-2); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font-size:13px; }
|
|
317
321
|
.c-box { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
|
|
318
322
|
.c-box textarea { width:100%; min-height:60px; resize:vertical; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:8px 10px; font:inherit; }
|
|
319
323
|
.c-actions { display:flex; gap:8px; }
|
|
320
324
|
.btn { font:inherit; font-size:13px; padding:7px 12px; border-radius:8px; cursor:pointer; border:1px solid var(--line); background:var(--surface-2); color:var(--ink); }
|
|
321
|
-
.btn.primary { background:var(--signal); color
|
|
325
|
+
.btn.primary { background:var(--signal); color:var(--on-accent); border-color:transparent; font-weight:600; }
|
|
322
326
|
.empty { color:var(--faint); font-style:italic; font-size:12.5px; }
|
|
323
327
|
:focus-visible { outline:2px solid var(--cool); outline-offset:2px; }
|
|
328
|
+
/* Below ~1180px the 8 labelled tabs no longer fit alongside brand + right cluster,
|
|
329
|
+
so tabs go icon-only (and scroll horizontally inside their own strip if still tight). */
|
|
330
|
+
@media (max-width:1180px){ .tabs .tab{ padding:7px 8px; } .tab-label{ display:none; } }
|
|
324
331
|
@media (max-width:900px){
|
|
325
332
|
.panel[data-panel="board"].is-active { grid-template-columns:1fr; }
|
|
326
333
|
.side { border-left:0; border-top:1px solid var(--line); flex-direction:row; flex-wrap:wrap; gap:16px 28px; }
|
|
327
334
|
.side .rail-block { min-width:200px; flex:1; }
|
|
328
335
|
.kpi-row { grid-template-columns:repeat(2,1fr); }
|
|
336
|
+
/* secondary status chips are noise on a small header — keep Run + settings + theme */
|
|
337
|
+
.meta-chip, .mode-chip { display:none; }
|
|
338
|
+
}
|
|
339
|
+
@media (max-width:640px){
|
|
340
|
+
.brand-text { display:none; } /* logo alone carries the brand */
|
|
341
|
+
.run-btn span:last-child { display:none; } /* Run → icon only */
|
|
342
|
+
.run-btn { padding:6px 9px; }
|
|
343
|
+
.sync span:last-child { display:none; } /* just the pulsing dot */
|
|
329
344
|
}
|
|
330
345
|
@media (max-width:520px){
|
|
331
346
|
.kpi-row { grid-template-columns:1fr; }
|
|
332
347
|
.donut-row { flex-direction:column; align-items:flex-start; }
|
|
348
|
+
.overview { padding:16px; }
|
|
349
|
+
.app-footer { flex-direction:column; align-items:flex-start; gap:8px; }
|
|
333
350
|
}
|
|
334
|
-
@media (max-width:760px){ .tabs .tab{ padding:7px 8px;} .tab-label{ display:none; } }
|
|
335
351
|
|
|
336
352
|
/* chat widget (floating agent runner) — same surface/radius/shadow tokens as the card system.
|
|
337
353
|
Bubble/log styles below (.msg, .bubble, .chat-idle, .approval…) are shared, unscoped classes
|
|
@@ -351,7 +367,7 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
351
367
|
.chat-log { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:11px; }
|
|
352
368
|
.chat-idle { color:var(--faint); font-style:italic; font-size:12.5px; }
|
|
353
369
|
.msg { display:flex; } .msg.you { justify-content:flex-end; }
|
|
354
|
-
.msg.you .bubble { background:var(--signal); color
|
|
370
|
+
.msg.you .bubble { background:var(--signal); color:var(--on-accent); border-radius:12px 12px 3px 12px; padding:8px 11px; font-size:13px; max-width:82%; font-weight:500; }
|
|
355
371
|
.msg.agent { margin:0; align-self:flex-start; max-width:92%; background:#05080b; border:1px solid var(--line); border-radius:12px 12px 12px 3px; padding:10px 12px; font-family:var(--mono); font-size:12px; line-height:1.5; color:#cdd9de; white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; }
|
|
356
372
|
[data-theme="light"] .msg.agent { background:#10151b; }
|
|
357
373
|
.msg.agentmsg { flex-direction:column; align-items:flex-start; gap:3px; max-width:88%; }
|
|
@@ -361,10 +377,11 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
361
377
|
.msg.agentmsg.k-status .bubble { border-color:color-mix(in srgb,var(--s-done) 35%,var(--line)); }
|
|
362
378
|
.msg.agentmsg.k-question .msg-role { color:var(--signal); }
|
|
363
379
|
.msg.agentmsg.k-handoff .msg-role { color:var(--s-to_analyze); }
|
|
364
|
-
|
|
365
|
-
.chat-
|
|
380
|
+
/* Composer: textarea on its own full-width row, compact actions right-aligned below. */
|
|
381
|
+
.chat-input { display:flex; flex-wrap:wrap; justify-content:flex-end; align-items:center; gap:8px; padding:12px 14px; }
|
|
382
|
+
.chat-ta { flex:1 1 100%; min-height:44px; max-height:140px; resize:vertical; font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line); border-radius:9px; padding:9px 11px; transition:border-color .15s; }
|
|
366
383
|
.chat-ta:focus { outline:2px solid var(--cool); outline-offset:1px; }
|
|
367
|
-
.chat-send { align-self:
|
|
384
|
+
.chat-send { flex:0 0 auto; align-self:auto; padding:8px 16px; }
|
|
368
385
|
.chat-warn { font-size:10.5px; color:var(--signal); margin:6px 12px 10px; }
|
|
369
386
|
.approval { display:flex; flex-direction:column; gap:8px; padding:10px 12px; background:var(--surface-2); border:1px solid color-mix(in srgb,var(--signal) 40%,var(--line)); border-radius:var(--radius); box-shadow:var(--shadow); }
|
|
370
387
|
.approval .c-actions { display:flex; gap:8px; }
|
|
@@ -394,7 +411,7 @@ body { background:var(--bg); color:var(--ink); font-family:var(--sans); font-siz
|
|
|
394
411
|
re-renders (which rebuild these nodes) then don't replay them — this is what stops the
|
|
395
412
|
dashboard from flickering on every agent action. */
|
|
396
413
|
/* cards/panels rise in on render */
|
|
397
|
-
@keyframes rise { from{ opacity:0; transform:translateY(
|
|
414
|
+
@keyframes rise { from{ opacity:0; transform:translateY(5px);} to{ opacity:1; transform:translateY(0);} }
|
|
398
415
|
body.booting .kpi, body.booting .ocard, body.booting .card { animation:rise .4s cubic-bezier(.2,.8,.2,1) both; }
|
|
399
416
|
body.booting .kpi-row .kpi:nth-child(1){ animation-delay:.02s; } body.booting .kpi-row .kpi:nth-child(2){ animation-delay:.06s; }
|
|
400
417
|
body.booting .kpi-row .kpi:nth-child(3){ animation-delay:.1s; } body.booting .kpi-row .kpi:nth-child(4){ animation-delay:.14s; }
|
|
@@ -407,7 +424,7 @@ body.booting .area-dot, body.booting .area-value { opacity:0; animation:fadein .
|
|
|
407
424
|
@keyframes fadein { to{ opacity:1; } }
|
|
408
425
|
|
|
409
426
|
/* donut arcs pop in, staggered by --i */
|
|
410
|
-
@keyframes pop { from{ opacity:0;
|
|
427
|
+
@keyframes pop { from{ opacity:0; } to{ opacity:1; } }
|
|
411
428
|
body.booting .seg-anim { transform-origin:center; transform-box:fill-box; opacity:0; animation:pop .45s cubic-bezier(.2,.8,.2,1) forwards; animation-delay:calc(var(--i,0)*.08s); }
|
|
412
429
|
|
|
413
430
|
/* phase bars grow + the count-up badge is driven from JS (countUp in app.js) */
|
|
@@ -452,7 +469,7 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
|
|
|
452
469
|
.settings-note { font-size:10.5px; color:var(--faint); margin:0; line-height:1.4; }
|
|
453
470
|
|
|
454
471
|
/* tab badge (open attention count) */
|
|
455
|
-
.tab-badge { display:inline-grid; place-items:center; min-width:16px; height:16px; padding:0 4px; margin-left:5px; border-radius:999px; background:var(--signal); color
|
|
472
|
+
.tab-badge { display:inline-grid; place-items:center; min-width:16px; height:16px; padding:0 4px; margin-left:5px; border-radius:999px; background:var(--signal); color:var(--on-accent); font-size:10px; font-weight:700; }
|
|
456
473
|
.tab-badge[hidden] { display:none; }
|
|
457
474
|
|
|
458
475
|
/* attention tab */
|
|
@@ -492,7 +509,7 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
|
|
|
492
509
|
.wf-card.off { opacity:.5; }
|
|
493
510
|
.wf-card-head { display:flex; align-items:center; gap:8px; }
|
|
494
511
|
.wf-num { display:grid; place-items:center; width:20px; height:20px; border-radius:999px; background:var(--surface-2); color:var(--muted); font-family:var(--mono); font-size:11px; flex-shrink:0; }
|
|
495
|
-
.wf-card:not(.off) .wf-num { background:var(--signal); color
|
|
512
|
+
.wf-card:not(.off) .wf-num { background:var(--signal); color:var(--on-accent); }
|
|
496
513
|
.wf-card-name { font-weight:600; font-size:13.5px; }
|
|
497
514
|
.wf-opt { font-family:var(--mono); font-size:9px; text-transform:uppercase; letter-spacing:.05em; color:var(--faint); border:1px solid var(--line); border-radius:4px; padding:1px 4px; }
|
|
498
515
|
.wf-card-meta { display:flex; flex-wrap:wrap; gap:6px; }
|
|
@@ -506,3 +523,253 @@ body.booting .ring-svg circle:last-of-type { transform-origin:center; animation:
|
|
|
506
523
|
.wf-conn { flex:0 0 auto; align-self:center; width:26px; height:2px; background:linear-gradient(90deg,var(--cool),var(--signal)); margin:0 2px; border-radius:2px; }
|
|
507
524
|
.wf-conn.off { background:var(--line); }
|
|
508
525
|
@media (max-width:720px){ .wf-track { flex-direction:column; align-items:stretch; } .wf-conn { width:2px; height:18px; margin:2px 0 2px 19px; } }
|
|
526
|
+
|
|
527
|
+
/* ================= v0.13.2 — reference re-skin (violet), settings tab, footer ================= */
|
|
528
|
+
|
|
529
|
+
/* KPI colored left border (accent set inline per card) */
|
|
530
|
+
.kpi.has-accent { border-left-width:3px; border-left-style:solid; }
|
|
531
|
+
|
|
532
|
+
/* Settings tab */
|
|
533
|
+
.settings-wrap { max-width:720px; margin:0 auto; padding:20px 22px; }
|
|
534
|
+
.settings-card { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:18px; display:flex; flex-direction:column; gap:16px; margin:14px 0; }
|
|
535
|
+
.settings-field { display:flex; flex-direction:column; gap:6px; }
|
|
536
|
+
.settings-field-label { font-size:11px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
|
|
537
|
+
.settings-select { font-family:inherit; font-size:14px; padding:9px 11px; border:1px solid var(--line); border-radius:9px; background:var(--surface-2); color:var(--ink); cursor:pointer; }
|
|
538
|
+
.settings-select:focus { outline:2px solid var(--signal); outline-offset:1px; }
|
|
539
|
+
.settings-saved { align-self:flex-start; font-size:12px; color:var(--s-done); font-family:var(--mono); }
|
|
540
|
+
.settings-saved[hidden] { display:none; }
|
|
541
|
+
.settings-readonly { background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; }
|
|
542
|
+
.settings-ro-row { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:10px 15px; border-bottom:1px solid var(--line); }
|
|
543
|
+
.settings-ro-row:last-child { border-bottom:none; }
|
|
544
|
+
.settings-ro-k { color:var(--muted); font-size:13px; }
|
|
545
|
+
.settings-ro-v { font-family:var(--mono); font-size:12.5px; color:var(--ink); }
|
|
546
|
+
|
|
547
|
+
/* App footer — professional, always at the bottom of the content */
|
|
548
|
+
.app-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; padding:14px 22px; border-top:1px solid var(--line); background:var(--surface); color:var(--muted); font-size:12.5px; }
|
|
549
|
+
.footer-left { display:flex; align-items:center; gap:9px; }
|
|
550
|
+
.footer-logo { display:inline-flex; height:18px; text-decoration:none; }
|
|
551
|
+
.footer-logo .brand-logo-img { height:18px; width:auto; }
|
|
552
|
+
.footer-name { font-weight:700; color:var(--ink); }
|
|
553
|
+
.footer-ver { font-family:var(--mono); font-size:11.5px; color:var(--signal); }
|
|
554
|
+
.footer-sep { color:var(--faint); }
|
|
555
|
+
.footer-lic { color:var(--muted); }
|
|
556
|
+
.footer-right { display:flex; align-items:center; gap:16px; }
|
|
557
|
+
.footer-right a, .footer-link { color:var(--muted); text-decoration:none; background:none; border:none; font:inherit; cursor:pointer; padding:0; }
|
|
558
|
+
.footer-right a:hover, .footer-link:hover { color:var(--signal); }
|
|
559
|
+
|
|
560
|
+
/* framework version chip in the header brand line */
|
|
561
|
+
.brand-ver { font-family:var(--mono); font-size:9.5px; color:var(--signal); border:1px solid color-mix(in srgb,var(--signal) 40%,var(--line)); border-radius:999px; padding:1px 6px; flex-shrink:0; }
|
|
562
|
+
.brand-ver[hidden] { display:none; }
|
|
563
|
+
@media (max-width:640px){ .brand-ver { display:none; } }
|
|
564
|
+
|
|
565
|
+
/* ---- mobile hamburger nav + FAB-safe footer (v0.13.2) ---- */
|
|
566
|
+
.nav-toggle { display:none; width:34px; height:34px; border:1px solid var(--line); border-radius:8px; background:var(--surface-2); cursor:pointer; padding:0; flex-direction:column; align-items:center; justify-content:center; gap:4px; flex-shrink:0; }
|
|
567
|
+
.nav-toggle span { display:block; width:16px; height:2px; background:var(--ink); border-radius:2px; transition:transform .2s, opacity .2s; }
|
|
568
|
+
body.nav-open .nav-toggle span:nth-child(1){ transform:translateY(6px) rotate(45deg); }
|
|
569
|
+
body.nav-open .nav-toggle span:nth-child(2){ opacity:0; }
|
|
570
|
+
body.nav-open .nav-toggle span:nth-child(3){ transform:translateY(-6px) rotate(-45deg); }
|
|
571
|
+
|
|
572
|
+
/* keep the footer's right-side links clear of the floating chat FAB (bottom-right) */
|
|
573
|
+
.app-footer { padding-right:92px; }
|
|
574
|
+
|
|
575
|
+
@media (max-width:900px){
|
|
576
|
+
.nav-toggle { display:flex; }
|
|
577
|
+
.brand-text { display:none; } /* logo + hamburger carry the brand on mobile */
|
|
578
|
+
.tabs { position:absolute; top:100%; left:0; right:0; flex-direction:column; align-items:stretch; gap:2px; max-width:none;
|
|
579
|
+
background:var(--surface); border-bottom:1px solid var(--line); box-shadow:var(--shadow); padding:8px;
|
|
580
|
+
max-height:0; overflow:hidden; opacity:0; pointer-events:none; transition:max-height .22s ease, opacity .18s; }
|
|
581
|
+
body.nav-open .tabs { max-height:82vh; overflow-y:auto; opacity:1; pointer-events:auto; }
|
|
582
|
+
.tabs .tab { justify-content:flex-start; padding:12px 14px; border-radius:9px; font-size:14px; }
|
|
583
|
+
.tabs .tab.is-active { box-shadow:inset 3px 0 0 var(--signal); }
|
|
584
|
+
.tab-label { display:inline !important; } /* full labels inside the dropdown */
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* ---- Workflow: vertical pipeline timeline (v0.13.2) ---- */
|
|
588
|
+
.wf-legend { display:flex; align-items:center; gap:8px; margin-bottom:18px; }
|
|
589
|
+
.wf-legend-dot { width:8px; height:8px; border-radius:50%; background:var(--signal); box-shadow:0 0 0 4px color-mix(in srgb,var(--signal) 18%,transparent); }
|
|
590
|
+
.wf-legend-txt { font-family:var(--mono); font-size:11.5px; color:var(--muted); }
|
|
591
|
+
.wf-flow { max-width:600px; }
|
|
592
|
+
.wf-step { display:grid; grid-template-columns:40px 1fr; column-gap:14px; }
|
|
593
|
+
.wf-rail { position:relative; display:flex; justify-content:center; }
|
|
594
|
+
.wf-rail::before { content:""; position:absolute; top:0; bottom:0; width:2px; background:var(--line); border-radius:2px; }
|
|
595
|
+
.wf-step.on .wf-rail::before { background:linear-gradient(var(--signal),color-mix(in srgb,var(--signal) 55%,var(--cool))); }
|
|
596
|
+
.wf-step.first .wf-rail::before { top:20px; }
|
|
597
|
+
.wf-step.last .wf-rail::before { bottom:auto; height:20px; }
|
|
598
|
+
.wf-node2 { position:relative; z-index:1; margin-top:10px; width:30px; height:30px; border-radius:50%; display:grid; place-items:center; font-family:var(--mono); font-size:12px; font-weight:700; background:var(--surface); border:2px solid var(--line); color:var(--faint); flex-shrink:0; transition:background .18s,border-color .18s,color .18s,box-shadow .18s; }
|
|
599
|
+
.wf-step.on .wf-node2 { background:var(--signal); border-color:transparent; color:var(--on-accent); box-shadow:0 0 0 4px color-mix(in srgb,var(--signal) 16%,transparent); }
|
|
600
|
+
.wf-body { margin:6px 0; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:13px 15px; cursor:pointer; transition:border-color .15s,transform .12s,box-shadow .15s; }
|
|
601
|
+
.wf-body:hover { border-color:color-mix(in srgb,var(--signal) 45%,var(--line)); transform:translateX(3px); box-shadow:var(--shadow); }
|
|
602
|
+
.wf-body:focus-visible { outline:2px solid var(--signal); outline-offset:2px; }
|
|
603
|
+
.wf-step.off .wf-body { opacity:.62; }
|
|
604
|
+
.wf-body-head { display:flex; align-items:center; gap:10px; }
|
|
605
|
+
.wf-step-name { font-weight:600; font-size:14px; letter-spacing:-.01em; }
|
|
606
|
+
.wf-body-meta { display:flex; flex-wrap:wrap; gap:6px; margin-top:9px; }
|
|
607
|
+
.wf-switch { margin-left:auto; width:36px; height:20px; border-radius:999px; background:var(--surface-2); border:1px solid var(--line); position:relative; transition:background .16s,border-color .16s; flex-shrink:0; }
|
|
608
|
+
.wf-switch.on { background:var(--signal); border-color:transparent; }
|
|
609
|
+
.wf-knob { position:absolute; top:2px; left:2px; width:14px; height:14px; border-radius:50%; background:var(--muted); transition:transform .18s cubic-bezier(.2,.8,.2,1),background .18s; }
|
|
610
|
+
.wf-switch.on .wf-knob { transform:translateX(16px); background:#fff; }
|
|
611
|
+
|
|
612
|
+
/* theme toggle now renders the moon icon */
|
|
613
|
+
.theme-toggle { color:var(--muted); }
|
|
614
|
+
.theme-toggle:hover { color:var(--ink); border-color:var(--cool); }
|
|
615
|
+
|
|
616
|
+
/* ---- Workflow: horizontal icon pipeline (v0.13.2) ---- */
|
|
617
|
+
.wf-pipeline { display:flex; align-items:flex-start; gap:0; overflow-x:auto; padding:24px 6px 12px; scrollbar-width:thin; }
|
|
618
|
+
.wf-pipeline::-webkit-scrollbar { height:8px; }
|
|
619
|
+
.wf-pipeline::-webkit-scrollbar-thumb { background:var(--line); border-radius:8px; }
|
|
620
|
+
.wf-step2 { position:relative; display:flex; flex-direction:column; align-items:center; flex:0 0 auto; width:118px; cursor:pointer; }
|
|
621
|
+
.wf-step2:focus-visible { outline:none; }
|
|
622
|
+
.wf-step2:focus-visible .wf-circle { outline:2px solid var(--signal); outline-offset:3px; }
|
|
623
|
+
.wf-circle { position:relative; width:60px; height:60px; border-radius:50%; display:grid; place-items:center; border:2px solid var(--line); background:var(--surface); color:var(--muted); transition:transform .18s,border-color .18s,color .18s,background .18s,box-shadow .18s; }
|
|
624
|
+
.wf-circle svg { width:26px; height:26px; }
|
|
625
|
+
.wf-step2:hover .wf-circle { transform:translateY(-3px); border-color:var(--cool); color:var(--ink); }
|
|
626
|
+
.wf-step2.on .wf-circle { border-color:var(--signal); color:var(--signal); box-shadow:0 0 0 4px color-mix(in srgb,var(--signal) 14%,transparent); }
|
|
627
|
+
.wf-step2.on:hover .wf-circle { background:var(--signal); color:var(--on-accent); }
|
|
628
|
+
.wf-step2.off .wf-circle { border-style:dashed; opacity:.65; }
|
|
629
|
+
.wf-step2.on .wf-circle::after { content:""; position:absolute; inset:-2px; border-radius:50%; animation:wf-ring 2.8s ease-out infinite; }
|
|
630
|
+
@keyframes wf-ring { 0%{ box-shadow:0 0 0 0 color-mix(in srgb,var(--signal) 32%,transparent);} 70%{ box-shadow:0 0 0 9px transparent;} 100%{ box-shadow:0 0 0 0 transparent;} }
|
|
631
|
+
.wf-caption { margin-top:13px; font-size:12px; font-weight:600; text-align:center; color:var(--ink); line-height:1.3; max-width:108px; }
|
|
632
|
+
.wf-step2.off .wf-caption { color:var(--muted); font-weight:500; }
|
|
633
|
+
.wf-opt2 { display:block; font-family:var(--mono); font-size:8.5px; text-transform:uppercase; letter-spacing:.06em; color:var(--faint); margin-top:3px; }
|
|
634
|
+
.wf-link { flex:1 1 26px; min-width:26px; height:2px; align-self:flex-start; margin-top:30px; border-radius:2px; background:var(--line); }
|
|
635
|
+
.wf-link.on { background-color:transparent; background-image:repeating-linear-gradient(90deg, color-mix(in srgb,var(--signal) 85%,transparent) 0 6px, transparent 6px 12px); background-size:12px 2px; background-repeat:repeat-x; animation:wf-flow-dash .6s linear infinite; }
|
|
636
|
+
@keyframes wf-flow-dash { to { background-position:12px 0; } }
|
|
637
|
+
body.booting .wf-step2 { opacity:0; animation:rise .4s cubic-bezier(.2,.8,.2,1) both; animation-delay:calc(var(--i,0)*.06s); }
|
|
638
|
+
|
|
639
|
+
/* ---- Workflow: selected-step highlight + inline detail zone (v0.13.2) ---- */
|
|
640
|
+
.wf-step2.is-selected .wf-circle { border-color:var(--signal); border-style:solid; color:var(--signal); box-shadow:0 0 0 5px color-mix(in srgb,var(--signal) 22%,transparent); }
|
|
641
|
+
.wf-step2.is-selected.off .wf-circle { opacity:1; }
|
|
642
|
+
.wf-step2.is-selected .wf-caption { color:var(--ink); font-weight:700; }
|
|
643
|
+
.wf-detail { margin-top:26px; max-width:720px; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); padding:18px 20px; }
|
|
644
|
+
.wf-detail-head { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
|
645
|
+
.wf-detail-num { display:grid; place-items:center; width:26px; height:26px; border-radius:50%; background:var(--signal); color:var(--on-accent); font-family:var(--mono); font-size:12px; font-weight:700; flex-shrink:0; }
|
|
646
|
+
.wf-detail-name { font-size:17px; font-weight:700; letter-spacing:-.01em; }
|
|
647
|
+
.wf-detail-status { font-family:var(--mono); font-size:10px; text-transform:uppercase; letter-spacing:.05em; padding:2px 8px; border-radius:999px; }
|
|
648
|
+
.wf-detail-status.on { color:var(--s-done); border:1px solid color-mix(in srgb,var(--s-done) 40%,var(--line)); }
|
|
649
|
+
.wf-detail-status.off { color:var(--muted); border:1px solid var(--line); }
|
|
650
|
+
.wf-detail-btn { margin-left:auto; }
|
|
651
|
+
.wf-detail-desc { color:var(--ink); font-size:13.5px; line-height:1.55; margin:13px 0 15px; }
|
|
652
|
+
.wf-detail-grid { display:grid; grid-template-columns:auto 1fr; gap:9px 18px; }
|
|
653
|
+
.wf-detail-row { display:contents; }
|
|
654
|
+
.wf-detail-k { color:var(--muted); font-size:13px; }
|
|
655
|
+
.wf-detail-v { color:var(--ink); font-family:var(--mono); font-size:12.5px; }
|
|
656
|
+
.wf-detail-skill { margin-top:15px; padding-top:14px; border-top:1px solid var(--line); color:var(--muted); font-size:12.5px; line-height:1.55; }
|
|
657
|
+
.wf-detail-skill b { color:var(--ink); font-weight:600; }
|
|
658
|
+
|
|
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; }
|
|
661
|
+
.wf-pop[hidden] { display:none; }
|
|
662
|
+
.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
|
+
.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
|
+
.wf-pop-actions { margin-top:15px; display:flex; }
|
|
665
|
+
.wf-pop .wf-detail-desc { margin:12px 0 14px; }
|
|
666
|
+
|
|
667
|
+
/* directional arrowhead at the end of each connector */
|
|
668
|
+
.wf-link { position:relative; }
|
|
669
|
+
.wf-link::after { content:""; position:absolute; right:-1px; top:50%; transform:translateY(-50%); width:0; height:0; border-left:6px solid var(--line); border-top:4px solid transparent; border-bottom:4px solid transparent; }
|
|
670
|
+
.wf-link.on::after { border-left-color:var(--signal); }
|
|
671
|
+
|
|
672
|
+
/* mobile: reflow into a centered wrap — no horizontal scroll (connectors hidden) */
|
|
673
|
+
@media (max-width:760px){
|
|
674
|
+
.wf-wrap { max-width:none; }
|
|
675
|
+
.wf-pipeline { flex-wrap:wrap; overflow-x:visible; justify-content:center; gap:8px 4px; padding:16px 0 8px; }
|
|
676
|
+
.wf-link { display:none; }
|
|
677
|
+
.wf-step2 { width:33.3%; max-width:120px; }
|
|
678
|
+
}
|
|
679
|
+
@media (max-width:420px){ .wf-step2 { width:50%; } }
|
|
680
|
+
|
|
681
|
+
/* ================= Design skins (data-design) ================= */
|
|
682
|
+
/* control-room = the bare :root defaults above. Add new designs as scoped blocks like "ocean" below.
|
|
683
|
+
Each design should override the full token set for BOTH dark and its [data-theme="light"] variant. */
|
|
684
|
+
|
|
685
|
+
/* --- Obsidian Ops: near-black mission-control, electric lime + cyan, mono precision --- */
|
|
686
|
+
:root[data-design="obsidian"] {
|
|
687
|
+
--bg:#0a0b0d; --surface:#131417; --surface-2:#1b1d21; --line:#23262b;
|
|
688
|
+
--ink:#e8e6e0; --muted:#8b909a; --faint:#5b6068;
|
|
689
|
+
--signal:#9be15d; --cool:#00e5ff; --on-accent:#0a0b0d;
|
|
690
|
+
--s-todo:#5b6068; --s-in_progress:#f2c94c; --s-to_validate:#56ccf2; --s-to_analyze:#a78bfa; --s-done:#3fb950; --s-blocked:#f2748a;
|
|
691
|
+
--radius:10px; --shadow:0 10px 26px rgba(0,0,0,.55);
|
|
692
|
+
}
|
|
693
|
+
/* mission-control precision: mono labels, tighter, a flat bordered KPI row with dividers.
|
|
694
|
+
NB: no hardcoded topbar colour — it inherits the token-based base so it follows light/dark. */
|
|
695
|
+
:root[data-design="obsidian"] .kpi-label,
|
|
696
|
+
:root[data-design="obsidian"] .rail-title,
|
|
697
|
+
:root[data-design="obsidian"] .panel-sub,
|
|
698
|
+
:root[data-design="obsidian"] .kpi-num { font-family:var(--mono); }
|
|
699
|
+
:root[data-design="obsidian"] .kpi-num { font-weight:800; letter-spacing:-.02em; }
|
|
700
|
+
:root[data-design="obsidian"] .brand-sub { color:var(--signal); }
|
|
701
|
+
:root[data-design="obsidian"] .kpi-row { gap:0; background:var(--surface); border:1px solid var(--line); border-radius:var(--radius); overflow:hidden; }
|
|
702
|
+
:root[data-design="obsidian"] .kpi { background:transparent; border:0; border-left:1px solid var(--line) !important; border-radius:0; }
|
|
703
|
+
:root[data-design="obsidian"] .kpi:first-child { border-left:0 !important; }
|
|
704
|
+
:root[data-design="obsidian"] .kpi:hover { box-shadow:none; background:color-mix(in srgb,var(--surface-2) 60%,transparent); }
|
|
705
|
+
:root[data-design="obsidian"] .wf-mini .dot, :root[data-design="obsidian"] .wf-strip .dot { box-shadow:0 0 8px color-mix(in srgb,var(--signal) 60%,transparent); }
|
|
706
|
+
:root[data-design="obsidian"] .btn.primary,
|
|
707
|
+
:root[data-design="obsidian"] .fchip.active[data-status="all"] { color:#0a0b0d; }
|
|
708
|
+
|
|
709
|
+
:root[data-design="obsidian"][data-theme="light"] {
|
|
710
|
+
--bg:#f0eee9; --surface:#ffffff; --surface-2:#e7e9e4; --line:#dcdcd4;
|
|
711
|
+
--ink:#1b1d21; --muted:#5b6068; --faint:#8b909a;
|
|
712
|
+
--signal:#4c9a2a; --cool:#0891b2; --on-accent:#ffffff;
|
|
713
|
+
--s-todo:#6a6f78; --s-in_progress:#b8860b; --s-to_validate:#0891b2; --s-to_analyze:#7c5cff; --s-done:#3fb950; --s-blocked:#d15168;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/* ================= Self-hosted fonts (offline, zero external dependency) ================= */
|
|
717
|
+
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/space-grotesk-400.woff2) format('woff2');}
|
|
718
|
+
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/space-grotesk-500.woff2) format('woff2');}
|
|
719
|
+
@font-face{font-family:'Space Grotesk';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/space-grotesk-700.woff2) format('woff2');}
|
|
720
|
+
@font-face{font-family:'Sora';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/sora-400.woff2) format('woff2');}
|
|
721
|
+
@font-face{font-family:'Sora';font-style:normal;font-weight:600;font-display:swap;src:url(fonts/sora-600.woff2) format('woff2');}
|
|
722
|
+
@font-face{font-family:'Sora';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/sora-700.woff2) format('woff2');}
|
|
723
|
+
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/ibm-plex-sans-400.woff2) format('woff2');}
|
|
724
|
+
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/ibm-plex-sans-500.woff2) format('woff2');}
|
|
725
|
+
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(fonts/ibm-plex-sans-600.woff2) format('woff2');}
|
|
726
|
+
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/jetbrains-mono-400.woff2) format('woff2');}
|
|
727
|
+
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/jetbrains-mono-500.woff2) format('woff2');}
|
|
728
|
+
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:700;font-display:swap;src:url(fonts/jetbrains-mono-700.woff2) format('woff2');}
|
|
729
|
+
|
|
730
|
+
/* headings / big numbers use the active design's display face */
|
|
731
|
+
.brand-name, .panel-title, .kpi-num, .wf-detail-name, .stat-tile-val { font-family:var(--display); }
|
|
732
|
+
|
|
733
|
+
/* Obsidian Ops fonts: JetBrains Mono (mono + display) + IBM Plex Sans (body) */
|
|
734
|
+
:root[data-design="obsidian"] { --sans:'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif; --mono:'JetBrains Mono', ui-monospace, monospace; --display:'JetBrains Mono', ui-monospace, monospace; }
|
|
735
|
+
|
|
736
|
+
/* --- Neon Command: glassmorphism, aurora violet + cyan, control-room --- */
|
|
737
|
+
:root[data-design="neon-command"] {
|
|
738
|
+
--bg:#0d0b1a; --surface:#171233; --surface-2:#1f1940; --line:#2e2857;
|
|
739
|
+
--ink:#eae6ff; --muted:#a99fd6; --faint:#6f689e;
|
|
740
|
+
--signal:#7c4dff; --cool:#00e5ff; --on-accent:#0d0b1a;
|
|
741
|
+
--s-todo:#6f689e; --s-in_progress:#ff9f45; --s-to_validate:#00e5ff; --s-to_analyze:#c47dff; --s-done:#3ddc97; --s-blocked:#ff6b8a;
|
|
742
|
+
--radius:16px; --shadow:0 18px 50px rgba(0,0,0,.55);
|
|
743
|
+
--sans:'Sora', system-ui, sans-serif; --display:'Space Grotesk', system-ui, sans-serif;
|
|
744
|
+
}
|
|
745
|
+
:root[data-design="neon-command"] body {
|
|
746
|
+
background:
|
|
747
|
+
radial-gradient(1200px 600px at 15% -5%, rgba(124,77,255,.22), transparent 60%),
|
|
748
|
+
radial-gradient(1000px 520px at 100% 8%, rgba(0,229,255,.14), transparent 55%),
|
|
749
|
+
var(--bg);
|
|
750
|
+
background-attachment:fixed;
|
|
751
|
+
}
|
|
752
|
+
:root[data-design="neon-command"] .kpi,
|
|
753
|
+
:root[data-design="neon-command"] .ocard,
|
|
754
|
+
:root[data-design="neon-command"] .card,
|
|
755
|
+
:root[data-design="neon-command"] .phase,
|
|
756
|
+
:root[data-design="neon-command"] .settings-card,
|
|
757
|
+
:root[data-design="neon-command"] .settings-readonly,
|
|
758
|
+
:root[data-design="neon-command"] .wf-pop,
|
|
759
|
+
:root[data-design="neon-command"] .approval {
|
|
760
|
+
background:linear-gradient(160deg, color-mix(in srgb,var(--surface) 80%,transparent), color-mix(in srgb,var(--surface-2) 50%,transparent));
|
|
761
|
+
border-color:color-mix(in srgb,var(--signal) 22%,var(--line));
|
|
762
|
+
backdrop-filter:blur(10px);
|
|
763
|
+
}
|
|
764
|
+
:root[data-design="neon-command"] .topbar { background:color-mix(in srgb,var(--bg) 68%,transparent); border-bottom-color:color-mix(in srgb,var(--signal) 22%,var(--line)); }
|
|
765
|
+
:root[data-design="neon-command"] .btn.primary,
|
|
766
|
+
:root[data-design="neon-command"] .run-btn { background:linear-gradient(120deg,var(--signal),var(--cool)); color:#fff; border-color:transparent; box-shadow:0 6px 20px color-mix(in srgb,var(--signal) 45%,transparent); }
|
|
767
|
+
:root[data-design="neon-command"] .brand-name { background:linear-gradient(120deg,var(--signal),var(--cool)); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
|
768
|
+
:root[data-design="neon-command"] .tab.is-active { background:color-mix(in srgb,var(--signal) 20%,transparent); box-shadow:inset 0 -2px 0 var(--cool); }
|
|
769
|
+
:root[data-design="neon-command"] .wf-circle { backdrop-filter:blur(6px); }
|
|
770
|
+
:root[data-design="neon-command"][data-theme="light"] {
|
|
771
|
+
--bg:#f2f0fb; --surface:#ffffff; --surface-2:#eceafa; --line:#ddd8f2;
|
|
772
|
+
--ink:#1a1440; --muted:#5a5486; --faint:#8b86b3;
|
|
773
|
+
--signal:#6d3bff; --cool:#0891b2; --on-accent:#ffffff;
|
|
774
|
+
--s-done:#0f9d63; --s-to_validate:#0891b2; --s-in_progress:#c2801e; --s-blocked:#d15168;
|
|
775
|
+
}
|
|
@@ -15,10 +15,22 @@ const orchestrator = require('./orchestrator');
|
|
|
15
15
|
const PORT = process.env.SPECTOFLOW_PORT ? Number(process.env.SPECTOFLOW_PORT) : 4319;
|
|
16
16
|
const PUBLIC = path.join(__dirname, 'public');
|
|
17
17
|
const ROOT = process.env.SPECTOFLOW_ROOT || path.resolve(__dirname, '..', '..');
|
|
18
|
-
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon' };
|
|
18
|
+
const MIME = { '.html':'text/html; charset=utf-8', '.css':'text/css; charset=utf-8', '.js':'application/javascript; charset=utf-8', '.png':'image/png', '.svg':'image/svg+xml', '.ico':'image/x-icon', '.woff2':'font/woff2', '.woff':'font/woff' };
|
|
19
19
|
const clients = new Set();
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
// Installed framework version: the manifest records it at init/update time. Fallback to the kit's
|
|
22
|
+
// own package.json — only reachable (and only used) when the server is run straight from templates/
|
|
23
|
+
// (dev/preview), never from an installed project whose sibling package.json belongs to the user.
|
|
24
|
+
function frameworkVersion(){
|
|
25
|
+
try { return JSON.parse(fs.readFileSync(path.join(ROOT, '.spectoflow', '.manifest.json'), 'utf8')).version; } catch {}
|
|
26
|
+
try { const pk = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'), 'utf8')); if (pk.name === 'spectoflow') return pk.version; } catch {}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
function project(){
|
|
30
|
+
const p = store.readProject(ROOT);
|
|
31
|
+
const v = frameworkVersion(); if (v) p.version = v;
|
|
32
|
+
return p;
|
|
33
|
+
}
|
|
22
34
|
function sendJSON(res,code,obj){ res.writeHead(code,{'Content-Type':'application/json; charset=utf-8'}); res.end(JSON.stringify(obj)); }
|
|
23
35
|
function body(req){ return new Promise(r=>{ let b=''; req.on('data',c=>b+=c); req.on('end',()=>{ try{r(JSON.parse(b||'{}'));}catch{r({});} }); }); }
|
|
24
36
|
function emit(obj){ const line='data: '+JSON.stringify(obj)+'\n\n'; for(const res of clients) res.write(line); }
|
|
@@ -30,6 +42,7 @@ function writeConfig(patch){
|
|
|
30
42
|
const cp = configPath(); const cfg = JSON.parse(fs.readFileSync(cp, 'utf8'));
|
|
31
43
|
if (patch.mode && ['autopilot','semi','manual'].includes(patch.mode)) cfg.mode = patch.mode;
|
|
32
44
|
if (typeof patch.language === 'string' && patch.language.trim()) cfg.language = patch.language.trim();
|
|
45
|
+
if (typeof patch.design === 'string' && /^[a-z0-9-]{1,40}$/.test(patch.design)) cfg.design = patch.design;
|
|
33
46
|
fs.writeFileSync(cp, JSON.stringify(cfg, null, 2) + '\n');
|
|
34
47
|
return cfg;
|
|
35
48
|
}
|