@bongos/core 1.19.1012 → 1.19.1013

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 (34) hide show
  1. package/.bongos-core.json +51 -41
  2. package/docs/adr/0331-the-sky-is-the-ideators-home.md +6 -1
  3. package/docs/copy-inventory.md +99 -56
  4. package/docs/copy-registry.json +830 -465
  5. package/docs/file-map.md +1 -1
  6. package/docs/module-api-changelog.md +2 -0
  7. package/modules/hall-ui/public/idea-objects.js +4 -1
  8. package/modules/hall-ui/public/sky-composer.js +155 -0
  9. package/modules/hall-ui/public/sky-data.js +287 -0
  10. package/modules/hall-ui/public/sky-draw.js +244 -0
  11. package/modules/hall-ui/public/sky-interaction.js +137 -0
  12. package/modules/hall-ui/public/sky-layout.js +81 -0
  13. package/modules/hall-ui/public/sky-panel.js +141 -0
  14. package/modules/hall-ui/public/sky-tour.js +80 -0
  15. package/modules/hall-ui/public/thinking.css +203 -267
  16. package/modules/hall-ui/public/thinking.html +119 -58
  17. package/modules/hall-ui/public/thinking.states.json +33 -9
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/src/bongos/routes/me.js +4 -1
  21. package/src/bongos/serve-internal.js +20 -6
  22. package/src/module-api.js +1 -1
  23. package/tests/hall_audit.mjs +27 -0
  24. package/tests/hall_page_gate_map.mjs +11 -5
  25. package/tests/hall_sky.mjs +393 -405
  26. package/tests/hall_thinking_room.mjs +274 -296
  27. package/tests/helpers.mjs +185 -217
  28. package/tests/ideator_large_dead_thought_proof.mjs +33 -25
  29. package/tests/ideator_traceable_proof.mjs +61 -39
  30. package/modules/hall-ui/public/sky.css +0 -365
  31. package/modules/hall-ui/public/sky.html +0 -79
  32. package/modules/hall-ui/public/sky.js +0 -580
  33. package/modules/hall-ui/public/sky.states.json +0 -20
  34. package/modules/hall-ui/public/thinking.js +0 -418
package/docs/file-map.md CHANGED
@@ -134,7 +134,7 @@
134
134
  │ ├── sessions/ ← CORE-DOMAIN carve (BV1.R82, default-on): the session_records corpus + data plane (upload, cross-builder search/deep-dive), the BFG read-the-room scorer, session-pulse/stale-claim helpers. Registers no port — reached via HTTP POST to its own /sessions route; resolves the economy `reward` port for the cost-plus token reward. CLAUDE.md, module.json, routes/sessions.js, bfg.js, pulse.js.
135
135
  │ ├── autonomy/ ← CORE-DOMAIN carve (BV1.R83, default-on): the MAS autonomy surfaces — the $0 deterministic autonomy gate (System 3, inert until armed), the conductor/architect CLIs, and the in-process routine timers (incl. the weekly peer-vote-tally poller). Registers no port; resolves the lifecycle `tallyPeerVotes` doorway. CLAUDE.md, module.json, routes/autonomy.js, autonomy.js.
136
136
  │ ├── status-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R84, default-on): the public status dashboard at status.<apex>, moved from public-status/. NO routes/db/port — declares contributes.webSurfaces:[{host:"status.",dir:"public"}], served by the loader web-surface seam (moduleWebSurfaces → serve-internal). CLAUDE.md, module.json, public/{index.html,status.js,style.css,cursors/,snapshots/}.
