spectoflow 0.21.0 → 0.22.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.
@@ -55,19 +55,32 @@ function chatContainers(){ return [$('#chatLog'),$('#chatTabLog')].filter(Boolea
55
55
  function scrollChat(container){ container.scrollTop=container.scrollHeight; }
56
56
  function clearIdle(container){ const i=container.querySelector('.chat-idle'); if(i) i.remove(); }
57
57
  function bubble(m){
58
- if(m.role==='user'){ const d=el('div','msg you'); d.append(el('div','bubble',m.text)); return d; }
59
- const wrap=el('div','msg agentmsg k-'+(m.kind||'message'));
60
- wrap.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
61
- wrap.append(el('div','bubble',m.text));
62
- return wrap;
58
+ let node;
59
+ if(m.role==='user'){ node=el('div','msg you'); node.append(el('div','bubble',m.text)); }
60
+ else{
61
+ node=el('div','msg agentmsg k-'+(m.kind||'message'));
62
+ node.append(el('div','msg-role', m.role + (m.agent&&m.agent!==m.role?(' · '+m.agent):'')));
63
+ node.append(el('div','bubble',m.text));
64
+ }
65
+ node.dataset.id=m.id; // lets renderChatLog tell a stale bubble from a genuinely new one
66
+ return node;
63
67
  }
68
+ function idleBlock(){ const d=el('div','chat-idle'); d.innerHTML=t('chat.idle'); return d; }
64
69
  function renderChatLog(container){
65
70
  if(!container) return;
66
71
  const st=stateFor(container); const msgs=(P.runtime&&P.runtime.messages)||[];
67
- if(msgs.length) clearIdle(container);
72
+ const ids=new Set(msgs.map(m=>m.id));
73
+ // Summarize/Clear REPLACE the server-side log (a digest that leaves the old messages sitting right
74
+ // below it wouldn't condense anything) — if anything we already rendered no longer exists, the log
75
+ // was reset under us: rebuild from scratch instead of just appending, or the stale bubbles never go
76
+ // away short of a full page reload.
77
+ const stale=[...st.rendered].some(id=>!ids.has(id));
78
+ if(stale){ container.innerHTML=''; st.rendered=new Set(); st.rawBlock=null; }
79
+ if(!msgs.length){ if(!container.querySelector('.chat-idle')) container.append(idleBlock()); renderApproval(container); return; }
80
+ clearIdle(container);
68
81
  let added=false;
69
82
  for(const m of msgs){ if(st.rendered.has(m.id)) continue; st.rendered.add(m.id); container.append(bubble(m)); added=true; }
70
- if(added) scrollChat(container);
83
+ if(added||stale) scrollChat(container);
71
84
  renderApproval(container);
72
85
  }
73
86
  function renderApproval(container){
@@ -151,7 +164,7 @@ function render(){
151
164
  if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
152
165
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
153
166
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
154
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings();
167
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles();
155
168
  renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
156
169
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
157
170
  applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
@@ -695,6 +708,39 @@ function editAttn(it,txtNode){
695
708
  ta.addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ e.preventDefault(); save(); } if(e.key==='Escape'){ done=true; renderAttention(); } });
696
709
  }
697
710
  async function addAttn(text){ flash(); await fetch('/api/attention',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text})}); }
711
+
712
+ // ---- Backlog "+ Add task" — a manual checkbox task, no agent involved ----
713
+ function openBacklogAddForm(){
714
+ const form=$('#backlogAddForm'); if(!form) return;
715
+ const list=$('#blPhaseList');
716
+ if(list){ list.innerHTML=''; allPhaseTitles().forEach(ti=> list.append(new Option(ti))); }
717
+ const err=$('#blAddError'); if(err) err.hidden=true;
718
+ form.hidden=false; form.classList.add('is-open');
719
+ $('#blAddTitle').focus();
720
+ }
721
+ function closeBacklogAddForm(){
722
+ const form=$('#backlogAddForm'); if(!form) return;
723
+ form.hidden=true; form.classList.remove('is-open');
724
+ ['blAddTitle','blAddPhase','blAddOwner'].forEach(id=>{ const f=$('#'+id); if(f) f.value=''; });
725
+ const lvl=$('#blAddLevel'); if(lvl) lvl.value='standard';
726
+ }
727
+ async function submitBacklogAdd(){
728
+ const err=$('#blAddError');
729
+ const title=($('#blAddTitle').value||'').trim();
730
+ if(!title){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } $('#blAddTitle').focus(); return; }
731
+ const phase=($('#blAddPhase').value||'').trim();
732
+ const owner=($('#blAddOwner').value||'').trim();
733
+ const level=$('#blAddLevel').value;
734
+ flash();
735
+ const r=await fetch('/api/task',{method:'POST',headers:{'Content-Type':'application/json'},
736
+ body:JSON.stringify({title, phase:phase||undefined, owner:owner||undefined, level})});
737
+ if(!r.ok){
738
+ const j=await r.json().catch(()=>({}));
739
+ if(err){ err.textContent=j.error||t('backlog.addFailed'); err.hidden=false; }
740
+ return;
741
+ }
742
+ closeBacklogAddForm();
743
+ }
698
744
  async function patchAttn(id,patch){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify(patch)}); }
