diff --git a/docs/FEATURE_MAP.md b/docs/FEATURE_MAP.md
index 7890ea88b..3369d0098 100644
--- a/docs/FEATURE_MAP.md
+++ b/docs/FEATURE_MAP.md
@@ -617,3 +617,11 @@ find apps/e2e/src/tests -name '*.test.ts' | wc -l
A new feature adds a catalog row, a tree node and, if it stores anything, its
tables in the schema diagram.
+
+The interactive page, [feature-map.html](feature-map.html), is generated. Edit
+its inventory in `docs/feature-map/data.js` (its layout in `template.html`, its
+script in `app.js`; the diagrams come from this page), then rebuild it:
+
+```bash
+npm run docs:feature-map
+```
diff --git a/docs/feature-map/app.js b/docs/feature-map/app.js
new file mode 100644
index 000000000..30c80a84e
--- /dev/null
+++ b/docs/feature-map/app.js
@@ -0,0 +1,674 @@
+(function () {
+ 'use strict';
+
+ const KINDS = [
+ { id: 'bug', label: 'Bug', icon: ' ' },
+ { id: 'improvement', label: 'Improvement', icon: ' ' },
+ { id: 'test', label: 'Test', icon: ' ' },
+ { id: 'feature', label: 'New sub-feature', icon: ' ' },
+ { id: 'manual', label: 'Manual test', icon: ' ' },
+ ];
+ const STATUSES = [
+ { id: 'backlog', label: 'Backlog', cls: '' },
+ { id: 'scheduled', label: 'Scheduled', cls: 'scheduled' },
+ { id: 'progress', label: 'In progress', cls: 'progress' },
+ { id: 'review', label: 'In review', cls: 'review' },
+ { id: 'blocked', label: 'Blocked', cls: 'blocked' },
+ { id: 'done', label: 'Done', cls: 'done' },
+ ];
+ const PRIOS = [
+ { id: 'P0', label: 'P0 · data loss or security' },
+ { id: 'P1', label: 'P1 · breaks a feature' },
+ { id: 'P2', label: 'P2 · normal' },
+ { id: 'P3', label: 'P3 · when there is time' },
+ ];
+ const RESULTS = [
+ { id: '', label: 'Not run', cls: '' },
+ { id: 'pass', label: 'Pass', cls: 'done' },
+ { id: 'partial', label: 'Partial', cls: 'progress' },
+ { id: 'fail', label: 'Fail', cls: 'blocked' },
+ ];
+ // The copy kept in the repo (docs/feature-map.html) has no shared board.
+ const STATIC = Boolean(window.FOX_MAP_STATIC);
+ const TABS = STATIC ? ['features', 'workflows', 'schema', 'engines', 'coverage'] : ['plan', 'features', 'workflows', 'schema', 'engines', 'coverage'];
+ const FEATURE_BY_ID = Object.fromEntries(FEATURES.map((f) => [f.id, f]));
+ const KIND_BY_ID = Object.fromEntries(KINDS.map((k) => [k.id, k]));
+ const STATUS_BY_ID = Object.fromEntries(STATUSES.map((s) => [s.id, s]));
+
+ const S = { tab: 'plan', view: 'board', q: '', feature: '', kind: '', owner: '', status: '', area: '' };
+ let db = null;
+ let user = null;
+ let meId = null;
+ let readOnly = false;
+ let dbState = 'loading';
+ let items = [];
+ let profiles = {};
+
+ const $ = (sel, root) => (root || document).querySelector(sel);
+ const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));
+
+ /** Build an element. Strings become text nodes, so stored text never becomes markup. */
+ function h(tag, attrs, ...kids) {
+ const node = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (v === null || v === undefined || v === false) continue;
+ if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
+ else if (k === 'class') node.className = v;
+ else node.setAttribute(k, v === true ? '' : String(v));
+ }
+ for (const kid of kids.flat()) {
+ if (kid === null || kid === undefined || kid === false) continue;
+ node.append(kid instanceof Node ? kid : document.createTextNode(String(kid)));
+ }
+ return node;
+ }
+ function svgIcon(markup) {
+ const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+ s.setAttribute('viewBox', '0 0 24 24');
+ s.setAttribute('aria-hidden', 'true');
+ s.innerHTML = markup; // constant icon markup from this file
+ return s;
+ }
+ function kindChip(id) {
+ const k = KIND_BY_ID[id] || KINDS[1];
+ return h('span', { class: 'kind' }, svgIcon(k.icon), k.label);
+ }
+ function stateChip(statusId) {
+ const s = STATUS_BY_ID[statusId] || STATUSES[0];
+ return h('span', { class: 'state ' + s.cls }, h('i'), s.label);
+ }
+ function resultChip(resultId) {
+ const r = RESULTS.find((x) => x.id === (resultId || '')) || RESULTS[0];
+ return h('span', { class: 'state ' + r.cls }, h('i'), r.label);
+ }
+ function prioPill(p) {
+ return h('span', { class: 'prio ' + (p || 'P2'), title: (PRIOS.find((x) => x.id === p) || PRIOS[2]).label }, p || 'P2');
+ }
+
+ const store = {
+ get(k) { try { return localStorage.getItem('foxmap:' + k); } catch { return null; } },
+ set(k, v) { try { localStorage.setItem('foxmap:' + k, v); } catch { /* private window */ } },
+ };
+
+ function toast(text) {
+ const root = $('#toast-root');
+ root.textContent = '';
+ const t = h('div', { class: 'toast', role: 'status' }, text);
+ root.append(t);
+ setTimeout(() => { if (t.parentNode) t.remove(); }, 3200);
+ }
+
+ // ---- dates ---------------------------------------------------------------
+ function parseDay(s) {
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s || '');
+ return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null;
+ }
+ function today() { const d = new Date(); return new Date(d.getFullYear(), d.getMonth(), d.getDate()); }
+ function fmtDay(s) {
+ const d = parseDay(s);
+ if (!d) return '';
+ const opts = d.getFullYear() === new Date().getFullYear() ? { day: 'numeric', month: 'short' } : { day: 'numeric', month: 'short', year: 'numeric' };
+ return new Intl.DateTimeFormat(undefined, opts).format(d);
+ }
+ function isOpen(it) { return it.status !== 'done'; }
+ function isOverdue(it) { const d = parseDay(it.target); return Boolean(d && isOpen(it) && d < today()); }
+
+ // ---- tabs ----------------------------------------------------------------
+ function showTab(tab, push) {
+ if (!TABS.includes(tab)) tab = TABS[0];
+ S.tab = tab;
+ for (const b of $$('nav.tabs button')) b.setAttribute('aria-selected', String(b.dataset.tab === tab));
+ for (const p of $$('.panel')) p.classList.toggle('active', p.id === 'panel-' + tab);
+ store.set('tab', tab);
+ if (push && location.hash !== '#' + tab) {
+ try { history.replaceState(null, '', '#' + tab); } catch { /* sandbox */ }
+ }
+ }
+
+ // ---- plan: filters -------------------------------------------------------
+ function fillSelects() {
+ const fsel = $('#f-feature');
+ fsel.textContent = '';
+ fsel.append(h('option', { value: '' }, 'All features'));
+ for (const a of AREAS) {
+ const g = h('optgroup', { label: a.name });
+ for (const f of FEATURES.filter((x) => x.area === a.id)) g.append(h('option', { value: f.id }, f.id + ' — ' + f.name));
+ fsel.append(g);
+ }
+ const ksel = $('#f-kind');
+ ksel.textContent = '';
+ ksel.append(h('option', { value: '' }, 'All kinds'));
+ for (const k of KINDS) ksel.append(h('option', { value: k.id }, k.label));
+ }
+
+ function matches(it, ignoreStatus) {
+ if (S.feature && it.feature !== S.feature) return false;
+ if (S.kind && it.kind !== S.kind) return false;
+ if (S.owner === 'me' && (!meId || it.owner !== meId)) return false;
+ if (S.owner === 'none' && it.owner) return false;
+ if (!ignoreStatus && S.status && it.status !== S.status) return false;
+ if (S.q) {
+ const hay = [it.title, it.detail, it.links, it.feature, it.sub, it.sprint].join(' ').toLowerCase();
+ if (!hay.includes(S.q.toLowerCase())) return false;
+ }
+ return true;
+ }
+ const PRIO_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
+ function byPriority(a, b) {
+ const p = (PRIO_RANK[a.priority] ?? 2) - (PRIO_RANK[b.priority] ?? 2);
+ if (p) return p;
+ const da = a.target || '9999', dbb = b.target || '9999';
+ if (da !== dbb) return da < dbb ? -1 : 1;
+ return String(b.updatedAt || '').localeCompare(String(a.updatedAt || ''));
+ }
+
+ // ---- plan: rendering -----------------------------------------------------
+ function renderSummary() {
+ const root = $('#summary');
+ root.textContent = '';
+ const visible = items.filter((it) => matches(it, true));
+ const open = visible.filter(isOpen).length;
+ root.append(
+ h('button', { type: 'button', 'aria-pressed': String(!S.status), onclick: () => { S.status = ''; renderPlan(); } }, h('b', {}, String(open)), 'open of ' + visible.length)
+ );
+ for (const s of STATUSES) {
+ const n = visible.filter((it) => it.status === s.id).length;
+ root.append(
+ h('button', { type: 'button', 'aria-pressed': String(S.status === s.id), onclick: () => { S.status = S.status === s.id ? '' : s.id; renderPlan(); } }, stateChip(s.id), h('b', {}, String(n)))
+ );
+ }
+ }
+
+ function ownerNode(id) {
+ const n = h('span', { class: 'owner', 'data-owner': id || '' });
+ paintOwner(n, id);
+ return n;
+ }
+ function paintOwner(node, id) {
+ node.textContent = '';
+ if (!id) { node.textContent = 'Unassigned'; return; }
+ const p = profiles[id];
+ if (p) node.append(h('img', { src: p.avatarUrl, alt: '' }), p.name || 'Someone');
+ else node.textContent = id === meId ? 'You' : 'Someone';
+ }
+ async function resolveOwners() {
+ if (!user) return;
+ const ids = [...new Set(items.map((i) => i.owner).filter(Boolean))];
+ if (!ids.length) return;
+ try { profiles = await user.profiles(ids); } catch { return; }
+ for (const n of $$('[data-owner]')) paintOwner(n, n.getAttribute('data-owner'));
+ }
+
+ function card(it) {
+ const canDrag = dbState === 'ready' && !readOnly;
+ const c = h('article', {
+ class: 'card', tabindex: '0', role: 'button', draggable: canDrag ? 'true' : 'false',
+ 'data-id': it.id, 'aria-label': (it.title || 'Untitled') + ' — open',
+ onclick: () => openEditor(it),
+ onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEditor(it); } },
+ ondragstart: (e) => { e.dataTransfer.setData('text/plain', it.id); e.dataTransfer.effectAllowed = 'move'; },
+ },
+ h('div', { class: 'r1' }, kindChip(it.kind), prioPill(it.priority)),
+ h('div', { class: 't' }, it.title || 'Untitled'),
+ h('div', { class: 'r3' },
+ h('span', { class: 'fid' }, it.feature || '—'),
+ ownerNode(it.owner),
+ it.target ? h('span', { class: isOverdue(it) ? 'overdue' : '' }, (isOverdue(it) ? 'Overdue · ' : '') + fmtDay(it.target)) : null
+ )
+ );
+ return c;
+ }
+
+ function renderBoard(body, list) {
+ const cols = S.status ? STATUSES.filter((s) => s.id === S.status) : STATUSES;
+ const board = h('div', { class: 'board', role: 'list' });
+ for (const s of cols) {
+ const inCol = list.filter((it) => it.status === s.id).sort(byPriority);
+ const col = h('div', { class: 'col', role: 'listitem', 'data-status': s.id, 'aria-label': s.label },
+ h('div', { class: 'colhead' }, stateChip(s.id), h('span', { class: 'n' }, String(inCol.length))),
+ inCol.length ? inCol.map(card) : h('div', { class: 'colempty' }, dbState === 'ready' ? 'Nothing here.' : '')
+ );
+ col.addEventListener('dragover', (e) => { if (readOnly || dbState !== 'ready') return; e.preventDefault(); col.classList.add('drop'); });
+ col.addEventListener('dragleave', () => col.classList.remove('drop'));
+ col.addEventListener('drop', (e) => {
+ e.preventDefault();
+ col.classList.remove('drop');
+ const id = e.dataTransfer.getData('text/plain');
+ if (id) void moveTo(id, s.id);
+ });
+ board.append(col);
+ }
+ body.append(board);
+ }
+
+ function weekStart(d) { const x = new Date(d); const day = (x.getDay() + 6) % 7; x.setDate(x.getDate() - day); return x; }
+ function renderSchedule(body, list) {
+ const shown = list.filter((it) => (S.status ? true : isOpen(it)));
+ const groups = new Map();
+ const overdue = [];
+ const nodate = [];
+ for (const it of shown) {
+ if (isOverdue(it)) { overdue.push(it); continue; }
+ const d = parseDay(it.target);
+ if (!d) { nodate.push(it); continue; }
+ const k = weekStart(d).toISOString().slice(0, 10);
+ if (!groups.has(k)) groups.set(k, []);
+ groups.get(k).push(it);
+ }
+ const wrap = h('div', { class: 'sched' });
+ const fmtWeek = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' });
+ const section = (title, rows, extraCls) => {
+ if (!rows.length) return;
+ rows.sort((a, b) => (a.target || '').localeCompare(b.target || '') || byPriority(a, b));
+ wrap.append(h('div', { class: 'section' },
+ h('h3', { class: extraCls || '' }, title, h('span', { class: 'n' }, rows.length + (rows.length === 1 ? ' item' : ' items'))),
+ h('div', { class: 'rows' }, rows.map(row))
+ ));
+ };
+ section('Overdue', overdue, 'overdue');
+ for (const k of [...groups.keys()].sort()) section('Week of ' + fmtWeek.format(new Date(k + 'T00:00:00')), groups.get(k));
+ section('No target date', nodate);
+ if (!wrap.childNodes.length) wrap.append(emptyState());
+ body.append(wrap);
+ }
+ function row(it) {
+ return h('article', {
+ class: 'row', tabindex: '0', role: 'button', onclick: () => openEditor(it),
+ onkeydown: (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEditor(it); } },
+ },
+ h('span', { class: isOverdue(it) ? 'overdue mono' : 'mono' }, it.target ? fmtDay(it.target) : '—'),
+ h('span', { class: 't' }, it.title || 'Untitled', ' ', h('span', { class: 'fid' }, it.feature || '—')),
+ h('span', { class: 'meta' }, kindChip(it.kind), stateChip(it.status), prioPill(it.priority), ownerNode(it.owner))
+ );
+ }
+
+ function renderManual(body) {
+ const feats = S.feature ? FEATURES.filter((f) => f.id === S.feature) : FEATURES;
+ const tbody = h('tbody');
+ for (const f of feats) {
+ const mine = items.filter((it) => it.feature === f.id && it.kind === 'manual');
+ const ran = mine.filter((it) => it.manual && it.manual.lastRun).sort((a, b) => String(b.manual.lastRun).localeCompare(String(a.manual.lastRun)));
+ const last = ran[0];
+ const openPasses = mine.filter(isOpen);
+ const next = openPasses.map((it) => it.target).filter(Boolean).sort()[0];
+ tbody.append(h('tr', {},
+ h('td', {}, h('span', { class: 'fid' }, f.id), ' ', f.name),
+ h('td', { class: 'num' }, last ? fmtDay(last.manual.lastRun) : '—'),
+ h('td', {}, resultChip(last ? last.manual.result : '')),
+ h('td', { class: 'num' }, String(openPasses.length)),
+ h('td', { class: 'num' }, next ? fmtDay(next) : '—'),
+ h('td', {}, canWrite() ? h('button', { class: 'btn', type: 'button', onclick: () => openEditor(null, { feature: f.id, kind: 'manual', status: 'scheduled' }) }, 'Plan a pass') : '')
+ ));
+ }
+ body.append(h('div', { class: 'tablebox' },
+ h('table', {}, h('thead', {}, h('tr', {},
+ h('th', {}, 'Feature'), h('th', { class: 'num' }, 'Last manual run'), h('th', {}, 'Result'),
+ h('th', { class: 'num' }, 'Open passes'), h('th', { class: 'num' }, 'Next'), h('th', {}, '')
+ )), tbody)
+ ));
+ }
+
+ function emptyState() {
+ if (dbState === 'loading') return h('div', { class: 'empty' }, 'Loading the shared board…');
+ if (dbState === 'absent') return h('div', { class: 'empty' }, 'Work items appear here once the shared board is available.');
+ if (!items.length) return h('div', { class: 'empty' }, 'No work items yet. Use “Add work item” to plan the first bug fix, test or manual-test pass.');
+ return h('div', { class: 'empty' }, 'No work items match these filters.');
+ }
+
+ function renderPlan() {
+ $('#ro-note').hidden = !(readOnly && dbState === 'ready');
+ $('#offline-note').hidden = dbState !== 'absent';
+ $('#add-item').hidden = !canWrite();
+ for (const b of $$('.seg [data-view]')) b.setAttribute('aria-pressed', String(b.dataset.view === S.view));
+ $('#f-feature').value = S.feature;
+ $('#f-kind').value = S.kind;
+ $('#f-owner').value = S.owner;
+ renderSummary();
+ const body = $('#plan-body');
+ body.textContent = '';
+ const list = items.filter((it) => matches(it, false));
+ if (S.view === 'manual') renderManual(body);
+ else if (dbState !== 'ready' || !list.length) {
+ if (S.view === 'board' && dbState === 'ready' && items.length) renderBoard(body, list);
+ else body.append(emptyState());
+ } else if (S.view === 'schedule') renderSchedule(body, list);
+ else renderBoard(body, list);
+ void resolveOwners();
+ }
+
+ // ---- writes ----------------------------------------------------------------
+ function canWrite() { return dbState === 'ready' && !readOnly; }
+ function bodyOf(it) { const { id, ...rest } = it; return rest; }
+
+ function handleWriteError(e) {
+ const code = e && e.code;
+ if (code === 'invalid_argument') {
+ readOnly = true;
+ toast('You can read this board but not change it.');
+ renderAll();
+ } else if (code === 'quota_exceeded') {
+ toast('The board is full. Delete finished items to make room.');
+ } else if (code === 'revoked' || code === 'not_granted') {
+ dbState = 'absent';
+ renderAll();
+ } else {
+ toast('Could not save. Check your connection and try again.');
+ }
+ }
+ async function save(id, body) {
+ if (!canWrite()) return false;
+ const now = new Date().toISOString();
+ try {
+ if (id) await db.doc('items/' + id).set({ ...body, updatedAt: now });
+ else await db.collection('items').doc().set({ ...body, createdAt: now, updatedAt: now, createdBy: meId || null });
+ return true;
+ } catch (e) {
+ handleWriteError(e);
+ return false;
+ }
+ }
+ async function moveTo(id, status) {
+ const it = items.find((x) => x.id === id);
+ if (!it || it.status === status) return;
+ if (await save(id, { ...bodyOf(it), status })) toast('Moved to ' + STATUS_BY_ID[status].label);
+ }
+ async function remove(id) {
+ if (!canWrite()) return false;
+ try { await db.doc('items/' + id).delete(); return true; } catch (e) { handleWriteError(e); return false; }
+ }
+
+ // ---- editor dialog ---------------------------------------------------------
+ let lastFocus = null;
+ function closeDialog() {
+ $('#dialog-root').textContent = '';
+ document.removeEventListener('keydown', onDialogKey);
+ if (lastFocus && document.contains(lastFocus)) lastFocus.focus();
+ }
+ function onDialogKey(e) { if (e.key === 'Escape') { e.preventDefault(); closeDialog(); } }
+
+ function openEditor(it, preset) {
+ lastFocus = document.activeElement;
+ const editing = Boolean(it);
+ const ro = !canWrite();
+ const v = it ? { ...bodyOf(it) } : {
+ title: '', feature: S.feature || '', sub: '', kind: S.kind || 'bug', status: 'backlog', priority: 'P2',
+ owner: null, target: '', sprint: '', links: '', detail: '', manual: { lastRun: '', result: '', notes: '' }, ...(preset || {}),
+ };
+ v.manual = { lastRun: '', result: '', notes: '', ...(v.manual || {}) };
+ let owner = v.owner || null;
+
+ const opt = (value, label, sel) => h('option', { value, selected: sel ? 'selected' : null }, label);
+ const fTitle = h('input', { type: 'text', id: 'ed-title', value: v.title || '', required: true, disabled: ro, maxlength: '200' });
+ const fFeature = h('select', { id: 'ed-feature', disabled: ro });
+ fFeature.append(opt('', 'Choose a feature…', !v.feature));
+ for (const a of AREAS) {
+ const g = h('optgroup', { label: a.name });
+ for (const f of FEATURES.filter((x) => x.area === a.id)) g.append(opt(f.id, f.id + ' — ' + f.name, f.id === v.feature));
+ fFeature.append(g);
+ }
+ const fSub = h('select', { id: 'ed-sub', disabled: ro });
+ const fillSubs = () => {
+ fSub.textContent = '';
+ fSub.append(opt('', 'The whole feature', !v.sub));
+ const f = FEATURE_BY_ID[fFeature.value];
+ for (const s of (f ? f.subs : [])) fSub.append(opt(s, s, s === v.sub));
+ };
+ fillSubs();
+ fFeature.addEventListener('change', () => { v.sub = ''; fillSubs(); });
+ const fKind = h('select', { id: 'ed-kind', disabled: ro }, KINDS.map((k) => opt(k.id, k.label, k.id === v.kind)));
+ const fStatus = h('select', { id: 'ed-status', disabled: ro }, STATUSES.map((s) => opt(s.id, s.label, s.id === v.status)));
+ const fPrio = h('select', { id: 'ed-prio', disabled: ro }, PRIOS.map((p) => opt(p.id, p.label, p.id === v.priority)));
+ const fTarget = h('input', { type: 'date', id: 'ed-target', value: v.target || '', disabled: ro });
+ const fSprint = h('input', { type: 'text', id: 'ed-sprint', value: v.sprint || '', disabled: ro, placeholder: 'e.g. 0.2.36x, November', maxlength: '80' });
+ const fLinks = h('input', { type: 'text', id: 'ed-links', value: v.links || '', disabled: ro, placeholder: 'PR, branch, issue or plan', maxlength: '400' });
+ const fDetail = h('textarea', { id: 'ed-detail', disabled: ro, maxlength: '4000', placeholder: 'What and why. For a bug: what happens, and what should.' });
+ fDetail.value = v.detail || '';
+ const fRun = h('input', { type: 'date', id: 'ed-run', value: v.manual.lastRun || '', disabled: ro });
+ const fResult = h('select', { id: 'ed-result', disabled: ro }, RESULTS.map((r) => opt(r.id, r.label, r.id === (v.manual.result || ''))));
+ const fNotes = h('textarea', { id: 'ed-notes', disabled: ro, maxlength: '2000', placeholder: 'Steps walked, what failed, environment' });
+ fNotes.value = v.manual.notes || '';
+
+ // Owner: stored as an id, shown by name.
+ const ownerShown = h('span', { class: 'owner' });
+ const paintChosen = () => paintOwner(ownerShown, owner);
+ paintChosen();
+ const fOwnerSearch = h('input', { type: 'search', id: 'ed-owner', placeholder: user ? 'Search people…' : 'Names need sign-in', disabled: ro || !user, autocomplete: 'off' });
+ const hits = h('div', { class: 'hits', hidden: true });
+ const showHits = async (q) => {
+ if (!user) return;
+ let list = [];
+ try { list = await user.search(q); } catch { list = []; }
+ hits.textContent = '';
+ for (const p of list) {
+ hits.append(h('button', { type: 'button', onclick: () => { owner = p.id; profiles[p.id] = p; paintChosen(); hits.hidden = true; fOwnerSearch.value = ''; } }, h('img', { src: p.avatarUrl, alt: '' }), p.name || 'Someone'));
+ }
+ hits.hidden = !list.length;
+ };
+ fOwnerSearch.addEventListener('focus', () => void showHits(''));
+ fOwnerSearch.addEventListener('input', () => void showHits(fOwnerSearch.value));
+ fOwnerSearch.addEventListener('blur', () => setTimeout(() => { hits.hidden = true; }, 180));
+ const meBtn = h('button', { class: 'btn ghost', type: 'button', disabled: ro || !meId, onclick: () => { owner = meId; paintChosen(); } }, 'Assign to me');
+ const clearBtn = h('button', { class: 'btn ghost', type: 'button', disabled: ro, onclick: () => { owner = null; paintChosen(); } }, 'Clear');
+
+ const err = h('p', { class: 'overdue', hidden: true });
+ const confirmRow = h('div', { class: 'confirm', hidden: true },
+ 'Delete this item for everyone?',
+ h('button', { class: 'btn danger', type: 'button', onclick: async () => { if (await remove(it.id)) { closeDialog(); toast('Deleted'); } } }, 'Delete'),
+ h('button', { class: 'btn', type: 'button', onclick: () => { confirmRow.hidden = true; } }, 'Keep it')
+ );
+
+ const onSave = async (e) => {
+ e.preventDefault();
+ const title = fTitle.value.trim();
+ if (!title || !fFeature.value) {
+ err.hidden = false;
+ err.textContent = !title ? 'Give the item a title.' : 'Choose the feature it belongs to.';
+ (title ? fFeature : fTitle).focus();
+ return;
+ }
+ const body = {
+ ...(it ? bodyOf(it) : {}),
+ title, feature: fFeature.value, sub: fSub.value, kind: fKind.value, status: fStatus.value, priority: fPrio.value,
+ owner: owner || null, target: fTarget.value, sprint: fSprint.value.trim(), links: fLinks.value.trim(), detail: fDetail.value,
+ manual: { lastRun: fRun.value, result: fResult.value, notes: fNotes.value },
+ };
+ if (await save(editing ? it.id : null, body)) { closeDialog(); toast(editing ? 'Saved' : 'Added'); }
+ };
+
+ const field = (label, control, id) => h('div', { class: 'field' }, h('label', { for: id }, label), control);
+ const form = h('form', { class: 'dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': 'ed-h', onsubmit: onSave },
+ h('div', { class: 'sechead' },
+ h('h2', { id: 'ed-h' }, ro ? 'Work item' : editing ? 'Edit work item' : 'New work item'),
+ editing && it.createdAt ? h('span', { class: 'stamp' }, 'Updated ' + fmtDay(String(it.updatedAt || it.createdAt).slice(0, 10))) : null
+ ),
+ field('Title', fTitle, 'ed-title'),
+ h('div', { class: 'grid2' }, field('Feature', fFeature, 'ed-feature'), field('Sub-feature', fSub, 'ed-sub')),
+ h('div', { class: 'grid2' }, field('Kind', fKind, 'ed-kind'), field('Status', fStatus, 'ed-status'), field('Priority', fPrio, 'ed-prio')),
+ h('div', { class: 'field' }, h('label', { for: 'ed-owner' }, 'Owner'),
+ h('div', { class: 'toolbar' }, ownerShown, meBtn, clearBtn),
+ h('div', { class: 'picker' }, fOwnerSearch, hits)
+ ),
+ h('div', { class: 'grid2' }, field('Target date', fTarget, 'ed-target'), field('Sprint or milestone', fSprint, 'ed-sprint')),
+ field('Links', fLinks, 'ed-links'),
+ field('Detail', fDetail, 'ed-detail'),
+ h('fieldset', {}, h('legend', {}, 'Manual test — last run'),
+ h('div', { class: 'grid2' }, field('Run on', fRun, 'ed-run'), field('Result', fResult, 'ed-result')),
+ field('Notes', fNotes, 'ed-notes')
+ ),
+ err,
+ confirmRow,
+ h('div', { class: 'actions' },
+ editing && !ro ? h('button', { class: 'btn danger', type: 'button', onclick: () => { confirmRow.hidden = false; } }, 'Delete') : h('span'),
+ h('div', { class: 'toolbar' },
+ h('button', { class: 'btn', type: 'button', onclick: closeDialog }, ro ? 'Close' : 'Cancel'),
+ ro ? null : h('button', { class: 'btn primary', type: 'submit' }, editing ? 'Save' : 'Add item')
+ )
+ )
+ );
+ const scrim = h('div', { class: 'scrim', onclick: (e) => { if (e.target === scrim) closeDialog(); } }, form);
+ $('#dialog-root').textContent = '';
+ $('#dialog-root').append(scrim);
+ document.addEventListener('keydown', onDialogKey);
+ (ro ? form.querySelector('button') : fTitle).focus();
+ }
+
+ // ---- features --------------------------------------------------------------
+ function renderAreas() {
+ const root = $('#areas');
+ root.textContent = '';
+ const mk = (id, label) => h('button', { type: 'button', 'aria-pressed': String(S.area === id), onclick: () => { S.area = id; renderAreas(); renderFeatures(); } }, label);
+ root.append(mk('', 'All'));
+ for (const a of AREAS) root.append(mk(a.id, a.name));
+ }
+ function renderFeatures() {
+ const grid = $('#fgrid');
+ grid.textContent = '';
+ for (const f of FEATURES.filter((x) => !S.area || x.area === S.area)) {
+ const open = items.filter((it) => it.feature === f.id && isOpen(it)).length;
+ const dl = h('dl', {});
+ for (const [k, val] of [['Opens in', f.ui], ['API', f.api], ['Code', f.code], ['Perms', f.perms], ['Tables', f.tables], ['Tests', f.tests], ['Watch', f.watch]]) {
+ if (!val) continue;
+ dl.append(h('dt', {}, k), h('dd', {}, val));
+ }
+ grid.append(h('article', { class: 'fcard' },
+ h('div', { class: 'h' }, h('h3', {}, f.name), h('span', { class: 'fid' }, f.id)),
+ h('div', { class: 'subs' }, f.subs.map((s) => h('span', { class: 'tag' }, s))),
+ dl,
+ h('div', { class: 'foot' },
+ h('button', { class: 'btn ghost openlink', type: 'button', onclick: () => { S.feature = f.id; S.status = ''; showTab('plan', true); renderPlan(); } },
+ dbState === 'ready' ? (open ? open + ' open work item' + (open === 1 ? '' : 's') + ' →' : 'No open work →') : 'Work items →'),
+ canWrite() ? h('button', { class: 'btn', type: 'button', onclick: () => openEditor(null, { feature: f.id }) }, 'Plan work') : null
+ )
+ ));
+ }
+ }
+
+ // ---- schema, engines, coverage ---------------------------------------------
+ function renderOwners() {
+ const tb = $('#owners tbody');
+ for (const [t, f, n] of TABLES) tb.append(h('tr', {}, h('td', {}, h('code', {}, t)), h('td', {}, h('span', { class: 'fid' }, f), ' ', (FEATURE_BY_ID[f] || {}).name || ''), h('td', { class: 'num' }, String(n))));
+ }
+ function renderMatrix() {
+ const t = $('#matrix');
+ t.append(h('thead', {}, h('tr', {}, h('th', {}, 'Engine'), ENGINES.cols.map((c) => h('th', {}, c)))));
+ const tb = h('tbody');
+ for (const [name, ...caps] of ENGINES.rows) {
+ tb.append(h('tr', {}, h('td', {}, name), caps.map((c) => h('td', { class: c ? 'yes' : 'no' }, c ? '✓ Yes' : '— No'))));
+ }
+ t.append(tb);
+ }
+ function niceStep(max) {
+ for (const s of [1, 2, 5, 10, 20, 25, 50, 100, 200]) if (max / s <= 5) return s;
+ return 500;
+ }
+ function barChart(rootId, title, note, rows, labelHead) {
+ const root = $('#' + rootId);
+ const max = Math.max(...rows.map((r) => r[1]));
+ const step = niceStep(max);
+ const top = Math.ceil(max / step) * step;
+ const ticks = [];
+ for (let v = 0; v <= top; v += step) ticks.push(v);
+ const tip = h('div', { class: 'tip', hidden: true });
+ const bars = h('div', { class: 'bars', style: '--n:' + (ticks.length - 1) });
+ for (const [label, value] of rows) {
+ const pct = (value / top) * 100;
+ const r = h('div', { class: 'brow' },
+ h('span', { class: 'blbl' }, label),
+ h('span', { class: 'btrack' },
+ h('span', { class: 'bfill', style: 'width:' + pct.toFixed(2) + '%' }),
+ h('span', { class: 'bval', style: 'left:calc(' + pct.toFixed(2) + '% + 6px)' }, String(value))
+ )
+ );
+ r.addEventListener('mousemove', (e) => {
+ const box = root.getBoundingClientRect();
+ tip.textContent = label + ': ' + value;
+ tip.hidden = false;
+ tip.style.left = Math.min(e.clientX - box.left + 12, box.width - 160) + 'px';
+ tip.style.top = (e.clientY - box.top - 34) + 'px';
+ });
+ r.addEventListener('mouseleave', () => { tip.hidden = true; });
+ bars.append(r);
+ }
+ const axis = h('div', { class: 'brow baxis', 'aria-hidden': 'true' }, h('span'),
+ h('span', { class: 'bticks' }, ticks.map((v) => h('span', { style: 'left:' + ((v / top) * 100).toFixed(2) + '%' }, String(v)))));
+ bars.append(axis);
+ const table = h('div', { class: 'tablebox', hidden: true },
+ h('table', {}, h('thead', {}, h('tr', {}, h('th', {}, labelHead), h('th', { class: 'num' }, 'Count'))),
+ h('tbody', {}, rows.map(([l, v]) => h('tr', {}, h('td', {}, l), h('td', { class: 'num' }, String(v)))))));
+ const toggle = h('button', { class: 'btn ghost', type: 'button', onclick: () => {
+ table.hidden = !table.hidden; bars.hidden = !table.hidden;
+ toggle.textContent = table.hidden ? 'Show as table' : 'Show as chart';
+ } }, 'Show as table');
+ root.append(
+ h('div', { class: 'sechead' }, h('div', {}, h('h3', {}, title), h('div', { class: 'stamp' }, note)), toggle),
+ bars, table, tip
+ );
+ }
+ function renderCoverage() {
+ barChart('chart-routes', 'HTTP routes by feature', '153 routes pinned by http-contract.test.ts', ROUTES, 'Feature');
+ barChart('chart-tests', 'Unit-test files by package', 'Files named *.test.ts(x), excluding e2e', TESTS, 'Package');
+ const g = $('#e2e-groups');
+ for (const name of E2E_GROUPS) g.append(h('span', { class: 'tag' }, name));
+ }
+
+ function renderAll() { renderPlan(); renderFeatures(); }
+
+ // ---- store connection ------------------------------------------------------
+ async function connect() {
+ let cl = window.claude;
+ if (!cl || typeof cl.use !== 'function') {
+ await new Promise((r) => setTimeout(r, 1200));
+ cl = window.claude;
+ }
+ if (!cl || typeof cl.use !== 'function') { dbState = 'absent'; renderAll(); return; }
+ try { user = await cl.use('user'); } catch { user = null; }
+ try { db = await cl.use('db'); } catch { db = null; }
+ if (!db) { dbState = 'absent'; renderAll(); return; }
+ if (user) {
+ try { meId = await user.id(); } catch { meId = null; }
+ try { if ((await user.can('data.write')) === false) readOnly = true; } catch { /* keep controls */ }
+ }
+ db.collection('items').onSnapshot(
+ (snap) => {
+ items = snap.docs.filter((d) => d.exists).map((d) => ({ id: d.id, ...d.data() }));
+ dbState = 'ready';
+ renderAll();
+ },
+ (e) => {
+ if (e && ['revoked', 'not_granted', 'capability_disabled', 'capability_removed'].includes(e.code)) dbState = 'absent';
+ else toast('The board stopped updating. Reload the page to reconnect.');
+ renderAll();
+ }
+ );
+ }
+
+ // ---- boot ------------------------------------------------------------------
+ function boot() {
+ fillSelects();
+ renderAreas();
+ renderOwners();
+ renderMatrix();
+ renderCoverage();
+ const savedView = store.get('view');
+ if (savedView && ['board', 'schedule', 'manual'].includes(savedView)) S.view = savedView;
+ for (const b of $$('nav.tabs button')) b.addEventListener('click', () => showTab(b.dataset.tab, true));
+ for (const b of $$('.seg [data-view]')) b.addEventListener('click', () => { S.view = b.dataset.view; store.set('view', S.view); renderPlan(); });
+ $('#q').addEventListener('input', (e) => { S.q = e.target.value; renderPlan(); });
+ $('#f-feature').addEventListener('change', (e) => { S.feature = e.target.value; renderPlan(); });
+ $('#f-kind').addEventListener('change', (e) => { S.kind = e.target.value; renderPlan(); });
+ $('#f-owner').addEventListener('change', (e) => { S.owner = e.target.value; renderPlan(); });
+ $('#add-item').addEventListener('click', () => openEditor(null));
+ if (STATIC) {
+ $('#tab-plan').hidden = true;
+ $('#panel-plan').hidden = true;
+ dbState = 'absent';
+ }
+ const hash = (location.hash || '').replace('#', '');
+ const saved = store.get('tab');
+ showTab(TABS.includes(hash) ? hash : TABS.includes(saved) ? saved : TABS[0], false);
+ renderAll();
+ if (!STATIC) void connect();
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+ else boot();
+})();
diff --git a/docs/feature-map/data.js b/docs/feature-map/data.js
new file mode 100644
index 000000000..fae8d2756
--- /dev/null
+++ b/docs/feature-map/data.js
@@ -0,0 +1,264 @@
+// Reference data: the inventory of what Fox Schema ships, measured from the
+// codebase on main 91d4165c09 (2026-10-25). Mirrors docs/FEATURE_MAP.md.
+// Work items are not here: they live in the page's shared store.
+const AREAS = [
+ { id: 'platform', name: 'Platform' },
+ { id: 'schema', name: 'Schema' },
+ { id: 'editor', name: 'SQL editor & data' },
+ { id: 'utilities', name: 'Utilities' },
+ { id: 'access', name: 'Access' },
+ { id: 'workflow', name: 'Workflow' },
+ { id: 'tools', name: 'CLI & engines' },
+];
+
+const FEATURES = [
+ { id: 'platform.auth', area: 'platform', name: 'Sign-in & accounts',
+ subs: ['Password sign-in', 'Register', 'First-run setup code', 'Launch session', 'Email verification', 'Forgot / reset password', 'SSO: Google, Microsoft, GitHub, broker', 'Sign out other sessions', 'Onboarding wizard'],
+ ui: 'Sign-in page, account banners', api: '/api/auth (17), /api/signup (3), /api/user (2)',
+ code: 'server auth, users, platform/identity · web auth', perms: 'public routes',
+ tables: 'users, sessions, auth_codes, user_preferences', tests: 'server 9 · web 3 · e2e auth-recovery, smoke' },
+ { id: 'platform.admin', area: 'platform', name: 'Administration',
+ subs: ['Users: create, invite, role, activate, set password, issue code', 'Hand over admin', 'Role → permission matrix', 'One admin or several', 'Sign-in settings: providers, mail, broker, public URL', 'Every workspace, restore archived', 'Demo role and demo mode: banner, sign-in hint'],
+ ui: 'Profile → Access control', api: '/api/admin (19)',
+ code: 'server admin · web admin', perms: 'admin.users, admin.roles',
+ tables: 'users, role_permissions, app_settings', tests: 'server 4 · web 5 · e2e admin-policy, demo-role' },
+ { id: 'platform.workspaces', area: 'platform', name: 'Workspaces',
+ subs: ['Rail button and menu', 'Create, switch, archive, restore', 'Settings', 'Members and workspace roles', 'Personal workspace lends no more than its account holds', 'Invites: accept, decline, 14 days', 'Public discover and join', 'Shared workspace scripts'],
+ ui: 'Rail button under the Fox mark', api: '/api/workspaces (20)',
+ code: 'server workspaces, platform/workspaces · web workspaces', perms: 'workspace.members, workspace.settings, workspace.create',
+ tables: 'workspaces, workspace_members, workspace_invites, workspace_scripts', tests: 'server 5 · web 3 · e2e workspaces, workspaces-sharing-limits' },
+ { id: 'platform.connections', area: 'platform', name: 'Connections & drivers',
+ subs: ['Credential manager', 'Connection form: password, Windows, LDAP', 'SSL and pool', 'SQLite / DuckDB file picker', 'Schema list', 'Connection test', 'Driver check and Install driver', 'Cloud provider credentials'],
+ ui: 'Creds (rail), connection modal', api: '/api/connections (4), /api/connection/test, /api/db/test, /api/driver (2)',
+ code: 'server connections, platform/connections, internal/driver-install · web connections', perms: 'workspace role; admin.drivers to install',
+ tables: 'connections, cloud_provider_credentials', tests: 'server 1 · web 1' },
+ { id: 'platform.secrets', area: 'platform', name: 'App secrets',
+ subs: ['Vault entries', 'Cloud secret providers', 'Resolve a secret'],
+ ui: 'Creds → secrets, editor Secrets panel', api: '/api/app-secrets (9)',
+ code: 'server admin (app-secrets mount)', perms: 'secrets.view, create, edit, delete',
+ tables: 'app_secrets, cloud_provider_credentials', tests: 'none dedicated' },
+ { id: 'platform.system', area: 'platform', name: 'Shell, health & updates',
+ subs: ['Activity rail and top toolbar', 'Command palette', 'Home', 'Backend-offline banner', 'Activity', 'App info and config', 'Update check and apply', 'Email and update settings', 'Send feedback'],
+ ui: 'App shell, Prefs', api: '/api/health, /api/config, /api/app-info, /api/activity, /api/updates (2)',
+ code: 'server system · web app/shell, app/settings, feedback', perms: '—',
+ tables: 'app_settings', tests: 'server 1 · web 2 · e2e smoke, lazy-loading, send-feedback, auto-error-catch' },
+
+ { id: 'schema.compare', area: 'schema', name: 'Compare & deploy',
+ subs: ['Connection pair', 'Browse bar', 'Schema tree', 'Object detail: DDL diff, blueprint, tabs', 'Diff briefing', 'Cross-dialect readiness', 'Dependency and validation warnings', 'Execute gate: missing FK, narrowing', 'Deploy confirm', 'Streamed migration progress'],
+ ui: 'Compare (rail)', api: '/api/compare, /api/schema/load, /api/schema/list, /api/migration/execute',
+ code: 'server compare, schema, migration · web compare, schema-diff · sql schema-diff, migrations, dialect', perms: 'schema.browse, schema.compare, schema.migrate',
+ tables: 'migration_runs', tests: 'sql 12 · web 14 · e2e compare-flow, execute-gate, schema-browse, 14 dialect suites' },
+ { id: 'schema.applies', area: 'schema', name: 'Migration history',
+ subs: ['Runs list', 'Script and results', 'Delete runs'],
+ ui: 'Applies (rail)', api: '/api/migrations (5)',
+ code: 'server migration · web migrations', perms: 'schema.migrate',
+ tables: 'migration_runs', tests: 'server 3 · web 1' },
+ { id: 'schema.git', area: 'schema', name: 'Migrations in Git',
+ subs: ['Repositories (bare local copies)', 'Branches', 'Fetch, pull, push', 'Commit a migration', 'Preview', 'Applied tracking', 'Activity'],
+ ui: 'Compare → Git, Admin', api: '/api/git (15)',
+ code: 'server git · web git', perms: 'git.view, git.manage',
+ tables: 'git_repos, git_applied, git_activity, migration_runs.git_*', tests: 'server 4 · web 1 · e2e git-migrations' },
+ { id: 'schema.snapshots', area: 'schema', name: 'Snapshots (Lokee)',
+ subs: ['Capture', 'Databases', 'Version timeline', 'Graph', 'Version compare', 'Change chart', 'Object inspector', 'Revert: plan → run', 'Force-migrate: plan → run', 'Rename a version'],
+ ui: 'Snapshots (rail)', api: '/api/lokee (11)',
+ code: 'server history · web lokee-weave · sql lokee-weave', perms: 'compare.history, schema.migrate.force',
+ tables: 'lokee_databases, lokee_versions, lokee_version_objects, lokee_objects, lokee_latest_objects, lokee_shapes', tests: 'sql 15 · server 3 · web 14 · e2e schema-history, schema-revert, schema-version-revert-edges' },
+
+ { id: 'editor.run', area: 'editor', name: 'Editor & run',
+ subs: ['Tabs, stored per workspace', 'Destinations', 'Run rail: In [n], ▶, include, Safe-mode marks, copy with values', 'Selection and caret run', 'Safe mode confirm', 'One transaction', 'Per-dialect write classification', 'Format SQL+JS', 'Clause-aware completions', 'Column picker', 'Shortcuts'],
+ ui: 'Editor (rail)', api: '/api/sql/execute',
+ code: 'server sql-editor (sql-execute, sql-page-wrap) · web sql-editor · sql sql-text, sql-editor', perms: 'editor.access, editor.run, editor.dml, editor.ddl, editor.grant',
+ tables: '—', tests: 'server 7 · web 59 (feature) · sql 16 · e2e sql-editor-smoke, -sqlite, -join-typing, -running-state' },
+ { id: 'editor.code', area: 'editor', name: 'FoxScript code cells',
+ subs: ['JS, TS, Node, Node-TS cells', 'Transpile', 'sql bridge: bound params, sql.list, sql.raw', '-- @set', 'Server Beam: sql.on(source / target)', 'faker, lodash, date-fns', 'Built-in samples'],
+ ui: 'Editor', api: '/api/sql/code-cell, /api/sql/code-cell/transpile',
+ code: 'server sql-editor (code-cell-*) · web sql-editor/lib', perms: 'editor.advanced (install-wide)',
+ tables: '—', tests: 'e2e sql-editor-code-cell-faker' },
+ { id: 'editor.results', area: 'editor', name: 'Results grid',
+ subs: ['By credential / side by side', 'Paging: rows per page, Next, Prev, seek', 'Cell and row edit', 'Copy and CSV export', 'Chart', 'Maximize', 'Runs drawer', 'Warnings'],
+ ui: 'Editor', api: 'with /api/sql/execute',
+ code: 'web ResultsPanel, DataGrid', perms: 'editor.datagrid.insert, update, delete',
+ tables: '—', tests: 'e2e sql-editor-result-edit, -copy-export' },
+ { id: 'editor.datacompare', area: 'editor', name: 'Data compare & migrate',
+ subs: ['Compare toggle and Source', 'Diff cells', 'Key columns', 'Add, edit, delete', 'Sync column', 'Apply: Transaction where it can roll back, stop or continue, backup snapshot', 'History', 'Preview SQL', 'Multi-target'],
+ ui: 'Editor, side by side', api: '/api/data-migrate/execute, /api/data-migrations (5)',
+ code: 'server data-migrate · web DataMigrateBar', perms: 'editor.dml',
+ tables: 'data_migrate_runs', tests: 'server 4 · e2e sql-editor-compare-multi-target' },
+ { id: 'editor.peek', area: 'editor', name: 'Data peek',
+ subs: ['Cmd/Ctrl-click to open', 'Stacked panels', 'WHERE (one row per AND), ORDER BY, limit', 'Paging', 'Row editor: add, edit, clone, delete, generators', 'Insight tab', 'FK drill and referenced-by', 'Date picker'],
+ ui: 'Editor', api: 'with /api/sql/execute',
+ code: 'web DataPeekPanel, PeekRowEditor, usePeekGridCrud', perms: 'editor.datagrid.*',
+ tables: '—', tests: 'e2e sql-editor-data-peek-grid, -peek-row-form, -peek-stacking' },
+ { id: 'editor.explorer', area: 'editor', name: 'Schema explorer & blueprint',
+ subs: ['Connection picker', 'Object tree', 'Insert names', 'Object source script', 'Table blueprint: create, edit', 'New table (engines with DDL)', 'Redis and MongoDB keys as tables, with type badges', 'Install driver banner'],
+ ui: 'Editor sidebar', api: '/api/schema/load',
+ code: 'web SqlSchemaExplorer, TableBlueprintModal, MissingDriverBanner', perms: 'editor.sidebar.schema, schema.browse',
+ tables: '—', tests: 'e2e sql-editor-blueprint, -column-picker' },
+ { id: 'editor.library', area: 'editor', name: 'Bookmarks, variables & scripts',
+ subs: ['Bookmarks', 'Samples', 'Recent', 'Variables', 'Secrets', 'Shared workspace scripts'],
+ ui: 'Editor sidebar', api: '/api/workspaces/scripts (3)',
+ code: 'web SqlBookmarksPanel, SqlVariablesPanel, SqlSecretsPanel', perms: 'editor.variables.*, editor.sidebar.*',
+ tables: 'workspace_scripts', tests: 'counted with editor.run' },
+ { id: 'editor.files', area: 'editor', name: 'File import & query',
+ subs: ['Chunked upload sessions', 'Column detection', 'Import into a table', 'Query an uploaded CSV, JSON or NDJSON file', 'Capacity', 'Database-file picker'],
+ ui: 'Editor → Files, Utils → Query files', api: '/api/files (11)',
+ code: 'server files (parse worker pool)', perms: 'editor.run',
+ tables: '—', tests: 'server 9' },
+
+ { id: 'utilities.indexes', area: 'utilities', name: 'Index management',
+ subs: ['Fragmentation', 'Custom probe: one read-only SELECT', 'Batch', 'Rebuild, reorg, optimize SQL', 'Db2: every index on the table', 'Drop'],
+ ui: 'Utils (rail)', api: '/api/schema/index-fragmentation, -batch',
+ code: 'server access (index-fragmentation) · sql utilities', perms: 'utility.access, utility.index.drop',
+ tables: '—', tests: 'sql 7 · e2e sql-editor-utilities, -utilities-dialects' },
+ { id: 'utilities.clone', area: 'utilities', name: 'Clone table',
+ subs: ['Clone a table definition and rows'],
+ ui: 'Utils', api: 'through /api/sql/execute', code: 'web CloneTableModal', perms: 'utility.access, editor.ddl',
+ tables: '—', tests: 'utilities e2e' },
+ { id: 'utilities.insights', area: 'utilities', name: 'Server insights',
+ subs: ['Server insights', 'Table insight', 'Query files'],
+ ui: 'Utils', api: '/api/schema/dba-utility, /api/schema/table-insight',
+ code: 'server access (dba-utilities)', perms: 'utility.access', tables: '—', tests: 'utilities e2e' },
+ { id: 'utilities.backup', area: 'utilities', name: 'Backup & Restore',
+ subs: ['Per-engine command builders', 'Server-side SQL backup after confirmation', 'Recorded backups', 'Saved defaults'],
+ ui: 'Utils', api: '/api/backup-settings (2)',
+ code: 'server backup · sql providers/*/*.backup.ts', perms: 'utility.access',
+ tables: 'backup_settings', tests: 'server 2 · e2e backup-restore-ui, backup-commands-dialects' },
+ { id: 'utilities.dbaccess', area: 'utilities', name: 'DB users & grants',
+ subs: ['Database Access modal'],
+ ui: 'Utils', api: '/api/schema/db-access', code: 'server access (db-access)', perms: 'utility.access, editor.grant',
+ tables: '—', tests: 'e2e database-access-dialects' },
+
+ { id: 'access.accounts', area: 'access', name: 'Database accounts',
+ subs: ['Create', 'Rename', 'Drop', 'Passwords (per-engine DDL)'],
+ ui: 'Access (rail)', api: '/api/schema/db-access',
+ code: 'web access (UserManagement) · sql access, providers/*/*.user-sql.ts', perms: 'access.access, access.users',
+ tables: '—', tests: 'sql 13 · web 12' },
+ { id: 'access.builder', area: 'access', name: 'Permission builder',
+ subs: ['Intent → GRANT / REVOKE', 'Grants stage', 'Object picker', 'Executes, stops at the first failure'],
+ ui: 'Access', api: '/api/schema/db-access',
+ code: 'web AccessPermissionPanel, AccessGrantsStage · sql providers/*/*.access-sql.ts', perms: 'access.builder, editor.grant',
+ tables: '—', tests: 'e2e access-assistant-sqlite, access-assistant-dialects' },
+ { id: 'access.inspector', area: 'access', name: 'Inspector & Insight',
+ subs: ['Who holds what', 'Insight: privilege compare', 'Permission matrix'],
+ ui: 'Access', api: '/api/schema/db-access',
+ code: 'web PermissionInspector, PermissionDiff, PermissionMatrix', perms: 'access.inspector, access.diff',
+ tables: '—', tests: 'access tests' },
+ { id: 'access.report', area: 'access', name: 'Access report',
+ subs: ['Report of grants and accounts'],
+ ui: 'Access', api: '/api/schema/db-access', code: 'web AccessReport', perms: 'access.report',
+ tables: '—', tests: 'access tests' },
+
+ { id: 'workflow.designer', area: 'workflow', name: 'Designer',
+ subs: ['Workflows list', 'Canvas: pipes and edges', 'Palette', 'Inspector', 'Triggers', 'SQL, script, HTTP and delimited editors', 'Dependencies'],
+ ui: 'Workflow (rail)', api: 'engine proxy under /api/workflow',
+ code: 'web workflow · workflow-engine definitions', perms: 'workflow.access, workflow.design',
+ tables: "engine's own store", tests: 'web 12 · e2e workflow-designer-roundtrip' },
+ { id: 'workflow.pipes', area: 'workflow', name: 'Pipes',
+ subs: ['db: SQL on every dialect, Postgres and MySQL sources', 'http: request, multi-http, transform, sink, cookies', 'notify: email, SMS, SMTP', 'control: loop, split, merge, goto, human gate, sub-workflow', 'utility: CSV, delimited, JSON, script, text, transforms, verify, files', 'trigger'],
+ ui: 'Designer palette', api: 'engine',
+ code: 'packages/workflow-engine/src/pipes/*', perms: 'workflow.design',
+ tables: '—', tests: 'workflow-engine 87' },
+ { id: 'workflow.runs', area: 'workflow', name: 'Runs',
+ subs: ['Execution panel', 'Cancel a live run', 'Answer a human gate', 'Variables', 'Credentials'],
+ ui: 'Workflow', api: 'engine proxy',
+ code: 'web RunsPanel, ExecutionPanel, VariablesPanel, CredentialsPanel', perms: 'workflow.run',
+ tables: "engine's own store", tests: 'workflow tests' },
+ { id: 'workflow.engine', area: 'workflow', name: 'Engine control plane',
+ subs: ['Settings and health', 'Saved-connection grants', 'Internal resolve and engine-config routes (token-guarded)'],
+ ui: 'Workflow → Engine', api: '/api/workflow (5), /api/workflow-internal (2)',
+ code: 'server workflow · apps/workflow-server · workflow-contract', perms: 'workflow.admin',
+ tables: 'workflow_settings, workflow_connection_grants', tests: 'server 6 · workflow-server 19 · e2e workflow-smoke' },
+
+ { id: 'cli', area: 'tools', name: 'foxschema CLI & TUI',
+ subs: ['setup', 'open', 'compare', 'migrate', 'snapshot', 'history', 'connections', 'drivers (install db2)', 'doctor', 'search', 'shortcut', 'reset-password', 'TUI screens'],
+ ui: 'Terminal', api: 'packages + local server', code: 'apps/cli', perms: '—',
+ tables: 'metadata store', tests: 'cli 25' },
+ { id: 'engines.dialects', area: 'tools', name: '16 database engines',
+ subs: ['SQL generation', 'Schema compare', 'Statement splitting', 'Type mapping', 'Drivers and pooling', 'Capability flags'],
+ ui: '—', api: '—', code: 'packages/sql/src/providers · packages/db/src/providers', perms: '—',
+ tables: '—', tests: 'sql 86 · db 24 · 14 dialect e2e suites' },
+];
+
+const ENGINES = {
+ cols: ['Schema compare', 'DB access', 'User mgmt', 'Command mode', 'Row editing', 'Transactions'],
+ rows: [
+ ['PostgreSQL', 1, 1, 1, 1, 1, 1], ['CockroachDB', 1, 1, 1, 1, 1, 1], ['YugabyteDB', 1, 1, 1, 1, 1, 1],
+ ['MySQL', 1, 1, 1, 1, 1, 1], ['MariaDB', 1, 1, 1, 1, 1, 1], ['TiDB', 1, 1, 1, 1, 1, 1],
+ ['Db2', 1, 1, 1, 1, 1, 1], ['SQL Server', 1, 1, 1, 1, 1, 1], ['Azure SQL', 1, 1, 1, 1, 1, 1],
+ ['Oracle', 1, 1, 1, 1, 1, 1], ['Redshift', 1, 1, 1, 1, 1, 1], ['SQLite', 1, 0, 0, 1, 1, 1],
+ ['DuckDB', 1, 0, 0, 1, 1, 1], ['ClickHouse', 1, 0, 1, 1, 0, 0], ['Redis', 0, 0, 0, 0, 1, 0],
+ ['MongoDB', 0, 0, 0, 0, 1, 0],
+ ],
+};
+
+// Routes per feature, from the 153 pinned in http-contract.test.ts.
+const ROUTES = [
+ ['platform.auth', 22], ['platform.workspaces', 20], ['platform.admin', 19], ['schema.git', 15],
+ ['schema.snapshots', 11], ['editor.files', 11], ['platform.secrets', 9], ['platform.connections', 8],
+ ['workflow.engine', 7], ['platform.system', 6], ['editor.datacompare', 6], ['schema.applies', 5],
+ ['utilities + access', 5], ['schema.compare', 4], ['editor.run + code', 3], ['utilities.backup', 2],
+];
+
+// Unit-test files per package.
+const TESTS = [
+ ['apps/web', 175], ['packages/server', 100], ['packages/workflow-engine', 87], ['packages/sql', 86],
+ ['apps/cli', 25], ['packages/db', 24], ['apps/workflow-server', 19], ['packages/shared', 12],
+ ['packages/workflow-contract', 1], ['packages/ui-shared', 1],
+];
+
+const E2E_GROUPS = ['PostgreSQL', 'MySQL', 'MariaDB', 'SQL Server', 'Oracle', 'DB2', 'SQLite', 'Azure SQL', 'ClickHouse', 'Redshift', 'CockroachDB', 'YugabyteDB', 'TiDB', 'DuckDB', 'Smoke', 'Sign-in', 'One admin or several', 'Workspaces', 'SQL Editor (14 specs)', 'Send feedback', 'Browse', 'History', 'Revert', 'Revert edges', 'Access SQLite', 'Workflow', 'Utilities', 'DB Access', 'Access all', 'Migrations in Git', 'Execute gate', 'Backup & Restore', 'Backup commands'];
+
+// Metadata table → owning feature, column count (schema.ts, 31 migrations).
+const TABLES = [
+ ['users', 'platform.auth', 11], ['sessions', 'platform.auth', 6], ['auth_codes', 'platform.auth', 6], ['user_preferences', 'platform.auth', 7],
+ ['role_permissions', 'platform.admin', 2], ['app_settings', 'platform.system', 3],
+ ['workspaces', 'platform.workspaces', 8], ['workspace_members', 'platform.workspaces', 5], ['workspace_invites', 'platform.workspaces', 10], ['workspace_scripts', 'editor.library', 8],
+ ['connections', 'platform.connections', 7], ['cloud_provider_credentials', 'platform.secrets', 7], ['app_secrets', 'platform.secrets', 8],
+ ['migration_runs', 'schema.applies', 18], ['git_repos', 'schema.git', 12], ['git_applied', 'schema.git', 9], ['git_activity', 'schema.git', 6],
+ ['lokee_databases', 'schema.snapshots', 10], ['lokee_versions', 'schema.snapshots', 19], ['lokee_version_objects', 'schema.snapshots', 6], ['lokee_objects', 'schema.snapshots', 9], ['lokee_latest_objects', 'schema.snapshots', 4], ['lokee_shapes', 'schema.snapshots', 3],
+ ['data_migrate_runs', 'editor.datacompare', 19], ['backup_settings', 'utilities.backup', 4],
+ ['workflow_settings', 'workflow.engine', 3], ['workflow_connection_grants', 'workflow.engine', 3], ['schema_migrations', 'platform.system', 3],
+];
+
+// Dialects: one plannable entry per engine, its checklist drawn from what the
+// engine supports (ENGINES above) plus the gotchas DIALECTS.md records.
+AREAS.push({ id: 'dialects', name: 'Dialects' });
+const DIALECTS = [
+ ['postgres', 'PostgreSQL', 'postgres', 'dialects/postgres', 'Run applies search_path. Renaming a role on md5 clears its password. The dev container trusts every login, so a passing password test proves nothing there.'],
+ ['cockroachdb', 'CockroachDB', 'cockroachDb', 'dialects/cockroachdb', 'DDL auto-commits (autocommit_before_ddl), so a failed migration leaves earlier DDL applied.'],
+ ['yugabytedb', 'YugabyteDB', 'yugabyteDb', 'dialects/yugabytedb', 'The dev container can stick on a stale master list; recreate its volume.'],
+ ['mysql', 'MySQL', 'mysql', 'dialects/mysql', 'DDL auto-commits. Executable comments (/*! … */) can carry writes. Table names are case-sensitive on Linux.'],
+ ['mariadb', 'MariaDB', 'mariaDb', 'dialects/mariadb', 'DDL auto-commits. Shares the mysql2 driver with MySQL.'],
+ ['tidb', 'TiDB', 'tiDb', 'dialects/tidb', 'DDL auto-commits.'],
+ ['db2', 'Db2', 'db2', 'dialects/db2', 'Fold the schema to uppercase, keep the table as SYSCAT stores it. REORG goes through SYSPROC.ADMIN_CMD. ibm_db needs a native build and has none for linux/arm64.'],
+ ['sqlserver', 'SQL Server', 'sqlServer', 'dialects/mssql', 'Dropping a database user leaves the server login.'],
+ ['azuresql', 'Azure SQL', 'azureSql', 'dialects/azuresql', 'TLS with certificate verification is on by default.'],
+ ['oracle', 'Oracle', 'oracle', 'dialects/oracle', 'DDL auto-commits. Quoted names keep their case. The e2e account needs dictionary access to list roles.'],
+ ['redshift', 'Redshift', 'redshift', 'dialects/redshift', 'Renaming a user always clears its password.'],
+ ['sqlite', 'SQLite', 'sqlLite', 'dialects/sqlite', 'A file, not a server. No grants. ALTER TABLE is limited, so some migrations rebuild the table.'],
+ ['duckdb', 'DuckDB', 'duckDb', 'dialects/duckdb', 'A file, not a server. No grants.'],
+ ['clickhouse', 'ClickHouse', 'clickHouse', 'dialects/clickhouse', 'No row editing and no transactions. GRANT exists, but Fox has no permission builder for it yet.'],
+ ['redis', 'Redis', 'redis', 'redis-editor', 'Runs the SQL subset. Keys read as tables: prefixes, lists, sets, sorted sets, streams and JSON (#552). Needs a password only when the connection names a user; a requirepass-only Redis keeps its password on the connection. The dev container falls back to no AUTH.'],
+ ['mongodb', 'MongoDB', 'mongodb', '—', 'Runs the SQL subset. Auth is off in the dev container.'],
+];
+for (const [id, name, dir, e2e, watch] of DIALECTS) {
+ const row = ENGINES.rows.find((r) => r[0] === name) || [name, 0, 0, 0, 0, 0, 0];
+ const [, compare, dbAccess, users, , rowEditing, tx] = row;
+ const subset = id === 'redis' || id === 'mongodb';
+ const subs = ['Connect, test and browse the schema', 'Driver check and Install driver'];
+ if (compare) subs.push('Compare two schemas', 'Generate and run migration DDL', 'Snapshots: capture, compare, revert');
+ subs.push(subset ? 'Run the SQL subset (SELECT / INSERT / UPDATE / DELETE)' : 'Run SQL; writes classified for this dialect', 'Results paging');
+ if (rowEditing) subs.push('Row editing and data peek');
+ subs.push('Data compare and migrate between two databases');
+ if (dbAccess) subs.push('Access: inspect, GRANT / REVOKE');
+ if (users) subs.push('Database accounts: create, rename, drop');
+ subs.push(tx ? 'One transaction rolls back' : 'One transaction refused with its reason');
+ subs.push('Backup command builder');
+ if (!subset) subs.push('Index fragmentation and maintenance SQL');
+ FEATURES.push({
+ id: 'dialect.' + id, area: 'dialects', name,
+ subs, ui: 'Every screen with a connection picker', api: 'all connection-backed routes',
+ code: 'packages/sql/src/providers/' + dir + ' · packages/db/src/providers/' + dir, perms: '—', tables: '—',
+ tests: 'e2e ' + e2e + ' · provider unit tests in packages/sql and packages/db', watch,
+ });
+}
diff --git a/docs/feature-map/template.html b/docs/feature-map/template.html
new file mode 100644
index 000000000..6b14161c7
--- /dev/null
+++ b/docs/feature-map/template.html
@@ -0,0 +1,374 @@
+
Fox Feature Map
+
+
+
+
+
+
+
+
+
+
Fox Feature Map
+
Measured on main · f9d1e138de · 2026-10-25 · mirrors docs/FEATURE_MAP.md
+
+
+ 33 features
+ 153 HTTP routes
+ 17 server modules
+ 14 web features
+ 16 engines
+ 28 metadata tables
+ 49 e2e specs
+ ~530 unit-test files
+
+
+ Plan
+ Features
+ Workflows
+ Schema
+ Engines
+ Coverage
+
+
+
+
+
+
+
+
+
+
+
+
Work plan
+
Bug fixes, improvements, tests, new sub-features and manual-test passes, each tied to a feature. Everyone with access sees the same board.
+
+
+ Add work item
+
+ You can read this board but not change it. Ask the page's owner for Contributor access to edit it.
+ The shared board is not available in this view, so work items cannot load. Open the page on claude.ai while signed in to see and edit them.
+
+
+
+
+
+
+
+
+
System
+
Clients reach one Fastify server through the same guards; features read and write the metadata store and reach the target databases through the driver package.
+
+
+
__M1__ Every request passes origin, session, workspace and permission checks before a feature runs.
+
+
+
+
Features Each card: what it does, where it opens, its API, code, permissions, tables and tests.
+
+
+
+
+
+
+
+
+
Workflows
+
The paths a change usually crosses. Read the one you are touching before changing a step in it.
+
+ Compare → deploy __M3__ The execute gate, the target lock and the history capture are the steps a schema change must keep.
+ SQL editor — Run __M4__ Which statements run, how each is classified for its destination's dialect, and where Safe mode and One transaction step in.
+
+
Data compare → data migrate __M5__
+
Snapshots — capture, compare, revert __M6__
+
+
+
+
Access — inspect, build, apply __M8__
+
+
+
+
+
+
+
Metadata schema
+
28 tables in packages/server/src/database/schema.ts, 31 append-only migrations. Solid lines are declared foreign keys; dotted lines are id columns the code joins on.
+
+ Identity and workspaces __M9__
+ Connections, secrets, data and workflow __M10__
+ Migrations, Git and snapshots __M11__
+
+
Which feature owns each table
+
+
+
+
+
+
+
+
Engines and capabilities
+
From dialectFeatures(), the one table every screen asks. A missing capability carries a reason the app shows instead of the control.
+
+
+ Redis and MongoDB run a fail-closed SQL subset in the editor: single-table SELECT, INSERT, UPDATE and DELETE with column = value predicates. DDL auto-commits on MySQL, MariaDB, TiDB, Oracle and CockroachDB, so their transactions cannot undo DDL that already ran.
+
+
+
+
+
+
Coverage
+
Where the API surface is, and how many test files guard each package. A feature with many routes and few tests is where a regression is most likely to slip through.
+
+
+
+
End-to-end suites
+
49 Playwright specs in 33 groups, run with npm run test:e2e:all. Dialect suites need their Docker container and a fresh seed.
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index febeb52c8..45ba6dade 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"lint": "eslint .",
"lint:security": "eslint . --config eslint.security.config.js --max-warnings 0",
"test-ids": "node scripts/test-ids/extract-test-ids.mjs --write",
+ "docs:feature-map": "node scripts/feature-map/build.mjs",
"feature:new": "node scripts/feature/new-feature.mjs",
"bundle:first-load": "node scripts/bundle/first-load.mjs apps/web/dist --budget-gzip-kb=170",
"audit:security": "npm audit --audit-level=high --omit=dev",
diff --git a/packages/shared/src/feature-map-build.test.ts b/packages/shared/src/feature-map-build.test.ts
new file mode 100644
index 000000000..e0800eddf
--- /dev/null
+++ b/packages/shared/src/feature-map-build.test.ts
@@ -0,0 +1,32 @@
+/**
+ * The feature-map generator (scripts/feature-map/build.mjs): docs/feature-map.html
+ * is built from docs/feature-map/ and the diagrams in docs/FEATURE_MAP.md.
+ */
+import { describe, expect, it } from 'vitest';
+import { buildBoardPage, buildStaticPage } from '../../../scripts/feature-map/build.mjs';
+
+const featureMap = '# Map\n\n```mermaid\nflowchart LR\n a --> b\n```\n\n```mermaid\nflowchart LR\n c[x < y & z]\n```\n';
+const template = 'T \n\n__M1__ \n';
+
+describe('feature map build', () => {
+ it('places the named diagram, escaped, and the data and script as written', () => {
+ const page = buildBoardPage({ template, data: "const A = 'cost: $& and $1';", app: 'run();', featureMap });
+ expect(page).toContain('flowchart LR\n c[x < y & z] ');
+ // A `$` pattern in the sources is text, not a replacement pattern.
+ expect(page).toContain("const A = 'cost: $& and $1';");
+ expect(page).toContain('run();');
+ });
+
+ it('refuses a template that places a diagram the doc does not have', () => {
+ expect(() =>
+ buildBoardPage({ template: template.replace('__M1__', '__M2__'), data: '', app: '', featureMap })
+ ).toThrow(/diagram 2; FEATURE_MAP.md has only 2/);
+ });
+
+ it('wraps the repo copy in a whole document with the board off', () => {
+ const page = buildStaticPage(buildBoardPage({ template, data: '', app: '', featureMap }));
+ expect(page.startsWith('')).toBe(true);
+ expect(page).toContain('T \n\n\n\n');
+ expect(page).toContain('mermaid@11.4.1');
+ });
+});
diff --git a/scripts/feature-map/build.d.mts b/scripts/feature-map/build.d.mts
new file mode 100644
index 000000000..7a5f74bc7
--- /dev/null
+++ b/scripts/feature-map/build.d.mts
@@ -0,0 +1,10 @@
+/** Types for build.mjs, for the tests that import it. */
+export interface FeatureMapSources {
+ template: string;
+ data: string;
+ app: string;
+ featureMap: string;
+}
+export function buildBoardPage(sources: FeatureMapSources): string;
+export function buildStaticPage(boardPage: string): string;
+export function readSources(root?: string): FeatureMapSources;
diff --git a/scripts/feature-map/build.mjs b/scripts/feature-map/build.mjs
new file mode 100644
index 000000000..1add4adab
--- /dev/null
+++ b/scripts/feature-map/build.mjs
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+/**
+ * Fox Schema (foxschema)
+ * Copyright 2024-2026 Huy Phan
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * Builds docs/feature-map.html, the interactive feature map, from:
+ *
+ * docs/feature-map/template.html page layout and styles; __M__ marks where diagram n goes
+ * docs/feature-map/data.js the inventory: features, routes, tables, engines, tests
+ * docs/feature-map/app.js the page's script
+ * docs/FEATURE_MAP.md the Mermaid diagrams, in the order they appear there
+ *
+ * Usage:
+ * node scripts/feature-map/build.mjs write docs/feature-map.html
+ * node scripts/feature-map/build.mjs --board also write the planning-board page
+ *
+ * The repo copy is a whole document with no shared board, and it loads Mermaid
+ * from a pinned CDN. The board page is the same page with the board turned on.
+ * It is published as an Artifact, which adds the document skeleton itself.
+ */
+import { readFileSync, writeFileSync } from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
+const SRC = path.join(ROOT, 'docs/feature-map');
+const read = (p) => readFileSync(p, 'utf8');
+
+/** HTML text escaping for a , leaving quotes alone. */
+const escapeText = (s) => s.replace(/&/g, '&').replace(//g, '>');
+
+/** The page with the board: every placeholder filled. */
+export function buildBoardPage({ template, data, app, featureMap }) {
+ const blocks = [...featureMap.matchAll(/```mermaid\n([\s\S]*?)```/g)].map((m) => m[1].trim());
+ // The page shows most of the doc's diagrams, not all: a placeholder past the
+ // doc's last diagram means one was removed or the template drifted.
+ for (const [, n] of template.matchAll(/__M(\d+)__/g)) {
+ if (Number(n) >= blocks.length) {
+ throw new Error(`template.html places diagram ${n}; FEATURE_MAP.md has only ${blocks.length}`);
+ }
+ }
+ // Functions as replacements, so a `$` in the source is never read as a pattern.
+ let page = template.replace(/__M(\d+)__/g, (_, n) => escapeText(blocks[Number(n)]));
+ page = page.replace('/*__DATA__*/', () => data);
+ page = page.replace('/*__APP__*/', () => app);
+ return page;
+}
+
+/** The copy kept in the repo: a whole document, no shared board, Mermaid from a pinned CDN. */
+export function buildStaticPage(boardPage) {
+ const cut = boardPage.indexOf('');
+ if (cut < 0) throw new Error('template.html has no ');
+ const head = boardPage.slice(0, cut);
+ const body = boardPage.slice(cut);
+ return (
+ '\n\n\n \n' +
+ ' \n' +
+ '\n' +
+ '\n' +
+ head +
+ '\n\n\n' +
+ body +
+ '\n\n' +
+ '\n\n\n"
+ );
+}
+
+export function readSources(root = ROOT) {
+ const src = path.join(root, 'docs/feature-map');
+ return {
+ template: read(path.join(src, 'template.html')),
+ data: read(path.join(src, 'data.js')),
+ app: read(path.join(src, 'app.js')),
+ featureMap: read(path.join(root, 'docs/FEATURE_MAP.md')),
+ };
+}
+
+const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+if (isMain) {
+ const board = buildBoardPage(readSources());
+ writeFileSync(path.join(ROOT, 'docs/feature-map.html'), buildStaticPage(board));
+ console.log(`wrote docs/feature-map.html (from ${path.relative(ROOT, SRC)}/)`);
+ const at = process.argv.indexOf('--board');
+ if (at > 0) {
+ const out = process.argv[at + 1];
+ if (!out) throw new Error('--board needs a file path');
+ writeFileSync(out, board);
+ console.log(`wrote ${out}`);
+ }
+}