@bongos/core 1.19.1012 → 1.19.1014

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 (76) hide show
  1. package/.bongos-core.json +113 -78
  2. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  3. package/docs/copy-inventory.md +120 -66
  4. package/docs/copy-registry.json +984 -498
  5. package/docs/file-map.md +1 -1
  6. package/docs/module-api-changelog.md +4 -0
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/copy-desk.html +1 -1
  12. package/modules/hall-ui/public/deploy.html +1 -1
  13. package/modules/hall-ui/public/diagrams.html +1 -1
  14. package/modules/hall-ui/public/drachmae.html +1 -1
  15. package/modules/hall-ui/public/fleet-costs.js +80 -0
  16. package/modules/hall-ui/public/fleet.html +64 -0
  17. package/modules/hall-ui/public/fleet.js +45 -0
  18. package/modules/hall-ui/public/fleet.states.json +14 -0
  19. package/modules/hall-ui/public/gate.html +1 -1
  20. package/modules/hall-ui/public/goal-map.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea-objects.js +4 -1
  24. package/modules/hall-ui/public/idea.html +1 -1
  25. package/modules/hall-ui/public/ideas.html +1 -1
  26. package/modules/hall-ui/public/index.html +1 -1
  27. package/modules/hall-ui/public/modules.html +1 -1
  28. package/modules/hall-ui/public/people.html +1 -1
  29. package/modules/hall-ui/public/primer.html +1 -1
  30. package/modules/hall-ui/public/profile.html +1 -1
  31. package/modules/hall-ui/public/project-settings.html +1 -1
  32. package/modules/hall-ui/public/ranks.html +1 -1
  33. package/modules/hall-ui/public/roadmap.html +1 -1
  34. package/modules/hall-ui/public/roster.html +1 -1
  35. package/modules/hall-ui/public/sessions.html +1 -1
  36. package/modules/hall-ui/public/settings.html +1 -1
  37. package/modules/hall-ui/public/shell.js +4 -0
  38. package/modules/hall-ui/public/sky-composer.js +155 -0
  39. package/modules/hall-ui/public/sky-data.js +287 -0
  40. package/modules/hall-ui/public/sky-draw.js +244 -0
  41. package/modules/hall-ui/public/sky-interaction.js +137 -0
  42. package/modules/hall-ui/public/sky-layout.js +81 -0
  43. package/modules/hall-ui/public/sky-panel.js +141 -0
  44. package/modules/hall-ui/public/sky-tour.js +80 -0
  45. package/modules/hall-ui/public/studio.html +1 -1
  46. package/modules/hall-ui/public/task.html +1 -1
  47. package/modules/hall-ui/public/thinking.css +203 -267
  48. package/modules/hall-ui/public/thinking.html +119 -58
  49. package/modules/hall-ui/public/thinking.states.json +33 -9
  50. package/modules/hall-ui/public/watch.html +1 -1
  51. package/modules/hall-ui/public/work.html +1 -1
  52. package/modules/provisioning/routes/provisioning.js +4 -4
  53. package/modules/public-landing/public/projects.html +7 -0
  54. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -0
  55. package/package-lock.json +2 -2
  56. package/package.json +1 -1
  57. package/src/bongos/auth.js +8 -0
  58. package/src/bongos/routes/me.js +4 -1
  59. package/src/bongos/serve-internal.js +25 -6
  60. package/src/module-api.js +1 -1
  61. package/tests/auth_resolve_failure.mjs +3 -2
  62. package/tests/fleet_cost_view.mjs +189 -0
  63. package/tests/hall_audit.mjs +27 -0
  64. package/tests/hall_nav.mjs +3 -1
  65. package/tests/hall_page_gate_map.mjs +13 -5
  66. package/tests/hall_sky.mjs +393 -405
  67. package/tests/hall_thinking_room.mjs +274 -296
  68. package/tests/helpers.mjs +185 -217
  69. package/tests/ideator_large_dead_thought_proof.mjs +33 -25
  70. package/tests/ideator_traceable_proof.mjs +61 -39
  71. package/tests/nav_permission_atoms.mjs +1 -1
  72. package/modules/hall-ui/public/sky.css +0 -365
  73. package/modules/hall-ui/public/sky.html +0 -79
  74. package/modules/hall-ui/public/sky.js +0 -580
  75. package/modules/hall-ui/public/sky.states.json +0 -20
  76. package/modules/hall-ui/public/thinking.js +0 -418
