spectoflow 0.17.1 → 0.18.0

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,13 @@ 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();
144
+ renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
138
145
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
146
+ applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
139
147
  }
140
148
 
141
149
  // ---- Requests tab: tasks awaiting review/input (to_validate / to_analyze) ----
@@ -144,7 +152,7 @@ function renderRequests(){
144
152
  const toAsk=SpectoStats.stats(P).toAsk||[];
145
153
  const count=$('#requestsCount'); if(count) count.textContent=toAsk.length;
146
154
  list.innerHTML='';
147
- if(!toAsk.length){ list.append(li('empty','Nothing awaiting you.')); return; }
155
+ if(!toAsk.length){ list.append(li('empty',t('requests.empty'))); return; }
148
156
  toAsk.forEach(t=>{
149
157
  const row=el('li','request-row'); row.tabIndex=0;
150
158
  row.append(el('span','request-id',t.id));
@@ -159,20 +167,35 @@ function renderRequests(){
159
167
  }
160
168
 
161
169
  // ---- right sidebar: "Journal" (read-only runtime.messages feed) ----
170
+ // Capped to the 5 most recent entries by default — a long-running project's journal used to render
171
+ // its entire history inline and blow up the sidebar. "See more" reveals the rest for this session
172
+ // (client-side only; not persisted, so the rail opens compact again next visit).
173
+ const JOURNAL_PAGE=5;
174
+ let journalExpanded=false;
162
175
  function renderSidebar(){ renderJournal(); }
163
176
  function renderJournal(){
164
177
  const box=$('#journal'); if(!box) return;
165
178
  const msgs=((P.runtime&&P.runtime.messages)||[]).slice().reverse(); // reverse-chronological
166
179
  $('#journalCount').textContent=msgs.length;
167
180
  box.innerHTML='';
168
- if(!msgs.length){ box.append(el('div','empty','No activity yet.')); return; }
169
- msgs.forEach(m=>{
181
+ if(!msgs.length){ box.append(el('div','empty',t('journal.empty'))); journalToggleBtn(0,0); return; }
182
+ const shown = journalExpanded ? msgs : msgs.slice(0,JOURNAL_PAGE);
183
+ shown.forEach(m=>{
170
184
  const row=el('div','journal-row'+(m.role==='user'?' j-you':' k-'+(m.kind||'message')));
171
185
  row.append(el('div','journal-head', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
172
186
  row.append(el('div','journal-text', m.text));
173
187
  box.append(row);
174
188
  });
189
+ journalToggleBtn(msgs.length, shown.length);
175
190
  }
191
+ function journalToggleBtn(total,shownCount){
192
+ const btn=$('#journalMore'); if(!btn) return;
193
+ if(total<=JOURNAL_PAGE){ btn.hidden=true; return; }
194
+ btn.hidden=false;
195
+ btn.textContent = journalExpanded ? t('journal.seeLess') : t('journal.seeMore')+` (${total-shownCount})`;
196
+ }
197
+ const journalMoreBtn=$('#journalMore');
198
+ if(journalMoreBtn) journalMoreBtn.addEventListener('click',()=>{ journalExpanded=!journalExpanded; renderJournal(); });
176
199
 
177
200
  // ---- SVG helpers ----------------------------------------------------------
178
201
  // The chart *markup* (donut/ring/bars arcs & rows) is built by the pure,
@@ -230,20 +253,20 @@ function renderOverview(){
230
253
 
231
254
  // KPI row
232
255
  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')));
256
+ kpis.append(kpiCard(t('kpi.globalProgress'), ring(s.pct,72), `${s.done}/${s.total} ${t('kpi.tasksLabel')}`, cssv('--s-done')));
257
+ kpis.append(kpiCard(t('status.in_progress'), numBlock(s.byStatus.in_progress||0,'var(--s-in_progress)'), t('kpi.tasksLabel'), cssv('--s-in_progress')));
258
+ 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
259
  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')));
260
+ let runVal='—', runSub=t('kpi.noRunsYet');
261
+ if(r.agents>0){ runVal=`${r.agents} ${t('kpi.running').toLowerCase()}`; runSub=t('kpi.agentsActive'); }
262
+ else if(r.orchestration&&r.orchestration.status){ runVal=r.orchestration.status; runSub=t('kpi.orchestration'); }
263
+ else if(r.lastRun){ runVal=r.lastRun.status||'—'; runSub=t('kpi.lastPrefix')+(r.lastRun.tool||'—'); }
264
+ kpis.append(kpiCard(t('kpi.running'), numBlock(runVal, r.agents>0?'var(--signal)':'var(--muted)', true), runSub, cssv('--signal')));
242
265
  box.append(kpis);
243
266
 
244
267
  // Status donut + legend
245
268
  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'});
269
+ const d=donut(segments,140,{center:String(s.total),sub:t('chart.tasksSub')});
247
270
  const legend=el('div','legend');
248
271
  segments.forEach(seg=>{
249
272
  const item=el('div','legend-item');
@@ -260,19 +283,19 @@ function renderOverview(){
260
283
  if(hist.length===1){ hist=[{date:hist[0].date,total:0,done:0},hist[0]]; }
261
284
  const area=htmlBlock('area-wrap', hist.length
262
285
  ? 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)}],
286
+ [{name:t('chart.scope'),color:cssv('--cool'),data:hist.map(h=>h.total)},
287
+ {name:t('chart.delivered'),color:cssv('--signal'),data:hist.map(h=>h.done)}],
265
288
  hist.map(h=>(h.date||'').slice(5)))
266
- : '<div class="empty">No history yet.</div>');
289
+ : '<div class="empty">'+t('chart.noHistory')+'</div>');
267
290
  // enrich the area's hit-rect tooltips with the actual scope/delivered values
268
291
  // for that point (charts.js keeps them pure — just the date label)
269
292
  area.querySelectorAll('.area-hit').forEach((hit,i)=>{
270
293
  const h=hist[i]; if(!h) return;
271
- hit.dataset.tip = `<b>${h.date||''}</b><br>Scope: ${h.total||0} · Delivered: ${h.done||0}`;
294
+ hit.dataset.tip = `<b>${h.date||''}</b><br>${t('chart.scope')}: ${h.total||0} · ${t('chart.delivered')}: ${h.done||0}`;
272
295
  });
273
296
  const topRow=el('div','overview-top');
274
- topRow.append(ocard('Status distribution', donutRow));
275
- topRow.append(ocard('Scope vs delivered', area));
297
+ topRow.append(ocard(t('chart.statusDistribution'), donutRow));
298
+ topRow.append(ocard(t('chart.scopeVsDelivered'), area));
276
299
  box.append(topRow);
277
300
 
278
301
  // Workflow-at-a-glance strip (reuses the wf-arrow flow animation)
@@ -284,8 +307,8 @@ function renderOverview(){
284
307
  strip.append(node);
285
308
  if(i<steps.length-1){ const a=el('div','wf-arrow'+(st.enabled&&steps[i+1].enabled?'':' off')); strip.append(a); }
286
309
  });
287
- if(!steps.length) strip.append(el('div','empty','No workflow defined.'));
288
- box.append(ocard('Workflow at a glance', strip));
310
+ if(!steps.length) strip.append(el('div','empty',t('board.noWorkflow')));
311
+ box.append(ocard(t('board.workflowGlance'), strip));
289
312
 
290
313
  // Per-phase progress bars — only phases that actually hold tasks (headings with no checkbox tasks
291
314
  // are noise, not phases), and cap the list height with an internal scroll so a big project with
@@ -294,7 +317,7 @@ function renderOverview(){
294
317
  if(phaseRows.length){
295
318
  const barsEl=bars(phaseRows);
296
319
  if(phaseRows.length>8) barsEl.classList.add('scroll-cap');
297
- box.append(ocard(`Phase progress (${phaseRows.length})`, barsEl));
320
+ box.append(ocard(t('board.phaseProgress',{n:phaseRows.length}), barsEl));
298
321
  }
299
322
  }
300
323
 
@@ -313,12 +336,12 @@ function renderBoard(){
313
336
  updateFilterChips();
314
337
 
315
338
  const specs=$('#specs'); specs.innerHTML=''; $('#specsCount').textContent=(P.specs||[]).length;
316
- if(!(P.specs||[]).length) specs.append(li('empty','none yet'));
339
+ if(!(P.specs||[]).length) specs.append(li('empty',t('board.noneYet')));
317
340
  (P.specs||[]).forEach(s=> specs.append(li(null,s)));
318
341
 
319
342
  const running = (P.runtime&&P.runtime.agents||[]).filter(a=>a.status==='running');
320
343
  const rl=$('#running'); rl.innerHTML=''; $('#runCount').textContent=running.length;
321
- if(!running.length) rl.append(li('empty','no agent running'));
344
+ if(!running.length) rl.append(li('empty',t('board.noAgentRunning')));
322
345
  running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
323
346
 
324
347
  const board=$('#board'); board.innerHTML='';
@@ -357,8 +380,8 @@ function renderKanban(board, tasks){
357
380
  }
358
381
  function updateBoardViewToggle(){ $$('#boardViewToggle .vt-btn').forEach(b=> b.classList.toggle('active', b.dataset.view===boardView)); }
359
382
  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; }
383
+ function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
384
+ function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
362
385
 
363
386
  // ---- Backlog tab: flat, sortable, filterable table of every task ----------
364
387
  // Rows are built from P.plans (not allTasks()) so each task carries its phase title.
@@ -408,21 +431,21 @@ function renderBacklog(){
408
431
  if(backlogPage>pages) backlogPage=pages;
409
432
  if(backlogPage<1) backlogPage=1;
410
433
  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); }
434
+ if(!all.length){ body.append(backlogEmptyRow(t('board.noPlans'))); return renderBacklogPager(0,1); }
435
+ if(!filtered.length){ body.append(backlogEmptyRow(t('board.noTasksMatch'))); return renderBacklogPager(0,1); }
413
436
  const start=(backlogPage-1)*BACKLOG_PAGE;
414
437
  filtered.slice(start,start+BACKLOG_PAGE).forEach(r=> body.append(backlogRow(r)));
415
438
  renderBacklogPager(filtered.length,pages);
416
439
  }
417
440
  function renderBacklogPager(total,pages){
418
441
  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;
442
+ if(total<=BACKLOG_PAGE){ if(total) pager.append(el('span','pager-info', t(total>1?'backlog.pagerTasks':'backlog.pagerTask',{n:total}))); return; }
443
+ const prev=el('button','pager-btn',t('backlog.prev')); prev.disabled=backlogPage<=1;
421
444
  prev.addEventListener('click',()=>{ backlogPage--; renderBacklog(); });
422
- const next=el('button','pager-btn','Next ›'); next.disabled=backlogPage>=pages;
445
+ const next=el('button','pager-btn',t('backlog.next')); next.disabled=backlogPage>=pages;
423
446
  next.addEventListener('click',()=>{ backlogPage++; renderBacklog(); });
424
447
  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);
448
+ pager.append(prev, el('span','pager-info',t('backlog.pagerRange',{from,to,total,page:backlogPage,pages})), next);
426
449
  }
427
450
  function backlogEmptyRow(txt){
428
451
  const tr=el('tr','backlog-empty-row'); const td=el('td',null,txt); td.colSpan=7; tr.append(td); return tr;
@@ -451,12 +474,12 @@ function loadExpanded(){
451
474
  }
452
475
  function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
453
476
  let expandedPhases=loadExpanded();
454
- function allPhaseTitles(){ const t=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> t.add(ph.title))); return [...t]; }
477
+ function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
455
478
  function updatePhaseToggleAll(){
456
479
  const btn=$('#phaseToggleAll'); if(!btn) return;
457
480
  const titles=allPhaseTitles();
458
- const allOpen = titles.length>0 && titles.every(t=> expandedPhases.has(t));
459
- btn.textContent = allOpen ? 'Collapse all' : 'Expand all';
481
+ const allOpen = titles.length>0 && titles.every(x=> expandedPhases.has(x));
482
+ btn.textContent = allOpen ? t('board.collapseAll') : t('board.expandAll');
460
483
  btn.dataset.state = allOpen ? 'open' : 'closed';
461
484
  }
462
485
 
@@ -515,17 +538,17 @@ function renderTask(t){
515
538
  // detail zone teaches the user what the pipeline is, not just that a step exists.
516
539
  function wfDesc(s){
517
540
  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.';
541
+ if(/brainstorm|idea|intake/.test(n)) return t('wf.desc.brainstorm');
542
+ if(/analy/.test(n)) return t('wf.desc.analysis');
543
+ if(/spec/.test(n)) return t('wf.desc.spec');
544
+ if(/plan/.test(n)) return t('wf.desc.plan');
545
+ if(/develop|implement|\bcode\b/.test(n)) return t('wf.desc.develop');
546
+ if(/integration/.test(n)) return t('wf.desc.integration');
547
+ if(/end.?to.?end|e2e/.test(n)) return t('wf.desc.e2e');
548
+ if(/unit/.test(n)||/\btest/.test(n)) return t('wf.desc.unit');
549
+ if(/review/.test(n)) return t('wf.desc.review');
550
+ if(/deploy|ship|release|publish/.test(n)) return t('wf.desc.deploy');
551
+ return t('wf.desc.fallback');
529
552
  }
530
553
  let wfPopStep=null; // name of the step whose click-popover is open (null = closed)
531
554
 
@@ -536,11 +559,11 @@ let wfPopStep=null; // name of the step whose click-popover is open (null = clos
536
559
  function renderWorkflow(){
537
560
  const box=$('#wfDiagram'); box.innerHTML='';
538
561
  const steps=P.workflow||[];
539
- if(!steps.length){ box.append(el('div','empty','No workflow defined.')); closeWfPop(); return; }
562
+ if(!steps.length){ box.append(el('div','empty',t('board.noWorkflow'))); closeWfPop(); return; }
540
563
  const enabledCount=steps.filter(s=>s.enabled).length;
541
564
  const legend=el('div','wf-legend');
542
565
  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`));
566
+ legend.append(el('span','wf-legend-txt',t('workflow.stepsEnabled',{enabled:enabledCount,total:steps.length})));
544
567
  box.append(legend);
545
568
  const pipe=el('div','wf-pipeline');
546
569
  steps.forEach((s,i)=>{
@@ -551,7 +574,7 @@ function renderWorkflow(){
551
574
  circle.innerHTML=(typeof ICON!=='undefined'&&ICON.wf)?ICON.wf(s.name):'';
552
575
  step.append(circle);
553
576
  const cap=el('div','wf-caption'); cap.append(document.createTextNode(s.name));
554
- if(s.optional) cap.append(el('span','wf-opt2','optional'));
577
+ if(s.optional) cap.append(el('span','wf-opt2',t('workflow.optional')));
555
578
  step.append(cap);
556
579
  const sel=(e)=>{ if(e) e.stopPropagation(); if(wfPopStep===s.name) closeWfPop(); else openWfPop(s.name); };
557
580
  step.addEventListener('click',sel);
@@ -570,19 +593,19 @@ function wfPopFill(pop, s, idx){
570
593
  const head=el('div','wf-detail-head');
571
594
  head.append(el('span','wf-detail-num',String(idx+1)));
572
595
  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'));
596
+ head.append(el('span','wf-detail-status '+(s.enabled?'on':'off'), s.enabled?t('workflow.enabled'):t('workflow.disabled')));
597
+ if(s.optional) head.append(el('span','wf-opt',t('workflow.optional')));
575
598
  pop.append(head);
576
599
  pop.append(el('p','wf-detail-desc', wfDesc(s)));
577
600
  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||'—')));
601
+ grid.append(wfDetailRow(t('workflow.capability'), s.cap||'—'));
602
+ grid.append(wfDetailRow(t('workflow.handledBy'), agent?(agent.title||agent.name):'—'));
603
+ grid.append(wfDetailRow(t('workflow.skill'), s.skill||'—'));
604
+ if(skill&&skill.standard) grid.append(wfDetailRow(t('workflow.standard'), skill.standard));
605
+ if(skill&&(skill.inputs||skill.outputs)) grid.append(wfDetailRow(t('workflow.flow'), (skill.inputs||'—')+' → '+(skill.outputs||'—')));
583
606
  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');
607
+ 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); }
608
+ const btn=el('button','btn '+(s.enabled?'':'primary')+' wf-detail-btn', s.enabled?t('workflow.disableStep'):t('workflow.enableStep'));
586
609
  btn.addEventListener('click',(e)=>{ e.stopPropagation(); toggleStep(s.name); });
587
610
  const actions=el('div','wf-pop-actions'); actions.append(btn); pop.append(actions);
588
611
  }
@@ -629,27 +652,27 @@ function renderAttention(){
629
652
  $$('.attn-filters .fchip').forEach(b=> b.classList.toggle('active', b.dataset.attn===attnFilter));
630
653
  const shown=items.filter(i=> attnFilter==='all' ? true : attnFilter==='resolved' ? i.status==='resolved' : i.status!=='resolved');
631
654
  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; }
655
+ if(!shown.length){ list.append(el('div','empty', attnFilter==='resolved'?t('attn.emptyResolved'):t('attn.emptyOpen'))); return; }
633
656
  shown.forEach(it=> list.append(attnRow(it)));
634
657
  }
635
658
  function attnRow(it){
636
659
  const row=el('div','attn-row'+(it.status==='resolved'?' is-resolved':'')+(it.source==='agent'?' from-agent':''));
637
660
  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'));
661
+ 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
662
  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'));
663
+ if(it.status==='resolved') head.append(el('span','chip s-done', it.promotedTo?('→ '+it.promotedTo):t('attn.resolvedChip')));
641
664
  row.append(head);
642
665
  const txt=el('div','attn-text', it.text); row.append(txt);
643
666
  const acts=el('div','attn-actions');
644
667
  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));
668
+ const val=el('button','btn primary',t('action.validateToTask')); val.addEventListener('click',()=>promoteAttn(it.id));
669
+ const res=el('button','btn',t('action.resolve')); res.addEventListener('click',()=>patchAttn(it.id,{status:'resolved'}));
670
+ const edit=el('button','btn',t('action.edit')); edit.addEventListener('click',()=>editAttn(it,txt));
648
671
  acts.append(val,res,edit);
649
672
  }else{
650
- const re=el('button','btn','Reopen'); re.addEventListener('click',()=>patchAttn(it.id,{status:'open'})); acts.append(re);
673
+ const re=el('button','btn',t('action.reopen')); re.addEventListener('click',()=>patchAttn(it.id,{status:'open'})); acts.append(re);
651
674
  }
652
- const del=el('button','btn danger','Delete'); del.addEventListener('click',()=>deleteAttn(it.id));
675
+ const del=el('button','btn danger',t('action.delete')); del.addEventListener('click',()=>deleteAttn(it.id));
653
676
  acts.append(del); row.append(acts);
654
677
  return row;
655
678
  }
@@ -665,12 +688,19 @@ async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encod
665
688
  async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
666
689
  async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
667
690
 
668
- // ---- Settings tab: change autonomy mode + output language (writes config.json) ----
669
- function setLangSelect(lang){
670
- const sel=$('#setLang'); if(!sel) return;
691
+ // ---- Settings tab + topbar quick-switch: change autonomy mode + output language (writes config.json) ----
692
+ // Mode/language can be changed from two places — the Settings tab (#setMode/#setLang) and the
693
+ // always-visible topbar selects (#topMode/#topLang, in .brand-sub, present in every design). Both
694
+ // pairs are kept in sync: renderSettings() (every render tick) writes the current config into all
695
+ // four; a change on any one immediately mirrors into its sibling before saving, so saveSettings()
696
+ // never reads a stale value from the pair the user didn't touch.
697
+ function fillLangSelect(sel,lang){
698
+ if(!sel) return;
671
699
  if(![...sel.options].some(o=>o.value===lang)){ const o=document.createElement('option'); o.value=lang; o.textContent=lang; sel.append(o); }
672
700
  sel.value=lang;
673
701
  }
702
+ function setLangSelect(lang){ fillLangSelect($('#setLang'),lang); fillLangSelect($('#topLang'),lang); }
703
+ function setModeSelects(mode){ [$('#setMode'),$('#topMode')].forEach(s=>{ if(s) s.value=mode; }); }
674
704
  // ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
675
705
  function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
676
706
  function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
@@ -678,7 +708,7 @@ async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token
678
708
 
679
709
  function renderSettings(){
680
710
  const c=(P&&P.config)||{};
681
- if($('#setMode')) $('#setMode').value=c.mode||'semi';
711
+ setModeSelects(c.mode||'semi');
682
712
  setLangSelect(c.language||'en');
683
713
  // design switcher — options from the DESIGNS registry (designs.js)
684
714
  const dsel=$('#setDesign');
@@ -694,26 +724,205 @@ function renderSettings(){
694
724
  const box=$('#settingsReadonly');
695
725
  if(box){
696
726
  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):'—']];
727
+ const rows=[[t('info.activeAgent'),c.agent||'—'],[t('info.projectType'),c.projectType||'—'],
728
+ [t('field.plansFolder'),c.plansDir||'plans'],[t('field.specsFolder'),c.specsDir||'specs'],
729
+ [t('field.frameworkVersion'), P&&P.version?('v'+P.version):'—']];
700
730
  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
731
  }
702
732
  const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
733
+ renderCustomize();
703
734
  }
704
735
  async function saveSettings(){
705
- flash(); const mode=$('#setMode').value, language=$('#setLang').value;
736
+ flash();
737
+ const mode=($('#setMode')||$('#topMode')).value, language=($('#setLang')||$('#topLang')).value;
706
738
  await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,language})});
707
739
  const s=$('#settingsSaved'); if(s){ s.hidden=false; setTimeout(()=>{ s.hidden=true; },1500); }
708
740
  }
709
741
 
742
+ // ---- Custom dashboards (Customize page → generate-dashboard skill) ---------------------------
743
+ // A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboard/custom/<id>.json, embedded
744
+ // in P.customDashboards by the server) — never raw HTML/CSS/JS. Every block below reuses the exact
745
+ // same components the built-in Board renders with (kpiCard/ocard/bars/donut/statTile/mdLite/el), so a
746
+ // generated dashboard automatically matches the active design — and any design switched to later —
747
+ // with zero page-specific styling. Mirrors the schema in .spectoflow/lib/custom-dashboard.js (the
748
+ // Node-side validator); this is the independent browser-side reader for the same shape.
749
+ function resolveBind(s,bindPath,fallback){
750
+ if(bindPath==null) return fallback;
751
+ let v=s;
752
+ for(const p of String(bindPath).split('.')){ if(v==null) return fallback; v=v[p]; }
753
+ return v==null?fallback:v;
754
+ }
755
+ function renderCustomBlock(b,s){
756
+ switch(b.type){
757
+ case 'markdown': return htmlBlock('cd-markdown', mdLite(b.content||''));
758
+ case 'kpi-row': {
759
+ const row=el('div','kpi-row');
760
+ (b.items||[]).forEach(it=>{
761
+ const val=it.bind!=null?resolveBind(s,it.bind,it.value):it.value;
762
+ row.append(kpiCard(it.label||'', numBlock(val==null?'—':val,it.color||'var(--signal)'), it.sub||'', cssv(it.colorVar||'--signal')));
763
+ });
764
+ return row;
765
+ }
766
+ case 'chart-bars': {
767
+ const rows=(b.rows||[]).map(r=>({label:r.label||'', pct:r.bind!=null?(resolveBind(s,r.bind,r.pct||0)):(r.pct||0), sub:r.sub||''}));
768
+ return ocard(b.title||'', bars(rows));
769
+ }
770
+ case 'chart-donut': {
771
+ const segs=(b.segments||[]).map(g=>({key:g.key||'', value:g.bind!=null?(resolveBind(s,g.bind,g.value||0)):(g.value||0), color:cssv(g.colorVar||'--muted')}));
772
+ const total=segs.reduce((a,x)=>a+(Number(x.value)||0),0);
773
+ const d=donut(segs,140,{center:String(total),sub:t('chart.tasksSub')});
774
+ const legend=el('div','legend');
775
+ segs.forEach(seg=>{ const item=el('div','legend-item'); const sw=el('span','legend-swatch'); sw.style.background=seg.color; item.append(sw, el('span','legend-label',seg.key), el('span','legend-count',String(seg.value))); legend.append(item); });
776
+ const row=el('div','donut-row'); row.append(d.wrap,legend);
777
+ return ocard(b.title||'', row);
778
+ }
779
+ case 'table': {
780
+ const wrap=el('div','table-scroll'); const tbl=el('table','backlog-table');
781
+ const thead=el('thead'); const htr=el('tr'); (b.columns||[]).forEach(c=>htr.append(el('th',null,String(c)))); thead.append(htr); tbl.append(thead);
782
+ const tbody=el('tbody'); (b.rows||[]).forEach(r=>{ const tr=el('tr'); (r||[]).forEach(c=>tr.append(el('td',null,String(c)))); tbody.append(tr); }); tbl.append(tbody);
783
+ wrap.append(tbl);
784
+ return b.title? ocard(b.title,wrap) : wrap;
785
+ }
786
+ case 'list': {
787
+ const ul=el('ul','flatlist'); (b.items||[]).forEach(x=>ul.append(li(null,String(x))));
788
+ return b.title? ocard(b.title,ul) : ul;
789
+ }
790
+ case 'stat-tile-row': {
791
+ const row=el('div','stat-tiles');
792
+ (b.items||[]).forEach(it=>{ const val=it.bind!=null?resolveBind(s,it.bind,it.value):it.value; row.append(statTile(val==null?'—':String(val), it.label||'', it.sub||'')); });
793
+ return row;
794
+ }
795
+ default: return el('div','empty','Unknown block type: '+b.type);
796
+ }
797
+ }
798
+ function customDashboardTabs(){ return $$('#tabs .tab[data-tab^="custom:"]'); }
799
+ // Adds/removes nav tabs + panels to match P.customDashboards, and refreshes an existing tab's label
800
+ // if the dashboard was regenerated with a new title. Console's rail and Orbit's radial menu both read
801
+ // #tabs live, so a custom tab appears in either design's navigation with no design-specific change.
802
+ function syncCustomTabs(list){
803
+ const tabsNav=$('#tabs'); if(!tabsNav) return;
804
+ const wanted=new Set(list.map(spec=>'custom:'+spec.id));
805
+ customDashboardTabs().forEach(btn=>{
806
+ const id=btn.dataset.tab;
807
+ if(!wanted.has(id)){ const panel=$('.panel[data-panel="'+id+'"]'); if(panel) panel.remove(); btn.remove(); }
808
+ });
809
+ list.forEach(spec=>{
810
+ const id='custom:'+spec.id;
811
+ let btn=$('#tabs .tab[data-tab="'+id+'"]');
812
+ if(!btn){
813
+ btn=el('button','tab'); btn.dataset.tab=id;
814
+ const icoKey=spec.icon||'info';
815
+ const ico=el('span','tab-ico'); ico.dataset.icon=icoKey; if(typeof ICON!=='undefined'&&ICON[icoKey]) ico.innerHTML=ICON[icoKey];
816
+ btn.append(ico, el('span','tab-label',spec.title||spec.id));
817
+ btn.addEventListener('click',()=>navigateTab(id));
818
+ tabsNav.append(btn);
819
+ const panel=el('section','panel'); panel.dataset.panel=id;
820
+ const wrap=el('div','custom-dash-wrap');
821
+ wrap.append(el('h2','panel-title',spec.title||spec.id));
822
+ wrap.append(el('div','custom-dash-body'));
823
+ panel.append(wrap);
824
+ $('.stage').append(panel);
825
+ } else {
826
+ const lbl=btn.querySelector('.tab-label'); if(lbl) lbl.textContent=spec.title||spec.id;
827
+ const title=$('.panel[data-panel="'+id+'"] .panel-title'); if(title) title.textContent=spec.title||spec.id;
828
+ }
829
+ });
830
+ }
831
+ function renderCustomDashboards(){
832
+ const list=P.customDashboards||[];
833
+ syncCustomTabs(list);
834
+ const s=SpectoStats.stats(P);
835
+ list.forEach(spec=>{
836
+ const box=$('.panel[data-panel="custom:'+spec.id+'"] .custom-dash-body'); if(!box) return;
837
+ box.innerHTML='';
838
+ (spec.blocks||[]).forEach(b=> box.append(renderCustomBlock(b,s)));
839
+ });
840
+ }
841
+
842
+ // ---- Settings → Customize: add dashboards/skills/agents by description, or "Auto" -------------
843
+ // Generation itself is real agent work (research, clarify, write a file) — this UI never does it
844
+ // client-side. It just constructs a plain-language prompt (recognized by AGENTS.md's Router, which
845
+ // hands it to framework-curator) and sends it through the SAME /api/run + group-chat pipeline every
846
+ // other "Run" already uses, then jumps to Chat so the requester watches it happen and can answer any
847
+ // clarifying question there — no separate conversational UI to build or keep in sync.
848
+ const CZ_KINDS=[
849
+ { kind:'dashboard', promptAdd:(d)=>'Add a custom dashboard: '+d, promptAuto:'Propose dashboard candidates for this project (Auto customize)' },
850
+ { kind:'skill', promptAdd:(d)=>'Create a new skill: '+d, promptAuto:'Propose skill candidates for this project (Auto customize)' },
851
+ { kind:'agent', promptAdd:(d)=>'Create a new agent: '+d, promptAuto:'Propose agent candidates for this project (Auto customize)' },
852
+ ];
853
+ function czItemsFor(kind){
854
+ if(kind==='dashboard') return (P.customDashboards||[]).map(spec=>({ title:spec.title||spec.id, sub:(spec.blocks||[]).length+' '+t('customize.blocksSub'), open:()=>navigateTab('custom:'+spec.id) }));
855
+ if(kind==='skill') return (P.skills||[]).filter(s=>s.custom).map(s=>({ title:s.name, sub:s.description||'', open:()=>openFileDrawer('skill',s) }));
856
+ return (P.agents||[]).filter(a=>a.custom).map(a=>({ title:a.title||a.name, sub:a.description||'', open:()=>openFileDrawer('agent',a) }));
857
+ }
858
+ function renderCustomize(){
859
+ const root=$('#czRoot'); if(!root) return;
860
+ const openKind=root.dataset.open||'';
861
+ root.innerHTML='';
862
+ CZ_KINDS.forEach(({kind})=>{
863
+ const items=czItemsFor(kind);
864
+ const block=el('div','cz-block');
865
+ const head=el('div','cz-head');
866
+ head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
867
+ const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
868
+ addBtn.setAttribute('aria-expanded',String(openKind===kind));
869
+ addBtn.addEventListener('click',()=>{ root.dataset.open=(openKind===kind)?'':kind; renderCustomize(); });
870
+ head.append(addBtn); block.append(head);
871
+ const list=el('div','cz-list');
872
+ if(!items.length) list.append(el('div','empty',t('customize.empty.'+kind)));
873
+ items.forEach(it=>{
874
+ const row=el('div','cz-item'); row.tabIndex=0;
875
+ row.append(el('span','cz-item-title',it.title));
876
+ if(it.sub) row.append(el('span','cz-item-sub',it.sub));
877
+ row.addEventListener('click',it.open);
878
+ row.addEventListener('keydown',(e)=>{ if(e.key==='Enter') it.open(); });
879
+ list.append(row);
880
+ });
881
+ block.append(list);
882
+ if(openKind===kind){
883
+ const form=el('div','cz-form');
884
+ const ta=el('textarea','chat-ta'); ta.placeholder=t('customize.describePh');
885
+ const sel=el('select','chat-agent');
886
+ const runners=Object.keys((P.config&&P.config.runners)||{claude:1});
887
+ runners.forEach((k)=>{ const o=document.createElement('option'); o.value=k; o.textContent=k; sel.append(o); });
888
+ if(P.config&&P.config.agent) sel.value=P.config.agent;
889
+ const actions=el('div','cz-form-actions');
890
+ const autoBtn=el('button','btn',t('customize.auto'));
891
+ const goBtn=el('button','btn primary',t('customize.generate'));
892
+ autoBtn.addEventListener('click',()=>czSubmit(kind,null,sel.value));
893
+ goBtn.addEventListener('click',()=>{ const v=ta.value.trim(); if(v) czSubmit(kind,v,sel.value); });
894
+ actions.append(sel,autoBtn,goBtn);
895
+ form.append(ta,actions);
896
+ block.append(form);
897
+ }
898
+ root.append(block);
899
+ });
900
+ }
901
+ async function czSubmit(kind,description,agent){
902
+ const cfg=CZ_KINDS.find((c)=>c.kind===kind);
903
+ const prompt=description?cfg.promptAdd(description):cfg.promptAuto;
904
+ await fetch('/api/run',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
905
+ const root=$('#czRoot'); if(root) root.dataset.open='';
906
+ navigateTab('chat');
907
+ }
908
+
710
909
  // ---- client-side routing: /<tab>[/<taskId>] via the History API ------------
910
+ // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
911
+ // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
912
+ // by a dedicated branch in tabFromPath()/navigateTab() instead.
711
913
  const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','settings'];
712
- function tabFromPath(){ const s=location.pathname.split('/').filter(Boolean); return ROUTES.includes(s[0])?s[0]:null; }
914
+ function tabFromPath(){
915
+ const s=location.pathname.split('/').filter(Boolean);
916
+ if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
917
+ return ROUTES.includes(s[0])?s[0]:null;
918
+ }
713
919
  function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
714
- function navigateTab(t,push){
715
- activeTab=t; try{ localStorage.setItem('spf-tab',t); }catch{}
716
- if(push!==false) history.pushState(null,'','/'+t);
920
+ function navigateTab(tabId,push){
921
+ activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
922
+ if(push!==false){
923
+ const isCustom=tabId.indexOf('custom:')===0;
924
+ history.pushState(null,'', isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId);
925
+ }
717
926
  applyActiveTab();
718
927
  closeNav(); // a tab pick closes the mobile menu
719
928
  }
@@ -731,8 +940,8 @@ function agentCard(a){
731
940
  const c=el('div','card'); c.tabIndex=0;
732
941
  c.append(el('div','ct',a.title||a.name));
733
942
  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);
943
+ const std=chipRow(t('team.standardsLabel'),a.standards); if(std) c.append(std);
944
+ const uses=chipRow(t('team.usesLabel'),a.uses); if(uses) c.append(uses);
736
945
  if(a.description) c.append(el('div','cd',a.description));
737
946
  const open=()=>openFileDrawer('agent',a);
738
947
  c.addEventListener('click',open);
@@ -743,7 +952,7 @@ function skillCard(s){
743
952
  const c=el('div','card'); c.tabIndex=0;
744
953
  c.append(el('div','ct',s.name));
745
954
  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);
955
+ const std=chipRow(t('team.standardLabel'), s.standard?[s.standard]:null); if(std) c.append(std);
747
956
  if(s.inputs||s.outputs) c.append(el('div','io', (s.inputs||'—')+' → '+(s.outputs||'—')));
748
957
  if(s.description) c.append(el('div','cd',s.description));
749
958
  const open=()=>openFileDrawer('skill',s);
@@ -803,19 +1012,19 @@ async function openFileDrawer(kind,obj){
803
1012
  openTaskId=null;
804
1013
  const rel = kind==='agent' ? ('agents/'+(obj.file||(obj.name+'.md'))) : ('skills/'+obj.name+'/SKILL.md');
805
1014
  const b=$('#drawerBody'); b.innerHTML='';
806
- b.append(el('div','d-id', kind==='agent'?'Agent':'Skill'));
1015
+ b.append(el('div','d-id', kind==='agent'?t('drawer.agent'):t('drawer.skill')));
807
1016
  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…'));
1017
+ const sec=el('div','d-section'); sec.append(el('div','d-label',t('drawer.file',{rel})));
1018
+ const body=el('div','md-body'); body.append(el('div','empty',t('drawer.loading')));
810
1019
  sec.append(body); b.append(sec);
811
1020
  $('#drawer').setAttribute('aria-hidden','false');
812
1021
  try{
813
1022
  const r=await fetch('/api/agentfile?path='+encodeURIComponent(rel));
814
1023
  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; }
1024
+ if(!r.ok){ body.innerHTML=''; body.append(el('div','empty', data.error||t('drawer.loadError'))); return; }
816
1025
  body.innerHTML=mdLite(data.content||'');
817
1026
  }catch(err){
818
- body.innerHTML=''; body.append(el('div','empty','Could not load this file.'));
1027
+ body.innerHTML=''; body.append(el('div','empty',t('drawer.loadError')));
819
1028
  }
820
1029
  }
821
1030
 
@@ -836,11 +1045,11 @@ function infoRow(label,value){
836
1045
  return row;
837
1046
  }
838
1047
  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;
1048
+ const tile=el('div','stat-tile');
1049
+ tile.append(el('div','stat-tile-val',String(value)));
1050
+ tile.append(el('div','stat-tile-label',label));
1051
+ if(sub) tile.append(el('div','stat-tile-sub',sub));
1052
+ return tile;
844
1053
  }
845
1054
  function renderInfo(){
846
1055
  const box=$('#infoGrid'); if(!box) return;
@@ -852,39 +1061,39 @@ function renderInfo(){
852
1061
 
853
1062
  // Project: mode/language/agent/type from config
854
1063
  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));
1064
+ projRows.append(infoRow(t('info.projectType'), c.projectType||'—'));
1065
+ projRows.append(infoRow(t('info.mode'), c.mode||'—'));
1066
+ projRows.append(infoRow(t('info.language'), c.language||'—'));
1067
+ projRows.append(infoRow(t('info.activeAgent'), c.agent||'—'));
1068
+ box.append(infoSection(t('info.project'),'info',projRows));
860
1069
 
861
1070
  // Runners: agent → command, monospace
862
1071
  const runners=c.runners||{};
863
1072
  const runnerKeys=Object.keys(runners);
864
1073
  const runnerRows=el('div','info-rows info-rows-mono');
865
- if(!runnerKeys.length) runnerRows.append(el('div','empty','No runners configured.'));
1074
+ if(!runnerKeys.length) runnerRows.append(el('div','empty',t('info.noRunners')));
866
1075
  runnerKeys.forEach(k=> runnerRows.append(infoRow(k, runners[k])));
867
- box.append(infoSection('Runners','run',runnerRows));
1076
+ box.append(infoSection(t('info.runners'),'run',runnerRows));
868
1077
 
869
1078
  // Counts: tasks/specs/agents/skills/enabled workflow steps
870
1079
  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));
1080
+ tiles.append(statTile(`${s.done}/${s.total}`,t('info.tasksLabel'),t('info.doneSub',{pct:s.pct})));
1081
+ tiles.append(statTile(String((P.specs||[]).length),t('info.specsLabel'),t('info.filesSub')));
1082
+ tiles.append(statTile(String((P.agents||[]).length),t('info.agentsLabel'),t('info.personasSub')));
1083
+ tiles.append(statTile(String((P.skills||[]).length),t('info.skillsLabel'),t('info.proceduresSub')));
1084
+ tiles.append(statTile(`${enabledSteps.length}/${steps.length}`,t('info.workflowLabel'),t('info.stepsEnabledSub')));
1085
+ box.append(infoSection(t('info.counts'),'board',tiles));
877
1086
 
878
1087
  // Specs: the P.specs filename list
879
1088
  const specsList=el('ul','flatlist');
880
1089
  const specs=P.specs||[];
881
- if(!specs.length) specsList.append(li('empty','none yet'));
1090
+ if(!specs.length) specsList.append(li('empty',t('board.noneYet')));
882
1091
  specs.forEach(sp=> specsList.append(li(null,sp)));
883
- box.append(infoSection('Specs','backlog',specsList));
1092
+ box.append(infoSection(t('info.specsLabel'),'backlog',specsList));
884
1093
 
885
1094
  // Workflow: compact list of enabled steps (name + cap/skill)
886
1095
  const wfList=el('div','info-wf-list');
887
- if(!enabledSteps.length) wfList.append(el('div','empty','No enabled workflow steps.'));
1096
+ if(!enabledSteps.length) wfList.append(el('div','empty',t('info.noEnabledSteps')));
888
1097
  enabledSteps.forEach(st=>{
889
1098
  const row=el('div','info-wf-row');
890
1099
  row.append(el('span','info-wf-name',st.name));
@@ -892,42 +1101,44 @@ function renderInfo(){
892
1101
  if(meta) row.append(el('span','info-wf-meta',meta));
893
1102
  wfList.append(row);
894
1103
  });
895
- box.append(infoSection('Workflow','workflow',wfList));
1104
+ box.append(infoSection(t('info.workflowLabel'),'workflow',wfList));
896
1105
  }
897
1106
 
898
1107
  function openDrawer(id,keep){
899
- const t=allTasks().find(x=>x.id===id); if(!t) return;
1108
+ // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
1109
+ // function calls it repeatedly below; shadowing it with a task variable would break every call.
1110
+ const task=allTasks().find(x=>x.id===id); if(!task) return;
900
1111
  openTaskId=id;
901
1112
  if(!keep && taskFromPath()!==id) history.pushState(null,'','/'+activeTab+'/'+encodeURIComponent(id));
902
1113
  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'));
1114
+ b.append(el('div','d-id',task.id+' · '+(task.level||'standard')+' · '+task.file));
1115
+ b.append(el('div','d-title',task.title));
1116
+ const sSec=el('div','d-section'); sSec.append(el('div','d-label',t('task.status')));
906
1117
  const sr=el('div','status-row');
907
1118
  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);
1119
+ const btn=el('button','status-btn'+(task.status===k?' active':''),STATUS[k]);
1120
+ if(task.status===k) btn.style.background=cssv('--s-'+k);
910
1121
  btn.addEventListener('click',()=>patchTask(id,{status:k}));
911
1122
  sr.append(btn);
912
1123
  });
913
1124
  sSec.append(sr); b.append(sSec);
914
1125
 
915
1126
  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); }
1127
+ if(tr){ const ts=el('div','d-section'); ts.append(el('div','d-label',t('task.tests')));
1128
+ 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
1129
 
919
- const cSec=el('div','d-section'); cSec.append(el('div','d-label','Comments'));
1130
+ const cSec=el('div','d-section'); cSec.append(el('div','d-label',t('task.comments')));
920
1131
  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.'));
1132
+ (task.comments||[]).forEach(cm=> list.append(el('div','comment',cm)));
1133
+ if(!(task.comments||[]).length) list.append(el('div','empty',t('task.noComments')));
923
1134
  cSec.append(list);
924
- const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder='Add a comment, a remark, feedback…';
1135
+ const box=el('div','c-box'); const ta=el('textarea'); ta.placeholder=t('task.addCommentPlaceholder');
925
1136
  const actions=el('div','c-actions');
926
- const add=el('button','btn','Add'); const an=el('button','btn primary','Add + to analyze');
1137
+ const add=el('button','btn',t('action.add')); const an=el('button','btn primary',t('action.addToAnalyze'));
927
1138
  add.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'note'); });
928
1139
  an.addEventListener('click',()=>{ if(ta.value.trim())addComment(id,ta.value.trim(),'analyze'); });
929
1140
  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.'));
1141
+ box.append(el('div','empty',t('task.toAnalyzeHint')));
931
1142
  cSec.append(box); b.append(cSec);
932
1143
  $('#drawer').setAttribute('aria-hidden','false');
933
1144
  if(keep) $('.drawer-panel').scrollTop=prev;
@@ -994,8 +1205,12 @@ $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilt
994
1205
  // settings — the footer link opens the Settings tab; selects save on change
995
1206
  const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
996
1207
  const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
997
- $('#setMode').addEventListener('change',saveSettings);
998
- $('#setLang').addEventListener('change',saveSettings);
1208
+ function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
1209
+ function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }
1210
+ $('#setMode').addEventListener('change',onModeSelectChange);
1211
+ $('#setLang').addEventListener('change',onLangSelectChange);
1212
+ const topModeSel=$('#topMode'); if(topModeSel) topModeSel.addEventListener('change',onModeSelectChange);
1213
+ const topLangSel=$('#topLang'); if(topLangSel) topLangSel.addEventListener('change',onLangSelectChange);
999
1214
  const setDesignSel=$('#setDesign'); if(setDesignSel) setDesignSel.addEventListener('change',()=>saveDesign(setDesignSel.value));
1000
1215
  // theme
1001
1216
  (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);