699
745
  async function deleteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id),{method:'DELETE'}); }
700
746
  async function promoteAttn(id){ flash(); await fetch('/api/attention/'+encodeURIComponent(id)+'/promote',{method:'POST'}); }
@@ -917,10 +963,14 @@ function czItemsFor(kind){
917
963
  function renderCustomize(){
918
964
  const root=$('#czRoot'); if(!root) return;
919
965
  const openKind=root.dataset.open||'';
966
+ // a block with its form open spans the full row (see CSS) — an auto-fit grid would otherwise
967
+ // still reserve empty trailing cells beside it for the two collapsed blocks that no longer fill
968
+ // out a row, so drop to a single column for the whole grid while any block is open.
969
+ root.classList.toggle('has-open', !!openKind);
920
970
  root.innerHTML='';
921
971
  CZ_KINDS.forEach(({kind})=>{
922
972
  const items=czItemsFor(kind);
923
- const block=el('div','cz-block');
973
+ const block=el('div','cz-block'+(openKind===kind?' is-open':''));
924
974
  const head=el('div','cz-head');
925
975
  head.append(el('h3',null,t('customize.'+kind+'s')+' ('+items.length+')'));
926
976
  const addBtn=el('button','btn cz-add',t('customize.add.'+kind));
@@ -967,11 +1017,15 @@ async function czSubmit(kind,description,agent){
967
1017
  // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
968
1018
  // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
969
1019
  // by a dedicated branch in tabFromPath()/navigateTab() instead.
970
- const ROUTES=['board','requests','attention','backlog','workflow','team','chat','info','docs','settings'];
1020
+ const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
1021
+ // the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
1022
+ // under that name still land on the Personalize tab instead of a blank panel.
1023
+ function normalizeTab(t){ return t==='settings'?'personalize':t; }
971
1024
  function tabFromPath(){
972
1025
  const s=location.pathname.split('/').filter(Boolean);
973
1026
  if(s[0]==='custom'&&s[1]) return 'custom:'+decodeURIComponent(s[1]);
974
- return ROUTES.includes(s[0])?s[0]:null;
1027
+ const t=normalizeTab(s[0]);
1028
+ return ROUTES.includes(t)?t:null;
975
1029
  }
976
1030
  function taskFromPath(){ const s=location.pathname.split('/').filter(Boolean); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
977
1031
  function navigateTab(tabId,push){
@@ -986,6 +1040,7 @@ function navigateTab(tabId,push){
986
1040
  // was scrolled to last (only on an actual switch INTO the tab — applyActiveTab() alone runs on
987
1041
  // every SSE render tick too, and re-scrolling/re-focusing there would fight the user's typing).
988
1042
  if(tabId==='chat') setTimeout(()=>{ scrollChat($('#chatTabLog')); $('#tabRunPrompt').focus(); },60);
1043
+ if(tabId==='files') renderFiles(); // the tree is fetched lazily — only load it on an actual switch in
989
1044
  }
990
1045
  function closeNav(){ document.body.classList.remove('nav-open'); const nt=$('#navToggle'); if(nt) nt.setAttribute('aria-expanded','false'); }
991
1046
 
@@ -1236,6 +1291,203 @@ function renderDocs(){
1236
1291
  box.append(note);
1237
1292
  }
1238
1293
 
1294
+ // ---- Files tab: browse the project tree, view/edit any text file (Markdown rendered, HTML
1295
+ // previewed in a sandboxed iframe, everything else as a plain monospace editor). The tree is
1296
+ // fetched lazily (only while this tab is active) and never refetched mid-edit — an SSE 'change'
1297
+ // event refreshes the TREE listing but never overwrites an open file's editor buffer, so an
1298
+ // unrelated agent write elsewhere can't clobber unsaved work here. ----
1299
+ let filesTreeData=null, filesOpenPath=null, filesOpenDirty=false;
1300
+ const filesOpenDirs=new Set(); // persists which tree folders are expanded across a refresh
1301
+ async function loadFilesTree(){
1302
+ try{
1303
+ const r=await fetch('/api/files/tree'); const d=await r.json().catch(()=>({}));
1304
+ filesTreeData = (r.ok && Array.isArray(d.tree)) ? d.tree : [];
1305
+ }catch{ filesTreeData=filesTreeData||[]; }
1306
+ renderFilesTree();
1307
+ }
1308
+ function renderFiles(){
1309
+ // Only the FIRST activation fetches — render() re-runs on every SSE 'change' (a chat message, a
1310
+ // task update, anything) and a full tree rebuild on each of those would yank rows out from under
1311
+ // an in-progress click. Fresh-after-your-own-action is handled by loadFilesTree() calls at the
1312
+ // point of action (filesCreate); the toolbar's Refresh button covers everything else.
1313
+ if(activeTab!=='files' || filesTreeData!=null) return;
1314
+ loadFilesTree();
1315
+ }
1316
+ function fNode(entry){
1317
+ const row=el('div','f-row'+(entry.type==='dir'&&filesOpenDirs.has(entry.path)?' is-open':'')+(entry.path===filesOpenPath?' is-active':''));
1318
+ row.tabIndex=0;
1319
+ if(entry.type==='dir'){
1320
+ const chev=document.createElementNS('http://www.w3.org/2000/svg','svg');
1321
+ chev.setAttribute('viewBox','0 0 18 18'); chev.setAttribute('class','f-chevron'); chev.setAttribute('fill','none'); chev.setAttribute('stroke','currentColor'); chev.setAttribute('stroke-width','1.8');
1322
+ chev.innerHTML='<path d="M6.5 4l6 5-6 5"/>';
1323
+ row.append(chev);
1324
+ } else row.append(el('span',null,''));
1325
+ row.append(el('span','f-name',entry.name));
1326
+ const wrap=el('div','f-node');
1327
+ wrap.append(row);
1328
+ if(entry.type==='dir'){
1329
+ const kids=el('div','f-children'); kids.hidden=!filesOpenDirs.has(entry.path);
1330
+ (entry.children||[]).forEach(c=> kids.append(fNode(c)));
1331
+ wrap.append(kids);
1332
+ row.addEventListener('click',()=>{
1333
+ const open=filesOpenDirs.has(entry.path);
1334
+ if(open) filesOpenDirs.delete(entry.path); else filesOpenDirs.add(entry.path);
1335
+ row.classList.toggle('is-open',!open); kids.hidden=open;
1336
+ });
1337
+ } else {
1338
+ row.addEventListener('click',()=> openFilesFile(entry.path));
1339
+ }
1340
+ return wrap;
1341
+ }
1342
+ function renderFilesTree(){
1343
+ const box=$('#filesTree'); if(!box) return;
1344
+ box.innerHTML='';
1345
+ if(!filesTreeData || !filesTreeData.length){ box.append(el('div','empty',t('files.empty'))); return; }
1346
+ filesTreeData.forEach(e=> box.append(fNode(e)));
1347
+ }
1348
+ function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
1349
+ async function openFilesFile(relPath){
1350
+ // no native confirm() dialog (it blocks the whole tab, including our own SSE/automation) — a
1351
+ // dirty editor just refuses to switch until the user explicitly saves or discards.
1352
+ if(filesOpenDirty){
1353
+ const actions=$('#filesContent .files-actions');
1354
+ if(actions && !actions.querySelector('.files-error-tip')){
1355
+ const tip=el('span','files-error-tip',t('files.discardConfirm')); actions.append(tip);
1356
+ }
1357
+ return;
1358
+ }
1359
+ filesOpenPath=relPath; filesOpenDirty=false;
1360
+ renderFilesTree();
1361
+ const box=$('#filesContent'); box.innerHTML='';
1362
+ box.append(el('div','files-empty',t('drawer.loading')));
1363
+ let data;
1364
+ try{ const r=await fetch('/api/files/read?'+new URLSearchParams({path:relPath})); data=await r.json().catch(()=>({})); if(!r.ok) throw new Error(data.error||'error'); }
1365
+ catch(err){ box.innerHTML=''; box.append(el('div','files-empty',err.message||t('files.loadError'))); return; }
1366
+ box.innerHTML='';
1367
+ const bar=el('div','files-toolbar-row');
1368
+ bar.append(el('div','files-path',relPath));
1369
+ const actions=el('div','files-actions'); bar.append(actions);
1370
+ box.append(bar);
1371
+ if(data.binary){ box.append(el('div','files-binary',t('files.binary'))); return; }
1372
+ const ext=filesExt(relPath);
1373
+ const content=data.content||'';
1374
+ if(ext==='md'||ext==='markdown'){ renderFilesMd(box,actions,relPath,content); }
1375
+ else if(ext==='html'||ext==='htm'){ renderFilesHtml(box,actions,relPath,content); }
1376
+ else { renderFilesText(box,actions,relPath,content); }
1377
+ }
1378
+ function filesSavedTip(actions){
1379
+ const tip=el('span','files-saved-tip',t('files.saved')); actions.append(tip);
1380
+ setTimeout(()=>tip.remove(),1500);
1381
+ }
1382
+ // Explicit, non-blocking way to abandon local edits (no confirm() dialog) — re-fetches the file
1383
+ // fresh from disk and clears the dirty flag so switching tree files is unblocked again.
1384
+ function filesDiscardBtn(relPath){
1385
+ const btn=el('button',null,t('files.discard')); btn.type='button';
1386
+ btn.addEventListener('click',()=>{ filesOpenDirty=false; openFilesFile(relPath); });
1387
+ return btn;
1388
+ }
1389
+ async function filesSave(relPath,content,actions){
1390
+ try{
1391
+ const r=await fetch('/api/files/write',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:relPath,content})});
1392
+ const d=await r.json().catch(()=>({}));
1393
+ if(!r.ok) throw new Error(d.error||'error');
1394
+ filesOpenDirty=false; filesSavedTip(actions);
1395
+ }catch(err){
1396
+ const tip=el('span','files-error-tip',err.message||t('files.saveError')); actions.append(tip);
1397
+ }
1398
+ }
1399
+ // Markdown: rendered preview by default (via the same mdLite renderer the Agents & Skills drawer
1400
+ // uses), with an Edit toggle that swaps in a plain-text editor plus a tiny insert-at-cursor toolbar.
1401
+ function renderFilesMd(box,actions,relPath,content){
1402
+ let editing=false;
1403
+ const editBtn=el('button','btn',t('files.edit'));
1404
+ actions.append(editBtn);
1405
+ const body=el('div','files-body-col');
1406
+ box.append(body);
1407
+ const showPreview=()=>{ body.innerHTML=''; const md=el('div','files-view md-body'); md.innerHTML=mdLite(content); body.append(md); editBtn.textContent=t('files.edit'); };
1408
+ const showEditor=()=>{
1409
+ body.innerHTML='';
1410
+ const tb=el('div','files-md-toolbar');
1411
+ const ta=el('textarea','files-editor'); ta.value=content;
1412
+ const wrapSel=(before,after)=>{ const s=ta.selectionStart,e=ta.selectionEnd; const v=ta.value; ta.value=v.slice(0,s)+before+v.slice(s,e)+after+v.slice(e); ta.focus(); ta.selectionStart=s+before.length; ta.selectionEnd=e+before.length; content=ta.value; filesOpenDirty=true; };
1413
+ [['B','**','**'],['I','_','_'],['H','## ',''],['Link','[','](url)']].forEach(([label,a,b])=>{
1414
+ const bt=el('button',null,label); bt.type='button'; bt.addEventListener('click',()=>wrapSel(a,b)); tb.append(bt);
1415
+ });
1416
+ const saveBtn=el('button',null,t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>{ content=ta.value; filesSave(relPath,content,actions); });
1417
+ tb.append(saveBtn,filesDiscardBtn(relPath));
1418
+ ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
1419
+ body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
1420
+ };
1421
+ editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
1422
+ showPreview();
1423
+ }
1424
+ // HTML: sandboxed iframe preview by default, plain-text editor on toggle (no WYSIWYG — the
1425
+ // dashboard stays zero-dependency, no editor library is loaded).
1426
+ function renderFilesHtml(box,actions,relPath,content){
1427
+ let editing=false;
1428
+ const editBtn=el('button','btn',t('files.edit'));
1429
+ actions.append(editBtn);
1430
+ const body=el('div','files-body-col');
1431
+ box.append(body);
1432
+ const showPreview=()=>{
1433
+ body.innerHTML='';
1434
+ const frame=document.createElement('iframe');
1435
+ frame.className='files-iframe'; frame.setAttribute('sandbox',''); frame.srcdoc=content;
1436
+ body.append(frame); editBtn.textContent=t('files.edit');
1437
+ };
1438
+ const showEditor=()=>{
1439
+ body.innerHTML='';
1440
+ const ta=el('textarea','files-editor'); ta.value=content;
1441
+ ta.addEventListener('input',()=>{ content=ta.value; filesOpenDirty=true; });
1442
+ const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button'; saveBtn.addEventListener('click',()=>filesSave(relPath,content,actions));
1443
+ const tb=el('div','files-md-toolbar'); tb.append(saveBtn,filesDiscardBtn(relPath));
1444
+ body.append(tb,ta); ta.focus(); editBtn.textContent=t('files.preview');
1445
+ };
1446
+ editBtn.addEventListener('click',()=>{ editing=!editing; if(editing) showEditor(); else showPreview(); });
1447
+ showPreview();
1448
+ }
1449
+ // Anything else that reads as text: a plain monospace editor, editable straight away.
1450
+ function renderFilesText(box,actions,relPath,content){
1451
+ const ta=el('textarea','files-editor'); ta.value=content;
1452
+ ta.addEventListener('input',()=>{ filesOpenDirty=true; });
1453
+ const saveBtn=el('button','btn primary',t('files.save')); saveBtn.type='button';
1454
+ saveBtn.addEventListener('click',()=>filesSave(relPath,ta.value,actions));
1455
+ actions.append(saveBtn,filesDiscardBtn(relPath));
1456
+ box.append(ta); ta.focus();
1457
+ }
1458
+ // "+ File"/"+ Folder" open the same inline form (never a native prompt()/alert() — those block the
1459
+ // whole tab, including our own SSE connection, until dismissed).
1460
+ let filesCreateKind='file';
1461
+ function openFilesCreateForm(kind){
1462
+ filesCreateKind=kind;
1463
+ const form=$('#filesCreateForm'); if(!form) return;
1464
+ const input=$('#filesCreateInput');
1465
+ input.placeholder = kind==='dir' ? t('files.newFolderPrompt') : t('files.newFilePrompt');
1466
+ input.value='';
1467
+ const err=$('#filesCreateError'); if(err) err.hidden=true;
1468
+ form.hidden=false; form.classList.add('is-open');
1469
+ input.focus();
1470
+ }
1471
+ function closeFilesCreateForm(){
1472
+ const form=$('#filesCreateForm'); if(!form) return;
1473
+ form.hidden=true; form.classList.remove('is-open');
1474
+ }
1475
+ async function submitFilesCreate(){
1476
+ const input=$('#filesCreateInput'); const err=$('#filesCreateError');
1477
+ const name=(input.value||'').trim();
1478
+ if(!name){ if(err){ err.textContent=t('backlog.addTitleRequired'); err.hidden=false; } return; }
1479
+ const rel=name.replace(/^\/+/,'');
1480
+ const kind=filesCreateKind;
1481
+ const endpoint = kind==='dir' ? '/api/files/mkdir' : '/api/files/write';
1482
+ const payload = kind==='dir' ? {path:rel} : {path:rel,content:''};
1483
+ const r=await fetch(endpoint,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
1484
+ const d=await r.json().catch(()=>({}));
1485
+ if(!r.ok){ if(err){ err.textContent=d.error||t('files.saveError'); err.hidden=false; } return; }
1486
+ closeFilesCreateForm();
1487
+ await loadFilesTree();
1488
+ if(kind!=='dir') openFilesFile(rel);
1489
+ }
1490
+
1239
1491
  function openDrawer(id,keep){
1240
1492
  // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
1241
1493
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
@@ -1283,7 +1535,7 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
1283
1535
  // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
1284
1536
  // 'change'/'message' event that lands right after a click.
1285
1537
  // initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
1286
- let activeTab = tabFromPath() || (()=>{ try{ return localStorage.getItem('spf-tab')||'board'; }catch{ return 'board'; } })();
1538
+ let activeTab = tabFromPath() || (()=>{ try{ return normalizeTab(localStorage.getItem('spf-tab'))||'board'; }catch{ return 'board'; } })();
1287
1539
  openTaskId = taskFromPath(); // deep-link straight to a task drawer
1288
1540
  function applyActiveTab(){
1289
1541
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
@@ -1308,6 +1560,9 @@ window.addEventListener('popstate',()=>{
1308
1560
  // brand logo → Board (SPA nav, no full reload)
1309
1561
  const brandLogo=$('.brand-logo'); if(brandLogo) brandLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
1310
1562
  applyActiveTab(); // sync to the resolved tab before the first render
1563
+ // an old bookmark/share to the pre-rename "/settings" URL: swap the address bar to the real
1564
+ // route once resolved, so the visible URL matches the "Personalize" tab it landed on.
1565
+ if(location.pathname.split('/').filter(Boolean)[0]==='settings') history.replaceState(null,'','/personalize');
1311
1566
  // filters (status chips + search) — client-side only, does not write anything
1312
1567
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1313
1568
  $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
@@ -1330,12 +1585,24 @@ $$('#backlogTable thead th').forEach(th=> th.addEventListener('click', ()=>{
1330
1585
  else { backlogSort.col=col; backlogSort.dir='asc'; }
1331
1586
  backlogPage=1; renderBacklog();
1332
1587
  }));
1588
+ // files tab: create a new file/folder (path relative to the project root, e.g. "notes/todo.md")
1589
+ const filesNewFileBtn=$('#filesNewFile'); if(filesNewFileBtn) filesNewFileBtn.addEventListener('click',()=>openFilesCreateForm('file'));
1590
+ const filesNewFolderBtn=$('#filesNewFolder'); if(filesNewFolderBtn) filesNewFolderBtn.addEventListener('click',()=>openFilesCreateForm('dir'));
1591
+ const filesRefreshBtn=$('#filesRefresh'); if(filesRefreshBtn) filesRefreshBtn.addEventListener('click',loadFilesTree);
1592
+ const filesCreateGoBtn=$('#filesCreateGo'); if(filesCreateGoBtn) filesCreateGoBtn.addEventListener('click',submitFilesCreate);
1593
+ const filesCreateCancelBtn=$('#filesCreateCancel'); if(filesCreateCancelBtn) filesCreateCancelBtn.addEventListener('click',closeFilesCreateForm);
1594
+ const filesCreateInputEl=$('#filesCreateInput'); if(filesCreateInputEl) filesCreateInputEl.addEventListener('keydown',e=>{ if(e.key==='Enter') submitFilesCreate(); });
1595
+ // backlog: manual "+ Add task" form (title required, phase/owner/level optional)
1596
+ $('#backlogAddBtn').addEventListener('click', ()=>{ const form=$('#backlogAddForm'); (form&&!form.hidden)?closeBacklogAddForm():openBacklogAddForm(); });
1597
+ $('#blAddCancel').addEventListener('click', closeBacklogAddForm);
1598
+ $('#blAddSubmit').addEventListener('click', submitBacklogAdd);
1599
+ $('#blAddTitle').addEventListener('keydown', e=>{ if(e.key==='Enter') submitBacklogAdd(); });
1333
1600
  // attention tab: add a note + filter chips
1334
1601
  $('#attnAddBtn').addEventListener('click',()=>{ const t=$('#attnInput'); const v=t.value.trim(); if(v){ addAttn(v); t.value=''; } });
1335
1602
  $('#attnInput').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter'){ const v=e.target.value.trim(); if(v){ addAttn(v); e.target.value=''; } } });
1336
1603
  $$('.attn-filters .fchip').forEach(b=> b.addEventListener('click',()=>{ attnFilter=b.dataset.attn; renderAttention(); }));
1337
1604
  // settings — the footer link opens the Settings tab; selects save on change
1338
- const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('settings'));
1605
+ const footerSettingsBtn=$('#footerSettings'); if(footerSettingsBtn) footerSettingsBtn.addEventListener('click',()=>navigateTab('personalize'));
1339
1606
  const footerLogo=$('.footer-logo'); if(footerLogo) footerLogo.addEventListener('click',e=>{ e.preventDefault(); navigateTab('board'); });
1340
1607
  function onModeSelectChange(e){ setModeSelects(e.target.value); saveSettings(); }
1341
1608
  function onLangSelectChange(e){ setLangSelect(e.target.value); saveSettings(); }
@@ -31,33 +31,32 @@ html[data-design="console"][data-theme="light"] {
31
31
  --cx-grid:rgba(15,23,40,.06);
32
32
  }
33
33
 
34
- /* ============ AMBIENT BACKDROP ============ */
34
+ /* ============ AMBIENT BACKDROP — a plain grid texture, no color glow ============ */
35
35
  html[data-design="console"] body { background:var(--bg); position:relative; }
36
36
  html[data-design="console"] body::before {
37
37
  content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
38
38
  background:
39
- radial-gradient(900px 500px at 10% -10%, color-mix(in srgb,var(--signal) 14%,transparent), transparent 60%),
40
- radial-gradient(800px 500px at 100% 0%, color-mix(in srgb,var(--cool) 14%,transparent), transparent 60%),
41
39
  linear-gradient(var(--cx-grid) 1px, transparent 1px), linear-gradient(90deg, var(--cx-grid) 1px, transparent 1px);
42
- background-size:auto,auto,32px 32px,32px 32px;
40
+ background-size:32px 32px,32px 32px;
43
41
  }
44
42
  html[data-design="console"] .topbar,
45
43
  html[data-design="console"] .stage { position:relative; z-index:1; }
46
44
 
47
45
  /* ============ LEFT ICON RAIL ============ */
48
46
  html[data-design="console"] nav.tabs#tabs {
49
- position:fixed; left:0; top:0; bottom:0; height:100vh; z-index:30; width:var(--cx-rail-w);
47
+ position:fixed; left:0; top:0; bottom:44px; z-index:30; width:var(--cx-rail-w);
50
48
  display:flex; flex-direction:column; align-items:center; gap:6px;
51
49
  padding:16px 10px; overflow-y:auto; overflow-x:hidden; max-width:none;
52
50
  background:color-mix(in srgb,var(--surface) 84%,transparent); backdrop-filter:blur(10px);
53
51
  border-right:1px solid var(--line); border-bottom:0;
52
+ transition:width .18s ease;
54
53
  }
55
- html[data-design="console"] .topbar { padding-left:calc(var(--cx-rail-w) + 16px); }
56
- html[data-design="console"] .stage { margin-left:var(--cx-rail-w); }
54
+ html[data-design="console"] .topbar { padding-left:calc(var(--cx-rail-w) + 16px); transition:padding-left .18s ease; }
55
+ html[data-design="console"] .stage { margin-left:var(--cx-rail-w); transition:margin-left .18s ease; }
57
56
  /* .app-footer is a sibling of .stage, not inside it — the rail is fixed to the viewport (not just
58
57
  .stage), so without this it clips the footer's left edge (the brand name loses its first letters)
59
58
  at every scroll position, not just at the top. */
60
- html[data-design="console"] .app-footer { margin-left:var(--cx-rail-w); }
59
+ html[data-design="console"] .app-footer { margin-left:var(--cx-rail-w); transition:margin-left .18s ease; }
61
60
  /* The header brand mark reads too small at the base 26px against this design's darker, denser
62
61
  topbar — give it more presence. The footer's own (deliberately smaller) logo keeps its existing,
63
62
  more specific rule and is unaffected. */
@@ -73,7 +72,7 @@ html[data-design="console"] nav.tabs#tabs .tab.is-active {
73
72
  color:var(--signal); background:var(--surface-2);
74
73
  box-shadow:inset 3px 0 0 var(--signal);
75
74
  }
76
- html[data-design="console"] nav.tabs#tabs .tab-ico { width:19px; height:19px; }
75
+ html[data-design="console"] nav.tabs#tabs .tab-ico { width:19px; height:19px; flex-shrink:0; }
77
76
  html[data-design="console"] nav.tabs#tabs .tab-label {
78
77
  position:absolute; left:60px; top:50%; transform:translateY(-50%) translateX(-4px);
79
78
  background:var(--surface-3); color:var(--ink); padding:5px 9px; border-radius:7px;
@@ -89,6 +88,32 @@ html[data-design="console"] nav.tabs#tabs .tab-badge {
89
88
  position:absolute; top:2px; right:4px; margin-left:0; min-width:15px; height:15px; font-size:9.5px;
90
89
  }
91
90
 
91
+ /* ---- rail expand/collapse toggle — icon-only stays the default; expanded shows menu names ---- */
92
+ html[data-design="console"] #cxRailToggle {
93
+ position:fixed; left:0; bottom:0; z-index:31; width:var(--cx-rail-w); height:44px;
94
+ display:flex; align-items:center; justify-content:center; gap:8px; padding:0;
95
+ background:color-mix(in srgb,var(--surface) 92%,transparent); backdrop-filter:blur(10px);
96
+ border-right:1px solid var(--line); border-top:1px solid var(--line);
97
+ color:var(--muted); cursor:pointer; transition:width .18s ease,color .18s ease;
98
+ }
99
+ html[data-design="console"] #cxRailToggle:hover,
100
+ html[data-design="console"] #cxRailToggle:focus-visible { color:var(--ink); }
101
+ html[data-design="console"] #cxRailToggle svg { width:16px; height:16px; flex-shrink:0; transition:transform .18s ease; }
102
+ html[data-design="console"] #cxRailToggle .cx-rail-toggle-label { display:none; font-size:11.5px; font-weight:600; white-space:nowrap; }
103
+ html[data-design="console"][data-rail="expanded"] { --cx-rail-w:208px; }
104
+ html[data-design="console"][data-rail="expanded"] nav.tabs#tabs { align-items:stretch; }
105
+ html[data-design="console"][data-rail="expanded"] nav.tabs#tabs .tab {
106
+ flex-direction:row; justify-content:flex-start; align-items:center; gap:12px;
107
+ width:100%; height:42px; padding:0 12px;
108
+ }
109
+ html[data-design="console"][data-rail="expanded"] nav.tabs#tabs .tab-label {
110
+ position:static; opacity:1; pointer-events:none; transform:none; background:none; color:inherit;
111
+ border:0; box-shadow:none; padding:0; font-weight:500;
112
+ }
113
+ html[data-design="console"][data-rail="expanded"] #cxRailToggle { justify-content:flex-start; padding:0 15px; }
114
+ html[data-design="console"][data-rail="expanded"] #cxRailToggle svg { transform:rotate(180deg); }
115
+ html[data-design="console"][data-rail="expanded"] #cxRailToggle .cx-rail-toggle-label { display:inline; }
116
+
92
117
  /* ============ REVEAL + HOVER LIFT (bento tiles / cards) ============ */
93
118
  html[data-design="console"] .kpi,
94
119
  html[data-design="console"] .ocard,
@@ -190,6 +215,7 @@ html[data-design="console"][data-theme="light"] .cx-cmdk { background:rgba(15,23
190
215
  html[data-design="console"] .stage { margin-left:0; margin-bottom:64px; }
191
216
  html[data-design="console"] .app-footer { margin-left:0; }
192
217
  html[data-design="console"] nav.tabs#tabs .tab-label { display:none !important; }
218
+ html[data-design="console"] #cxRailToggle { display:none; } /* the bottom bar has no room for it, and there's no rail width left to expand */
193
219
  }
194
220
 
195
221
  @media (prefers-reduced-motion: reduce) {
@@ -131,11 +131,47 @@
131
131
  tabsHome = null;
132
132
  }
133
133
 
134
+ // ---- rail expand/collapse — icon-only stays the default; a viewer can opt into showing the
135
+ // menu names too, persisted per browser (like the Board's List/Kanban toggle). ----
136
+ var railToggle = null;
137
+ function railExpanded() {
138
+ try { return localStorage.getItem('spf-console-rail') === 'expanded'; } catch (e) { return false; }
139
+ }
140
+ function applyRailState() {
141
+ var expanded = railExpanded();
142
+ document.documentElement.setAttribute('data-rail', expanded ? 'expanded' : 'collapsed');
143
+ if (railToggle) railToggle.setAttribute('aria-label', expanded ? 'Collapse sidebar' : 'Expand sidebar');
144
+ var label = railToggle && railToggle.querySelector('.cx-rail-toggle-label');
145
+ if (label) label.textContent = expanded ? 'Collapse' : 'Expand';
146
+ }
147
+ function toggleRail() {
148
+ var expanded = !railExpanded();
149
+ try { localStorage.setItem('spf-console-rail', expanded ? 'expanded' : 'collapsed'); } catch (e) {}
150
+ applyRailState();
151
+ }
152
+ function injectRailToggle() {
153
+ if (railToggle || document.getElementById('cxRailToggle')) return;
154
+ railToggle = document.createElement('button');
155
+ railToggle.id = 'cxRailToggle';
156
+ railToggle.type = 'button';
157
+ railToggle.innerHTML =
158
+ '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg>' +
159
+ '<span class="cx-rail-toggle-label"></span>';
160
+ railToggle.addEventListener('click', toggleRail);
161
+ document.body.appendChild(railToggle);
162
+ applyRailState();
163
+ }
164
+ function removeRailToggle() {
165
+ if (railToggle) { railToggle.remove(); railToggle = null; }
166
+ document.documentElement.removeAttribute('data-rail');
167
+ }
168
+
134
169
  function activate() {
135
170
  if (active) return;
136
171
  active = true;
137
172
  dockRail();
138
173
  injectButton();
174
+ injectRailToggle();
139
175
  document.addEventListener('keydown', onKeydown);
140
176
  }
141
177
 
@@ -144,6 +180,7 @@
144
180
  active = false;
145
181
  close();
146
182
  removeButton();
183
+ removeRailToggle();
147
184
  undockRail();
148
185
  document.removeEventListener('keydown', onKeydown);
149
186
  }