@@ -0,0 +1,155 @@
1
+ // modules/hall-ui/public/sky-composer.js — THE SKY, part 6 of 7: the composer
2
+ // (task 1004231 / sky part 3 of 5; ADR 0331).
3
+ //
4
+ // The prototype's "the composer" section, wired to the ledger. Three things
5
+ // the prototype guessed are read for real here, and nothing else changed:
6
+ //
7
+ // • GRAVITY — "the goal that catches it lights as you type" — is the real
8
+ // landing verdict, not a keyword match: the goal suggester (GET
9
+ // /goals/suggestions, ranked per building version, the hall composer's own
10
+ // read) names the likeliest well, and POST /inbox/preview-landing says what
11
+ // the matrix would do with it. The sentence READS that verdict; it decides
12
+ // nothing (ideas.js's rule: never re-derive the landing matrix here).
13
+ // • FILING is POST /inbox exactly as the hall form files — quick with the
14
+ // line (`title`, `body_md`, `kind`, and the goal the preview named), full
15
+ // with the template's fields (`grade: 'full'`, `template_key`,
16
+ // `field_values`) — so the record is identical to the form's.
17
+ // • THE FIVE QUESTIONS are the template's (GET /inbox/template, base-v2),
18
+ // asked in order after the line with skip and cancel, never a copy kept
19
+ // here (tests/idea_hall_composer.mjs's rule).
20
+ //
21
+ // The nod runs the existing promote path (POST /tasks/:id/promote on the task
22
+ // the routed filing became); the panel draws its button only for a holder of
23
+ // `idea.triage`.
24
+
25
+ (() => {
26
+ 'use strict';
27
+
28
+ const SKY = window.OTBSky; const S = SKY.S;
29
+ const { $, NOW, esc, short, api, API } = SKY;
30
+
31
+ const IDLE = 'gravity — the goal that catches it lights as you type; letting it float is a real answer';
32
+
33
+ // ---------------------------------------------------------------- gravity
34
+ // A monotonic token so a slow answer to an older draft never paints over a
35
+ // newer one (ideas.js's gravitySeq, for the same reason).
36
+ let gravitySeq = 0, gravityTimer = null;
37
+ S.gravityVerdict = null;
38
+ function gravitySentence(v) {
39
+ if (!v || !v.outcome) return 'nothing catches it — it will float in open space until something does';
40
+ const name = v.goal ? esc(v.goal.title || `goal ${v.goal.id}`) : '';
41
+ if (v.outcome === 'refused') return esc(v.refusal && v.refusal.reason ? v.refusal.reason : `${v.goal ? v.goal.title : 'that goal'} will not take this filing.`);
42
+ if (v.outcome === 'routed') return `gravity · <b class="hot">${name}</b> would catch it — a task in its well, ${v.status === 'ready' ? 'claimable straight away' : 'waiting for a nod'}`;
43
+ if (v.outcome === 'routed_home') return `gravity · <b class="hot">${esc(v.version_id || '')}</b> would catch it — a task in its maintenance goal`;
44
+ return 'nothing catches it — it will float in open space until something does';
45
+ }
46
+ async function suggestGoal(title) {
47
+ const ids = S.buildingVersionIds; if (!ids.length) return null;
48
+ const answers = await Promise.all(ids.map((v) => api.request('GET', `${API}/goals/suggestions?version=${encodeURIComponent(v)}&title=${encodeURIComponent(title)}&kind=feature&limit=1`).catch(() => null)));
49
+ let best = null;
50
+ for (const r of answers) { const s = r && r.ok && r.data && Array.isArray(r.data.suggestions) ? r.data.suggestions[0] : null; if (s && (!best || s.score > best.score)) best = s; }
51
+ return best;
52
+ }
53
+ async function refreshGravity() {
54
+ const seq = ++gravitySeq; const v = $('thought').value.trim();
55
+ if (!v || full.step >= 0) return;
56
+ const goal = await suggestGoal(v); if (seq !== gravitySeq) return;
57
+ const body = { kind: 'feature' }; if (goal) body.goal_id = Number(goal.id); if (full.on) body.grade = 'full';
58
+ const r = await api.request('POST', `${API}/inbox/preview-landing`, { body }).catch(() => null); if (seq !== gravitySeq) return;
59
+ const verdict = r && r.ok ? r.data : null;
60
+ S.gravityVerdict = verdict;
61
+ const caught = verdict && verdict.outcome === 'routed' && verdict.goal ? (S.goalById.get(String(verdict.goal.id)) || { id: String(verdict.goal.id), title: verdict.goal.title }) : null;
62
+ S.gravityGoal = caught; S.dirty = true;
63
+ $('gravity').innerHTML = gravitySentence(verdict);
64
+ }
65
+ $('thought').addEventListener('input', () => {
66
+ if (full.step >= 0) return; const v = $('thought').value.trim();
67
+ $('file').classList.toggle('live', v.length > 0);
68
+ clearTimeout(gravityTimer);
69
+ if (!v) { gravitySeq++; S.gravityGoal = null; S.gravityVerdict = null; S.dirty = true; $('gravity').textContent = IDLE; return; }
70
+ gravityTimer = setTimeout(refreshGravity, 300);
71
+ });
72
+
73
+ // ---------------------------------------------------------------- the five questions
74
+ // THE FIVE QUESTIONS are the template's (base-v2), asked in order after the line. Quick is the
75
+ // baseline: one line, nothing more. Full is a choice made before the line is filed, never after.
76
+ function fields() { const t = S.template; return t ? t.fields.map((f) => ({ key: f.key, label: f.label, floor: Number(f.substance_min) || 0 })) : []; }
77
+ const full = { on: false, step: -1, title: '', answers: {} };
78
+ const QUICK_HINT = 'release a thought — one line', FULL_HINT = 'release a thought — one line, then five questions';
79
+ function resetFull() { full.step = -1; full.title = ''; full.answers = {}; $('fullSteps').hidden = true; $('thought').placeholder = full.on ? FULL_HINT : QUICK_HINT; $('fileGo').textContent = 'file'; }
80
+ $('fullToggle').addEventListener('click', () => { full.on = !full.on; $('fullToggle').setAttribute('aria-pressed', String(full.on)); resetFull(); $('thought').focus(); if ($('thought').value.trim()) { clearTimeout(gravityTimer); gravityTimer = setTimeout(refreshGravity, 300); } });
81
+ function showStepPrompt() {
82
+ const FIELDS = fields(); const f = FIELDS[full.step]; $('fullSteps').hidden = false;
83
+ $('fullSteps').innerHTML = `<b>full idea · ${full.step + 1} of ${FIELDS.length}</b><span>${esc(f.label)}</span><span class="hint">~${f.floor} characters</span><button type="button" class="skip" id="stepSkip">skip</button><button type="button" class="skip" id="stepCancel">cancel</button>`;
84
+ $('thought').placeholder = f.label; $('fileGo').textContent = full.step === FIELDS.length - 1 ? 'file the full idea' : 'next';
85
+ $('stepSkip').addEventListener('click', () => advance('')); $('stepCancel').addEventListener('click', cancelFull);
86
+ }
87
+ function cancelFull() { const t = full.title; full.on = false; $('fullToggle').setAttribute('aria-pressed', 'false'); resetFull(); $('thought').value = t; $('thought').dispatchEvent(new Event('input')); $('thought').focus(); }
88
+ function advance(v) { const FIELDS = fields(); full.answers[FIELDS[full.step].key] = v; full.step++; $('thought').value = ''; if (full.step < FIELDS.length) showStepPrompt(); else { fileIdea(full.title, { grade: 'full', fields: { ...full.answers } }); resetFull(); } $('thought').focus(); }
89
+ $('thought').addEventListener('keydown', (e) => { if (e.key === 'Escape' && full.step >= 0) { e.preventDefault(); cancelFull(); } });
90
+ function completeness(vals) { return fields().filter((f) => (vals[f.key] || '').trim().length >= f.floor).length; }
91
+
92
+ // ---------------------------------------------------------------- filing
93
+ function receipt(html, go) { $('receiptText').innerHTML = html; $('receipt').hidden = false; $('receiptGo').hidden = !go; $('receiptGo').onclick = go || null; }
94
+ function explainFiling(r) {
95
+ const err = r && r.data && r.data.error; const code = err && (typeof err === 'object' ? err.code : err); const details = (err && typeof err === 'object' && err.details) || {};
96
+ if (details.reason) return `not filed — ${esc(details.reason)}`;
97
+ if (code) return `not filed — ${esc(String(code).replace(/_/g, ' '))}`;
98
+ if (r && (r.status === 401 || r.status === 403)) return 'not filed — your session may have expired. reload and try again';
99
+ return `not filed (${r ? r.status : 'no answer'}) — nothing was saved, your line is still here`;
100
+ }
101
+ async function fileIdea(title, opts) {
102
+ const isFull = opts.grade === 'full'; const FIELDS = fields();
103
+ if (isFull && !S.template) { receipt('not filed — the full idea template could not be read'); return; }
104
+ const body = isFull
105
+ ? { title, grade: 'full', template_key: S.template.key, field_values: { ...opts.fields } }
106
+ : { title, body_md: '', kind: 'feature' };
107
+ const verdict = S.gravityVerdict;
108
+ if (!isFull && verdict && verdict.outcome === 'routed' && verdict.goal) body.goal_id = Number(verdict.goal.id);
109
+ $('fileGo').disabled = true;
110
+ const r = await api.request('POST', `${API}/inbox`, { body }).catch(() => null);
111
+ $('fileGo').disabled = false;
112
+ if (!r || !r.ok || !r.data || !r.data.idea) { $('thought').value = title; $('file').classList.add('live'); receipt(explainFiling(r)); return; }
113
+ S.filedN++;
114
+ const row = r.data.idea; const routed = r.data.routed_task; const routing = row.routing;
115
+ const caughtId = routed && routing && routing.routed ? String(routed.goal_id ?? (verdict && verdict.goal && verdict.goal.id)) : null;
116
+ const g = caughtId ? (S.goalById.get(caughtId) || { id: caughtId, title: (verdict && verdict.goal && verdict.goal.title) || 'a goal' }) : null;
117
+ const awaiting = !!(routed && routed.status === 'backlog');
118
+ const idea = {
119
+ id: String(row.id), title: row.title || title, kind: row.kind || 'feature', status: row.status || 'open', capturedAt: row.captured_at || new Date().toISOString(), grade: row.grade || (isFull ? 'full' : 'quick'), fields: isFull ? { ...opts.fields } : null,
120
+ lineage: routed ? [{ id: String(routed.id), title: routed.title || title, status: routed.status || 'backlog', goalId: caughtId, origin: 'route' }] : [], descentTasks: [],
121
+ size: { credits: 0, payments: 0, scale: 0, historical: false },
122
+ state: { state: 'protostar', position: g ? 'well' : 'open-space', wellGoalId: g ? g.id : null, awaitingNod: awaiting, hasWork: false, attributable: true, brightness: 1 },
123
+ st: 'protostar', well: g ? g.id : null, lit: 1, justFiled: true, awaiting, age: 0, born: NOW, origin: 'human', filedVia: null, mergedInto: null,
124
+ };
125
+ S.baseIdeas.unshift(idea); SKY.applyRehearsal(); SKY.placeIdeas(); const placed = S.ideas.find((i) => i.id === idea.id);
126
+ S.flying = { idea: placed, t0: performance.now(), ms: SKY.reduce ? 1 : 1100 };
127
+ $('thought').value = ''; $('file').classList.remove('live'); S.gravityGoal = null; S.gravityVerdict = null; gravitySeq++; $('gravity').textContent = IDLE;
128
+ const done = isFull ? completeness(opts.fields) : 0; const landedFull = row.grade === 'full';
129
+ receipt((isFull ? (landedFull ? `filed as a full idea · <b>${done} of ${FIELDS.length}</b> answers above the floor · ` : 'filed, but stored as a quick idea — the grade could not be written · ') : 'filed · ') + (g ? `caught by <b>${esc(short(g.title))}</b> · a task in its well, ${awaiting ? 'waiting for a nod' : 'claimable straight away'}` : 'nothing caught it · floating in open space'), () => SKY.pick({ kind: 'idea', ref: placed }, true));
130
+ SKY.renderRoom(); SKY.updateStats(); S.dirty = true;
131
+ }
132
+ $('file').addEventListener('submit', (e) => {
133
+ e.preventDefault(); const v = $('thought').value.trim();
134
+ if (full.on && full.step >= 0) { advance(v); return; }
135
+ if (!v) return;
136
+ if (full.on) { full.title = v; full.step = 0; $('thought').value = ''; showStepPrompt(); S.dirty = true; return; }
137
+ fileIdea(v, { grade: 'quick' });
138
+ });
139
+ $('receiptX').addEventListener('click', () => { $('receipt').hidden = true; });
140
+
141
+ // ---------------------------------------------------------------- the nod
142
+ // The existing awaiting-nod promote path, on the task the routed filing
143
+ // became. The button is drawn only for a holder of idea.triage (sky-panel.js).
144
+ async function nod(i, btn) {
145
+ const t = (i.lineage || []).find((x) => x.origin === 'route' && x.status === 'backlog') || (i.lineage || [])[0];
146
+ if (!t) { btn.textContent = 'could not nod — no task to make claimable'; btn.disabled = true; return; }
147
+ btn.disabled = true;
148
+ const r = await api.request('POST', `${API}/tasks/${encodeURIComponent(t.id)}/promote`, { body: {} }).catch(() => null);
149
+ if (r && r.ok) { i.awaiting = false; i.nodded = true; if (i.state) i.state.awaitingNod = false; t.status = 'ready'; btn.textContent = 'nodded — a builder can claim it now'; SKY.renderRoom(); S.dirty = true; return; }
150
+ btn.disabled = false; const err = r && r.data && r.data.error; const code = err && (typeof err === 'object' ? err.code : err);
151
+ btn.textContent = `could not nod — ${code ? String(code).replace(/_/g, ' ') : (r ? r.status : 'no answer')}`;
152
+ }
153
+
154
+ Object.assign(SKY, { fields, full, completeness, fileIdea, refreshGravity, gravitySentence, nod, resetFull });
155
+ })();
@@ -0,0 +1,287 @@
1
+ // modules/hall-ui/public/sky-data.js — THE SKY, part 1 of 7: helpers, the
2
+ // shared state, the vocabulary, the world and the thoughts (task 1004231 /
3
+ // sky part 3 of 5, goal 1000095; ADR 0331, ADR 0227 §11).
4
+ //
5
+ // THE PAGE IS docs/design/mocks/sky/sky.template.html, LANDED. The owner adopted
6
+ // that prototype as the design of record — copy, layout, colours, tour text,
7
+ // toggles, interactions — and this page is its pixel contract, built for real.
8
+ // The template's one ~700-line script is split into seven files under the
9
+ // 1,500-line ratchet (the README says a split is not a deviation; a changed
10
+ // behaviour inside one is). Every file reads and writes ONE shared object,
11
+ // `window.OTBSky`: `SKY.S` is the mutable state the prototype kept in closure
12
+ // `let`s, everything else on SKY is a function or a constant. Load order is
13
+ // data → layout → draw → interaction → panel → composer → tour, and a file only
14
+ // CALLS a later file's function through `SKY.name(...)`, never at load time.
15
+ //
16
+ // WHAT THIS FILE OWNS. The helpers the template opened with; the five-object
17
+ // vocabulary read off ./idea-objects.js (the shared table — never a copy);
18
+ // the adaptors from the two reads the page makes (GET /sky, GET /sky/mine —
19
+ // part 2) onto the field names the prototype's snapshot carried; the version
20
+ // BANDS, derived from GET /versions because the core cannot hardcode one
21
+ // instance's version ids; the goal layout; and the thoughts — their state,
22
+ // their placement, their words.
23
+ //
24
+ // TWO LAYERS ARE HIDDEN UNTIL PART 5 makes them ledger facts (the README's
25
+ // "must not ship" rows): the "claude layer" pill and every born-of mark. The
26
+ // code that draws them is kept, and it reads `filed_via` / `bornOf` that are
27
+ // simply absent today — so part 5 turns them on by shipping the facts.
28
+
29
+ (() => {
30
+ 'use strict';
31
+
32
+ const SKY = window.OTBSky = window.OTBSky || {};
33
+
34
+ // ---------------------------------------------------------------- helpers
35
+ const $ = (id) => document.getElementById(id);
36
+ const TAU = Math.PI * 2;
37
+ const GOLDEN = 137.50776405003785 * Math.PI / 180;
38
+ const reduce = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);
39
+ const NOW = Date.now();
40
+ function hash(s) { let h = 2166136261; s = String(s); for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0) / 4294967296; }
41
+ function rng(seed) { let t = Math.floor(hash(seed) * 1e9) + 1; return () => { t += 0x6D2B79F5; let r = Math.imul(t ^ (t >>> 15), 1 | t); r ^= r + Math.imul(r ^ (r >>> 7), 61 | r); return ((r ^ (r >>> 14)) >>> 0) / 4294967296; }; }
42
+ const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
43
+ const lerp = (a, b, t) => a + (b - a) * t;
44
+ const smooth = (v, a, b) => { const t = clamp((v - a) / (b - a), 0, 1); return t * t * (3 - 2 * t); };
45
+ const ease = (t) => 1 - Math.pow(1 - t, 3);
46
+ // esc — the prototype builds HTML strings, and every title, criterion and
47
+ // goal name passes through here first, so a title never becomes markup.
48
+ function esc(s) { return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
49
+ function fmtDate(iso) { if (!iso) return '—'; const d = new Date(iso); return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' }).toLowerCase(); }
50
+ function fmtDay(t) { return new Date(t).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }).toLowerCase(); }
51
+ function ageDays(iso) { return iso ? (NOW - new Date(iso).getTime()) / 86400000 : 0; }
52
+ function plain(md) { return String(md || '').replace(/\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`#>]/g, '').replace(/\s+/g, ' ').trim(); }
53
+ const short = (t) => String(t).split(' — ')[0];
54
+ const store = { get: (k) => { try { return localStorage.getItem(k); } catch { return null; } }, set: (k, v) => { try { localStorage.setItem(k, v); } catch { /* fine */ } } };
55
+ const STOP = new Set('a an the to of in on for and or is it this that with from by as at be are was not no we you your our into onto when what how why should could would can will every each all any some one two page let me without does did has have had its their them then than but so if'.split(' '));
56
+ function tokens(s) { return String(s || '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length > 2 && !STOP.has(w)); }
57
+ const toMs = (iso) => iso ? new Date(iso).getTime() : 0;
58
+ // The hall's own doors — the prototype linked to a fixed hall origin; the
59
+ // page links to the hall it is served from (apex /builders or the subdomain).
60
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
61
+ const API = '/api/bongos';
62
+ // baseUrl:'' — full paths through api.request, the way the room and the work
63
+ // page do it (thinking.js's rule: never mix the two client idioms on one page).
64
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
65
+
66
+ // ---------------------------------------------------------------- the five objects
67
+ // The word, the noun and the sentence come from the ONE shared table
68
+ // (idea-objects.js, ADR 0322's other half); the server decides WHICH object.
69
+ const objects = window.OTBIdeaObjects;
70
+ const OBJ = {};
71
+ for (const key of objects.ORDER) { const o = objects.objectFor(key); OBJ[key] = { word: o.label, noun: o.noun, blurb: o.blurb }; }
72
+
73
+ // ---------------------------------------------------------------- shared state
74
+ // Every `let` the prototype's closure held, on one object so seven files can
75
+ // read and write the same page. Data-derived fields are filled by buildWorld.
76
+ const S = {
77
+ goals: [], goalById: new Map(), taskById: new Map(), edges: [], BANDS: [], ASPECT: 1, BORN: { goals: {}, criteria: {} },
78
+ baseIdeas: [], ideas: [], becameGoal: new Map(), becameCrit: new Map(), T0: NOW - 86400000,
79
+ hasFiledVia: false, canNod: false, template: null, buildingVersionIds: [], loaded: false,
80
+ rehearsal: false, claudeLayer: false, influence: false, ideasOnMap: false, recentOnly: false,
81
+ lens: 'mine', picked: null, hover: null, filter: 'all', gfilter: 'all', dirty: true, gravityGoal: null, flying: null, tourSpot: null,
82
+ clock: NOW, playing: false, clockHidden: store.get('sky-clock') === 'off',
83
+ charted: new Map(), focusSet: null, explore: { on: store.get('sky-explore') !== 'off', seen: new Set(JSON.parse(store.get('sky-seen') || '[]')) },
84
+ cam: { x: 0, y: 0, k: 0.12 }, flight: null, W: 0, H: 0, DPR: 1, t0: 0, shuttles: [],
85
+ drag: null, menuEdge: null, menuTimer: null, filedN: 0, tourI: -1, spotted: [], exploreWas: null, lastTick: 0,
86
+ };
87
+
88
+ // ---------------------------------------------------------------- the reads → the snapshot's names
89
+ // The prototype read a snapshot (docs/design/mocks/sky/snapshot.js). Each
90
+ // adaptor maps one real read onto the same field names, so the world below
91
+ // is the prototype's world line for line.
92
+ function goalFromSky(g) {
93
+ return {
94
+ id: String(g.goal_id), title: g.title || 'A private goal', subtitle: g.subtitle || null, version: g.version_id, status: g.status,
95
+ taskTotal: Number(g.task_total) || 0, taskDone: Number(g.task_done) || 0, createdAt: g.created_at || null,
96
+ crit: (g.criteria || []).map((c) => ({ id: String(c.id), text: c.criterion_md || '', satisfied: c.satisfied === true, tasks: (c.task_ids || []).map(String), pendingReview: c.pending_review === true })),
97
+ tasks: (g.tasks || []).map((t) => ({ id: String(t.id), title: t.title || 'a task', status: t.status, credits: t.credits_reward == null ? 0 : Number(t.credits_reward), createdAt: t.created_on || null, sourceRef: t.idea_id != null ? String(t.idea_id) : null })),
98
+ };
99
+ }
100
+ const taskRow = (t) => ({ id: String(t.task_id ?? t.id), title: t.title || 'a task', status: t.status, goalId: t.goal_id != null ? String(t.goal_id) : null, origin: t.origin || null });
101
+ function ideaFromMine(i) {
102
+ const st = i.state || null;
103
+ return {
104
+ id: String(i.id), title: i.title || '', kind: i.kind || null, status: i.status, capturedAt: i.captured_at || null, grade: i.grade || 'quick',
105
+ fields: i.field_values && typeof i.field_values === 'object' ? i.field_values : null,
106
+ lineage: (i.lineage_tasks || []).map(taskRow), descentTasks: (i.descent_tasks || []).map(taskRow),
107
+ size: i.size || null,
108
+ state: st ? { ...st, wellGoalId: st.wellGoalId != null ? String(st.wellGoalId) : null } : null,
109
+ mergedInto: i.merged_into_idea_id != null ? String(i.merged_into_idea_id) : null,
110
+ // filed_via is part 5's fact. Until it exists every thought is a person's
111
+ // (the owner: historic ideas with no filed_via SHOW by default).
112
+ filedVia: i.filed_via ?? null, origin: i.filed_via === 'session' ? 'claude' : 'human',
113
+ };
114
+ }
115
+
116
+ // ---------------------------------------------------------------- the bands
117
+ // Versions are bands told apart by their ground. The prototype named this
118
+ // instance's versions by hand; the core cannot, so the bands are DERIVED:
119
+ // the versions still building sit at the centre (the present), shipped
120
+ // versions ring outward newest first, and small versions share a band so no
121
+ // ring is razor-thin — which is the grouping the prototype drew by eye.
122
+ function bandsFor(versions, goals) {
123
+ const goalsIn = (id) => goals.filter((g) => g.version === id).length;
124
+ const known = new Set();
125
+ const present = versions.filter((v) => v.status !== 'shipped' && v.status !== 'archived').map((v) => v.id);
126
+ const shipped = versions.filter((v) => v.status === 'shipped' || v.status === 'archived').sort((a, b) => toMs(b.shipped_at || b.started_at) - toMs(a.shipped_at || a.started_at)).map((v) => v.id);
127
+ for (const g of goals) if (!versions.some((v) => v.id === g.version)) known.add(g.version);
128
+ const bands = [{ versions: [...present, ...known], r0: 0, r1: 2000, label: present.map((v) => v.toLowerCase()).join(' · ') || 'the present', state: 'building', tone: .028 }];
129
+ let group = [], count = 0;
130
+ const close = () => { if (group.length) { bands.push({ versions: group, count }); group = []; count = 0; } };
131
+ for (const id of shipped) { group.push(id); count += goalsIn(id); if (count >= 6) close(); }
132
+ close();
133
+ let r = 2300;
134
+ for (let n = 1; n < bands.length; n++) {
135
+ const b = bands[n]; const width = clamp(400 + 380 * Math.sqrt(b.count), 600, 2500);
136
+ b.r0 = r; b.r1 = r + width; r = b.r1 + 300; b.label = b.versions.map((v) => v.toLowerCase()).join(' · ');
137
+ b.state = n === bands.length - 1 ? 'the beginning' : 'shipped'; b.tone = Math.max(0.006, 0.016 - (n - 1) * 0.004); delete b.count;
138
+ }
139
+ return bands;
140
+ }
141
+
142
+ // ---------------------------------------------------------------- the world
143
+ // Laid out on a circle, then stretched once to the screen's shape, so a desk
144
+ // gets a sky as wide as its glass.
145
+ function wellRadius(g) { const t = Math.log2(1 + g.mass / 400); const base = 70 + 60 * t; return g.status === 'open' ? base : g.status === 'achieved' ? base * 0.8 : base * 0.5; }
146
+ function ringR(g, i, n) { return g.r * (n === 1 ? 0.68 : 0.36 + (0.9 - 0.36) * i / (n - 1)); }
147
+ function layoutGoals(goals, BANDS, ASPECT) {
148
+ for (const band of BANDS) {
149
+ const members = goals.filter((g) => band.versions.includes(g.version)); members.sort((a, b) => b.mass - a.mass);
150
+ const n = members.length; band.count = n; band.tasks = members.reduce((a, g) => a + (g.taskTotal || 0), 0);
151
+ members.forEach((g, i) => { g.r = wellRadius(g); const a = i * GOLDEN + hash(g.id) * 0.5; const f = Math.sqrt((i + 0.5) / n); const rad = band.r0 === 0 ? f * band.r1 * 0.82 : band.r0 + (band.r1 - band.r0) * (0.15 + 0.7 * hash(g.id + 'r')); g.x = Math.cos(a) * rad; g.y = Math.sin(a) * rad; g.band = band; });
152
+ }
153
+ for (let it = 0; it < 360; it++) {
154
+ for (let i = 0; i < goals.length; i++) for (let j = i + 1; j < goals.length; j++) {
155
+ const a = goals[i], b = goals[j]; const dx = b.x - a.x, dy = b.y - a.y; const d = Math.hypot(dx, dy) || 1;
156
+ const min = a.r + b.r + (a.status === 'open' && b.status === 'open' ? 260 : 120);
157
+ if (d < min) { const push = (min - d) / 2; const ux = dx / d, uy = dy / d; a.x -= ux * push; a.y -= uy * push; b.x += ux * push; b.y += uy * push; }
158
+ }
159
+ for (const g of goals) { const d = Math.hypot(g.x, g.y) || 1; const lo = g.band.r0 + g.r + 30, hi = g.band.r1 - g.r - 30; if (g.band.r0 === 0) { if (d > hi) { g.x *= hi / d; g.y *= hi / d; } } else if (d < lo) { g.x *= lo / d; g.y *= lo / d; } else if (d > hi) { g.x *= hi / d; g.y *= hi / d; } }
160
+ }
161
+ for (const g of goals) g.x *= ASPECT; // the one stretch
162
+ }
163
+ function taskAt(t, tt) { const a = t.ang + (reduce ? 0 : tt * t.drift); return [Math.cos(a) * (t.orbit + t.jit), Math.sin(a) * (t.orbit + t.jit)]; }
164
+
165
+ // buildWorld — the two reads (plus the versions) into the prototype's world.
166
+ // Called once, after the reads; everything it derives lands on S.
167
+ function buildWorld({ sky, mine, versions }) {
168
+ S.ASPECT = clamp(innerWidth / Math.max(1, innerHeight), 1, 1.8);
169
+ const BORN = S.BORN;
170
+ const goals = ((sky && sky.goals) || []).map(goalFromSky).map((g) => ({ ...g, bornOf: BORN.goals[g.id] || null, born: toMs(g.createdAt) }));
171
+ const goalById = new Map(goals.map((g) => [g.id, g]));
172
+ const taskById = new Map(); for (const g of goals) for (const t of g.tasks) { t.goal = g; t.born = t.createdAt ? new Date(t.createdAt).getTime() : 0; taskById.set(t.id, t); }
173
+ for (const g of goals) { const oldest = Math.min(...g.tasks.map((t) => t.born || Infinity)); if (oldest < (g.born || Infinity)) g.born = oldest; }
174
+ for (const g of goals) {
175
+ const c = g.tasks.reduce((a, t) => a + (t.credits || 0), 0); g.credits = c; g.mass = c > 0 ? c : (g.taskTotal || 0) * 25;
176
+ g.active = g.tasks.filter((t) => t.status === 'active').length;
177
+ g.recent = g.tasks.filter((t) => NOW - t.born < 7 * 86400000).length;
178
+ // ALIVE is motion, not status: a builder holding a task now, or real work landing this week.
179
+ g.live = g.status === 'open' ? clamp(g.active * 0.6 + Math.max(0, g.recent - 3) / 30, 0, 1) : 0;
180
+ }
181
+ const BANDS = bandsFor(((versions && versions.versions) || []).map((v) => ({ id: String(v.id), status: v.status, shipped_at: v.shipped_at, started_at: v.started_at })), goals);
182
+ layoutGoals(goals, BANDS, S.ASPECT);
183
+ for (const g of goals) {
184
+ const n = Math.min(g.crit.length, 8);
185
+ for (let i = 0; i < g.crit.length; i++) { const c = g.crit[i]; c.credits = c.tasks.reduce((a, id) => a + ((taskById.get(id) || {}).credits || 0), 0); c.bornOf = BORN.criteria[c.id] || null; c.goal = g; c.index = i; }
186
+ for (const t of g.tasks) {
187
+ const ci = g.crit.findIndex((c) => c.tasks.includes(t.id));
188
+ t.orbit = ci >= 0 && ci < n ? ringR(g, ci, n) : g.r * 0.99;
189
+ const key = t.sourceRef ? 'idea:' + t.sourceRef : 'word:' + (tokens(t.title).sort((a, b) => b.length - a.length)[0] || t.id);
190
+ t.ang = hash(key) * TAU + (hash(t.id) - 0.5) * 0.09; t.jit = (hash(t.id + 'j') - 0.5) * 8; t.ci = ci; t.kin = key;
191
+ // work in motion moves: a held or claimable task drifts along its ring; landed work holds still
192
+ t.drift = (t.status === 'active' ? 0.03 : t.status === 'ready' ? 0.012 : 0) * (hash(t.id + 'd') > 0.5 ? 1 : -1);
193
+ }
194
+ }
195
+ const rows = ((mine && mine.ideas) || []).map(ideaFromMine);
196
+ S.hasFiledVia = rows.some((i) => i.filedVia != null);
197
+ S.baseIdeas = rows.map((i) => ({ ...i, st: (i.state && i.state.state) || 'protostar', well: (i.state && i.state.wellGoalId) || null, lit: (i.state && typeof i.state.brightness === 'number') ? i.state.brightness : 1, age: ageDays(i.capturedAt), born: toMs(i.capturedAt), origin: i.origin || 'human', descentTasks: i.descentTasks || [] }));
198
+ S.becameGoal = new Map(Object.entries(BORN.goals).map(([g, i]) => [i, g]));
199
+ S.becameCrit = new Map(); for (const g of goals) for (const c of g.crit) if (c.bornOf) S.becameCrit.set(c.bornOf, c);
200
+ S.goals = goals; S.goalById = goalById; S.taskById = taskById; S.BANDS = BANDS;
201
+ S.edges = ((sky && sky.edges) || []).map((e) => ({ from: String(e.from), to: String(e.to), count: Number(e.count) || 0 })).filter((e) => goalById.has(e.from) && goalById.has(e.to)).map((e, n) => ({ ...e, id: 'e' + n, a: goalById.get(e.from), b: goalById.get(e.to) }));
202
+ const t0 = Math.min(...goals.map((g) => g.born || Infinity), ...S.baseIdeas.map((i) => i.born || Infinity), ...[...taskById.values()].map((t) => t.born || Infinity));
203
+ S.T0 = Number.isFinite(t0) && t0 < NOW ? t0 : NOW - 86400000;
204
+ applyRehearsal(); placeIdeas();
205
+ S.loaded = true;
206
+ }
207
+ const edgesOf = (g) => S.edges.filter((e) => e.a === g || e.b === g);
208
+
209
+ // ---------------------------------------------------------------- the thoughts
210
+ function applyRehearsal() {
211
+ if (!S.rehearsal) { S.ideas = S.baseIdeas; return; }
212
+ let k = 0;
213
+ S.ideas = S.baseIdeas.map((i) => { const c = { ...i }; if (i.st === 'star') { k++; if (k % 6 === 0) { c.st = 'remnant'; c.lit = 0; c.invented = true; } else if (k % 3 === 0) { c.lit = 0.35 + hash(i.id) * 0.45; c.invented = true; } } if (i.st === 'protostar' && i.well && hash(i.id + 'n') < 0.35) { c.awaiting = true; c.invented = true; } return c; });
214
+ }
215
+ function inLayer(i) { return !!(S.claudeLayer || i.origin !== 'claude' || i.fresh); }
216
+ function shown(i) { return inLayer(i) && (S.filter === 'all' || i.st === S.filter) && (S.lens !== 'all' || S.ideasOnMap) && i.born <= S.clock && (S.lens !== 'all' || !i.well || SKY.seen(S.goalById.get(i.well))) && (S.lens !== 'simple' || !S.recentOnly || i.age <= 30); }
217
+ // diameter — SIZE is the credits channel and only that: the server's `scale`
218
+ // (one compression curve, modules/ideas/trail-size.js), never a curve of its
219
+ // own, and never brightness — a remnant is as big as its work was.
220
+ function diameter(i) { const s = i.size && typeof i.size.scale === 'number' ? clamp(i.size.scale, 0, 1) : 0; return 6 + 22 * s; }
221
+ function positionWord(i) { if (i.st !== 'protostar') return null; return i.well ? (i.awaiting || (i.state && i.state.awaitingNod) ? 'waiting for a nod' : 'has a home') : 'nothing has claimed it'; }
222
+ // brightnessWord — THE THREE ANSWERS STAY THREE: a share, "unknown" when the
223
+ // removal facts could not be read (attributable:false is not zero), or
224
+ // nothing when there was never any work to be alive (idea-state.js step 3).
225
+ function brightnessWord(i) { if (!(i.state && i.state.hasWork) && i.st !== 'remnant' && !(i.lit < 1)) return null; if (i.state && i.state.attributable === false) return 'still alive — unknown'; const p = Math.round(i.lit * 100); return p >= 100 ? 'all of it still alive' : p <= 0 ? 'none of it still alive' : p + '% still alive'; }
226
+ // where a thought's descent reaches: every goal holding a task that fell out of it, beyond the well that caught it
227
+ function reach(i) { const s = new Set(); for (const t of [...i.lineage, ...i.descentTasks]) { const tk = S.taskById.get(t.id); const g = (tk && tk.goal) || (t.goalId && S.goalById.get(t.goalId)); if (g && g.id !== i.well) s.add(g); } return [...s]; }
228
+ function became(i) {
229
+ if (S.becameGoal.has(i.id)) return 'a goal — it shaped the space';
230
+ if (S.becameCrit.has(i.id)) return 'a criterion — a ring in ' + short(S.becameCrit.get(i.id).goal.title);
231
+ const n = i.lineage.length + i.descentTasks.length;
232
+ if (i.st === 'satellite') return 'folded into another thought';
233
+ if (i.st === 'cold-body') return 'declined at triage';
234
+ if (n === 0) return 'nothing yet';
235
+ const shipped = [...i.lineage, ...i.descentTasks].filter((t) => t.status === 'shipped').length; const r = reach(i).length;
236
+ return `${n} task${n === 1 ? '' : 's'}${shipped ? `, ${shipped} shipped` : ''}${r ? `, reaching ${r} other goal${r === 1 ? '' : 's'}` : ''}`;
237
+ }
238
+ function temp(i) { const t = clamp(i.age / 180, 0, 1); return [Math.round(lerp(255, 175, t)), Math.round(lerp(238, 196, t)), Math.round(lerp(222, 235, t))]; }
239
+ function placeIdeas() {
240
+ const { goals, goalById, ideas, becameGoal, becameCrit, BANDS, ASPECT } = S;
241
+ const byWell = new Map(); const open = []; const byId = new Map(ideas.map((i) => [i.id, i]));
242
+ for (const i of ideas) {
243
+ i.shaped = null;
244
+ if (becameGoal.has(i.id) && goalById.has(becameGoal.get(i.id))) { const g = goalById.get(becameGoal.get(i.id)); i.x = g.x; i.y = g.y; i.shaped = { kind: 'goal', goal: g }; i.well = i.well || g.id; continue; }
245
+ if (becameCrit.has(i.id)) { const c = becameCrit.get(i.id); const g = c.goal; const n = Math.min(g.crit.length, 8); const rr = c.index < n ? ringR(g, c.index, n) : g.r * 0.99; const a = hash(i.id) * TAU; i.x = g.x + Math.cos(a) * rr; i.y = g.y + Math.sin(a) * rr; i.shaped = { kind: 'ring', goal: g, crit: c }; i.well = i.well || g.id; continue; }
246
+ const host = i.mergedInto && byId.get(i.mergedInto); const w = (host || i).well; i.host = host || null;
247
+ if (w && goalById.has(w)) { if (!byWell.has(w)) byWell.set(w, []); byWell.get(w).push(i); } else open.push(i);
248
+ }
249
+ for (const [w, list] of byWell) {
250
+ const g = goalById.get(w); const anchored = list.filter((i) => !i.host || !list.includes(i.host));
251
+ anchored.forEach((i, k) => { const a = k * GOLDEN + hash(i.id) * 0.4; const f = anchored.length <= 1 ? 0.5 : k / (anchored.length - 1); const rr = g.r * (0.16 + 0.66 * f); i.x = g.x + Math.cos(a) * rr; i.y = g.y + Math.sin(a) * rr; });
252
+ for (const i of list) if (i.host && list.includes(i.host)) { const a = hash(i.id) * TAU; i.x = i.host.x + Math.cos(a) * 14; i.y = i.host.y + Math.sin(a) * 14; }
253
+ }
254
+ const core = BANDS[0];
255
+ for (const i of open) {
256
+ const r = rng(i.id + 'open'); let ok = false; const target = core.r1 * (0.22 + 0.74 * clamp(i.age / 180, 0, 1));
257
+ for (let t = 0; t < 48 && !ok; t++) { const a = r() * TAU, rad = target + (r() - 0.5) * core.r1 * 0.14; const x = Math.cos(a) * rad * ASPECT, y = Math.sin(a) * rad; ok = true; for (const g of goals) if (g.band === core && Math.hypot(g.x - x, g.y - y) < g.r + 70) { ok = false; break; } if (ok) { i.x = x; i.y = y; } }
258
+ if (!ok) { i.x = Math.cos(hash(i.id) * TAU) * target * ASPECT; i.y = Math.sin(hash(i.id) * TAU) * target; }
259
+ if (i.host && i.host.x != null) { const a = hash(i.id) * TAU; i.x = i.host.x + Math.cos(a) * 14; i.y = i.host.y + Math.sin(a) * 14; }
260
+ }
261
+ for (const g of goals) g.thoughts = ideas.filter((i) => i.well === g.id);
262
+ }
263
+ function touched(g) { return g.thoughts.some(inLayer) || (g.bornOf && inLayer(S.baseIdeas.find((i) => i.id === g.bornOf) || {})) || g.crit.some((c) => c.bornOf); }
264
+
265
+ // ---------------------------------------------------------------- the reads
266
+ // load — the whole sky in five requests, all at once: the wells (GET /sky),
267
+ // the caller's trail (GET /sky/mine), the versions (the bands), /me (who may
268
+ // nod), and the Full Idea template (the composer's five questions). A read
269
+ // that fails leaves its part empty and the page still draws — an empty sky
270
+ // is a state, not an error.
271
+ async function load() {
272
+ const get = (p) => api.request('GET', `${API}${p}`).then((r) => (r && r.ok ? r.data : null)).catch(() => null);
273
+ const [sky, mine, versions, me, tpl] = await Promise.all([get('/sky'), get('/sky/mine'), get('/versions?limit=200'), get('/me'), get('/inbox/template')]);
274
+ S.canNod = !!(me && me.permissions && me.permissions['idea.triage'] === true);
275
+ S.template = tpl && tpl.template && Array.isArray(tpl.template.fields) ? tpl.template : null;
276
+ S.buildingVersionIds = ((versions && versions.versions) || []).filter((v) => v.status === 'building').map((v) => String(v.id));
277
+ S.truncated = !!(mine && mine.truncated);
278
+ buildWorld({ sky, mine, versions });
279
+ return { sky, mine, versions, me };
280
+ }
281
+
282
+ Object.assign(SKY, {
283
+ S, $, TAU, GOLDEN, reduce, NOW, hash, rng, clamp, lerp, smooth, ease, esc, fmtDate, fmtDay, ageDays, plain, short, store, tokens, toMs, hallBase, API, api,
284
+ OBJ, objects, goalFromSky, ideaFromMine, bandsFor, wellRadius, ringR, taskAt, buildWorld, edgesOf,
285
+ applyRehearsal, inLayer, shown, diameter, positionWord, brightnessWord, reach, became, temp, placeIdeas, touched, load,
286
+ });
287
+ })();