spectoflow 0.23.5 → 0.27.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.
Files changed (90) hide show
  1. package/README.md +49 -16
  2. package/bin/postinstall.js +1 -0
  3. package/bin/spectoflow.js +226 -35
  4. package/lib/adapters.js +8 -2
  5. package/{templates/lib → lib}/custom-dashboard.js +2 -2
  6. package/{templates/lib → lib}/customize-prompts.js +1 -1
  7. package/lib/dashboard/connector.js +193 -0
  8. package/lib/dashboard/handlers.js +53 -0
  9. package/lib/{hub-server.js → dashboard/hub-server.js} +134 -51
  10. package/lib/dashboard/inject-design.js +41 -0
  11. package/lib/dashboard/meeting.js +116 -0
  12. package/lib/dashboard/ops.js +257 -0
  13. package/{templates → lib}/dashboard/orchestrator.js +17 -8
  14. package/{templates → lib}/dashboard/public/app.js +743 -124
  15. package/{templates → lib}/dashboard/public/charts.js +3 -3
  16. package/lib/dashboard/public/commands.js +77 -0
  17. package/{templates → lib}/dashboard/public/designs/console.css +7 -19
  18. package/{templates → lib}/dashboard/public/designs/orbit.css +3 -4
  19. package/{templates → lib}/dashboard/public/designs.js +2 -2
  20. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  21. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  22. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  23. package/{templates → lib}/dashboard/public/hub.html +1 -1
  24. package/{templates → lib}/dashboard/public/hub.js +27 -4
  25. package/{templates → lib}/dashboard/public/i18n.js +60 -18
  26. package/{templates → lib}/dashboard/public/icons.js +2 -0
  27. package/{templates → lib}/dashboard/public/index.html +106 -13
  28. package/{templates → lib}/dashboard/public/styles.css +161 -26
  29. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  46. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  47. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  48. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  49. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  50. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  51. package/lib/dashboard/routes.js +38 -0
  52. package/{templates → lib}/dashboard/runner.js +13 -7
  53. package/{templates → lib}/dashboard/summarize.js +1 -1
  54. package/lib/detect.js +16 -1
  55. package/lib/global-config.js +65 -0
  56. package/lib/init.js +10 -4
  57. package/lib/registry.js +8 -8
  58. package/{templates/lib → lib}/store.js +16 -12
  59. package/lib/update.js +69 -1
  60. package/lib/workspace.js +119 -0
  61. package/package.json +3 -3
  62. package/templates/AGENTS.md +6 -6
  63. package/templates/README.md +4 -3
  64. package/templates/agents/framework-curator.md +11 -11
  65. package/templates/capabilities.md +1 -1
  66. package/templates/config.json +23 -0
  67. package/templates/dashboards/.gitkeep +0 -0
  68. package/templates/skills/generate-dashboard/SKILL.md +15 -13
  69. package/templates/dashboard/custom/.gitkeep +0 -3
  70. package/templates/dashboard/handlers.js +0 -251
  71. package/templates/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  72. package/templates/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  73. package/templates/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
  74. package/templates/dashboard/server.js +0 -73
  75. package/templates/lib/agents-registry.js +0 -65
  76. /package/{templates → lib}/dashboard/files.js +0 -0
  77. /package/{templates → lib}/dashboard/public/designs/console.js +0 -0
  78. /package/{templates → lib}/dashboard/public/designs/orbit.js +0 -0
  79. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-400.woff2 +0 -0
  80. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-500.woff2 +0 -0
  81. /package/{templates → lib}/dashboard/public/fonts/ibm-plex-sans-600.woff2 +0 -0
  82. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-400.woff2 +0 -0
  83. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-500.woff2 +0 -0
  84. /package/{templates → lib}/dashboard/public/fonts/jetbrains-mono-700.woff2 +0 -0
  85. /package/{templates → lib}/dashboard/public/fonts/sora-400.woff2 +0 -0
  86. /package/{templates → lib}/dashboard/public/fonts/sora-600.woff2 +0 -0
  87. /package/{templates → lib}/dashboard/public/fonts/sora-700.woff2 +0 -0
  88. /package/{templates → lib}/dashboard/public/logo-dark.png +0 -0
  89. /package/{templates → lib}/dashboard/public/logo-white.png +0 -0
  90. /package/{templates → lib}/dashboard/public/stats.js +0 -0
