spectoflow 0.17.0 → 0.17.5

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.
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
+ // Kept as a mutable object (not reassigned) so every existing STATUS[key] lookup keeps working
3
+ // unchanged; updateStatusLabels() (called at the top of every render()) refreshes its values from
4
+ // the current UI language, so a language change re-labels every status chip/column/legend for free.
2
5
  const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To validate', to_analyze:'To analyze', done:'Done', blocked:'Blocked' };
6
+ function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('status.'+k); }
3
7
  let P = null, openTaskId = null;
4
8
  let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
5
9
  let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
@@ -73,8 +77,8 @@ function renderApproval(container){
73
77
  if(!o || o.status!=='awaiting_approval') return;
74
78
  row=el('div','approval');
75
79
  row.append(el('div','msg-role','orchestrator · awaiting approval'));
76
- const a=el('button','btn primary','Approve'); a.addEventListener('click',()=>approve('approve'));
77
- const c=el('button','btn','Cancel'); c.addEventListener('click',()=>approve('cancel'));
80
+ const a=el('button','btn primary',t('action.approve')); a.addEventListener('click',()=>approve('approve'));
81
+ const c=el('button','btn',t('action.cancel')); c.addEventListener('click',()=>approve('cancel'));
78
82
  const acts=el('div','c-actions'); acts.append(a,c); row.append(acts);
79
83
  container.append(row); scrollChat(container);
80
84
  }
@@ -117,9 +121,11 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
117
121
 
