spectoflow 0.24.0 → 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 (53) hide show
  1. package/README.md +28 -6
  2. package/bin/spectoflow.js +88 -6
  3. package/lib/dashboard/connector.js +193 -0
  4. package/lib/dashboard/handlers.js +2 -27
  5. package/lib/dashboard/hub-server.js +103 -11
  6. package/lib/dashboard/inject-design.js +41 -0
  7. package/lib/dashboard/meeting.js +116 -0
  8. package/lib/dashboard/ops.js +83 -2
  9. package/lib/dashboard/public/app.js +738 -118
  10. package/lib/dashboard/public/charts.js +3 -3
  11. package/lib/dashboard/public/commands.js +77 -0
  12. package/lib/dashboard/public/designs/console.css +7 -19
  13. package/lib/dashboard/public/designs/orbit.css +3 -4
  14. package/lib/dashboard/public/designs.js +2 -2
  15. package/lib/dashboard/public/fonts/bricolage-grotesque-400.woff2 +0 -0
  16. package/lib/dashboard/public/fonts/bricolage-grotesque-600.woff2 +0 -0
  17. package/lib/dashboard/public/fonts/bricolage-grotesque-700.woff2 +0 -0
  18. package/lib/dashboard/public/hub.html +1 -1
  19. package/lib/dashboard/public/hub.js +27 -4
  20. package/lib/dashboard/public/i18n.js +60 -18
  21. package/lib/dashboard/public/icons.js +2 -0
  22. package/lib/dashboard/public/index.html +106 -13
  23. package/lib/dashboard/public/styles.css +161 -26
  24. package/lib/dashboard/public/vendor/prism/prism-bash.min.js +1 -0
  25. package/lib/dashboard/public/vendor/prism/prism-c.min.js +1 -0
  26. package/lib/dashboard/public/vendor/prism/prism-clike.min.js +1 -0
  27. package/lib/dashboard/public/vendor/prism/prism-core.min.js +1 -0
  28. package/lib/dashboard/public/vendor/prism/prism-cpp.min.js +1 -0
  29. package/lib/dashboard/public/vendor/prism/prism-csharp.min.js +1 -0
  30. package/lib/dashboard/public/vendor/prism/prism-css.min.js +1 -0
  31. package/lib/dashboard/public/vendor/prism/prism-docker.min.js +1 -0
  32. package/lib/dashboard/public/vendor/prism/prism-go.min.js +1 -0
  33. package/lib/dashboard/public/vendor/prism/prism-java.min.js +1 -0
  34. package/lib/dashboard/public/vendor/prism/prism-json.min.js +1 -0
  35. package/lib/dashboard/public/vendor/prism/prism-kotlin.min.js +1 -0
  36. package/lib/dashboard/public/vendor/prism/prism-markdown.min.js +1 -0
  37. package/lib/dashboard/public/vendor/prism/prism-markup-templating.min.js +1 -0
  38. package/lib/dashboard/public/vendor/prism/prism-markup.min.js +1 -0
  39. package/lib/dashboard/public/vendor/prism/prism-php.min.js +1 -0
  40. package/lib/dashboard/public/vendor/prism/prism-python.min.js +1 -0
  41. package/lib/dashboard/public/vendor/prism/prism-ruby.min.js +1 -0
  42. package/lib/dashboard/public/vendor/prism/prism-rust.min.js +1 -0
  43. package/lib/dashboard/public/vendor/prism/prism-sql.min.js +1 -0
  44. package/lib/dashboard/public/vendor/prism/prism-swift.min.js +1 -0
  45. package/lib/dashboard/public/vendor/prism/prism-yaml.min.js +1 -0
  46. package/lib/dashboard/routes.js +38 -0
  47. package/lib/dashboard/runner.js +7 -2
  48. package/lib/workspace.js +36 -1
  49. package/package.json +3 -3
  50. package/templates/config.json +23 -0
  51. package/lib/dashboard/public/fonts/space-grotesk-400.woff2 +0 -0
  52. package/lib/dashboard/public/fonts/space-grotesk-500.woff2 +0 -0
  53. package/lib/dashboard/public/fonts/space-grotesk-700.woff2 +0 -0