@@ -6,20 +6,48 @@ const STATUS = { todo:'To do', in_progress:'In progress', to_validate:'To valida
6
6
  function updateStatusLabels(){ for(const k of Object.keys(STATUS)) STATUS[k]=t('status.'+k); }
7
7
  let P = null, openTaskId = null;
8
8
  let filter = { status: 'all', q: '' }; // board filter state — client-side only, read-only
9
- let boardView = (()=>{ try{ return localStorage.getItem('spf-board-view')||'list'; }catch{ return 'list'; } })(); // 'list' | 'kanban'
10
- let sideHidden = (()=>{ try{ return localStorage.getItem('spf-side-hidden')==='1'; }catch{ return false; } })(); // right sidebar (Journal/Specs/Running) — mainly to give Kanban's own-width columns more room
9
+ // boardView/sideHidden/expandedPhases/activeTab/chatOpen used to be per-viewer localStorage
10
+ // preferences; they are now real project settings, persisted server-side via /api/settings (same
11
+ // mechanism as design/mode/language/agent — see saveSetting() below) so they survive a reload on any
12
+ // device/browser, including through the online-hosted dashboard. Each starts at the same default the
13
+ // old localStorage-absent fallback used, then is reconciled from P.config the first time it loads
14
+ // (see syncSettingsFromServer(), called once from load()) — after that they're local state mutated
15
+ // only by user actions, exactly like the old localStorage-backed vars were.
16
+ // The 13 native tab ids, mirroring ops.js's NATIVE_TABS exactly — the client's own source of truth
17
+ // for "what's a real native tab id" (used to validate P.config.navTabs before trusting it, same
18
+ // defensive stance as ROUTES further down). Task 1 shipped the original 11, all default-enabled; Task
19
+ // 2 (Sous-projet C) registered 'notes' — the Bloc note scratchpad — as the first tab that must start
20
+ // OFF by default (an opt-in feature, per the user's explicit request); Task 3 registers 'meeting' —
21
+ // the Daily meeting tab — the same way, so DEFAULT_OFF_TABS exists rather than a blanket "every
22
+ // native tab defaults to enabled" the way Task 1 assumed.
23
+ const NATIVE_TABS = ['board', 'chat', 'requests', 'attention', 'backlog', 'workflow', 'team', 'files', 'notes', 'meeting', 'info', 'docs', 'personalize'];
24
+ const DEFAULT_OFF_TABS = new Set(['notes', 'meeting']);
25
+ function defaultNavTabs() { return NATIVE_TABS.map((id) => ({ id, enabled: !DEFAULT_OFF_TABS.has(id) })); }
26
+ let boardView = 'list'; // 'list' | 'kanban'
27
+ let sideHidden = false; // right sidebar (Journal/Specs/Running) — mainly to give Kanban's own-width columns more room
28
+ // Kanban column visibility + per-column page size (Sous-projet B, Task 2) — same persisted-project-
29
+ // setting shape as the vars above. kanbanColumns defaults to every status (today's behavior); the
30
+ // server (writeConfig()) refuses to ever persist an empty array, and the client UI mirrors that same
31
+ // rule (see toggleKanbanColumn()) so the last visible column can't be turned off either way.
32
+ let kanbanColumns = Object.keys(STATUS);
33
+ let kanbanPageSize = 10; // 10 | 20
34
+ // Per-column "show more" reveal state — deliberately NOT persisted (a per-view convenience, not a
35
+ // saved preference): starts empty every load and is reset on any full render() tick (SSE reload) or
36
+ // on re-filtering (search) / changing the page size, but survives a "Show more" click itself (that
37
+ // handler adds to this set then re-renders the board without clearing it first).
38
+ let kanbanExpanded = new Set();
11
39
  let backlogFilter = { status: 'open', q: '' }; // backlog defaults to open (not-done) tasks
12
40
  let backlogSort = { col: 'id', dir: 'asc' }; // backlog sort state — client-side only
13
41
  let backlogPage = 1; const BACKLOG_PAGE = 25; // backlog pagination — client-side only
14
42
  let attnFilter = 'open'; // attention tab filter — client-side only
15
- // apply the persisted design skin as early as possible (before the first paint of app-driven DOM)
16
- (function(){ try{ const d=localStorage.getItem('spf-design'); if(d) document.documentElement.setAttribute('data-design',d); }catch{} })();
43
+ // The design skin itself is applied server-side, before the client even sees this file: hub-server.js
44
+ // (lib/dashboard/inject-design.js) stamps data-design="..." straight into the served index.html's
45
+ // <html> tag from the project's real config.design — no localStorage read needed for first paint.
17
46
 
18
47
  // The project this dashboard tab is showing — derived once from the URL's /p/<id>/... prefix. The
19
48
  // hub-server's legacy-route redirect (sub-project 3) guarantees a bookmark without this prefix never
20
- // reaches this file directly; it 302s to a /p/<id>/... URL first. Null when served by the older
21
- // single-project templates/dashboard/server.js (no prefix at all) — every helper below no-ops in
22
- // that case, preserving today's exact single-project behavior.
49
+ // reaches this file directly; it 302s to a /p/<id>/... URL first. Null defensively (no prefix at
50
+ // all) — every helper below no-ops in that case rather than assuming the prefix is always present.
23
51
  const PROJECT_ID = (() => { const m = location.pathname.match(/^\/p\/([0-9a-f]{6})(?:\/|$)/); return m ? m[1] : null; })();
24
52
  // Every /api/* fetch/EventSource call funnels its URL through this — the one place a project id gets
25
53
  // attached, so no call site can forget it. Handles both "no query string yet" (?p=) and "already has
@@ -32,6 +60,232 @@ function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : res
32
60
  // strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
33
61
  function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
34
62
 
63
+ // ---- online dashboard (C1): offline, read-only state ----
64
+ // Served by the relay while a project's machine is away, /api/project carries online:false + lastSeen.
65
+ // One guard on fetch() short-circuits every mutating /api call to a 503 (instead of touching ~40 call
66
+ // sites); the banner says why; the busy state (updateChatBusyUI) disables the run/orchestrate buttons.
67
+ // The local hub never sends online:false, so none of this ever triggers locally.
68
+ let OFFLINE=false;
69
+ const _fetch=window.fetch.bind(window);
70
+ window.fetch=(url,opts)=>{
71
+ const method=((opts&&opts.method)||'GET').toUpperCase();
72
+ if(OFFLINE&&method!=='GET'&&String(url).startsWith('/api/')) return Promise.resolve(new Response(JSON.stringify({error:t('offline.readonly')}),{status:503,headers:{'Content-Type':'application/json'}}));
73
+ return _fetch(url,opts);
74
+ };
75
+ function setOffline(p){
76
+ OFFLINE=p.online===false;
77
+ document.body.classList.toggle('is-offline',OFFLINE);
78
+ const bar=$('#offlineBar'); if(!bar) return;
79
+ bar.hidden=!OFFLINE;
80
+ if(OFFLINE){ const when=p.lastSeen?new Date(p.lastSeen).toLocaleString():'—'; $('#offlineText').textContent=t('offline.banner',{name:p.projectName||'project',when}); }
81
+ updateChatBusyUI();
82
+ }
83
+
84
+ // ---- generalized per-project settings persistence (Sous-projet B) ----
85
+ // Every dashboard preference below (theme/boardView/sideHidden/expandedPhases/activeTab/chatOpen) is
86
+ // now a real config.json field, saved through the exact same /api/settings op used by design/mode/
87
+ // language/agent — see docs' Sous-projet B note. A frequent-write setting (activeTab, chatOpen,
88
+ // expandedPhases can change on nearly every click) is debounced so a burst of clicks collapses into
89
+ // one write/round-trip; the visible UI/DOM always updates synchronously and instantly regardless —
90
+ // only the persistence call is delayed. Reused for the rarer settings too (design/theme/boardView/
91
+ // sideHidden) since a 300ms delay on one isolated click is imperceptible and it keeps one code path.
92
+ let _saveSettingTimer = null, _saveSettingPending = null;
93
+ function saveSetting(patch) {
94
+ _saveSettingPending = Object.assign(_saveSettingPending || {}, patch);
95
+ clearTimeout(_saveSettingTimer);
96
+ _saveSettingTimer = setTimeout(() => {
97
+ const body = _saveSettingPending; _saveSettingPending = null;
98
+ fetch(withProject('/api/settings'), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }).catch(() => {});
99
+ }, 400);
100
+ }
101
+ // Reconciles the local, session-scoped preference vars from the server's config the first time a
102
+ // project's data loads — after that they're local state, mutated only by user actions (exactly like
103
+ // the old localStorage-backed vars were), and saved back out via saveSetting() above. Only ever
104
+ // applied once (settingsSynced) so a later reload (SSE-driven, e.g. another viewer editing a task)
105
+ // never clobbers a change the user just made locally that hasn't finished its debounce yet.
106
+ let settingsSynced = false;
107
+ function syncSettingsFromServer() {
108
+ if (settingsSynced || !P || !P.config) return;
109
+ settingsSynced = true;
110
+ const c = P.config;
111
+ if (typeof c.theme === 'string') document.documentElement.setAttribute('data-theme', c.theme);
112
+ if (typeof c.boardView === 'string') boardView = c.boardView;
113
+ if (typeof c.sideHidden === 'boolean') { sideHidden = c.sideHidden; applySideHidden(); }
114
+ if (Array.isArray(c.expandedPhases)) { expandedPhases = new Set(c.expandedPhases); updatePhaseToggleAll(); }
115
+ // activeTab: a URL deep-link (tabFromPath()) always wins over the saved preference — matches the
116
+ // old localStorage priority order exactly (tabFromPath() || saved || 'board').
117
+ if (typeof c.activeTab === 'string' && !tabFromPath()) { activeTab = normalizeTab(c.activeTab); applyActiveTab(); }
118
+ if (typeof c.chatOpen === 'boolean' && c.chatOpen) setChat(true);
119
+ if (Array.isArray(c.kanbanColumns) && c.kanbanColumns.length) kanbanColumns = c.kanbanColumns.filter((k) => STATUS[k]);
120
+ if (c.kanbanPageSize === 10 || c.kanbanPageSize === 20) kanbanPageSize = c.kanbanPageSize;
121
+ }
122
+
123
+ // ---- nav tabs enable/disable/reorder (Sous-projet C, Task 1) ----------------------------------
124
+ // Unlike the per-viewer preferences above, navTabs is read fresh from P.config on every render() —
125
+ // it's shared, structural nav config (closer to P.customDashboards than to a per-viewer convenience
126
+ // like boardView), so another viewer/session disabling or reordering a tab must take effect on this
127
+ // tab's very next SSE-driven reload, not just after this tab's own next full page load. A local
128
+ // optimistic edit (toggleNavTab()/moveNavTab() below) mutates P.config.navTabs directly so it's
129
+ // still reflected instantly and survives until the debounced save round-trips.
130
+ function currentNavTabs() {
131
+ const c = P && P.config, arr = c && c.navTabs;
132
+ // A config saved before a new native tab existed (e.g. any project's navTabs from before this task
133
+ // added 'notes' — including one with no navTabs field at all, like a project that predates Task 1)
134
+ // is a genuinely common case, not just a malformed one: keep whatever known, non-duplicate entries
135
+ // it already has (preserving the viewer's own enable-state/order) and only append the ids it's
136
+ // missing, each at ITS OWN default (see DEFAULT_OFF_TABS) — never reset the whole array back to
137
+ // defaults just because it's short one id. A truly invalid entry (unknown id, duplicate, wrong
138
+ // shape) is dropped rather than trusted.
139
+ const known = Array.isArray(arr)
140
+ ? arr.filter((v) => v && typeof v === 'object' && typeof v.id === 'string' && typeof v.enabled === 'boolean' && NATIVE_TABS.includes(v.id))
141
+ : [];
142
+ const noDupes = new Set(known.map((v) => v.id)).size === known.length;
143
+ const base = noDupes ? known.slice() : [];
144
+ NATIVE_TABS.forEach((id) => { if (!base.find((v) => v.id === id)) base.push({ id, enabled: !DEFAULT_OFF_TABS.has(id) }); });
145
+ return base.map((v) => ({ id: v.id, enabled: v.id === 'personalize' ? true : !!v.enabled }));
146
+ }
147
+ // Hides/reorders the native <button class="tab"> elements inside #tabs to match currentNavTabs() —
148
+ // Console's rail and Orbit's radial menu both read #tabs live (see syncCustomTabs()'s own comment),
149
+ // so operating on the shared DOM here covers every design with no per-design code. Custom-dashboard
150
+ // tabs (syncCustomTabs()) are never touched: each native button is inserted right before whichever
151
+ // tab was already the first "custom:" one (or appended at the end if there isn't one yet), so native
152
+ // tabs always land before custom ones regardless of the order these two functions run in.
153
+ function applyNavTabs() {
154
+ const nav = $('#tabs'); if (!nav) return;
155
+ const list = currentNavTabs();
156
+ const anchor = nav.querySelector('.tab[data-tab^="custom:"]') || null;
157
+ list.forEach((entry) => {
158
+ const btn = nav.querySelector('.tab[data-tab="' + entry.id + '"]');
159
+ if (!btn) return;
160
+ btn.hidden = !entry.enabled;
161
+ nav.insertBefore(btn, anchor);
162
+ });
163
+ // If the currently active NATIVE tab just became disabled (e.g. the user disabled the tab they're
164
+ // viewing, or another viewer did and this tab just reloaded), navigate to a sensible fallback
165
+ // instead of leaving a hidden/disabled panel showing.
166
+ const activeEntry = list.find((e) => e.id === activeTab);
167
+ if (activeEntry && !activeEntry.enabled) {
168
+ const fallback = list.find((e) => e.id === 'board' && e.enabled) || list.find((e) => e.enabled);
169
+ if (fallback) navigateTab(fallback.id);
170
+ }
171
+ }
172
+ // Toggles one tab's enabled state (locked no-op for 'personalize' — mirrors the server's own refusal
173
+ // so a click there gives immediate, obvious feedback instead of a silently-ignored save) and applies
174
+ // it live, then persists the whole array via saveSetting() (the "whole array" contract writeConfig()
175
+ // expects, same as toggleKanbanColumn()'s kanbanColumns save).
176
+ function toggleNavTab(id) {
177
+ if (id === 'personalize') return;
178
+ const next = currentNavTabs().map((e) => e.id === id ? { id, enabled: !e.enabled } : e);
179
+ if (P && P.config) P.config.navTabs = next;
180
+ applyNavTabs(); renderNavTabsSettings(); saveSetting({ navTabs: next });
181
+ }
182
+ // Moves one tab up/down in the order (no-op at either boundary — mirrored by disabling the button in
183
+ // renderNavTabsSettings()) and applies + persists exactly like toggleNavTab().
184
+ function moveNavTab(id, dir) {
185
+ const list = currentNavTabs();
186
+ const i = list.findIndex((e) => e.id === id);
187
+ const j = i + dir;
188
+ if (i < 0 || j < 0 || j >= list.length) return;
189
+ [list[i], list[j]] = [list[j], list[i]];
190
+ if (P && P.config) P.config.navTabs = list;
191
+ applyNavTabs(); renderNavTabsSettings(); saveSetting({ navTabs: list });
192
+ }
193
+ // Renders the Personalize → "Navigation tabs" card: one row per native tab, in its own order — a
194
+ // checkbox (disabled + explained for 'personalize', which can never be turned off) and up/down
195
+ // reorder buttons (disabled at either boundary). Reuses each tab's own nav.* i18n label — no new tab
196
+ // names invented here.
197
+ function renderNavTabsSettings() {
198
+ const box = $('#navTabsList'); if (!box) return;
199
+ const list = currentNavTabs();
200
+ box.innerHTML = '';
201
+ list.forEach((entry, idx) => {
202
+ const locked = entry.id === 'personalize';
203
+ const row = el('div', 'nav-tabs-row' + (locked ? ' is-locked' : ''));
204
+ const cb = document.createElement('input');
205
+ cb.type = 'checkbox'; cb.checked = entry.enabled; cb.disabled = locked;
206
+ cb.setAttribute('aria-label', t('nav.' + (entry.id === 'personalize' ? 'settings' : entry.id)));
207
+ cb.addEventListener('change', () => toggleNavTab(entry.id));
208
+ const label = el('span', 'nav-tabs-label', t('nav.' + (entry.id === 'personalize' ? 'settings' : entry.id)));
209
+ if (locked) label.append(el('span', 'nav-tabs-lock-note', t('settings.navTabs.locked')));
210
+ const move = el('div', 'nav-tabs-move');
211
+ const up = el('button', null, '↑'); up.type = 'button'; up.title = t('settings.navTabs.moveUp'); up.setAttribute('aria-label', t('settings.navTabs.moveUp'));
212
+ up.disabled = idx === 0; up.addEventListener('click', () => moveNavTab(entry.id, -1));
213
+ const down = el('button', null, '↓'); down.type = 'button'; down.title = t('settings.navTabs.moveDown'); down.setAttribute('aria-label', t('settings.navTabs.moveDown'));
214
+ down.disabled = idx === list.length - 1; down.addEventListener('click', () => moveNavTab(entry.id, 1));
215
+ move.append(up, down);
216
+ row.append(cb, label, move);
217
+ box.append(row);
218
+ });
219
+ }
220
+
221
+ // Personalize → "Commands" card. Lists the effective commands (built-ins until the user edits, then
222
+ // the persisted config.commands); each row toggles enabled, or opens an inline edit form. Add opens a
223
+ // blank form. All mutations write the WHOLE array via saveSetting({commands}) — the contract
224
+ // writeConfig() expects — mirroring toggleNavTab()/toggleKanbanColumn(). No native prompt/confirm
225
+ // (they block SSE): delete is a direct button (matches renderNavTabsSettings, which gates nothing on
226
+ // relay read-only either — there is no `P.config.readonly`/offline gating on this card, by design).
227
+ let _cmdEditIndex = null; // index being edited, -1 = adding, null = form closed
228
+ function currentCommands() { return SpectoCommands.effectiveCommands((P && P.config) || {}).map((c) => ({ trigger: c.trigger, description: c.description || '', instruction: c.instruction || '', enabled: c.enabled !== false })); }
229
+ function saveCommands(list) { if (P && P.config) P.config.commands = list; renderCommandsSettings(); saveSetting({ commands: list }); }
230
+ // Any list mutation other than the form's own submit closes an open edit form first — an index
231
+ // captured when the form opened can otherwise point past the end of the list, or at a DIFFERENT
232
+ // entry, once rows are added/removed/reordered underneath it (see renderCommandsSettings's own
233
+ // defensive guard below for the belt-and-suspenders half of this fix).
234
+ function toggleCommand(i) { _cmdEditIndex = null; const list = currentCommands(); if (!list[i]) return; list[i].enabled = !list[i].enabled; saveCommands(list); }
235
+ function deleteCommand(i) { _cmdEditIndex = null; const list = currentCommands(); list.splice(i, 1); saveCommands(list); }
236
+ function restoreCommands() { _cmdEditIndex = null; saveCommands(SpectoCommands.BUILTIN_COMMANDS.map((c) => ({ ...c }))); }
237
+ function openCmdForm(i) { _cmdEditIndex = i; renderCommandsSettings(); }
238
+ function closeCmdForm() { _cmdEditIndex = null; renderCommandsSettings(); }
239
+ function submitCmdForm() {
240
+ const trg = $('#cmdFormTrigger').value.trim().toLowerCase();
241
+ const desc = $('#cmdFormDesc').value.trim();
242
+ const instr = $('#cmdFormInstr').value.trim();
243
+ const errEl = $('#cmdFormErr'); errEl.textContent = '';
244
+ const list = currentCommands();
245
+ const others = list.filter((_, idx) => idx !== _cmdEditIndex).map((c) => c.trigger);
246
+ const v = SpectoCommands.validateTrigger(trg, others);
247
+ if (!v.ok) { errEl.textContent = t('settings.commands.err.' + v.error); return; }
248
+ if (!instr) { errEl.textContent = t('settings.commands.err.emptyInstruction'); return; }
249
+ const entry = { trigger: trg, description: desc, instruction: instr, enabled: _cmdEditIndex >= 0 ? list[_cmdEditIndex].enabled : true };
250
+ if (_cmdEditIndex >= 0) list[_cmdEditIndex] = entry; else list.push(entry);
251
+ _cmdEditIndex = null; saveCommands(list);
252
+ }
253
+ function renderCommandsSettings() {
254
+ const box = $('#commandsList'); if (!box) return;
255
+ box.innerHTML = '';
256
+ const list = currentCommands();
257
+ list.forEach((c, i) => {
258
+ const row = el('div', 'cmd-row');
259
+ const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = c.enabled;
260
+ cb.setAttribute('aria-label', c.trigger); cb.addEventListener('change', () => toggleCommand(i));
261
+ const lab = el('span', 'cmd-row-label'); lab.append(el('span', 'cmd-trigger', '/' + c.trigger));
262
+ if (c.description) lab.append(el('span', 'cmd-desc', c.description));
263
+ const acts = el('div', 'cmd-row-acts');
264
+ const edit = el('button', 'btn', t('action.edit')); edit.type = 'button'; edit.addEventListener('click', () => openCmdForm(i));
265
+ const del = el('button', 'btn danger', t('action.delete')); del.type = 'button'; del.addEventListener('click', () => deleteCommand(i));
266
+ acts.append(edit, del); row.append(cb, lab, acts); box.append(row);
267
+ });
268
+ if (_cmdEditIndex !== null) {
269
+ // Defensive: a stale index (from a mutation that somehow didn't already reset it) must never
270
+ // throw here — fall back to a blank form rather than crashing render() mid-tick.
271
+ const editing = (_cmdEditIndex >= 0 && list[_cmdEditIndex]) ? list[_cmdEditIndex] : { trigger: '', description: '', instruction: '' };
272
+ const form = el('div', 'cmd-form');
273
+ const mk = (id, ph, val, tag) => { const f = document.createElement(tag || 'input'); f.id = id; f.placeholder = ph; f.value = val || ''; return f; };
274
+ const tr = mk('cmdFormTrigger', t('settings.commands.ph.trigger'), editing.trigger);
275
+ const de = mk('cmdFormDesc', t('settings.commands.ph.desc'), editing.description);
276
+ const ins = mk('cmdFormInstr', t('settings.commands.ph.instruction'), editing.instruction, 'textarea'); ins.className = 'chat-ta';
277
+ const err = el('div', 'cmd-error'); err.id = 'cmdFormErr';
278
+ const save = el('button', 'btn primary', t('action.save')); save.type = 'button'; save.addEventListener('click', submitCmdForm);
279
+ const cancel = el('button', 'btn', t('action.cancel')); cancel.type = 'button'; cancel.addEventListener('click', closeCmdForm);
280
+ const btns = el('div', 'cmd-form-btns'); btns.append(save, cancel);
281
+ form.append(el('label', null, t('settings.commands.ph.trigger')), tr, el('label', null, t('settings.commands.ph.desc')), de, el('label', null, t('settings.commands.ph.instruction')), ins, err, btns);
282
+ box.append(form);
283
+ }
284
+ const addBtn = $('#cmdAddBtn'), resBtn = $('#cmdRestoreBtn');
285
+ if (addBtn) addBtn.onclick = () => openCmdForm(-1);
286
+ if (resBtn) resBtn.onclick = restoreCommands;
287
+ }
288
+
35
289
  const $ = (s,r=document)=>r.querySelector(s);