137
- │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,gate,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Four ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages), `oversight.css` (the OVERSIGHT family — government, watch, gate, task 1003469 (harbor was its fourth until task 1003890 removed the dev box): the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface) and `panel.css` (the PANEL family — settings, profile, task 1003308 (pair was its third until task 1003890 removed the dev box): the row, the switch, the field with its label above, the chip control, the four voices and the sign-in gate; the hall's my-projects page was DELETED in the same task — "my projects" is the hub's user-level page, and the hall keeps no link or redirect; the family's fourth page `not-ready.html` was deleted by task 1003450, along with the emptied `requireNonXenosPage` gate that was the only thing redirecting to it). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `settings.css` the rail layout, the group head, the token ladder and the voices grid, `profile.css` the identity band and the shelf, `people.css` the directory's identity cell (task 1003470 made the legacy door `/scouting` ship the same body and script as `/people`; task 1003440 then deleted the door — `scouting.html` and `scouting.states.json` are gone and the URL 301s to `/people`), `thinking.css` THE ROOM's figure run, size bar and five-object marks (task 1004098 / BV2.R03 — `thinking.{html,js}` at `/thinking`, named for its own h1 because `room.css` is the reading-room archetype and `board-room` is the government surface; `idea-objects.js` beside it is the shared object vocabulary the sky will take too).
137
+ │ ├── hall-ui/ ← CORE-DOMAIN WEB-SURFACE carve (BV1.R85, default-on): the authenticated builders' hall at builders.<apex>, moved from public-builders/ (~13k LOC, the largest). NO routes/db/port — declares contributes.webSurfaces:[{host:"builders.",dir:"public"}] for discovery, but UNLIKE status-ui keeps its DEDICATED serving block in serve-internal (the /builders shim + canonical redirects + citizen/Archon page gates + asset-stamping + hall-widget injection), partitioned out of the generic web-surface loop and served from BUILDERS_DIR. CLAUDE.md, module.json, public/{index.html,builders.js,style.css,work,watch,gate,settings,ranks,sessions,primer,diagrams,atlas,...}.{html,js},cursors/,chime.wav. Four ARCHETYPE sheets sit beside the per-page ones: `record.css` (the task + idea records, task 1003305), `room.css` (the READING ROOMS — the primer + the diagrams index, task 1003306: the reading measure and rhythm, the table of contents, the progress hairline and the framed figure diagram-viewer.js upgrades on both pages), `oversight.css` (the OVERSIGHT family — government, watch, gate, task 1003469 (harbor was its fourth until task 1003890 removed the dev box): the row, the figure band inside a panel, the group head, the inline form, the grant toggle and the shared confirm modal; named oversight because the vocabulary wall bans "governance" on the government surface) and `panel.css` (the PANEL family — settings, profile, task 1003308 (pair was its third until task 1003890 removed the dev box): the row, the switch, the field with its label above, the chip control, the four voices and the sign-in gate; the hall's my-projects page was DELETED in the same task — "my projects" is the hub's user-level page, and the hall keeps no link or redirect; the family's fourth page `not-ready.html` was deleted by task 1003450, along with the emptied `requireNonXenosPage` gate that was the only thing redirecting to it). A page sheet then holds only what one page has and the others do not — `primer.css` the acknowledge gate, `diagrams.css` the legend strip, `gate.css` the hold reasons and file chips, `watch.css` the severities, the report reader and the grade trend, `settings.css` the rail layout, the group head, the token ladder and the voices grid, `profile.css` the identity band and the shelf, `people.css` the directory's identity cell (task 1003470 made the legacy door `/scouting` ship the same body and script as `/people`; task 1003440 then deleted the door — `scouting.html` and `scouting.states.json` are gone and the URL 301s to `/people`), `thinking.css` THE SKY's one sheet (task 1004231 / sky part 3 — `thinking.html` at `/thinking` is the prototype the owner adopted as the design of record, `docs/design/mocks/sky/`, built over `GET /sky` + `GET /sky/mine`; its script is the seven `sky-*.js` files on one shared `window.OTBSky`; it replaced THE ROOM of task 1004098, which survives as the list drawer, and the old `/sky` page, whose URL 301s here; `idea-objects.js` beside it is the shared five-object vocabulary).
138
138
  │ ├── lifecycle/ ← CORE-DOMAIN carve (BV1.R86, default-on): the build state machine — tasks · claims · versions · done-when · goals · dependencies · claim→ship→grade→credit · github-push + merge-lock + conflict-resolve + publish-reconciler. The LAST, most-coupled carve; registers the `lifecycle` kernel port (createTask, classifyTaskKind, tallyPeerVotes, work-tracking reads). CLAUDE.md, module.json, routes/{tasks,claims,versions,done-when,goals,dependencies,gate-approvals,analytics}.js, lifecycle.js, github-push.js, merge-lock.js, conflict-resolve.js, publish-reconciler.js, task-visuals.js + task-visual-db.js + routes/visuals.js (the optional ship-time visual, task 1003109 — the route file is NOT named task-*.js on purpose; see the module CLAUDE.md).