@@ -6,14 +6,43 @@ 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
@@ -31,6 +60,232 @@ function projectPath(rest) { return PROJECT_ID ? '/p/' + PROJECT_ID + rest : res
31
60
  // strip happens, so tabFromPath()/taskFromPath() never have to know about the prefix twice.
32
61
  function pathSegments() { const s = location.pathname.split('/').filter(Boolean); return (s[0] === 'p' && s[1]) ? s.slice(2) : s; }
33
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
+
34
289
  const $ = (s,r=document)=>r.querySelector(s);
35
290
  const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
36
291
  const el=(t,c,x)=>{const e=document.createElement(t); if(c)e.className=c; if(x!=null)e.textContent=x; return e;};
@@ -38,7 +293,9 @@ const allTasks=()=> (P.plans||[]).flatMap(pl=>pl.phases.flatMap(ph=>ph.tasks.map
38
293
  const runtimeTests=(id)=> (P.runtime&&P.runtime.tests&&P.runtime.tests[id])||null;
39
294
 
40
295
  async function load(){
41
- 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);
42
299
  if(openTaskId) openDrawer(openTaskId,true);
43
300
  }
44
301
  // Coalesce bursts of SSE 'change'/'message' events into one reload so the board doesn't
@@ -121,10 +378,12 @@ function appendRaw(chunk){
121
378
  scrollChat(container);
122
379
  });
123
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.
124
384
  function setChat(open){
125
385
  $('#chat').setAttribute('aria-hidden', open?'false':'true');
126
386
  $('#chatFab').classList.toggle('is-open',open);
127
- try{ localStorage.setItem('spf-chat', open?'1':'0'); }catch{}
128
387
  // Land where you can actually type, not wherever the log happened to be scrolled last.
129
388
  if(open) setTimeout(()=>{ scrollChat($('#chatLog')); $('#runPrompt').focus(); },60);
130
389
  }
@@ -134,20 +393,86 @@ function setChat(open){
134
393
  // isChatBusy() also guards the Ctrl/Cmd+Enter keyboard shortcuts, which call doRun() directly and
135
394
  // would otherwise bypass the buttons' own disabled state.
136
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
+ }
137
402
  async function doRun(promptEl,agentEl){
138
403
  if(isChatBusy()) return;
139
404
  promptEl=promptEl||$('#runPrompt'); agentEl=agentEl||$('#runAgent');
140
- const prompt=promptEl.value.trim(); if(!prompt) return;
405
+ const raw=promptEl.value.trim(); if(!raw) return;
141
406
  const agent=agentEl.value;
142
- await fetch(withProject('/api/run'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt,agent})});
143
- 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
144
411
  }
145
412
  async function doOrchestrate(promptEl){
146
413
  if(isChatBusy()) return;
147
414
  promptEl=promptEl||$('#runPrompt');
148
- const prompt=promptEl.value.trim(); if(!prompt) return;
149
- await fetch(withProject('/api/orchestrate'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({request:prompt})});
150
- 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;
151
476
  }
152
477
  async function approve(decision){ await fetch(withProject('/api/orchestrate/approve'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision})}); }
153
478
  // ---- chat context management: condense the log via the agent, or wipe it (Chat tab only — the