36
290
  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
37
291
  const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
@@ -39,7 +293,9 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
39
293
  const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
40
294
 
41
295
  async function load(){
42
- const r = await fetch(withProject('/api/project')); P = await r.json(); render();
296
+ const r = await fetch(withProject('/api/project')); P = await r.json();
297
+ syncSettingsFromServer();
298
+ render(); setOffline(P);
43
299
  if(openTaskId) openDrawer(openTaskId,true);
44
300
  }
45
301
  // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
@@ -122,10 +378,12 @@ function appendRaw(chunk){
122
378
  scrollChat(container);
123
379
  });
124
380
  }
381
+ // DOM-only — does not persist. Called both by real user toggles (which persist separately, see the
382
+ // chatFab/chatClose/runQuickBtn listeners below) and by syncSettingsFromServer() reflecting a saved
383
+ // chatOpen back onto a fresh page load, which must never re-trigger a save of the very value it read.
125
384
  function setChat(open){
126
385
  $('#chat').setAttribute('aria-hidden', open?'false':'true');
127
386
  $('#chatFab').classList.toggle('is-open',open);
128
- try{ localStorage.setItem('spf-chat', open?'1':'0'); }catch{}
129
387
  // Land where you can actually type, not wherever the log happened to be scrolled last.
130
388
  if(open) setTimeout(()=>{ scrollChat($('#chatLog')); $('#runPrompt').focus(); },60);
131
389
  }