118
122
  function render(){
119
123
  const c = P.config||{};
120
- $('#projectName').textContent = c.projectType || 'project';
124
+ i18nSetLang(c.language||'en'); updateStatusLabels(); // language drives the whole UI, not just agent output
125
+ $('#projectName').textContent = P.projectName || c.projectType || 'project';
121
126
  const bv=$('#brandVer'); if(bv){ if(P.version){ bv.textContent='v'+P.version; bv.hidden=false; } else { bv.hidden=true; } }
122
- $('#brandSub').textContent = (c.mode||'semi') + ' · ' + (c.language||'en');
127
+ // mode/language are edited live from the bar itself — see the #topMode/#topLang selects synced
128
+ // in renderSettings() below (called on every render tick) and saved via saveSettings().
123
129
  // agent select — widget (#runAgent) and Chat tab (#tabRunAgent) each get their own populated
124
130
  // <select>, since an id can't be shared by two elements; same option list, same source of truth.
125
131
  const runners=Object.keys((c.runners)||{claude:1});
@@ -131,11 +137,12 @@ function render(){
131
137
  const meterFill=$('#globalMeterFill');
132
138
  if(meterFill) meterFill.style.width=(s.pct||0)+'%';
133
139
  const meter=$('#globalMeter');
134
- if(meter) meter.title=`Global progress: ${s.pct}% (${s.done}/${s.total} tasks)`;
140
+ if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
135
141
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
136
142
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
137
143
  renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderSettings();
138
144
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
145
+ applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
139
146
  }
140
147
 
141
148
  // ---- Requests tab: tasks awaiting review/input (to_validate / to_analyze) ----
@@ -144,7 +151,7 @@ function renderRequests(){
144
151
  const toAsk=SpectoStats.stats(P).toAsk||[];
145
152
  const count=$('#requestsCount'); if(count) count.textContent=toAsk.length;
146
153
  list.innerHTML='';
147
- if(!toAsk.length){ list.append(li('empty','Nothing awaiting you.')); return; }
154
+ if(!toAsk.length){ list.append(li('empty',t('requests.empty'))); return; }
148
155
  toAsk.forEach(t=>{
149
156
  const row=el('li','request-row'); row.tabIndex=0;
150
157
  row.append(el('span','request-id',t.id));
@@ -159,20 +166,35 @@ function renderRequests(){
159
166
  }
160
167
 
161
168
  // ---- right sidebar: "Journal" (read-only runtime.messages feed) ----
169
+ // Capped to the 5 most recent entries by default — a long-running project's journal used to render
170
+ // its entire history inline and blow up the sidebar. "See more" reveals the rest for this session
171
+ // (client-side only; not persisted, so the rail opens compact again next visit).
172
+ const JOURNAL_PAGE=5;
173
+ let journalExpanded=false;
162
174
  function renderSidebar(){ renderJournal(); }
163
175
  function renderJournal(){
164
176
  const box=$('#journal'); if(!box) return;
165
177
  const msgs=((P.runtime&&P.runtime.messages)||[]).slice().reverse(); // reverse-chronological
166
178
  $('#journalCount').textContent=msgs.length;
167
179
  box.innerHTML='';
168
- if(!msgs.length){ box.append(el('div','empty','No activity yet.')); return; }
169
- msgs.forEach(m=>{
180
+ if(!msgs.length){ box.append(el('div','empty',t('journal.empty'))); journalToggleBtn(0,0); return; }
181
+ const shown = journalExpanded ? msgs : msgs.slice(0,JOURNAL_PAGE);
182
+ shown.forEach(m=>{
170
183
  const row=el('div','journal-row'+(m.role==='user'?' j-you':' k-'+(m.kind||'message')));
171
184
  row.append(el('div','journal-head', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
172
185
  row.append(el('div','journal-text', m.text));
173
186
  box.append(row);
174
187
  });
188
+ journalToggleBtn(msgs.length, shown.length);
175
189
  }
190
+ function journalToggleBtn(total,shownCount){
191
+ const btn=$('#journalMore'); if(!btn) return;
192
+ if(total<=JOURNAL_PAGE){ btn.hidden=true; return; }
193
+ btn.hidden=false;
194
+ btn.textContent = journalExpanded ? t('journal.seeLess') : t('journal.seeMore')+` (${total-shownCount})`;
195
+ }
196
+ const journalMoreBtn=$('#journalMore');
197
+ if(journalMoreBtn) journalMoreBtn.addEventListener('click',()=>{ journalExpanded=!journalExpanded; renderJournal(); });
176
198
 
177
199
  // ---- SVG helpers ----------------------------------------------------------
178
200
  // The chart *markup* (donut/ring/bars arcs & rows) is built by the pure,
@@ -230,20 +252,20 @@ function renderOverview(){
230
252
 
231
253
  // KPI row
232
254
  const kpis=el('div','kpi-row');
233
- kpis.append(kpiCard('Global progress', ring(s.pct,72), `${s.done}/${s.total} tasks`, cssv('--s-done')));
234
- kpis.append(kpiCard('In progress', numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), 'tasks', cssv('--s-in_progress')));
235
- kpis.append(kpiCard('To validate', numBlock(s.byStatus.to_validate||0,'var(--s-to_validate)'), 'awaiting review', cssv('--s-to_validate')));
255
+ kpis.append(kpiCard(t('kpi.globalProgress'), ring(s.pct,72), `${s.done}/${s.total} ${t('kpi.tasksLabel')}`, cssv('--s-done')));
256
+ kpis.append(kpiCard(t('status.in_progress'), numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), t('kpi.tasksLabel'), cssv('--s-in_progress')));
257
+ kpis.append(kpiCard(t('status.to_validate'), numBlock(s.byStatus.to_validate||0,'var(--s-to_validate)'), t('kpi.awaitingReview'), cssv('--s-to_validate')));
236
258
  const r=s.running||{};
237
- let runVal='—', runSub='no runs yet';
238
- if(r.agents>0){ runVal=`${r.agents} running`; runSub='agents active'; }
239
- else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub='orchestration'; }
240
- else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub='last: '+(r.lastRun.tool||'—'); }
241
- kpis.append(kpiCard('Running', numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub, cssv('--signal')));
259
+ let runVal='—', runSub=t('kpi.noRunsYet');
260
+ if(r.agents>0){ runVal=`${r.agents} ${t('kpi.running').toLowerCase()}`; runSub=t('kpi.agentsActive'); }
261
+ else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub=t('kpi.orchestration'); }
262
+ else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub=t('kpi.lastPrefix')+(r.lastRun.tool||'—'); }
263
+ kpis.append(kpiCard(t('kpi.running'), numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub, cssv('--signal')));
242
264
  box.append(kpis);
243
265
 
244
266
  // Status donut + legend
245
267
  const segments=s.statuses.map(k=>({key:k,value:s.byStatus[k]||0,color:cssv('--s-'+k)}));
246
- const d=donut(segments,140,{center:String(s.total),sub:'tasks'});
268
+ const d=donut(segments,140,{center:String(s.total),sub:t('chart.tasksSub')});
247
269
  const legend=el('div','legend');
248
270
  segments.forEach(seg=>{
249
271
  const item=el('div','legend-item');
@@ -260,19 +282,19 @@ function renderOverview(){
260
282
  if(hist.length===1){ hist=[{date:hist[0].date,total:0,done:0},hist[0]]; }
261
283
  const area=htmlBlock('area-wrap', hist.length
262
284
  ? SpectoCharts.area(
263
- [{name:'Scope',color:cssv('--cool'),data:hist.map(h=>h.total)},
264
- {name:'Delivered',color:cssv('--signal'),data:hist.map(h=>h.done)}],
285
+ [{name:t('chart.scope'),color:cssv('--cool'),data:hist.map(h=>h.total)},
286
+ {name:t('chart.delivered'),color:cssv('--signal'),data:hist.map(h=>h.done)}],
265
287
  hist.map(h=>(h.date||'').slice(5)))
266
- : '<div class="empty">No history yet.</div>');
288
+ : '<div class="empty">'+t('chart.noHistory')+'</div>');
267
289
  // enrich the area's hit-rect tooltips with the actual scope/delivered values
268
290
  // for that point (charts.js keeps them pure — just the date label)
269
291
  area.querySelectorAll('.area-hit').forEach((hit,i)=>{
270
292
  const h=hist[i]; if(!h) return;
271
- hit.dataset.tip = `<b>${h.date||''}</b><br>Scope: ${h.total||0} · Delivered: ${h.done||0}`;
293
+ hit.dataset.tip = `<b>${h.date||''}</b><br>${t('chart.scope')}: ${h.total||0} · ${t('chart.delivered')}: ${h.done||0}`;
272
294
  });
273
295
  const topRow=el('div','overview-top');
274
- topRow.append(ocard('Status distribution', donutRow));
275
- topRow.append(ocard('Scope vs delivered', area));
296
+ topRow.append(ocard(t('chart.statusDistribution'), donutRow));
297
+ topRow.append(ocard(t('chart.scopeVsDelivered'), area));
276
298
  box.append(topRow);
277
299
 
278
300
  // Workflow-at-a-glance strip (reuses the wf-arrow flow animation)
@@ -284,8 +306,8 @@ function renderOverview(){
284
306
  strip.append(node);
285
307
  if(i<steps.length-1){ const a=el('div','wf-arrow'+(st.enabled&&steps[i+1].enabled?'':' off')); strip.append(a); }
286
308
  });
287
- if(!steps.length) strip.append(el('div','empty','No workflow defined.'));
288
- box.append(ocard('Workflow at a glance', strip));
309
+ if(!steps.length) strip.append(el('div','empty',t('board.noWorkflow')));
310
+ box.append(ocard(t('board.workflowGlance'), strip));
289
311
 
290
312
  // Per-phase progress bars — only phases that actually hold tasks (headings with no checkbox tasks
291
313
  // are noise, not phases), and cap the list height with an internal scroll so a big project with
@@ -294,7 +316,7 @@ function renderOverview(){
294
316
  if(phaseRows.length){
295
317
  const barsEl=bars(phaseRows);
296
318
  if(phaseRows.length>8) barsEl.classList.add('scroll-cap');
297
- box.append(ocard(`Phase progress (${phaseRows.length})`, barsEl));
319
+ box.append(ocard(t('board.phaseProgress',{n:phaseRows.length}), barsEl));
298
320
  }
299
321
  }
300
322
 
@@ -313,12 +335,12 @@ function renderBoard(){
313
335
  updateFilterChips();
314
336
 
315
337
  const specs=$('#specs'); specs.innerHTML=''; $('#specsCount').textContent=(P.specs||[]).length;
316
- if(!(P.specs||[]).length) specs.append(li('empty','none yet'));
338
+ if(!(P.specs||[]).length) specs.append(li('empty',t('board.noneYet')));
317
339
  (P.specs||[]).forEach(s=> specs.append(li(null,s)));
318
340
 
319
341
  const running = (P.runtime&&P.runtime.agents||[]).filter(a=>a.status==='running');
320
342
  const rl=$('#running'); rl.innerHTML=''; $('#runCount').textContent=running.length;
321
- if(!running.length) rl.append(li('empty','no agent running'));
343
+ if(!running.length) rl.append(li('empty',t('board.noAgentRunning')));
322
344
  running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
323
345
 
324
346
  const board=$('#board'); board.innerHTML='';
@@ -357,8 +379,8 @@ function renderKanban(board, tasks){
357
379
  }
358
380
  function updateBoardViewToggle(){ $$('#boardViewToggle .vt-btn').forEach(b=> b.classList.toggle('active', b.dataset.view===boardView)); }
359
381
  function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
360
- function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.'; return d; }
361
- function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent='No tasks match this filter.'; return d; }
382
+ function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
383
+ function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
362
384
 
363
385
  // ---- Backlog tab: flat, sortable, filterable table of every task ----------
364
386
  // Rows are built from P.plans (not allTasks()) so each task carries its phase title.
@@ -408,21 +430,21 @@ function renderBacklog(){
408
430
  if(backlogPage>pages) backlogPage=pages;
409
431
  if(backlogPage<1) backlogPage=1;
410
432
  body.innerHTML='';
411
- if(!all.length){ body.append(backlogEmptyRow('No plans yet. Ask your agent to build something — it will run Intake and write plans/*.md.')); return renderBacklogPager(0,1); }
412
- if(!filtered.length){ body.append(backlogEmptyRow('No tasks match this filter.')); return renderBacklogPager(0,1); }
433
+ if(!all.length){ body.append(backlogEmptyRow(t('board.noPlans'))); return renderBacklogPager(0,1); }
434
+ if(!filtered.length){ body.append(backlogEmptyRow(t('board.noTasksMatch'))); return renderBacklogPager(0,1); }
413
435
  const start=(backlogPage-1)*BACKLOG_PAGE;
414
436
  filtered.slice(start,start+BACKLOG_PAGE).forEach(r=> body.append(backlogRow(r)));
415
437
  renderBacklogPager(filtered.length,pages);
416
438
  }
417
439
  function renderBacklogPager(total,pages){
418
440
  const pager=$('#backlogPager'); if(!pager) return; pager.innerHTML='';
419
- if(total<=BACKLOG_PAGE){ if(total) pager.append(el('span','pager-info',`${total} task${total>1?'s':''}`)); return; }
420
- const prev=el('button','pager-btn','‹ Prev'); prev.disabled=backlogPage<=1;
441
+ if(total<=BACKLOG_PAGE){ if(total) pager.append(el('span','pager-info', t(total>1?'backlog.pagerTasks':'backlog.pagerTask',{n:total}))); return; }
442
+ const prev=el('button','pager-btn',t('backlog.prev')); prev.disabled=backlogPage<=1;
421
443
  prev.addEventListener('click',()=>{ backlogPage--; renderBacklog(); });
422
- const next=el('button','pager-btn','Next ›'); next.disabled=backlogPage>=pages;
444
+ const next=el('button','pager-btn',t('backlog.next')); next.disabled=backlogPage>=pages;
423
445
  next.addEventListener('click',()=>{ backlogPage++; renderBacklog(); });
424
446
  const from=(backlogPage-1)*BACKLOG_PAGE+1, to=Math.min(total,backlogPage*BACKLOG_PAGE);
425
- pager.append(prev, el('span','pager-info',`${from}–${to} of ${total} · page ${backlogPage}/${pages}`), next);
447
+ pager.append(prev, el('span','pager-info',t('backlog.pagerRange',{from,to,total,page:backlogPage,pages})), next);
426
448
  }
427
449
  function backlogEmptyRow(txt){
428
450
  const tr=el('tr','backlog-empty-row'); const td=el('td',null,txt); td.colSpan=7; tr.append(td); return tr;
@@ -451,12 +473,12 @@ function loadExpanded(){
451
473
  }
452
474
  function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
453
475
  let expandedPhases=loadExpanded();
454
- function allPhaseTitles(){ const t=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> t.add(ph.title))); return [...t]; }
476
+ function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
455
477
  function updatePhaseToggleAll(){
456
478
  const btn=$('#phaseToggleAll'); if(!btn) return;
457
479
  const titles=allPhaseTitles();
458
- const allOpen = titles.length>0 && titles.every(t=> expandedPhases.has(t));
459
- btn.textContent = allOpen ? 'Collapse all' : 'Expand all';
480
+ const allOpen = titles.length>0 && titles.every(x=> expandedPhases.has(x));
481
+ btn.textContent = allOpen ? t('board.collapseAll') : t('board.expandAll');
460
482
  btn.dataset.state = allOpen ? 'open' : 'closed';
461
483
  }
462
484
 
@@ -515,17 +537,17 @@ function renderTask(t){
515
537
  // detail zone teaches the user what the pipeline is, not just that a step exists.
516
538
  function wfDesc(s){
517
539
  const n=String(s.name||'').toLowerCase();
518
- 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.';
519
- 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.';
520
- 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.';
521
- 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.';
522
- 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.';
523
- if(/integration/.test(n)) return 'Check that the pieces work together — modules, services and data paths across component boundaries, not just in isolation.';
524
- 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.';
525
- if(/unit/.test(n)||/\btest/.test(n)) return 'Verify each unit in isolation — fast, focused tests that lock in behaviour and catch regressions early.';
526
- if(/review/.test(n)) return 'A final quality pass — correctness, security and standards — before the work is considered done.';
527
- if(/deploy|ship|release|publish/.test(n)) return 'Release the delivered work — ship it to its target environment.';
528
- return 'A step in the delivery pipeline.';
540
+ if(/brainstorm|idea|intake/.test(n)) return t('wf.desc.brainstorm');
541
+ if(/analy/.test(n)) return t('wf.desc.analysis');
542
+ if(/spec/.test(n)) return t('wf.desc.spec');
543
+ if(/plan/.test(n)) return t('wf.desc.plan');
544
+ if(/develop|implement|\bcode\b/.test(n)) return t('wf.desc.develop');
545
+ if(/integration/.test(n)) return t('wf.desc.integration');
546
+ if(/end.?to.?end|e2e/.test(n)) return t('wf.desc.e2e');
547
+ if(/unit/.test(n)||/\btest/.test(n)) return t('wf.desc.unit');
548
+ if(/review/.test(n)) return t('wf.desc.review');
549
+ if(/deploy|ship|release|publish/.test(n)) return t('wf.desc.deploy');
550
+ return t('wf.desc.fallback');
529
551
  }
530
552
  let wfPopStep=null; // name of the step whose click-popover is open (null = closed)
531
553
 
@@ -536,11 +558,11 @@ let wfPopStep=null; // name of the step whose click-popover is open (null = clos
536
558
  function renderWorkflow(){
537
559
  const box=$('#wfDiagram'); box.innerHTML='';
538
560
  const steps=P.workflow||[];
539
- if(!steps.length){ box.append(el('div','empty','No workflow defined.')); closeWfPop(); return; }
561
+ if(!steps.length){ box.append(el('div','empty',t('board.noWorkflow'))); closeWfPop(); return; }
540
562
  const enabledCount=steps.filter(s=>s.enabled).length;
541
563
  const legend=el('div','wf-legend');
542
564
  legend.append(el('span','wf-legend-dot'));
543
- legend.append(el('span','wf-legend-txt',`${enabledCount} of ${steps.length} steps enabled — click a step to see what it does`));
565
+ legend.append(el('span','wf-legend-txt',t('workflow.stepsEnabled',{enabled:enabledCount,total:steps.length})));
544
566
  box.append(legend);
545
567
  const pipe=el('div','wf-pipeline');
546
568
  steps.forEach((s,i)=>{
@@ -551,7 +573,7 @@ function renderWorkflow(){
551
573
  circle.innerHTML=(typeof ICON!=='undefined'&&ICON.wf)?ICON.wf(s.name):'';
552
574
  step.append(circle);
553
575
  const cap=el('div','wf-caption'); cap.append(document.createTextNode(s.name));
554
- if(s.optional) cap.append(el('span','wf-opt2','optional'));
576
+ if(s.optional) cap.append(el('span','wf-opt2',t('workflow.optional')));
555
577
  step.append(cap);
556
578
  const sel=(e)=>{ if(e) e.stopPropagation(); if(wfPopStep===s.name) closeWfPop(); else openWfPop(s.name); };
557
579
  step.addEventListener('click',sel);
@@ -570,19 +592,19 @@ function wfPopFill(pop, s, idx){
570
592
  const head=el('div','wf-detail-head');
571
593
  head.append(el('span','wf-detail-num',String(idx+1)));
572
594
  head.append(el('span','wf-detail-name',s.name));
573
- head.append(el('span','wf-detail-status '+(s.enabled?'on':'off'), s.enabled?'enabled':'disabled'));
574
- if(s.optional) head.append(el('span','wf-opt','optional'));
595
+ head.append(el('span','wf-detail-status '+(s.enabled?'on':'off'), s.enabled?t('workflow.enabled'):t('workflow.disabled')));
596
+ if(s.optional) head.append(el('span','wf-opt',t('workflow.optional')));
575
597
  pop.append(head);
576
598
  pop.append(el('p','wf-detail-desc', wfDesc(s)));
577
599
  const grid=el('div','wf-detail-grid');
578
- grid.append(wfDetailRow('Capability', s.cap||'—'));
579
- grid.append(wfDetailRow('Handled by', agent?(agent.title||agent.name):'—'));
580
- grid.append(wfDetailRow('Skill', s.skill||'—'));
581
- if(skill&&skill.standard) grid.append(wfDetailRow('Standard', skill.standard));
582
- if(skill&&(skill.inputs||skill.outputs)) grid.append(wfDetailRow('Flow', (skill.inputs||'—')+' → '+(skill.outputs||'—')));
600
+ grid.append(wfDetailRow(t('workflow.capability'), s.cap||'—'));
601
+ grid.append(wfDetailRow(t('workflow.handledBy'), agent?(agent.title||agent.name):'—'));
602
+ grid.append(wfDetailRow(t('workflow.skill'), s.skill||'—'));
603
+ if(skill&&skill.standard) grid.append(wfDetailRow(t('workflow.standard'), skill.standard));
604
+ if(skill&&(skill.inputs||skill.outputs)) grid.append(wfDetailRow(t('workflow.flow'), (skill.inputs||'—')+' → '+(skill.outputs||'—')));
583
605
  pop.append(grid);
584
- 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); }
585
- const btn=el('button','btn '+(s.enabled?'':'primary')+' wf-detail-btn', s.enabled?'Disable step':'Enable step');
606
+ if(skill&&skill.description){ const sk=el('div','wf-detail-skill'); sk.append(el('b',null,t('workflow.skillPrefix',{name:skill.name}))); sk.append(document.createTextNode(skill.description)); pop.append(sk); }
607
+ const btn=el('button','btn '+(s.enabled?'':'primary')+' wf-detail-btn', s.enabled?t('workflow.disableStep'):t('workflow.enableStep'));
586
608
  btn.addEventListener('click',(e)=>{ e.stopPropagation(); toggleStep(s.name); });
587
609
  const actions=el('div','wf-pop-actions'); actions.append(btn); pop.append(actions);
588
610
  }
@@ -629,27 +651,27 @@ function renderAttention(){
629
651
  $$('.attn-filters .fchip').forEach(b=> b.classList.toggle('active', b.dataset.attn===attnFilter));
630
652
  const shown=items.filter(i=> attnFilter==='all' ? true : attnFilter==='resolved' ? i.status==='resolved' : i.status!=='resolved');
631
653
  list.innerHTML='';
632
- if(!shown.length){ list.append(el('div','empty', attnFilter==='resolved'?'Nothing resolved yet.':'No points of attention. The agent surfaces them here as it works — or add your own note above.')); return; }
654
+ if(!shown.length){ list.append(el('div','empty', attnFilter==='resolved'?t('attn.emptyResolved'):t('attn.emptyOpen'))); return; }
633
655
  shown.forEach(it=> list.append(attnRow(it)));
634
656
  }
635
657
  function attnRow(it){
636
658
  const row=el('div','attn-row'+(it.status==='resolved'?' is-resolved':'')+(it.source==='agent'?' from-agent':''));
637
659
  const head=el('div','attn-head');
638
- head.append(el('span','attn-src '+(it.source==='agent'?'is-agent':'is-user'), it.source==='agent'?('⚑ '+(it.by||'agent')):'✎ you'));
660
+ head.append(el('span','attn-src '+(it.source==='agent'?'is-agent':'is-user'), it.source==='agent'?('⚑ '+(it.by||t('attn.agentFallback'))):('✎ '+t('attn.you'))));
639
661
  if(it.at) head.append(el('span','attn-time',(String(it.at).replace('T',' ')).slice(0,16)));
640
- if(it.status==='resolved') head.append(el('span','chip s-done', it.promotedTo?('→ '+it.promotedTo):'resolved'));
662
+ if(it.status==='resolved') head.append(el('span','chip s-done', it.promotedTo?('→ '+it.promotedTo):t('attn.resolvedChip')));
641
663
  row.append(head);
642
664
  const txt=el('div','attn-text', it.text); row.append(txt);
643
665
  const acts=el('div','attn-actions');
644
666
  if(it.status!=='resolved'){
645
- const val=el('button','btn primary','Validate → task'); val.addEventListener('click',()=>promoteAttn(it.id));
646
- const res=el('button','btn','Resolve'); res.addEventListener('click',()=>patchAttn(it.id,{status:'resolved'}));
647
- const edit=el('button','btn','Edit'); edit.addEventListener('click',()=>editAttn(it,txt));
667
+ const val=el('button','btn primary',t('action.validateToTask')); val.addEventListener('click',()=>promoteAttn(it.id));
668
+ const res=el('button','btn',t('action.resolve')); res.addEventListener('click',()=>patchAttn(it.id,{status:'resolved'}));
669
+ const edit=el('button','btn',t('action.edit')); edit.addEventListener('click',()=>editAttn(it,txt));
648
670
  acts.append(val,res,edit);
649
671
  }else{
650
- const re=el('button','btn','Reopen'); re.addEventListener('click',()=>patchAttn(it.id,{status:'open'})); acts.append(re);
672
+ const re=el('button','btn',t('action.reopen')); re.addEventListener('click',()=>patchAttn(it.id,{status:'open'})); acts.append(re);
651
673
  }
652
- const del=el('button','btn danger','Delete'); del.addEventListener('click',()=>deleteAttn(it.id));
674
+ const del=el('button','btn danger',t('action.delete')); del.addEventListener('click',()=>deleteAttn(it.id));
653
675
  acts.append(del); row.append(acts);
654
676
  return row;
655
677
  }
@@ -665,12 +687,19 @@ async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encod
665
687
  async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
666
688
  async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
667
689
 
668
- // ---- Settings tab: change autonomy mode + output language (writes config.json) ----
669
- function setLangSelect(lang){
670
- const sel=$('#setLang'); if(!sel) return;
690
+ // ---- Settings tab + topbar quick-switch: change autonomy mode + output language (writes config.json) ----
691
+ // Mode/language can be changed from two places — the Settings tab (#setMode/#setLang) and the
692
+ // always-visible topbar selects (#topMode/#topLang, in .brand-sub, present in every design). Both
693
+ // pairs are kept in sync: renderSettings() (every render tick) writes the current config into all
694
+ // four; a change on any one immediately mirrors into its sibling before saving, so saveSettings()
695
+ // never reads a stale value from the pair the user didn't touch.
696
+ function fillLangSelect(sel,lang){
697
+ if(!sel) return;
671
698
  if(![...sel.options].some(o=>o.value===lang)){ const o=document.createElement('option'); o.value=lang; o.textContent=lang; sel.append(o); }
672
699
  sel.value=lang;
673
700
  }
701
+ function setLangSelect(lang){ fillLangSelect($('#setLang'),lang); fillLangSelect($('#topLang'),lang); }
702
+ function setModeSelects(mode){ [$('#setMode'),$('#topMode')].forEach(s=>{ if(s) s.value=mode; }); }
674
703
  // ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
675
704
  function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
676
705
  function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
@@ -678,7 +707,7 @@ async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token
678
707
 
679
708
  function renderSettings(){
680
709
  const c=(P&&P.config)||{};
681
- if($('#setMode')) $('#setMode').value=c.mode||'semi';
710
+ setModeSelects(c.mode||'semi');
682
711
  setLangSelect(c.language||'en');
683
712
  // design switcher — options from the DESIGNS registry (designs.js)
684
713
  const dsel=$('#setDesign');
@@ -694,15 +723,16 @@ function renderSettings(){
694
723
  const box=$('#settingsReadonly');
695
724
  if(box){
696
725
  box.innerHTML='';
697
- const rows=[['Active agent',c.agent||'—'],['Project type',c.projectType||'—'],
698
- ['Plans folder',c.plansDir||'plans'],['Specs folder',c.specsDir||'specs'],
699
- ['Framework version', P&&P.version?('v'+P.version):'—']];
726
+ const rows=[[t('info.activeAgent'),c.agent||'—'],[t('info.projectType'),c.projectType||'—'],
727
+ [t('field.plansFolder'),c.plansDir||'plans'],[t('field.specsFolder'),c.specsDir||'specs'],
728
+ [t('field.frameworkVersion'), P&&P.version?('v'+P.version):'—']];
700
729
  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); });
701
730
  }
702
731
  const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
703
732
  }
704
733
  async function saveSettings(){
705
- flash(); const mode=$('#setMode').value, language=$('#setLang').value;
734
+ flash();
735
+ const mode=($('#setMode')||$('#topMode')).value, language=($('#setLang')||$('#topLang')).value;
706
736
  await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
707
737
  const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
708
738
  }
@@ -731,8 +761,8 @@ function agentCard(a){
731
761
  const c=el('div','card'); c.tabIndex=0;
732
762
  c.append(el('div','ct',a.title||a.name));
733
763
  if(a.capability) c.append(el('div','cc',a.capability));
734
- const std=chipRow('standards',a.standards); if(std) c.append(std);
735
- const uses=chipRow('uses',a.uses); if(uses) c.append(uses);
764
+ const std=chipRow(t('team.standardsLabel'),a.standards); if(std) c.append(std);
765
+ const uses=chipRow(t('team.usesLabel'),a.uses); if(uses) c.append(uses);
736
766
  if(a.description) c.append(el('div','cd',a.description));
737
767
  const open=()=>openFileDrawer('agent',a);
738
768
  c.addEventListener('click',open);
@@ -743,7 +773,7 @@ function skillCard(s){
743
773
  const c=el('div','card'); c.tabIndex=0;
744
774
  c.append(el('div','ct',s.name));
745
775
  if(s.capability) c.append(el('div','cc',s.capability));
746
- const std=chipRow('standard', s.standard?[s.standard]:null); if(std) c.append(std);
776
+ const std=chipRow(t('team.standardLabel'), s.standard?[s.standard]:null); if(std) c.append(std);
747
777
  if(s.inputs||s.outputs) c.append(el('div','io', (s.inputs||'—')+' → '+(s.outputs||'—')));
748
778
  if(s.description) c.append(el('div','cd',s.description));
749
779
  const open=()=>openFileDrawer('skill',s);
@@ -803,19 +833,19 @@ async function openFileDrawer(kind,obj){
803
833
  openTaskId=null;
804
834
  const rel = kind==='agent' ? ('agents/'+(obj.file||(obj.name+'.md'))) : ('skills/'+obj.name+'/SKILL.md');
805
835
  const b=$('#drawerBody'); b.innerHTML='';
806
- b.append(el('div','d-id', kind==='agent'?'Agent':'Skill'));
836
+ b.append(el('div','d-id', kind==='agent'?t('drawer.agent'):t('drawer.skill')));
807
837
  b.append(el('div','d-title', obj.title||obj.name));
808
- const sec=el('div','d-section'); sec.append(el('div','d-label','File · '+rel));
809
- const body=el('div','md-body'); body.append(el('div','empty','Loading…'));
838
+ const sec=el('div','d-section'); sec.append(el('div','d-label',t('drawer.file',{rel})));
839
+ const body=el('div','md-body'); body.append(el('div','empty',t('drawer.loading')));
810
840
  sec.append(body); b.append(sec);
811
841
  $('#drawer').setAttribute('aria-hidden','false');
812
842
  try{
813
843
  const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
814
844
  const data=await r.json().catch(()=>({}));
815
- if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||'Could not load this file.')); return; }
845
+ if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||t('drawer.loadError'))); return; }
816
846
  body.innerHTML=mdLite(data.content||'');
817
847
  }catch(err){
818
- body.innerHTML=''; body.append(el('div','empty','Could not load this file.'));
848
+ body.innerHTML=''; body.append(el('div','empty',t('drawer.loadError')));
819
849
  }
820
850
  }
821
851
 
@@ -836,11 +866,11 @@ function infoRow(label,value){
836
866
  return row;
837
867
  }
838
868
  function statTile(value,label,sub){
839
- const t=el('div','stat-tile');
840
- t.append(el('div','stat-tile-val',String(value)));
841
- t.append(el('div','stat-tile-label',label));
842
- if(sub) t.append(el('div','stat-tile-sub',sub));
843
- return t;
869
+ const tile=el('div','stat-tile');
870
+ tile.append(el('div','stat-tile-val',String(value)));
871
+ tile.append(el('div','stat-tile-label',label));
872
+ if(sub) tile.append(el('div','stat-tile-sub',sub));
873
+ return tile;
844
874
  }
845
875
  function renderInfo(){
846
876
  const box=$('#infoGrid'); if(!box) return;
@@ -852,39 +882,39 @@ function renderInfo(){
852
882
 
853
883
  // Project: mode/language/agent/type from config
854
884
  const projRows=el('div','info-rows');
855
- projRows.append(infoRow('Project type', c.projectType||'—'));
856
- projRows.append(infoRow('Mode', c.mode||'—'));
857
- projRows.append(infoRow('Language', c.language||'—'));
858
- projRows.append(infoRow('Active agent', c.agent||'—'));
859
- box.append(infoSection('Project','info',projRows));
885
+ projRows.append(infoRow(t('info.projectType'), c.projectType||'—'));
886
+ projRows.append(infoRow(t('info.mode'), c.mode||'—'));
887
+ projRows.append(infoRow(t('info.language'), c.language||'—'));
888
+ projRows.append(infoRow(t('info.activeAgent'), c.agent||'—'));
889
+ box.append(infoSection(t('info.project'),'info',projRows));
860
890
 
861
891
  // Runners: agent → command, monospace
862
892
  const runners=c.runners||{};
863
893
  const runnerKeys=Object.keys(runners);
864
894
  const runnerRows=el('div','info-rows info-rows-mono');
865
- if(!runnerKeys.length) runnerRows.append(el('div','empty','No runners configured.'));
895
+ if(!runnerKeys.length) runnerRows.append(el('div','empty',t('info.noRunners')));
866
896
  runnerKeys.forEach(k=> runnerRows.append(infoRow(k, runners[k])));
867
- box.append(infoSection('Runners','run',runnerRows));
897
+ box.append(infoSection(t('info.runners'),'run',runnerRows));
868
898
 
869
899
  // Counts: tasks/specs/agents/skills/enabled workflow steps
870
900
  const tiles=el('div','stat-tiles');
871
- tiles.append(statTile(`${s.done}/${s.total}`,'Tasks',`${s.pct}% done`));
872
- tiles.append(statTile(String((P.specs||[]).length),'Specs','files'));
873
- tiles.append(statTile(String((P.agents||[]).length),'Agents','personas'));
874
- tiles.append(statTile(String((P.skills||[]).length),'Skills','procedures'));
875
- tiles.append(statTile(`${enabledSteps.length}/${steps.length}`,'Workflow','steps enabled'));
876
- box.append(infoSection('Counts','board',tiles));
901
+ tiles.append(statTile(`${s.done}/${s.total}`,t('info.tasksLabel'),t('info.doneSub',{pct:s.pct})));
902
+ tiles.append(statTile(String((P.specs||[]).length),t('info.specsLabel'),t('info.filesSub')));
903
+ tiles.append(statTile(String((P.agents||[]).length),t('info.agentsLabel'),t('info.personasSub')));
904
+ tiles.append(statTile(String((P.skills||[]).length),t('info.skillsLabel'),t('info.proceduresSub')));
905
+ tiles.append(statTile(`${enabledSteps.length}/${steps.length}`,t('info.workflowLabel'),t('info.stepsEnabledSub')));
906
+ box.append(infoSection(t('info.counts'),'board',tiles));
877
907
 
878
908
  // Specs: the P.specs filename list
879
909
  const specsList=el('ul','flatlist');
880
910
  const specs=P.specs||[];
881
- if(!specs.length) specsList.append(li('empty','none yet'));
911
+ if(!specs.length) specsList.append(li('empty',t('board.noneYet')));
882
912
  specs.forEach(sp=> specsList.append(li(null,sp)));
883
- box.append(infoSection('Specs','backlog',specsList));
913
+ box.append(infoSection(t('info.specsLabel'),'backlog',specsList));
884
914
 
885
915
  // Workflow: compact list of enabled steps (name + cap/skill)
886
916
  const wfList=el('div','info-wf-list');
887
- if(!enabledSteps.length) wfList.append(el('div','empty','No enabled workflow steps.'));
917
+ if(!enabledSteps.length) wfList.append(el('div','empty',t('info.noEnabledSteps')));
888
918
  enabledSteps.forEach(st=>{
889
919
  const row=el('div','info-wf-row');
890
920
  row.append(el('span','info-wf-name',st.name));
@@ -892,42 +922,44 @@ function renderInfo(){
892
922
  if(meta) row.append(el('span','info-wf-meta',meta));
893
923
  wfList.append(row);
894
924
  });
895
- box.append(infoSection('Workflow','workflow',wfList));
925
+ box.append(infoSection(t('info.workflowLabel'),'workflow',wfList));
896
926
  }
897
927
 
898
928
  function openDrawer(id,keep){
899
- const t=allTasks().find(x=>x.id===id); if(!t) return;
929
+ // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
930
+ // function calls it repeatedly below; shadowing it with a task variable would break every call.
931
+ const task=allTasks().find(x=>x.id===id); if(!task) return;
900
932
  openTaskId=id;
901
933
  if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
902
934
  const b=$('#drawerBody'); const prev=keep?$('.drawer-panel').scrollTop:0; b.innerHTML='';
903
- b.append(el('div','d-id',t.id+' · '+(t.level||'standard')+' · '+t.file));
904
- b.append(el('div','d-title',t.title));
905
- const sSec=el('div','d-section'); sSec.append(el('div','d-label','Status'));
935
+ b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
936
+ b.append(el('div','d-title',task.title));
937
+ const sSec=el('div','d-section'); sSec.append(el('div','d-label',t('task.status')));
906
938
  const sr=el('div','status-row');
907
939
  Object.keys(STATUS).forEach(k=>{
908
- const btn=el('button','status-btn'+(t.status===k?' active':''),STATUS[k]);
909
- if(t.status===k) btn.style.background=cssv('--s-'+k);
940
+ const btn=el('button','status-btn'+(task.status===k?' active':''),STATUS[k]);
941
+ if(task.status===k) btn.style.background=cssv('--s-'+k);
910
942
  btn.addEventListener('click',()=>patchTask(id,{status:k}));
911
943
  sr.append(btn);
912
944
  });
913
945
  sSec.append(sr); b.append(sSec);
914
946
 
915
947
  const tr=runtimeTests(id);
916
- if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label','Tests'));
917
- ts.append(el('div','d-text', tr.failed?`${tr.failed} failing, ${tr.passed||0} passing`:`${tr.passed||0} passing`)); b.append(ts); }
948
+ if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label',t('task.tests')));
949
+ ts.append(el('div','d-text', tr.failed?t('task.failingPassing',{f:tr.failed,p:tr.passed||0}):t('task.passing',{n:tr.passed||0}))); b.append(ts); }
918
950
 
919
- const cSec=el('div','d-section'); cSec.append(el('div','d-label','Comments'));
951
+ const cSec=el('div','d-section'); cSec.append(el('div','d-label',t('task.comments')));
920
952
  const list=el('div','comments');
921
- (t.comments||[]).forEach(cm=> list.append(el('div','comment',cm)));
922
- if(!(t.comments||[]).length) list.append(el('div','empty','No comments.'));
953
+ (task.comments||[]).forEach(cm=> list.append(el('div','comment',cm)));
954
+ if(!(task.comments||[]).length) list.append(el('div','empty',t('task.noComments')));
923
955
  cSec.append(list);
924
- const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder='Add a comment, a remark, feedback…';
956
+ const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder=t('task.addCommentPlaceholder');
925
957
  const actions=el('div','c-actions');
926
- const add=el('button','btn','Add'); const an=el('button','btn primary','Add + to analyze');
958
+ const add=el('button','btn',t('action.add')); const an=el('button','btn primary',t('action.addToAnalyze'));
927
959
  add.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'note'); });