@@ -177,11 +502,15 @@ function flash(){ const s=$('#sync'); s.classList.add('saving'); $('#syncLabel')
177
502
  let sseBusy=false;
178
503
  function updateChatBusyUI(){
179
504
  const orchStatus=P&&P.runtime&&P.runtime.orchestration&&P.runtime.orchestration.status;
180
- const busy=sseBusy||orchStatus==='running';
505
+ const busy=sseBusy||orchStatus==='running'||OFFLINE;
181
506
  document.body.classList.toggle('chat-busy',!!busy);
182
- [$('#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; });
183
511
  const tabStatus=$('#tabChatStatus'); if(tabStatus) tabStatus.hidden=!busy;
184
512
  const widgetStatus=$('#widgetChatStatus'); if(widgetStatus) widgetStatus.hidden=!busy;
513
+ const meetingStatus=$('#meetingRunStatus'); if(meetingStatus) meetingStatus.hidden=!busy;
185
514
  }
186
515
 
187
516
  function render(){
@@ -202,10 +531,12 @@ function render(){
202
531
  if(meterFill) meterFill.style.width=(s.pct||0)+'%';
203
532
  const meter=$('#globalMeter');
204
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
205
535
  renderOverview(); renderBoard(); renderBacklog(); renderWorkflow(); renderTeam();
206
536
  renderChatLog($('#chatLog')); renderChatLog($('#chatTabLog'));
207
- renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); applySideHidden(); updateChatBusyUI();
537
+ renderSidebar(); renderRequests(); renderAttention(); renderInfo(); renderDocs(); renderSettings(); renderFiles(); renderNotes(); renderMeeting(); applySideHidden(); updateChatBusyUI();
208
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)
209
540
  applyActiveTab(); // re-apply the current tab so an SSE-driven re-render never resets to Board
210
541
  applyI18nStatic(); // re-translate the static markup (nav, headers, placeholders…) for this tick's language
211
542
  }
@@ -418,7 +749,8 @@ function renderBoard(){
418
749
  running.forEach(a=>{ const e=li('run-live',''); e.innerHTML=`<b>${a.tool}</b> · ${a.task||'—'}`; rl.append(e); });
419
750
 
420
751
  const board=$('#board'); board.innerHTML='';
421
- updateBoardViewToggle();
752
+ updateBoardViewToggle(); updateKanbanPageSizeToggle();
753
+ if(kanbanColsOpen) renderKanbanColsPop(); // keep an open popover's checkbox state fresh across a live reload
422
754
  board.classList.toggle('is-kanban', boardView==='kanban');
423
755
  if(!tasks.length){ board.append(emptyState()); return; }
424
756
  if(boardView==='kanban'){ renderKanban(board, tasks); return; } // columns by status
@@ -432,12 +764,15 @@ function renderBoard(){
432
764
  if(!shown) board.append(noMatchState());
433
765
  updatePhaseToggleAll();
434
766
  }
435
- // 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.
436
771
  function renderKanban(board, tasks){
437
772
  const q=filter.q.trim().toLowerCase();
438
773
  const match=(t)=> !q || (t.title+' '+t.id).toLowerCase().includes(q);
439
774
  const cols=el('div','kanban');
440
- Object.keys(STATUS).forEach(st=>{
775
+ Object.keys(STATUS).filter(st=> kanbanColumns.includes(st)).forEach(st=>{
441
776
  const colTasks=tasks.filter(t=> t.status===st && match(t));
442
777
  const col=el('div','kanban-col');
443
778
  const head=el('div','kanban-col-head');
@@ -446,12 +781,23 @@ function renderKanban(board, tasks){
446
781
  col.append(head);
447
782
  const body=el('div','kanban-col-body');
448
783
  if(!colTasks.length) body.append(el('div','kanban-empty','—'));
449
- colTasks.forEach(t=> body.append(renderTask(t)));
450
- 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);
451
796
  });
452
797
  board.append(cols);
453
798
  }
454
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)); }
455
801
  function li(cls,txt){ const e=el('li',cls); e.textContent=txt; return e; }
456
802
  function emptyState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noPlans'); return d; }
457
803
  function noMatchState(){ const d=el('div','empty'); d.style.padding='40px'; d.textContent=t('board.noTasksMatch'); return d; }
@@ -540,13 +886,11 @@ function backlogRow(r){
540
886
 
541
887
  // ---- phase expand state: we track which phases are EXPANDED (default = none), so on a big
542
888
  // project the board opens compact — just phase headers with progress — and the user opens what
543
- // they need. Persisted per phase title (guarded for private mode). ----
544
- function loadExpanded(){
545
- try{ const raw=localStorage.getItem('spf-expanded'); const arr=raw?JSON.parse(raw):[]; return new Set(Array.isArray(arr)?arr:[]); }
546
- catch{ return new Set(); }
547
- }
548
- function saveExpanded(set){ try{ localStorage.setItem('spf-expanded', JSON.stringify([...set])); }catch{} }
549
- 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();
550
894
  function allPhaseTitles(){ const set=new Set(); (P.plans||[]).forEach(pl=> pl.phases.forEach(ph=> set.add(ph.title))); return [...set]; }
551
895
  function updatePhaseToggleAll(){
552
896
  const btn=$('#phaseToggleAll'); if(!btn) return;
@@ -865,10 +1209,13 @@ async function saveAgent(id){
865
1209
  const r=await fetch(withProject('/api/settings'),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({agent:id})});
866
1210
  if(!r.ok){ const body=await r.json().catch(()=>({})); showAgentError(body.error||t('topbar.agent.none')); setAgentSelects(); return; }
867
1211
  }
868
- // ---- 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). ----
869
1216
  function currentDesign(){ return document.documentElement.getAttribute('data-design')||'console'; }
870
- function applyDesign(id){ document.documentElement.setAttribute('data-design',id); try{ localStorage.setItem('spf-design',id); }catch{} }
871
- 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}); }
872
1219
 