@@ -135,20 +393,86 @@ function setChat(open){
135
393
  // isChatBusy() also guards the Ctrl/Cmd+Enter keyboard shortcuts, which call doRun() directly and
136
394
  // would otherwise bypass the buttons' own disabled state.
137
395
  function isChatBusy(){ return sseBusy || (P&&P.runtime&&P.runtime.orchestration&&P.runtime.orchestration.status==='running'); }
396
+ // If `raw` is a slash-command invocation of a known enabled command, return the expanded
397
+ // { prompt, display }; otherwise null (the raw text is sent unchanged). Uses the live config.
398
+ function expandForSend(raw){
399
+ try { return SpectoCommands.expandCommand(raw, SpectoCommands.effectiveCommands((P&&P.config)||{})); }
400
+ catch(_){ return null; }
401
+ }
138
402
  async function doRun(promptEl,agentEl){
139
403
  if(isChatBusy()) return;
140
404
  promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
141
- const prompt=promptEl.value.trim(); if(!prompt) return;
405
+ const raw=promptEl.value.trim(); if(!raw) return;
142
406
  const agent=agentEl.value;
143
- await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
144
- promptEl.value=''; // the prompt renders as a bubble from the message log
407
+ const ex=expandForSend(raw);
408
+ const body=ex ? {prompt:ex.prompt, display:ex.display, agent} : {prompt:raw, agent};
409
+ await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
410
+ promptEl.value=''; hideCmdMenu(); // the prompt renders as a bubble from the message log
145
411
  }
146
412
  async function doOrchestrate(promptEl){
147
413
  if(isChatBusy()) return;
148
414
  promptEl=promptEl||$('#runPrompt');
149
- const prompt=promptEl.value.trim(); if(!prompt) return;
150
- await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
151
- promptEl.value='';
415
+ const raw=promptEl.value.trim(); if(!raw) return;
416
+ const ex=expandForSend(raw);
417
+ await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request: ex?ex.prompt:raw})});
418
+ promptEl.value=''; hideCmdMenu();
419
+ }
420
+ // ---- slash-command autocomplete: a single reused #cmdMenu popover, anchored above whichever chat
421
+ // textarea is active. Shows while the input is exactly "/word" (no space yet); hides once args begin.
422
+ // Keyboard (menu open only): ↑/↓ move, Enter/Tab select, Esc close — plain Enter in a textarea is a
423
+ // newline (send is Ctrl/Cmd+Enter), so intercepting it here doesn't change send behavior. ----
424
+ let _cmdMenuInput=null, _cmdMenuItems=[], _cmdMenuSel=-1;
425
+ function cmdMenuEl(){ return $('#cmdMenu'); }
426
+ function hideCmdMenu(){ const m=cmdMenuEl(); if(m){ m.hidden=true; m.innerHTML=''; } _cmdMenuInput=null; _cmdMenuItems=[]; _cmdMenuSel=-1; }
427
+ function slashQuery(v){ const m=String(v||'').match(/^\/([a-z0-9_-]*)$/i); return m?m[1]:null; }
428
+ function openCmdMenu(inputEl){
429
+ const q=slashQuery(inputEl.value);
430
+ if(q===null){ hideCmdMenu(); return; }
431
+ const cmds=SpectoCommands.matchCommands(q, SpectoCommands.effectiveCommands((P&&P.config)||{}));
432
+ const m=cmdMenuEl(); if(!m) return;
433
+ _cmdMenuInput=inputEl; _cmdMenuItems=cmds; _cmdMenuSel=cmds.length?0:-1;
434
+ m.innerHTML='';
435
+ if(!cmds.length){ m.append(el('div','cmd-empty', t('commands.menuEmpty'))); }
436
+ else cmds.forEach((c,i)=>{
437
+ const it=el('div','cmd-item'+(i===0?' is-sel':'')); it.setAttribute('role','option'); it.dataset.i=String(i);
438
+ it.append(el('span','cmd-trigger','/'+c.trigger));
439
+ if(c.description) it.append(el('span','cmd-desc',c.description));
440
+ it.addEventListener('mousedown',(e)=>{ e.preventDefault(); pickCmd(i); });
441
+ m.append(it);
442
+ });
443
+ m.hidden=false; positionCmdMenu(inputEl);
444
+ }
445
+ function positionCmdMenu(inputEl){
446
+ const m=cmdMenuEl(); if(!m||m.hidden) return;
447
+ const r=inputEl.getBoundingClientRect();
448
+ m.style.left=Math.round(r.left)+'px';
449
+ m.style.width=Math.round(r.width)+'px';
450
+ // place above the input; if it would clip the top, place below instead
451
+ const h=m.offsetHeight||160;
452
+ const top = r.top-h-6 >= 8 ? r.top-h-6 : r.bottom+6;
453
+ m.style.top=Math.round(top)+'px';
454
+ }
455
+ function moveCmdSel(d){
456
+ if(!_cmdMenuItems.length) return;
457
+ _cmdMenuSel=(_cmdMenuSel+d+_cmdMenuItems.length)%_cmdMenuItems.length;
458
+ $$('.cmd-item',cmdMenuEl()).forEach((n,i)=>n.classList.toggle('is-sel',i===_cmdMenuSel));
459
+ }
460
+ function pickCmd(i){
461
+ const c=_cmdMenuItems[i]; const inp=_cmdMenuInput;
462
+ if(!c||!inp) { hideCmdMenu(); return; }
463
+ inp.value='/'+c.trigger+' ';
464
+ hideCmdMenu();
465
+ inp.focus(); inp.selectionStart=inp.selectionEnd=inp.value.length;
466
+ }
467
+ // Returns true if it handled the key (caller should preventDefault + stop).
468
+ function cmdMenuKey(e){
469
+ const m=cmdMenuEl(); if(!m||m.hidden) return false;
470
+ if((e.metaKey||e.ctrlKey)&&e.key==='Enter') return false; // let the send shortcut through untouched
471
+ if(e.key==='ArrowDown'){ if(_cmdMenuItems.length){ moveCmdSel(1); return true; } return false; }
472
+ if(e.key==='ArrowUp'){ if(_cmdMenuItems.length){ moveCmdSel(-1); return true; } return false; }
473
+ if(e.key==='Enter'||e.key==='Tab'){ if(_cmdMenuSel>=0){ pickCmd(_cmdMenuSel); return true; } }
474
+ if(e.key==='Escape'){ hideCmdMenu(); return true; }
475
+ return false;
152
476
  }
153
477
  async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
154
478
  // ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
@@ -178,11 +502,15 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
178
502
  let sseBusy=false;
179
503
  function updateChatBusyUI(){
180
504
  const orchStatus=P&&P.runtime&&P.runtime.orchestration&&P.runtime.orchestration.status;
181
- const busy=sseBusy||orchStatus==='running';
505
+ const busy=sseBusy||orchStatus==='running'||OFFLINE;
182
506
  document.body.classList.toggle('chat-busy',!!busy);
183
- [$('#runBtn'),$('#orchBtn'),$('#widgetSummarizeBtn'),$('#tabRunBtn'),$('#tabOrchBtn'),$('#tabSummarizeBtn')].forEach(b=>{ if(b) b.disabled=!!busy; });
507
+ // #meetingGenBtn/#meetingRunStatus (Sous-projet C, Task 3) ride the exact same busy signal — this
508
+ // is a single local agent process, so a meeting Generate run must disable Send/Orchestrate/
509
+ // Summarize too, and vice versa (only one agent run is ever in flight at once).
510
+ [$('#runBtn'),$('#orchBtn'),$('#widgetSummarizeBtn'),$('#tabRunBtn'),$('#tabOrchBtn'),$('#tabSummarizeBtn'),$('#meetingGenBtn')].forEach(b=>{ if(b) b.disabled=!!busy; });
184
511
  const tabStatus=$('#tabChatStatus'); if(tabStatus) tabStatus.hidden=!busy;
185
512
  const widgetStatus=$('#widgetChatStatus'); if(widgetStatus) widgetStatus.hidden=!busy;
513
+ const meetingStatus=$('#meetingRunStatus'); if(meetingStatus) meetingStatus.hidden=!busy;
186
514
  }
187
515
 
188
516
  function render(){
@@ -203,10 +531,12 @@ function render(){
203
531
  if(meterFill) meterFill.style.width=(s.pct||0)+'%';
204
532
  const meter=$('#globalMeter');
205
533
  if(meter) meter.title=`${t('kpi.globalProgress')}: ${s.pct}% (${s.done}/${s.total} ${t('kpi.tasksLabel')})`;
534
+ kanbanExpanded.clear(); // a full SSE-driven reload always collapses any "show more" a viewer had open
206
535
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
207
536
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
208
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden(); updateChatBusyUI();
537
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); renderNotes(); renderMeeting(); applySideHidden(); updateChatBusyUI();
209
538
  renderCustomDashboards(); // adds/removes nav tabs + panels before applyActiveTab() below reads them
539
+ applyNavTabs(); // hide/reorder native tabs per config.navTabs (may itself navigate away from a just-disabled active tab)
210
540
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
211
541
  applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
212
542
  }
@@ -419,7 +749,8 @@ function renderBoard(){
419
749
  running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
420
750
 
421
751
  const board=$('#board'); board.innerHTML='';
422
- updateBoardViewToggle();
752
+ updateBoardViewToggle(); updateKanbanPageSizeToggle();
753
+ if(kanbanColsOpen) renderKanbanColsPop(); // keep an open popover's checkbox state fresh across a live reload
423
754
  board.classList.toggle('is-kanban', boardView==='kanban');
424
755
  if(!tasks.length){ board.append(emptyState()); return; }
425
756
  if(boardView==='kanban'){ renderKanban(board, tasks); return; } // columns by status
@@ -433,12 +764,15 @@ function renderBoard(){
433
764
  if(!shown) board.append(noMatchState());
434
765
  updatePhaseToggleAll();
435
766
  }
436
- // Kanban view — one column per status, filtered by the text search (columns already are the statuses).
767
+ // Kanban view — one column per status, filtered by the text search (columns already are the
768
+ // statuses). Only enabled columns (kanbanColumns) render at all; each column shows up to
769
+ // kanbanPageSize tasks (of its own filtered list) with a "Show more" reveal for the rest —
770
+ // see kanbanExpanded above for the reset rules.
437
771
  function renderKanban(board, tasks){
438
772
  const q=filter.q.trim().toLowerCase();
439
773
  const match=(t)=> !q || (t.title+' '+t.id).toLowerCase().includes(q);
440
774
  const cols=el('div','kanban');
441
- Object.keys(STATUS).forEach(st=>{
775
+ Object.keys(STATUS).filter(st=> kanbanColumns.includes(st)).forEach(st=>{
442
776
  const colTasks=tasks.filter(t=> t.status===st && match(t));
443
777
  const col=el('div','kanban-col');
444
778
  const head=el('div','kanban-col-head');
@@ -447,12 +781,23 @@ function renderKanban(board, tasks){
447
781
  col.append(head);
448
782
  const body=el('div','kanban-col-body');
449
783
  if(!colTasks.length) body.append(el('div','kanban-empty','—'));
450
- colTasks.forEach(t=> body.append(renderTask(t)));
451
- col.append(body); cols.append(col);
784
+ const expanded=kanbanExpanded.has(st);
785
+ const visible=expanded?colTasks:colTasks.slice(0,kanbanPageSize);
786
+ visible.forEach(t=> body.append(renderTask(t)));
787
+ col.append(body);
788
+ const remaining=colTasks.length-visible.length;
789
+ if(remaining>0){
790
+ const more=el('button','mini-btn kanban-more',t('board.kanbanShowMore',{n:remaining}));
791
+ more.type='button';
792
+ more.addEventListener('click',()=>{ kanbanExpanded.add(st); renderBoard(); });
793
+ col.append(more);
794
+ }
795
+ cols.append(col);
452
796
  });
453
797
  board.append(cols);
454
798
  }
455
799
  function updateBoardViewToggle(){ $$('#boardViewToggle .vt-btn').forEach(b=> b.classList.toggle('active', b.dataset.view===boardView)); }
800
+ function updateKanbanPageSizeToggle(){ $$('#kanbanPageSize .vt-btn').forEach(b=> b.classList.toggle('active', Number(b.dataset.size)===kanbanPageSize)); }
456
801
  function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
457
802
  function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
458
803
  function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
@@ -541,13 +886,11 @@ function backlogRow(r){
541
886
 
542
887
  // ---- phase expand state: we track which phases are EXPANDED (default = none), so on a big
543
888
  // project the board opens compact — just phase headers with progress — and the user opens what
544
- // they need. Persisted per phase title (guarded for private mode). ----
545
- function loadExpanded(){
546
- try{ const raw=localStorage.getItem('spf-expanded'); const arr=raw?JSON.parse(raw):[]; return new Set(Array.isArray(arr)?arr:[]); }
547
- catch{ return new Set(); }
548
- }
549
- function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
550
- let expandedPhases=loadExpanded();
889
+ // they need. Persisted per phase title, project-wide (config.json → expandedPhases, debounced —
890
+ // see saveSetting()); starts empty and is reconciled from server config once, in
891
+ // syncSettingsFromServer(). ----
892
+ function saveExpanded(set){ saveSetting({expandedPhases:[...set]}); }
893
+ let expandedPhases=new Set();
551
894
  function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
552
895
  function updatePhaseToggleAll(){
553
896
  const btn=$('#phaseToggleAll'); if(!btn) return;
@@ -866,10 +1209,13 @@ async function saveAgent(id){
866
1209
  const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
867
1210
  if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
868
1211
  }
869
- // ---- design skins (data-design) — switchable, persisted per viewer + as the project default ----
1212
+ // ---- design skins (data-design) — a shared, project-level setting (not per-viewer, Sous-projet B):
1213
+ // first paint comes from the server (inject-design.js stamps data-design into index.html itself, from
1214
+ // config.design), instant switching still applies the DOM attribute directly client-side, and the
1215
+ // choice is persisted the same way as any other project setting (saveSetting -> /api/settings). ----
870
1216
  function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
871
- function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
872
- async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); try{ await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({design:id})}); }catch{} }
1217
+ function applyDesign(id){ document.documentElement.setAttribute('data-design',id); }
1218
+ async function saveDesign(id){ applyDesign(id); if(P) render(); /* re-read token colours into the SVG charts */ flash(); saveSetting({design:id}); }
873
1219
 
874
1220
  function renderSettings(){
875
1221
  const c=(P&&P.config)||{};
@@ -881,10 +1227,9 @@ function renderSettings(){
881
1227
  if(dsel){
882
1228
  const designs=(typeof DESIGNS!=='undefined')?DESIGNS:[{id:'control-room',name:'Control Room'}];
883
1229
  if(dsel.options.length!==designs.length){ dsel.innerHTML=''; designs.forEach(d=>{ const o=document.createElement('option'); o.value=d.id; o.textContent=d.name; if(d.desc) o.title=d.desc; dsel.append(o); }); }
884
- // reconcile: a per-viewer choice (localStorage) wins; else the project default (config.design)
885
- let active; try{ active=localStorage.getItem('spf-design'); }catch{}
886
- if(!active && c.design) active=c.design;
887
- if(active && active!==currentDesign() && designs.some(d=>d.id===active)) document.documentElement.setAttribute('data-design',active);
1230
+ // reconcile from the project-level default (config.design) only — design is no longer a
1231
+ // per-viewer localStorage choice (Sous-projet B).
1232
+ if(c.design && c.design!==currentDesign() && designs.some(d=>d.id===c.design)) document.documentElement.setAttribute('data-design',c.design);
888
1233
  dsel.value=currentDesign();
889
1234
  }
890
1235
  const box=$('#settingsReadonly');
@@ -896,6 +1241,8 @@ function renderSettings(){
896
1241
  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); });
897
1242
  }
898
1243
  const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
1244
+ renderNavTabsSettings();
1245
+ renderCommandsSettings();
899
1246
  renderCustomize();
900
1247
  }