928
960
  an.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'analyze'); });
929
961
  actions.append(add,an); box.append(ta,actions);
930
- box.append(el('div','empty','"To analyze" moves the task back so the agent picks it up next round.'));
962
+ box.append(el('div','empty',t('task.toAnalyzeHint')));
931
963
  cSec.append(box); b.append(cSec);
932
964
  $('#drawer').setAttribute('aria-hidden','false');
933
965
  if(keep) $('.drawer-panel').scrollTop=prev;
@@ -994,8 +1026,12 @@ $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilt
994
1026
  // settings — the footer link opens the Settings tab; selects save on change
995
1027
  const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
996
1028
  const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
997
- $('#setMode').addEventListener('change',saveSettings);
998
- $('#setLang').addEventListener('change',saveSettings);
1029
+ function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
1030
+ function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }
1031
+ $('#setMode').addEventListener('change',onModeSelectChange);
1032
+ $('#setLang').addEventListener('change',onLangSelectChange);
1033
+ const topModeSel=$('#topMode'); if(topModeSel) topModeSel.addEventListener('change',onModeSelectChange);
1034
+ const topLangSel=$('#topLang'); if(topLangSel) topLangSel.addEventListener('change',onLangSelectChange);
999
1035
  const setDesignSel=$('#setDesign'); if(setDesignSel) setDesignSel.addEventListener('change',()=>saveDesign(setDesignSel.value));
1000
1036
  // theme
1001
1037
  (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);