873
1220
  function renderSettings(){
874
1221
  const c=(P&&P.config)||{};
@@ -880,10 +1227,9 @@ function renderSettings(){
880
1227
  if(dsel){
881
1228
  const designs=(typeof DESIGNS!=='undefined')?DESIGNS:[{id:'control-room',name:'Control Room'}];
882
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); }); }
883
- // reconcile: a per-viewer choice (localStorage) wins; else the project default (config.design)
884
- let active; try{ active=localStorage.getItem('spf-design'); }catch{}
885
- if(!active && c.design) active=c.design;
886
- 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);
887
1233
  dsel.value=currentDesign();
888
1234
  }
889
1235
  const box=$('#settingsReadonly');
@@ -895,6 +1241,8 @@ function renderSettings(){
895
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); });
896
1242
  }
897
1243
  const fv=$('#footerVer'); if(fv) fv.textContent = (P&&P.version) ? ('v'+P.version) : '';
1244
+ renderNavTabsSettings();
1245
+ renderCommandsSettings();
898
1246
  renderCustomize();
899
1247
  }
900
1248
  async function saveSettings(){
@@ -1077,7 +1425,7 @@ async function czSubmit(kind,description,agent){
1077
1425
  // A custom dashboard (Customize page) gets its own tab id "custom:<id>" and its own URL shape
1078
1426
  // /custom/<id> — kept out of ROUTES (a fixed list) since the set of custom ids is dynamic; recognized
1079
1427
  // by a dedicated branch in tabFromPath()/navigateTab() instead.
1080
- 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'];
1081
1429
  // the tab used to be named/routed "settings" — old bookmarks and any localStorage value saved
1082
1430
  // under that name still land on the Personalize tab instead of a blank panel.
1083
1431
  function normalizeTab(t){ return t==='settings'?'personalize':t; }
@@ -1090,7 +1438,9 @@ function tabFromPath(){
1090
1438
  function taskFromPath(){ const s=pathSegments(); return (ROUTES.includes(s[0])&&s[1])?decodeURIComponent(s[1]):null; }
1091
1439
  function navigateTab(tabId,push){
1092
1440
  closeWfPop(); // an open popover is anchored to whichever panel is currently active — never carry it across
1093
- 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});
1094
1444
  if(push!==false){
1095
1445
  const isCustom=tabId.indexOf('custom:')===0;
1096
1446
  history.pushState(null,'', projectPath(isCustom ? '/custom/'+encodeURIComponent(tabId.slice(7)) : '/'+tabId));
@@ -1418,80 +1768,42 @@ function renderFilesTree(){
1418
1768
  }
1419
1769
  function filesExt(p){ const m=/\.([a-z0-9]+)$/i.exec(p||''); return m?m[1].toLowerCase():''; }
1420
1770
 
1421
- // ---- lightweight syntax highlighting — zero-dependency (no CodeMirror/Monaco, per the explicit
1422
- // call made when this tab was designed): a single char-scanner tokenizer good enough to make code
1423
- // readable at a glance in a file browser, not a language-correct parser. Anything not recognized
1424
- // (or with no lang mapping) just renders as plain, unstyled text — never a rendering error. ----
1425
- const FILES_HL_LANG = {
1426
- js: { comments:[['//','\n'],['/*','*/']], strings:['"',"'",'`'],
1427
- 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(' ') },
1428
- json: { comments:[], strings:['"'], keywords:'true false null'.split(' ') },
1429
- css: { comments:[['/*','*/']], strings:['"',"'"], keywords:[] },
1430
- html: { comments:[['<!--','-->']], strings:['"',"'"], keywords:[], tags:true },
1431
- py: { comments:[['#','\n']], strings:['"',"'"],
1432
- 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(' ') },
1433
- sh: { comments:[['#','\n']], strings:['"',"'"],
1434
- keywords:'if then else elif fi for while do done case esac function return exit export local readonly'.split(' ') },
1435
- yml: { comments:[['#','\n']], strings:['"',"'"], keywords:'true false null'.split(' ') },
1436
- };
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. ----
1437
1775
  function filesHlLang(ext){
1438
- if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'js';
1776
+ if(['js','mjs','cjs','ts','jsx','tsx'].includes(ext)) return 'javascript';
1439
1777
  if(ext==='json') return 'json';
1440
1778
  if(ext==='css') return 'css';
1441
- if(['html','htm'].includes(ext)) return 'html';
1442
- if(ext==='py') return 'py';
1443
- if(['sh','bash'].includes(ext)) return 'sh';
1444
- 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';
1445
1796
  return null;
1446
1797
  }
1447
- // Scans `src` one character at a time, classifying spans as it goes; returns an HTML string with
1448
- // each span wrapped in a colored <span> (escaped — this is the only place raw file content becomes
1449
- // 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.
1450
1802
  function filesHighlight(src,langKey){
1451
- const lang=FILES_HL_LANG[langKey];
1452
- if(!lang) return escHtml(src);
1453
- const n=src.length;
1454
- let i=0,html='',plain='';
1455
- const flushPlain=()=>{ if(plain){ html+=escHtml(plain); plain=''; } };
1456
- const isWordChar=c=>/[A-Za-z0-9_$]/.test(c);
1457
- while(i<n){
1458
- let matched=false;
1459
- // comments
1460
- for(const [open,close] of lang.comments){
1461
- if(src.startsWith(open,i)){
1462
- 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);
1463
- flushPlain(); html+='<span class="hl-comment">'+escHtml(src.slice(i,end))+'</span>'; i=end; matched=true; break;
1464
- }
1465
- }
1466
- if(matched) continue;
1467
- // strings
1468
- if(lang.strings.includes(src[i])){
1469
- const q=src[i]; let j=i+1;
1470
- while(j<n && src[j]!==q){ if(src[j]==='\\') j++; j++; }
1471
- j=Math.min(j+1,n);
1472
- flushPlain(); html+='<span class="hl-string">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
1473
- }
1474
- // html tags (bonus: <tag ...> / </tag>) — a light touch, not full attribute-vs-value parsing
1475
- if(lang.tags && src[i]==='<' && /[a-zA-Z/!]/.test(src[i+1]||'')){
1476
- const end=src.indexOf('>',i); const j=end===-1?n:end+1;
1477
- flushPlain(); html+='<span class="hl-tag">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
1478
- }
1479
- // numbers
1480
- if(/[0-9]/.test(src[i]) && !isWordChar(src[i-1]||'')){
1481
- let j=i; while(j<n && /[0-9.]/.test(src[j])) j++;
1482
- flushPlain(); html+='<span class="hl-number">'+escHtml(src.slice(i,j))+'</span>'; i=j; continue;
1483
- }
1484
- // keywords
1485
- if(isWordChar(src[i]) && !isWordChar(src[i-1]||'')){
1486
- let j=i; while(j<n && isWordChar(src[j])) j++;
1487
- const word=src.slice(i,j);
1488
- if(lang.keywords.includes(word)){ flushPlain(); html+='<span class="hl-keyword">'+escHtml(word)+'</span>'; i=j; continue; }
1489
- plain+=word; i=j; continue;
1490
- }
1491
- plain+=src[i]; i++;
1803
+ if(langKey && window.Prism && Prism.languages && Prism.languages[langKey]){
1804
+ return Prism.highlight(src, Prism.languages[langKey], langKey);
1492
1805
  }
1493
- flushPlain();
1494
- return html;
1806
+ return escHtml(src);
1495
1807
  }
1496
1808
  // A textarea can't render colored text itself, so this overlays one, transparent, on top of a
1497
1809
  // highlighted <pre><code> "backdrop" showing through it (the standard technique for a highlighted
@@ -1655,6 +1967,208 @@ async function submitFilesCreate(){
1655
1967
  if(kind!=='dir') openFilesFile(rel);
1656
1968
  }
1657
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
+
1658
2172
  function openDrawer(id,keep){
1659
2173
  // named `task`, not `t` — `t` is the global translation function (see i18n.js) and this whole
1660
2174
  // function calls it repeatedly below; shadowing it with a task variable would break every call.
@@ -1701,8 +2215,9 @@ const cssv=(v)=> getComputedStyle(document.documentElement).getPropertyValue(v).
1701
2215
  // and render()'s SSE-driven re-render (triggered by the snapshot write / polling) re-applies it
1702
2216
  // too instead of ever resetting to Board; this is what keeps a tab selected across a race with a
1703
2217
  // 'change'/'message' event that lands right after a click.
1704
- // initial tab: the URL path wins (deep-link / refresh), else the persisted tab, else board
1705
- 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';
1706
2221
  openTaskId = taskFromPath(); // deep-link straight to a task drawer
1707
2222
  function applyActiveTab(){
1708
2223
  $$('#tabs .tab').forEach(t=> t.classList.toggle('is-active', t.dataset.tab===activeTab));
@@ -1747,18 +2262,104 @@ applyActiveTab(); // sync to the resolved tab before the first render
1747
2262
  if(pathSegments()[0]==='settings') history.replaceState(null,'',projectPath('/personalize'));
1748
2263
  // filters (status chips + search) — client-side only, does not write anything
1749
2264
  $$('#statusChips .fchip').forEach(b=> b.addEventListener('click', ()=>{ filter.status=b.dataset.status; renderBoard(); }));
1750
- $('#search').addEventListener('input', e=>{ filter.q=e.target.value; renderBoard(); });
2265
+ $('#search').addEventListener('input', e=>{ filter.q=e.target.value; kanbanExpanded.clear(); renderBoard(); });
1751
2266
  // board view switch — List (phase-grouped) vs Kanban (columns by status), persisted per viewer
1752
- $$('#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
+ }));
1753
2346
  // right sidebar hide/show — Kanban's own-width columns need the room, and the toggle stays
1754
2347
  // persisted per viewer like every other layout preference here
1755
2348
  function applySideHidden(){
1756
- const panel=$('.panel[data-panel="board"]'); const btn=$('#sideToggle');
2349
+ const panel=$('.panel[data-panel="board"]');
1757
2350
  if(panel) panel.classList.toggle('side-hidden', sideHidden);
1758
- 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
+ }
1759
2356
  }
1760
- const sideToggleBtn=$('#sideToggle');
1761
- 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);
1762
2363
  applySideHidden();
1763
2364
  // expand / collapse all phases (List view) — keeps a big board compact by default
1764
2365
  const phaseToggleAllBtn=$('#phaseToggleAll');
@@ -1813,9 +2414,11 @@ const topAgentSel=$('#topAgent'); if(topAgentSel) topAgentSel.addEventListener('
1813
2414
  const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('change',(e)=>saveAgent(e.target.value));
1814
2415
  // hub-mode pill — only ever shown when this tab is actually served via the multi-project hub
1815
2416
  (function(){ const p=$('#hubPill'); if(p && PROJECT_ID) p.hidden=false; })();
1816
- // theme
1817
- (function(){ const s=localStorage.getItem('spf-theme'); if(s)document.documentElement.setAttribute('data-theme',s);
1818
- $('#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
+ })();
1819
2422
  // chart tooltip — a single floating layer, shown on hover over any [data-tip]
1820
2423
  // element (area-hit rects, future hit targets) and positioned near the cursor.
1821
2424
  (function(){
@@ -1837,10 +2440,14 @@ const setAgentSel=$('#setAgent'); if(setAgentSel) setAgentSel.addEventListener('
1837
2440
  $$('[data-icon]').forEach(node=>{ const svg=ICON[node.dataset.icon]; if(svg) node.innerHTML=svg; });
1838
2441
  })();
1839
2442
  // chat widget
1840
- $('#runQuickBtn').addEventListener('click',()=> setChat(true));
1841
- $('#chatFab').addEventListener('click',()=> setChat($('#chat').getAttribute('aria-hidden')==='true'));
1842
- $('#chatClose').addEventListener('click',()=> setChat(false));
1843
- 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.
1844
2451
  $('#runBtn').addEventListener('click',()=>doRun());
1845
2452
  $('#orchBtn').addEventListener('click',()=>doOrchestrate());
1846
2453
  $('#widgetSummarizeBtn').addEventListener('click',()=>summarizeChat($('#runAgent')));
@@ -1851,7 +2458,20 @@ $('#tabRunBtn').addEventListener('click',()=>doRun($('#tabRunPrompt'),$('#tabRun
1851
2458
  $('#tabOrchBtn').addEventListener('click',()=>doOrchestrate($('#tabRunPrompt')));
1852
2459
  $('#tabSummarizeBtn').addEventListener('click',()=>summarizeChat($('#tabRunAgent')));
1853
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);
1854
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); });
1855
2475
  $('#drawerClose').addEventListener('click',closeDrawer);
1856
2476
  $('#drawerScrim').addEventListener('click',closeDrawer);
1857
2477
  document.addEventListener('keydown',e=>{ if(e.key==='Escape')closeDrawer(); });