901
1248
  async function saveSettings(){
@@ -906,12 +1253,12 @@ async function saveSettings(){
906
1253
  }
907
1254
 
908
1255
  // ---- Custom dashboards (Customize page → generate-dashboard skill) ---------------------------
909
- // A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboard/custom/<id>.json, embedded
1256
+ // A custom dashboard is a DECLARATIVE block spec (.spectoflow/dashboards/<id>.json, embedded
910
1257
  // in P.customDashboards by the server) — never raw HTML/CSS/JS. Every block below reuses the exact
911
1258
  // same components the built-in Board renders with (kpiCard/ocard/bars/donut/statTile/mdLite/el), so a
912
1259
  // generated dashboard automatically matches the active design — and any design switched to later —
913
- // with zero page-specific styling. Mirrors the schema in .spectoflow/lib/custom-dashboard.js (the
914
- // Node-side validator); this is the independent browser-side reader for the same shape.
1260
+ // with zero page-specific styling. The block schema is enforced by `spectoflow dashboard validate`
1261
+ // (Node-side validator); this is the independent browser-side reader for the same shape.
915
1262
  function resolveBind(s,bindPath,fallback){
916
1263
  if(bindPath==null) return fallback;
917
1264
  let v=s;
@@ -1078,7 +1425,7 @@ async function czSubmit(kind,description,agent){
1078
1425
  // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
1079
1426
  // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
1080
1427
  // by a dedicated branch in tabFromPath()/navigateTab() instead.
1081
- const ROUTES=['board','requests','attention','backlog','workflow','team','files','chat','info','docs','personalize'];
1428
+ const ROUTES=['board','requests','attention','backlog','workflow','team','files','notes','meeting','chat','info','docs','personalize'];
1082
1429
  // the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
1083
1430
  // under that name still land on the Personalize tab instead of a blank panel.
1084
1431
  function normalizeTab(t){ return t==='settings'?'personalize':t; }
@@ -1091,7 +1438,9 @@ function tabFromPath(){
1091
1438
  function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1092
1439
  function navigateTab(tabId,push){
1093
1440
  closeWfPop(); // an open popover is anchored to whichever panel is currently active — never carry it across
1094
- activeTab=tabId; try{ localStorage.setItem('spf-tab',tabId); }catch{}
1441
+ closeKanbanColsPop(); kanbanExpanded.clear(); // ditto for the Kanban columns popover + any "show more" reveals
1442
+ hideCmdMenu(); // ditto for the slash-command popover — never carry it across a tab switch
1443
+ activeTab=tabId; saveSetting({activeTab:tabId});
1095
1444
  if(push!==false){
1096
1445
  const isCustom=tabId.indexOf('custom:')===0;
1097
1446
  history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
@@ -1419,80 +1768,42 @@ function renderFilesTree(){
1419
1768
  }
1420
1769
  function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
1421
1770
 
1422
- // ---- lightweight syntax highlighting — zero-dependency (no CodeMirror/Monaco, per the explicit
1423
- // call made when this tab was designed): a single char-scanner tokenizer good enough to make code
1424
- // readable at a glance in a file browser, not a language-correct parser. Anything not recognized
1425
- // (or with no lang mapping) just renders as plain, unstyled text — never a rendering error. ----
1426
- const FILES_HL_LANG = {
1427
- js: { comments:[['//','\n'],['/*','*/']], strings:['"',"'",'`'],
1428
- keywords:'const let var function return if else for while do switch case break continue new class extends super this typeof instanceof in of try catch finally throw async await yield import export default from as null undefined true false void delete'.split(' ') },
1429
- json: { comments:[], strings:['"'], keywords:'true false null'.split(' ') },
1430
- css: { comments:[['/*','*/']], strings:['"',"'"], keywords:[] },
1431
- html: { comments:[['<!--','-->']], strings:['"',"'"], keywords:[], tags:true },
1432
- py: { comments:[['#','\n']], strings:['"',"'"],
1433
- keywords:'def class return if elif else for while break continue pass import from as try except finally raise with lambda yield async await None True False and or not in is del global nonlocal'.split(' ') },
1434
- sh: { comments:[['#','\n']], strings:['"',"'"],
1435
- keywords:'if then else elif fi for while do done case esac function return exit export local readonly'.split(' ') },
1436
- yml: { comments:[['#','\n']], strings:['"',"'"], keywords:'true false null'.split(' ') },
1437
- };
1771
+ // ---- syntax highlighting — vendored Prism.js (self-hosted static files under
1772
+ // vendor/prism/, zero npm dependency; see index.html for the load order and Prism.manual note).
1773
+ // Anything not recognized (or with no lang mapping, or before Prism has loaded a grammar for it)
1774
+ // just renders as plain, escaped text — never a rendering error. ----
1438
1775
  function filesHlLang(ext){
1439
- if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'js';
1776
+ if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'javascript';
1440
1777
  if(ext==='json') return 'json';
1441
1778
  if(ext==='css') return 'css';
1442
- if(['html','htm'].includes(ext)) return 'html';
1443
- if(ext==='py') return 'py';
1444
- if(['sh','bash'].includes(ext)) return 'sh';
1445
- if(['yml','yaml'].includes(ext)) return 'yml';
1779
+ if(['html','htm'].includes(ext)) return 'markup';
1780
+ if(ext==='py') return 'python';
1781
+ if(['sh','bash'].includes(ext)) return 'bash';
1782
+ if(['yml','yaml'].includes(ext)) return 'yaml';
1783
+ if(ext==='java') return 'java';
1784
+ if(ext==='go') return 'go';
1785
+ if(ext==='cs') return 'csharp';
1786
+ if(ext==='c') return 'c';
1787
+ if(['cpp','cc','cxx','h','hpp'].includes(ext)) return 'cpp';
1788
+ if(ext==='rs') return 'rust';
1789
+ if(ext==='rb') return 'ruby';
1790
+ if(ext==='php') return 'php';
1791
+ if(ext==='swift') return 'swift';
1792
+ if(['kt','kts'].includes(ext)) return 'kotlin';
1793
+ if(ext==='sql') return 'sql';
1794
+ if(['md','markdown'].includes(ext)) return 'markdown';
1795
+ if(ext==='dockerfile') return 'docker';
1446
1796
  return null;
1447
1797
  }
1448
- // Scans `src` one character at a time, classifying spans as it goes; returns an HTML string with
1449
- // each span wrapped in a colored <span> (escaped — this is the only place raw file content becomes
1450
- // markup, so nothing here may skip escHtml).
1798
+ // Delegates to Prism.highlight(), which both tokenizes AND HTML-escapes the source as it goes —
1799
+ // this is the only place raw file content becomes markup, so the escaping is Prism's own, not
1800
+ // ours; falls back to plain escHtml() when there's no langKey or Prism has no grammar for it
1801
+ // (e.g. the vendored scripts failed to load), matching the previous "no lang matched" behavior.
1451
1802
  function filesHighlight(src,langKey){
1452
- const lang=FILES_HL_LANG[langKey];
1453
- if(!lang) return escHtml(src);
1454
- const n=src.length;
1455
- let i=0,html='',plain='';
1456
- const flushPlain=()=>{ if(plain){ html+=escHtml(plain); plain=''; } };
1457
- const isWordChar=c=>/[A-Za-z0-9_$]/.test(c);
1458
- while(i<n){
1459
- let matched=false;
1460
- // comments
1461
- for(const [open,close] of lang.comments){
1462
- if(src.startsWith(open,i)){
1463
- const end = close==='\n' ? (src.indexOf('\n',i)===-1?n:src.indexOf('\n',i)) : (src.indexOf(close,i+open.length)===-1?n:src.indexOf(close,i+open.length)+close.length);
1464
- flushPlain(); html+='<span class="hl-comment">'+escHtml(src.slice(i,end))+'</span>'; i=end; matched=true; break;
1465
- }
1466
- }
1467
- if(matched) continue;
1468
- // strings
1469
- if(lang.strings.includes(src[i])){
1470
- const q=src[i]; let j=i+1;
1471
- while(j<n && src[j]!==q){ if(src[j]==='\\') j++; j++; }
1472
- j=Math.min(j+1,n);
1473
- flushPlain(); html+='<span class="hl-string">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
1474
- }
1475
- // html tags (bonus: <tag ...> / </tag>) — a light touch, not full attribute-vs-value parsing
1476
- if(lang.tags && src[i]==='<' && /[a-zA-Z/!]/.test(src[i+1]||'')){
1477
- const end=src.indexOf('>',i); const j=end===-1?n:end+1;
1478
- flushPlain(); html+='<span class="hl-tag">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
1479
- }
1480
- // numbers
1481
- if(/[0-9]/.test(src[i]) && !isWordChar(src[i-1]||'')){
1482
- let j=i; while(j<n && /[0-9.]/.test(src[j])) j++;
1483
- flushPlain(); html+='<span class="hl-number">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
1484
- }
1485
- // keywords
1486
- if(isWordChar(src[i]) && !isWordChar(src[i-1]||'')){
1487
- let j=i; while(j<n && isWordChar(src[j])) j++;
1488
- const word=src.slice(i,j);
1489
- if(lang.keywords.includes(word)){ flushPlain(); html+='<span class="hl-keyword">'+escHtml(word)+'</span>'; i=j; continue; }
1490
- plain+=word; i=j; continue;
1491
- }
1492
- plain+=src[i]; i++;
1803
+ if(langKey && window.Prism && Prism.languages && Prism.languages[langKey]){
1804
+ return Prism.highlight(src, Prism.languages[langKey], langKey);
1493
1805
  }
1494
- flushPlain();
1495
- return html;
1806
+ return escHtml(src);
1496
1807
  }
1497
1808
  // A textarea can't render colored text itself, so this overlays one, transparent, on top of a
1498
1809
  // highlighted <pre><code> "backdrop" showing through it (the standard technique for a highlighted
@@ -1656,6 +1967,208 @@ async function submitFilesCreate(){
1656
1967
  if(kind!=='dir') openFilesFile(rel);
1657
1968
  }
1658
1969
 
1970
+ // ---- Bloc note (Sous-projet C, Task 2) ----------------------------------------------------------
1971
+ // One freeform Markdown scratchpad per project, stored at .spectoflow/notes.md — reuses the exact
1972
+ // same files.read/files.write ops (and their /api/files/read|write routes) the File Explorer already
1973
+ // drives, and the exact same filesCodeEditor() backdrop+textarea component the Markdown file editor
1974
+ // there uses (langKey:'markdown', matching renderFilesMd's own call). No parallel persistence path.
1975
+ // The path is relative to the PROJECT ROOT (same convention as every other /api/files/* call — see
1976
+ // files.js's safePath()), so '.spectoflow/notes.md' is what actually lands inside .spectoflow/.
1977
+ const NOTES_PATH = '.spectoflow/notes.md';
1978
+ let notesLoaded = false, notesSaveTimer = null;
1979
+ // Only the FIRST activation fetches (mirrors renderFiles()'s own "fetch once" guard) — a full SSE
1980
+ // reload must never yank a note out from under an in-progress, not-yet-saved edit.
1981
+ function renderNotes(){
1982
+ if(activeTab!=='notes' || notesLoaded) return;
1983
+ notesLoaded = true;
1984
+ loadNotes();
1985
+ }
1986
+ async function loadNotes(){
1987
+ const box=$('#notesEditor'); if(!box) return;
1988
+ box.innerHTML=''; box.append(el('div','files-empty',t('drawer.loading')));
1989
+ let content='';
1990
+ try{
1991
+ const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:NOTES_PATH})));
1992
+ const d=await r.json().catch(()=>({}));
1993
+ // "Not found." just means no note has ever been saved yet — an empty scratchpad, not an error.
1994
+ if(r.ok) content=d.content||'';
1995
+ else if(d.error && d.error!=='Not found.') throw new Error(d.error);
1996
+ }catch(err){ notesSetStatus('error'); }
1997
+ box.innerHTML='';
1998
+ const {wrap}=filesCodeEditor(content,'markdown',(v)=>notesScheduleSave(v));
1999
+ box.append(wrap);
2000
+ }
2001
+ // A scratchpad should feel like it's always saved, not like a form to submit — every keystroke
2002
+ // (debounced) writes straight through files.write, no explicit Save button. 700ms: a bit longer than
2003
+ // the 400ms generic settings debounce (saveSetting()), since this carries larger free text on every
2004
+ // keystroke rather than one small settings patch, but still short enough that a reload moments after
2005
+ // typing never loses more than a fraction of a second of work.
2006
+ function notesScheduleSave(content){
2007
+ notesSetStatus('saving');
2008
+ clearTimeout(notesSaveTimer);
2009
+ notesSaveTimer=setTimeout(()=>notesSave(content),700);
2010
+ }
2011
+ async function notesSave(content){
2012
+ try{
2013
+ const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:NOTES_PATH,content})});
2014
+ const d=await r.json().catch(()=>({}));
2015
+ if(!r.ok) throw new Error(d.error||'error');
2016
+ notesSetStatus('saved');
2017
+ }catch(err){ notesSetStatus('error'); }
2018
+ }
2019
+ // Small, unobtrusive status label next to the note — mirrors the Files tab's own tip pattern
2020
+ // (files.saved/files.saveError) rather than inventing a new toast/notification system.
2021
+ function notesSetStatus(state){
2022
+ const tip=$('#notesStatus'); if(!tip) return;
2023
+ if(state==='saving'){ tip.textContent=t('notes.saving'); tip.className='note-status is-saving'; }
2024
+ else if(state==='saved'){
2025
+ tip.textContent=t('files.saved'); tip.className='note-status is-saved';
2026
+ setTimeout(()=>{ if(tip.classList.contains('is-saved')) tip.textContent=''; },1500);
2027
+ } else { tip.textContent=t('files.saveError'); tip.className='note-status is-error'; }
2028
+ }
2029
+
2030
+ // ---- Daily meeting (Sous-projet C, Task 3) ------------------------------------------------------
2031
+ // One dated Markdown note per day, .spectoflow/meetings/<date>.md. MANUAL editing reuses the exact
2032
+ // same files.read/files.write ops + filesCodeEditor() component as Bloc note above (700ms debounced
2033
+ // autosave, "Not found." treated as an empty editor). GENERATE is the new half: POST
2034
+ // /api/meeting/generate spawns the active agent (same run-start/run-end/change SSE shape as
2035
+ // chat.summarize — see updateChatBusyUI() above, which already disables #meetingGenBtn and drives
2036
+ // #meetingRunStatus for free) and writes straight into the SAME file this tab already reads/writes,
2037
+ // so a generated note lands in the editor exactly like a manual edit would.
2038
+ // "Today" is always the SERVER's local date (P.todayDate, from GET /api/project — see meeting.js's
2039
+ // todayLocal()), never the browser's: this is a single-writer local tool and the filename itself must
2040
+ // be unambiguous, so a viewer's own time zone must never decide which file "today" resolves to.
2041
+ let meetingHistoryLoaded=false, meetingSelectedDate=null, meetingEditorDate=null, meetingAwaitingGen=false, meetingSaveTimer=null;
2042
+ function meetingFilePath(date){ return '.spectoflow/meetings/'+date+'.md'; }
2043
+ function meetingToday(){ return (P && P.todayDate) || new Date().toISOString().slice(0,10); } // fallback only until the first /api/project response lands
2044
+ // Reuses files.tree (already fetched by the Files tab for the same project) rather than a new
2045
+ // meeting.list op — the .spectoflow/meetings/<date>.md files are already ordinary nodes in that tree
2046
+ // (files.js's buildTree() only denies .git/node_modules), so the dated history is derived client-side
2047
+ // from data this tab needs to fetch anyway.
2048
+ function meetingDatesFromTree(tree){
2049
+ const dot=(tree||[]).find(n=>n.name==='.spectoflow'&&n.type==='dir');
2050
+ const mtg=dot && (dot.children||[]).find(n=>n.name==='meetings'&&n.type==='dir');
2051
+ const kids=mtg ? (mtg.children||[]) : [];
2052
+ return kids.filter(n=>n.type==='file'&&/^\d{4}-\d{2}-\d{2}\.md$/.test(n.name)).map(n=>n.name.slice(0,-3)).sort().reverse(); // newest first
2053
+ }
2054
+ // Only the FIRST activation fetches the tree (mirrors renderNotes()'s own "fetch once" guard) — an
2055
+ // unrelated SSE reload must never re-walk the tree out from under the viewer mid-browse. The editor
2056
+ // itself is reloaded separately, keyed off the selected date (see renderMeeting() below).
2057
+ function renderMeeting(){
2058
+ if(activeTab!=='meeting') return;
2059
+ if(!meetingSelectedDate) meetingSelectedDate=meetingToday();
2060
+ if(!meetingHistoryLoaded){ meetingHistoryLoaded=true; loadMeetingHistory(); }
2061
+ if(meetingEditorDate!==meetingSelectedDate) loadMeetingEditor(meetingSelectedDate);
2062
+ // A generate run just finished (run-end already flipped sseBusy off, by the time the debounced
2063
+ // 'change' reload that follows it reaches render()) — force a refetch even though the date itself
2064
+ // didn't change, so the generated content actually appears.
2065
+ if(meetingAwaitingGen && !sseBusy){ meetingAwaitingGen=false; loadMeetingEditor(meetingSelectedDate,true); loadMeetingHistory(); }
2066
+ meetingSyncHistoryHighlight(); // cheap DOM-only sync — must run every tick, not just after a (network) history refetch
2067
+ meetingRenderToolbar();
2068
+ }
2069
+ async function loadMeetingHistory(){
2070
+ const box=$('#meetingHistory'); if(!box) return;
2071
+ let dates=[];
2072
+ try{
2073
+ const r=await fetch(withProject('/api/files/tree'));
2074
+ const d=await r.json().catch(()=>({}));
2075
+ if(r.ok) dates=meetingDatesFromTree(d.tree);
2076
+ }catch(err){ /* history is best-effort — an empty list still leaves today selectable/creatable */ }
2077
+ const today=meetingToday();
2078
+ const all=[today,...dates.filter(x=>x!==today)]; // today is always selectable/creatable, even with zero history yet
2079
+ box.innerHTML='';
2080
+ all.forEach(date=>{
2081
+ const li=el('li',date===meetingSelectedDate?'is-active':'', date===today?(date+' · '+t('meeting.today')):date);
2082
+ li.dataset.date=date;
2083
+ li.addEventListener('click',()=>{ if(date===meetingSelectedDate) return; meetingSelectedDate=date; renderMeeting(); });
2084
+ box.append(li);
2085
+ });
2086
+ }
2087
+ // Just clicking a different history row changes meetingSelectedDate without re-fetching the tree (no
2088
+ // network round-trip needed to know which row is now selected) — so the "is-active" class has to be
2089
+ // re-synced from existing DOM rows on every renderMeeting() tick, not only when loadMeetingHistory()
2090
+ // itself (re)builds the list.
2091
+ function meetingSyncHistoryHighlight(){
2092
+ const box=$('#meetingHistory'); if(!box) return;
2093
+ box.querySelectorAll('li').forEach(li=>li.classList.toggle('is-active',li.dataset.date===meetingSelectedDate));
2094
+ }
2095
+ async function loadMeetingEditor(date,force){
2096
+ if(!force && meetingEditorDate===date) return;
2097
+ const box=$('#meetingEditor'); if(!box) return;
2098
+ meetingEditorDate=date;
2099
+ box.innerHTML=''; box.append(el('div','files-empty',t('drawer.loading')));
2100
+ let content='';
2101
+ try{
2102
+ const r=await fetch(withProject('/api/files/read?'+new URLSearchParams({path:meetingFilePath(date)})));
2103
+ const d=await r.json().catch(()=>({}));
2104
+ // "Not found." just means no entry has been written for this date yet — an empty editor, not an error.
2105
+ if(r.ok) content=d.content||'';
2106
+ else if(d.error && d.error!=='Not found.') throw new Error(d.error);
2107
+ }catch(err){ meetingSetStatus('error'); }
2108
+ if(meetingSelectedDate!==date) return; // the viewer switched dates while this fetch was in flight
2109
+ box.innerHTML='';
2110
+ const {wrap}=filesCodeEditor(content,'markdown',(v)=>meetingScheduleSave(date,v));
2111
+ box.append(wrap);
2112
+ meetingRenderToolbar();
2113
+ }
2114
+ function meetingScheduleSave(date,content){
2115
+ meetingSetStatus('saving');
2116
+ clearTimeout(meetingSaveTimer);
2117
+ meetingSaveTimer=setTimeout(()=>meetingSave(date,content),700);
2118
+ }
2119
+ async function meetingSave(date,content){
2120
+ try{
2121
+ const r=await fetch(withProject('/api/files/write'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({path:meetingFilePath(date),content})});
2122
+ const d=await r.json().catch(()=>({}));
2123
+ if(!r.ok) throw new Error(d.error||'error');
2124
+ meetingSetStatus('saved');
2125
+ }catch(err){ meetingSetStatus('error'); }
2126
+ }
2127
+ // Reuses the Files tab's own neutral tip classes/copy (files.saved/files.saveError) — this panel is a
2128
+ // journal/report, not a post-it, so Notes' own themed .note-status treatment doesn't belong here.
2129
+ function meetingSetStatus(state){
2130
+ const tip=$('#meetingStatus'); if(!tip) return;
2131
+ if(state==='saving'){ tip.textContent=t('notes.saving'); tip.className='files-saved-tip'; }
2132
+ else if(state==='saved'){
2133
+ tip.textContent=t('files.saved'); tip.className='files-saved-tip';
2134
+ setTimeout(()=>{ if(tip.textContent===t('files.saved')) tip.textContent=''; },1500);
2135
+ } else { tip.textContent=t('files.saveError'); tip.className='files-error-tip'; }
2136
+ }
2137
+ function meetingRenderToolbar(){
2138
+ const dateEl=$('#meetingDate'); if(dateEl) dateEl.textContent=meetingSelectedDate||'';
2139
+ const isToday=meetingSelectedDate===meetingToday();
2140
+ const genBtn=$('#meetingGenBtn'); if(genBtn) genBtn.hidden=!isToday; // Generate is offered for TODAY only — see report
2141
+ if(!isToday) meetingHideConfirm();
2142
+ }
2143
+ function meetingHasContent(){
2144
+ const ta=$('#meetingEditor .files-code-input');
2145
+ return !!(ta && ta.value.trim());
2146
+ }
2147
+ function meetingHideConfirm(){ const box=$('#meetingConfirm'); if(box) box.hidden=true; }
2148
+ // Overwrite safety, no native confirm()/alert() (blocks the whole tab, including SSE): reuses this
2149
+ // codebase's existing non-blocking "explicit second action" shape (see filesDiscardBtn's own comment
2150
+ // above) — an inline bar with its own Cancel/Generate-anyway buttons, shown only when the date being
2151
+ // generated already has non-empty content, instead of a native dialog.
2152
+ function meetingGenerateClick(){
2153
+ if(isChatBusy()) return;
2154
+ if(meetingHasContent()){ const box=$('#meetingConfirm'); if(box) box.hidden=false; return; }
2155
+ doMeetingGenerate();
2156
+ }
2157
+ async function doMeetingGenerate(){
2158
+ meetingAwaitingGen=true;
2159
+ const agentEl=$('#tabRunAgent')||$('#runAgent');
2160
+ try{
2161
+ const r=await fetch(withProject('/api/meeting/generate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:agentEl?agentEl.value:undefined,date:meetingSelectedDate})});
2162
+ if(!r.ok){
2163
+ // The op threw before ever spawning (e.g. "No runner configured…") — no run-start/run-end SSE
2164
+ // pair will ever arrive to clear this, so surface the error and reset the flag right here.
2165
+ meetingAwaitingGen=false;
2166
+ const d=await r.json().catch(()=>({}));
2167
+ const tip=$('#meetingStatus'); if(tip){ tip.textContent=d.error||t('files.saveError'); tip.className='files-error-tip'; }
2168
+ }
2169
+ }catch(err){ meetingAwaitingGen=false; meetingSetStatus('error'); }
2170
+ }
2171
+
1659
2172
  function openDrawer(id,keep){
1660
2173
  // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
1661
2174
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
@@ -1702,8 +2215,9 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
1702
2215
  // and render()'s SSE-driven re-render (triggered by the snapshot write / polling) re-applies it
1703
2216
  // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
1704
2217
  // 'change'/'message' event that lands right after a click.
1705
- // initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
1706
- let activeTab = tabFromPath() || (()=>{ try{ return normalizeTab(localStorage.getItem('spf-tab'))||'board'; }catch{ return 'board'; } })();
2218
+ // initial tab: the URL path wins (deep-link / refresh), else board — the persisted tab (config.json
2219
+ // -> activeTab) is reconciled in once P.config is available, see syncSettingsFromServer().
2220
+ let activeTab = tabFromPath() || 'board';
1707
2221
  openTaskId = taskFromPath(); // deep-link straight to a task drawer
1708
2222
  function applyActiveTab(){
1709
2223
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
@@ -1748,18 +2262,104 @@ applyActiveTab(); // sync to the resolved tab before the first render
1748
2262
  if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
1749
2263
  // filters (status chips + search) — client-side only, does not write anything
1750
2264
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1751
- $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
2265
+ $('#search').addEventListener('input', e=>{ filter.q=e.target.value; kanbanExpanded.clear(); renderBoard(); });
1752
2266
  // board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
1753
- $$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; try{ localStorage.setItem('spf-board-view',boardView); }catch{} renderBoard(); }));
2267
+ $$('#boardViewToggle .vt-btn').forEach(b=> b.addEventListener('click', ()=>{ boardView=b.dataset.view; saveSetting({boardView}); renderBoard(); }));
2268
+
2269
+ // ---- Kanban column visibility + page size (Sous-projet B, Task 2) ----
2270
+ // A small anchored popover (same interaction shape as the Workflow step popover: opens on click,
2271
+ // closes on outside click/Esc, keyboard-accessible) lets the viewer toggle any status column off
2272
+ // except the last one still visible — mirroring the server's own "never persist an empty
2273
+ // kanbanColumns" rule (writeConfig() in ops.js) so the UI never lets you attempt what the server
2274
+ // would silently refuse anyway.
2275
+ let kanbanColsOpen=false;
2276
+ function toggleKanbanColsPop(){ kanbanColsOpen?closeKanbanColsPop():openKanbanColsPop(); }
2277
+ function openKanbanColsPop(){
2278
+ kanbanColsOpen=true;
2279
+ renderKanbanColsPop();
2280
+ const btn=$('#kanbanColsBtn'); if(btn) btn.setAttribute('aria-expanded','true');
2281
+ }
2282
+ function closeKanbanColsPop(){
2283
+ kanbanColsOpen=false;
2284
+ const p=$('#kanbanColsPop'); if(p) p.hidden=true;
2285
+ const btn=$('#kanbanColsBtn'); if(btn) btn.setAttribute('aria-expanded','false');
2286
+ }
2287
+ function renderKanbanColsPop(){
2288
+ const pop=$('#kanbanColsPop'); const btn=$('#kanbanColsBtn'); if(!pop||!btn) return;
2289
+ pop.innerHTML='';
2290
+ Object.keys(STATUS).forEach(st=>{
2291
+ const checked=kanbanColumns.includes(st);
2292
+ const isLastOne=checked&&kanbanColumns.length<=1;
2293
+ const row=document.createElement('label'); row.className='kcp-row'+(isLastOne?' is-locked':'');
2294
+ const cb=document.createElement('input'); cb.type='checkbox'; cb.checked=checked; cb.disabled=isLastOne;
2295
+ cb.addEventListener('change',()=> toggleKanbanColumn(st));
2296
+ row.append(cb, el('span',null,STATUS[st]));
2297
+ pop.append(row);
2298
+ });
2299
+ pop.append(el('div','kcp-note',t('board.kanbanColumnsHint')));
2300
+ positionKanbanColsPop(btn,pop);
2301
+ }
2302
+ // Real bug found in QA: with no clamping, a popover taller than the room below its anchor simply
2303
+ // overflowed off the bottom of the viewport with no way to scroll to it (it's position:fixed, so it
2304
+ // never grows the document's own scrollable height). Cap its height to whatever room is actually
2305
+ // available (flipping above the button when that has more room, same idea as positionWfPop()) and
2306
+ // let it scroll internally past that.
2307
+ function positionKanbanColsPop(anchorEl,pop){
2308
+ pop.style.visibility='hidden'; pop.hidden=false; pop.style.maxHeight='';
2309
+ const r=anchorEl.getBoundingClientRect();
2310
+ const vpH=window.innerHeight, m=8, edge=10;
2311
+ const below=vpH-r.bottom-m-edge, above=r.top-m-edge;
2312
+ const useAbove=below<160&&above>below;
2313
+ pop.style.maxHeight=Math.max(120,(useAbove?above:below))+'px';
2314
+ pop.style.overflowY='auto';
2315
+ const pw=pop.offsetWidth||200;
2316
+ const left=Math.max(10,Math.min(r.left,window.innerWidth-pw-10));
2317
+ pop.style.left=left+'px';
2318
+ if(useAbove) pop.style.top=(r.top-m-pop.offsetHeight)+'px';
2319
+ else pop.style.top=(r.bottom+6)+'px';
2320
+ pop.style.visibility='';
2321
+ }
2322
+ function toggleKanbanColumn(st){
2323
+ let next;
2324
+ if(kanbanColumns.includes(st)){
2325
+ if(kanbanColumns.length<=1) return; // the last visible column can't be disabled — same rule the server enforces
2326
+ next=kanbanColumns.filter(x=>x!==st);
2327
+ } else {
2328
+ next=[...kanbanColumns,st];
2329
+ }
2330
+ kanbanColumns=next;
2331
+ saveSetting({kanbanColumns});
2332
+ renderBoard();
2333
+ if(kanbanColsOpen) renderKanbanColsPop(); // keep the open popover's checkbox/lock state in sync
2334
+ }
2335
+ const kanbanColsBtn=$('#kanbanColsBtn');
2336
+ if(kanbanColsBtn) kanbanColsBtn.addEventListener('click',e=>{ e.stopPropagation(); toggleKanbanColsPop(); });
2337
+ document.addEventListener('click',e=>{ if(kanbanColsOpen && !e.target.closest('#kanbanColsPop') && !e.target.closest('#kanbanColsBtn')) closeKanbanColsPop(); });
2338
+ document.addEventListener('keydown',e=>{ if(e.key==='Escape' && kanbanColsOpen) closeKanbanColsPop(); });
2339
+ window.addEventListener('resize',()=>{ if(kanbanColsOpen) closeKanbanColsPop(); });
2340
+ // Kanban page size (10/20) — same view-toggle visual pattern as List/Kanban itself. Changing it
2341
+ // resets every column's "show more" reveal state (kanbanExpanded) since the new cap applies fresh.
2342
+ $$('#kanbanPageSize .vt-btn').forEach(b=> b.addEventListener('click',()=>{
2343
+ const size=Number(b.dataset.size); if(size!==10&&size!==20) return;
2344
+ kanbanPageSize=size; kanbanExpanded.clear(); saveSetting({kanbanPageSize}); renderBoard();
2345
+ }));
1754
2346
  // right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
1755
2347
  // persisted per viewer like every other layout preference here
1756
2348
  function applySideHidden(){
1757
- const panel=$('.panel[data-panel="board"]'); const btn=$('#sideToggle');
2349
+ const panel=$('.panel[data-panel="board"]');
1758
2350
  if(panel) panel.classList.toggle('side-hidden', sideHidden);
1759
- if(btn){ btn.setAttribute('aria-pressed', String(sideHidden)); btn.title=t(sideHidden?'board.showSidebar':'board.hideSidebar'); }
2351
+ // two entry points (topbar + inline on the sidebar itself) drive the same state — keep both in sync
2352
+ for(const btn of $$('#sideToggle, #sideToggleInline')){
2353
+ btn.setAttribute('aria-pressed', String(sideHidden));
2354
+ btn.title=t(sideHidden?'board.showSidebar':'board.hideSidebar');
2355
+ }
1760
2356
  }
1761
- const sideToggleBtn=$('#sideToggle');
1762
- if(sideToggleBtn) sideToggleBtn.addEventListener('click', ()=>{ sideHidden=!sideHidden; try{ localStorage.setItem('spf-side-hidden', sideHidden?'1':'0'); }catch{} applySideHidden(); });
2357
+ function toggleSideHidden(){
2358
+ sideHidden=!sideHidden;
2359
+ saveSetting({sideHidden});
2360
+ applySideHidden();
2361
+ }
2362
+ for(const btn of $$('#sideToggle, #sideToggleInline')) btn.addEventListener('click', toggleSideHidden);
1763
2363
  applySideHidden();
1764
2364
  // expand / collapse all phases (List view) — keeps a big board compact by default
1765
2365
  const phaseToggleAllBtn=$('#phaseToggleAll');
@@ -1814,9 +2414,11 @@ const topAgentSel=$('#topAgent'); if(topAgentSel) topAgentSel.addEventListener('
1814
2414
  const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('change',(e)=>saveAgent(e.target.value));
1815
2415
  // hub-mode pill — only ever shown when this tab is actually served via the multi-project hub
1816
2416
  (function(){ const p=$('#hubPill'); if(p && PROJECT_ID) p.hidden=false; })();
1817
- // theme
1818
- (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);
1819
- $('#themeToggle').addEventListener('click',()=>{ const c=document.documentElement.getAttribute('data-theme'); const n=c==='dark'?'light':'dark'; document.documentElement.setAttribute('data-theme',n); localStorage.setItem('spf-theme',n); }); })();
2417
+ // theme — a real project setting now (config.json -> theme), reconciled onto <html data-theme> once
2418
+ // P.config is available (syncSettingsFromServer()); defaults to dark (CSS's own default) until then.
2419
+ (function(){
2420
+ $('#themeToggle').addEventListener('click',()=>{ const c=document.documentElement.getAttribute('data-theme'); const n=c==='dark'?'light':'dark'; document.documentElement.setAttribute('data-theme',n); saveSetting({theme:n}); });
2421
+ })();
1820
2422
  // chart tooltip — a single floating layer, shown on hover over any [data-tip]