139
139
  │ ├── copy-desk/ ← CORE-DOMAIN module (task 1003113 / R02 of goal 1000074, default-on): the COPY half of the design contract — ADR 0081 gave design TOKENS a repo-owned source of truth, text had none. Owns the FLAG (anyone in any role marks a string or a whole surface as needing an artist's attention, with a reason required in BOTH the route and a CHECK constraint) and the QUEUE an artist reads it from. READS R01's committed registry (docs/copy-registry.json, generated by scripts/gds/copy-inventory.js) and never writes it. NO LIVE CMS is the hard non-goal: no column here could hold replacement text, and copy_no_cms.mjs pins that. Key is `copy-desk`, not `copy` — the branding contract already owns a `copy` block, so the bare word reds the "core carries no module code" fitness check. R03 (task 1003114, ADR 0233) added the PROPOSAL: an artist writes the replacement wording and it becomes a Bongos task carrying a fenced copy-proposal patch — no row, NO second migration (a proposals table is the forbidden column with extra steps), and scripts/gds/copy-apply.js lands it as a real diff under a claim. CLAUDE.md, module.json, registry.js, flags.js, proposals.js, routes/copy-desk.js, migrations/copy_desk_001_flags.sql, tests/{copy_flags,copy_queue,copy_no_cms,copy_proposals}.mjs. Its hall surface lives with hall-ui (public/copy-desk.{html,js,css}), per the one-key-per-web-surface precedent.
140
140
  │ ├── ui-design/ ← CORE-DOMAIN module (task 1003324 / ADR 0197, default-on): the design capability every builder on every instance gets — ships METHOD, never a world. Absorbs ADR 0081's layer: adapters/{claude-design,figma}/index.js (moved from src/ui/adapters/), scripts/{design-sync,figma-design-sync,validate-design}.js (moved from scripts/gds/, which keeps thin shims), docs/design-contract.md (moved from docs/). Declaration-only: contributes.skills design (the /design playbook, new) + design-sync + figma-design-sync; no routes/port/migrations, so disabling it touches no served surface. CLAUDE.md separates the platform FLOORS (the Fifteen Rule, the dark twins, no page :root, the 24px floor, AA in both modes, the kill switch) from the instance's WORLD (its packs + DESIGN.md). kit/ (task 1003320) is the look-before-you-ship kit: lib.js (the floors, the states/probes contracts, the action + expectation vocabularies, the WCAG maths, the stub spawner), serve.js (any surface through the doorway's real transforms + fixtures/), render.js (states × 1440/390/320 × dark/light, audited), probe.js (PASS/FAIL contracts), check-mock.js (platform rules + the instance's DESIGN.md taste bans, then the detector), tells.js (task 1003329 / ADR 0223: the anti-pattern detector as two tiers — floors FAIL, craft tells WARN, kit-ignore waivers per file; the rendered twins live in lib.js factsInPage), contrast.js + png.js; a page's <page>.states.json / <page>.probes.json sit BESIDE the surface (modules/public-landing/public/, modules/hall-ui/public/). Recipe: docs/recipes/ui-look-before-you-ship.md. skills/ (task 1003321 / ADR 0198) is the vendoring policy: policy.json (licence allowlist, verdicts that may land, sidecar fields, preamble markers), PREAMBLE.md (the platform preamble every vendored SKILL.md opens with), README.md (the roster — both origins scanned dangerous on 2026-08-28, landed as rebuild specs; nothing vendored — and the landing recipe); a landable copy goes under skills/vendor/<name>/ with PROVENANCE.md + LICENSE and is materialised into an instance's .claude/skills/ by claude-materialize.js step 1b. skills/<name>/ (task 1003325 / ADR 0220) are the in-house rebuilds from the specs, first-party and counted: design-taste-frontend (the default taste skill), design-taste-frontend-v1 (the earlier generation, kept distinct), high-end-visual-design (the polish pass), redesign-existing-projects (the in-place upgrade); each opens with the same preamble and carries a "Rebuilt from" section instead of a sidecar. impeccable/ (task 1003326 / ADR 0221) is the design-craft method as ONE skill with sub-commands: SKILL.md (the router, the menu, the craft floor, the bounded finish) + reference/<sub-command>.md (eighteen playbooks, one loaded per request); no hooks, no bypass agent, no telemetry, no live server. minimalist-ui/, industrial-brutalist-ui/, gpt-taste/, stitch-design-taste/ (task 1003327 / ADR 0230) are the four looks, composition grammars with no colours of their own: the palette is the instance's pack and the texture is a named entry in styles/; a substrate is an archetype that still ships both modes, gpt-taste's selection is derived from the route rather than rolled, and stitch-design-taste writes a DESIGN.md-shaped contract document whose thirteen are transcribed from the pack. image-to-code/, imagegen-frontend-web/, imagegen-frontend-mobile/, brandkit/ (task 1003328 / ADR 0222) are the image family under the hero-only rule: plates and hero objects only, the pair (JPEG dark plate + WebP alpha cutout) with a <file>.json sidecar beside each, through the instance's art pipeline key; the grove specimen's styles/grove/assets/ is the worked example. style/ (task 1003475 / ADR 0231) is the module's first FIRST-PARTY skill (no upstream, so a `## First party` section instead of `## Rebuilt from`): the /style session in which an owner authors their own look in four bounded steps, rendering the nearest library entries before asking, drafting inside the contract, retuning by measurement, and handing over the one-step adoption without ever writing config/branding.json. styles/ (task 1003323 / ADR 0219) is the style library: <name>/pack.json (the theme.ui overlay, exactly the fifteen) + DESIGN.md (the look's derived recipes, thirteen in lockstep with the pack) + mock.html (the specimen); chrome-world (pinned to the neutral pack), expedition, grove, blueprint (the first cool look, authored through /style at task 1003475); STYLE=<name> on the kit lays a look over the instance's pack through GDS_BRANDING_FILE; tests/ui_design_styles.mjs runs the world/cosmos/hall token suites once per alternate (UI_DESIGN_PACK). config/design-tokens.* + design-sources.json STAY in config/ as the host boundary; public-landing keeps its own key (first customer, not owner).
@@ -2498,5 +2498,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2498
2498
  landed since 1.19.1010 with no explicit bump. run 36066617514. (task 1002620)
2499
2499
  1.19.1012 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2500
2500
  landed since 1.19.1011 with no explicit bump. run 36067980398. (task 1002620)
2501
+ 1.19.1013 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2502
+ landed since 1.19.1012 with no explicit bump. run 36072935774. (task 1002620)
2501
2503
  ---------------------------------------------------------------------------
2502
2504
  ```
@@ -42,7 +42,10 @@
42
42
  protostar: {
43
43
  label: 'waiting',
44
44
  noun: 'protostar',
45
- blurb: 'filed, not yet ignited',
45
+ // The sky's fuller sentence (docs/design/mocks/sky/sky.template.html, the
46
+ // design of record — task 1004231): the second axis, position, spelled
47
+ // into the object's own blurb, since the sky is where it can be seen.
48
+ blurb: 'filed, not yet ignited. in a well it awaits a nod; in open space nothing has claimed it',
46
49
  },
47
50
  satellite: {
48
51
  label: 'folded in',
@@ -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
+ })();