1821
2423
  // element (area-hit rects, future hit targets) and positioned near the cursor.
1822
2424
  (function(){
@@ -1838,10 +2440,14 @@ const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('
1838
2440
  $$('[data-icon]').forEach(node=>{ const svg=ICON[node.dataset.icon]; if(svg) node.innerHTML=svg; });
1839
2441
  })();
1840
2442
  // chat widget
1841
- $('#runQuickBtn').addEventListener('click',()=> setChat(true));
1842
- $('#chatFab').addEventListener('click',()=> setChat($('#chat').getAttribute('aria-hidden')==='true'));
1843
- $('#chatClose').addEventListener('click',()=> setChat(false));
1844
- try{ if(localStorage.getItem('spf-chat')==='1') setChat(true); }catch{}
2443
+ // user-driven chat toggles persist (debounced) — see setChat()'s own comment for why the DOM change
2444
+ // and the save are split apart.
2445
+ function toggleChat(open){ setChat(open); saveSetting({chatOpen:open}); }
2446
+ $('#runQuickBtn').addEventListener('click',()=> toggleChat(true));
2447
+ $('#chatFab').addEventListener('click',()=> toggleChat($('#chat').getAttribute('aria-hidden')==='true'));
2448
+ $('#chatClose').addEventListener('click',()=> toggleChat(false));
2449
+ // initial chatOpen state (if saved server-side) is applied by syncSettingsFromServer(), called from
2450
+ // load() once P.config is available — nothing to do here at parse time.
1845
2451
  $('#runBtn').addEventListener('click',()=>doRun());
1846
2452
  $('#orchBtn').addEventListener('click',()=>doOrchestrate());
1847
2453
  $('#widgetSummarizeBtn').addEventListener('click',()=>summarizeChat($('#runAgent')));
@@ -1852,7 +2458,20 @@ $('#tabRunBtn').addEventListener('click',()=>doRun($('#tabRunPrompt'),$('#tabRun
1852
2458
  $('#tabOrchBtn').addEventListener('click',()=>doOrchestrate($('#tabRunPrompt')));
1853
2459
  $('#tabSummarizeBtn').addEventListener('click',()=>summarizeChat($('#tabRunAgent')));
1854
2460
  $('#tabClearBtn').addEventListener('click',clearChat);
2461
+ // Daily meeting (Sous-projet C, Task 3) — Generate + its inline, non-blocking overwrite confirm.
2462
+ $('#meetingGenBtn').addEventListener('click',meetingGenerateClick);
2463
+ $('#meetingConfirmBtn').addEventListener('click',()=>{ meetingHideConfirm(); doMeetingGenerate(); });
2464
+ $('#meetingCancelBtn').addEventListener('click',meetingHideConfirm);
1855
2465
  $('#tabRunPrompt').addEventListener('keydown',e=>{ if((e.metaKey||e.ctrlKey)&&e.key==='Enter')doRun($('#tabRunPrompt'),$('#tabRunAgent')); });
2466
+ // Slash-command autocomplete — wired on both chat textareas (floating widget + Chat tab).
2467
+ ['#runPrompt','#tabRunPrompt'].forEach((sel)=>{
2468
+ const inp=$(sel); if(!inp) return;
2469
+ inp.addEventListener('input',()=>openCmdMenu(inp));
2470
+ inp.addEventListener('keydown',(e)=>{ if(cmdMenuKey(e)){ e.preventDefault(); e.stopPropagation(); } }, true); // intercept menu navigation keys only while the menu is open
2471
+ inp.addEventListener('blur',()=>setTimeout(hideCmdMenu,120)); // let a menu mousedown win first
2472
+ });
2473
+ document.addEventListener('click',(e)=>{ const m=cmdMenuEl(); if(m&&!m.hidden&&!m.contains(e.target)&&!/^(runPrompt|tabRunPrompt)$/.test(e.target.id)) hideCmdMenu(); });
2474
+ window.addEventListener('resize',()=>{ if(_cmdMenuInput) positionCmdMenu(_cmdMenuInput); });
1856
2475
  $('#drawerClose').addEventListener('click',closeDrawer);
1857
2476
  $('#drawerScrim').addEventListener('click',closeDrawer);
1858
2477
  document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDrawer(); });