@bongos/core 1.19.930 → 1.19.931

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.
@@ -0,0 +1,580 @@
1
+ // modules/hall-ui/public/sky.js — THE SKY (task 1004105 / BV2.R26 of goal
2
+ // 1000095, criterion `ideator-sky-tells-the-truth`; spec
3
+ // docs/specs/bongos-v2-ideator-surface.md decisions 2, 4, 11, 12 and 20; the
4
+ // agreed shape is docs/design/mocks/ideator-surface/Sky.dc.html and the visual
5
+ // half is docs/design/ideator-surface-direction.md).
6
+ //
7
+ // "The ambient view: size compressed, brightness live, goals as gravity wells
8
+ // and their criteria as the rings inside them."
9
+ //
10
+ // IT DRAWS NOTHING IT COMPUTES. Every fact on this page was already answered by
11
+ // somebody else: `modules/ideas/idea-state.js` says WHICH of the five objects a
12
+ // thought is (ADR 0322), `./idea-objects.js` says what that object is CALLED
13
+ // and what sentence goes with it, and economy's `size.scale` is the compressed
14
+ // figure a drawing is supposed to use. This file turns those three into
15
+ // geometry and nothing more. The room (thinking.js) is its sibling and reads
16
+ // the SAME endpoint — `GET /inbox/by-builder/:id` — which is the whole reason
17
+ // the sky is cheap: it arrives last, over numbers the rest of the work already
18
+ // produced.
19
+ //
20
+ // IT IS NEVER THE ONLY PATH TO ANYTHING IT SHOWS (spec decision 11, and the
21
+ // direction's first refusal). That is not a slogan here, it is three concrete
22
+ // properties this file has to keep:
23
+ // - every thought drawn is a real <button> carrying the whole sentence in its
24
+ // aria-label, so the picture is readable without seeing it;
25
+ // - the panel for the picked thought links out to its record, its well and
26
+ // its descent, so nothing terminates here;
27
+ // - the page says, in words and with a link, that the same trail is a LIST at
28
+ // /thinking. A reader who cannot use a map is not stranded on one.
29
+ //
30
+ // IT IS THE VIEWER'S OWN SKY, not the project's. The read is the author's own
31
+ // trail — the id comes from GET /me and never from the URL, exactly as the room
32
+ // does it — so the h1 says "your sky" rather than claiming to draw every
33
+ // thought in the instance. A project-wide sky is a different read with a
34
+ // different gate and is not this task.
35
+ //
36
+ // THE LAYOUT IS A GRID OF WELLS, not the mock's single continuous starfield.
37
+ // The mock is a shape and not a pixel contract (mocks/ideator-surface/README):
38
+ // what it fixes is that goals are wells, criteria are the rings inside them,
39
+ // thoughts sit in the well that caught them, and the unclaimed float apart. A
40
+ // continuous field of absolute coordinates keeps that at 1280px and loses all
41
+ // of it at 320, where wells would overlap into one smear. One well per grid
42
+ // cell keeps the reading at every width and restacks on a phone for free.
43
+
44
+ (() => {
45
+ 'use strict';
46
+
47
+ const API = '/api/bongos';
48
+ // baseUrl:'' — this page builds full paths itself through api.request, the
49
+ // same idiom the room takes and for the same reason: the trail read is
50
+ // reached by a path the generated namespaced client carries no method for.
51
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
52
+ const { escapeHtml, $ } = window.OTB;
53
+ const objects = window.OTBIdeaObjects;
54
+
55
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
56
+ const ideaHref = (id) => `${hallBase()}/idea/${encodeURIComponent(id)}`;
57
+ const taskHref = (id) => `${hallBase()}/task/${encodeURIComponent(id)}`;
58
+ const goalHref = (id) => `${hallBase()}/goals#/goal/${encodeURIComponent(id)}`;
59
+ const roomHref = () => `${hallBase()}/thinking`;
60
+
61
+ // The route's own ceiling and the room's own cap, restated rather than
62
+ // shared: the two pages read the same endpoint and a sky that stopped at a
63
+ // different row than the room would be a second, quieter claim about the same
64
+ // trail. If one of these moves the other has to.
65
+ const PAGE = 100;
66
+ const CAP = 300;
67
+ // Criteria are one extra read PER WELL, so the number of wells is the number
68
+ // of extra requests. Past this the rings are simply not drawn: a well with no
69
+ // rings is an honest well (it says nothing about its criteria), where twelve
70
+ // background reads on page load would be the sky making filing slower, which
71
+ // is the one thing spec decision 12 forbids it to do.
72
+ const WELL_DETAIL_MAX = 8;
73
+
74
+ const state = {
75
+ ideas: [],
76
+ total: 0,
77
+ capped: false,
78
+ loading: false,
79
+ // goalId → title. Absent until /goals answers; an absent title is drawn as
80
+ // the id, never as a blank well.
81
+ goals: new Map(),
82
+ // goalId → [{ satisfied }] in sort order. Absent means "not read", which is
83
+ // drawn as no rings — never as a goal with zero criteria.
84
+ criteria: new Map(),
85
+ picked: null,
86
+ };
87
+
88
+ // ---- geometry ------------------------------------------------------------
89
+ //
90
+ // Every number below is a PERCENTAGE of the well's own box, so one placement
91
+ // serves 1440, 390 and 320 without a second breakpoint's worth of arithmetic.
92
+
93
+ // The golden angle. Successive multiples of it never repeat and never bunch,
94
+ // which is what puts n thoughts round a well evenly for every n without a
95
+ // per-n table — the same reason a sunflower uses it.
96
+ const GOLDEN = 137.50776405003785;
97
+
98
+ // A stable per-idea jitter so two thoughts at the same spiral index in two
99
+ // different wells do not sit at pixel-identical spots, and so the field does
100
+ // not visibly re-shuffle when a background page lands. Derived from the id
101
+ // and nothing else: the same trail draws the same sky on every load, which
102
+ // matters because a screenshot pass has to be reproducible.
103
+ function hash(id) {
104
+ let h = 2166136261;
105
+ const s = String(id);
106
+ for (let i = 0; i < s.length; i += 1) {
107
+ h ^= s.charCodeAt(i);
108
+ h = Math.imul(h, 16777619);
109
+ }
110
+ return ((h >>> 0) % 1000) / 1000;
111
+ }
112
+
113
+ // Where thought `i` of `n` sits inside a circular field, as { left, top } in
114
+ // per cent. The innermost ring is left clear: a thought sitting on the well's
115
+ // own centre reads as the goal rather than as something inside it.
116
+ function place(id, i, n) {
117
+ const a = ((i * GOLDEN) + hash(id) * 24) * (Math.PI / 180);
118
+ // Spread over the annulus rather than filling the disc — with one thought
119
+ // the fraction is 0.5, which is mid-annulus, not dead centre.
120
+ const frac = n <= 1 ? 0.5 : (i / (n - 1));
121
+ const r = 14 + 24 * frac;
122
+ return { left: 50 + r * Math.cos(a), top: 50 + r * Math.sin(a) };
123
+ }
124
+
125
+ // A satellite is drawn ON the thought it was folded into, a fixed short step
126
+ // away at a stable angle. "It orbits that one now" is a claim about POSITION
127
+ // that the mark alone cannot make: a satellite dropped into its host's well
128
+ // at the next free spiral slot can land on the far side of the field from the
129
+ // thought it belongs to, which draws the opposite of what the design says.
130
+ // The step is fixed rather than scaled by either body, so two thoughts never
131
+ // resolve to the same point however large the host is.
132
+ function orbit(around, satelliteId) {
133
+ const a = (hash(satelliteId) * 360) * (Math.PI / 180);
134
+ return { left: around.left + 8 * Math.cos(a), top: around.top + 8 * Math.sin(a) };
135
+ }
136
+
137
+ // The drawn diameter, in px, from the server's compressed scale.
138
+ //
139
+ // THE CURVE IS THE SERVER'S AND THERE IS NOT A SECOND ONE HERE. Size is
140
+ // long-tailed, economy compresses it once at the read
141
+ // (modules/economy/idea-size.js compressIdeaSize) and the direction is
142
+ // explicit that the compressed value is what a drawing uses. A log taken here
143
+ // on top of that one is exactly how the sky and the room end up drawing the
144
+ // same thought at two different sizes — the failure idea-objects.js exists to
145
+ // prevent, one axis over.
146
+ const D_MIN = 9;
147
+ const D_MAX = 32;
148
+ function diameter(idea) {
149
+ const size = idea.size;
150
+ if (!size || typeof size.credits !== 'number') return D_MIN;
151
+ const scale = Number(size.scale);
152
+ if (!Number.isFinite(scale) || scale <= 0) return D_MIN;
153
+ return Math.round(D_MIN + (D_MAX - D_MIN) * Math.min(1, Math.max(0, scale)));
154
+ }
155
+
156
+ // `--lit` is the live share, and it rides the glyph exactly as it does in the
157
+ // room: a brightness that could not be READ leaves the glyph FULL and puts
158
+ // the doubt in words. Dimming on `brightness: null` would state that work is
159
+ // gone when the only thing that happened is that nobody could look.
160
+ function litOf(st) {
161
+ return (st && st.hasWork === true && st.attributable === true && typeof st.brightness === 'number')
162
+ ? st.brightness
163
+ : 1;
164
+ }
165
+
166
+ // ---- what a thought says -------------------------------------------------
167
+
168
+ // The whole sentence, for the aria-label and the title. A glyph in a field is
169
+ // the one thing on this page that carries no text of its own, so everything
170
+ // the picture encodes — the object, the size, the live share, the position —
171
+ // has to be here or it is only available to someone who can see it.
172
+ function sentence(idea) {
173
+ const st = idea.state;
174
+ const o = objects.objectFor(st && st.state);
175
+ const bits = [idea.title || `Idea ${idea.id}`, o.label];
176
+ const pos = objects.positionWord(st);
177
+ if (pos) bits.push(pos);
178
+ const size = idea.size;
179
+ if (!size || typeof size.credits !== 'number') bits.push('size unknown');
180
+ else if (size.credits > 0) bits.push(`size ${size.credits}`);
181
+ const bright = objects.brightnessWord(st);
182
+ if (bright) bits.push(bright);
183
+ return bits.join(' — ');
184
+ }
185
+
186
+ // `grow` is the panel's magnification and nothing else: the picked thought is
187
+ // drawn from the SAME diameter the field drew, multiplied, so the panel can
188
+ // never show a thought at a size the field disagrees with.
189
+ function glyphHtml(idea, grow) {
190
+ const st = idea.state;
191
+ const d = Math.round(diameter(idea) * (grow || 1));
192
+ const lit = litOf(st);
193
+ const size = idea.size;
194
+ // `size: null` is "the reward port could not be asked", not "it earned
195
+ // nothing" (trail-size.js). Drawn at the floor diameter, because inventing
196
+ // a middle one would be a claim — and marked, so the floor is not read as a
197
+ // measured zero.
198
+ const unknown = !size || typeof size.credits !== 'number';
199
+ return `<span class="sky-glyph" data-object="${escapeHtml(String((st && st.state) || 'unknown'))}"${unknown ? ' data-size="unknown"' : ''} style="--d:${d}px;--lit:${lit.toFixed(2)}" aria-hidden="true"></span>`;
200
+ }
201
+
202
+ function thoughtHtml(idea, p) {
203
+ const label = sentence(idea);
204
+ const picked = String(state.picked) === String(idea.id);
205
+ return `<button type="button" class="sky-body" data-idea="${escapeHtml(String(idea.id))}"
206
+ ${picked ? 'data-picked="1" ' : ''}aria-pressed="${picked ? 'true' : 'false'}"
207
+ style="left:${p.left.toFixed(2)}%;top:${p.top.toFixed(2)}%"
208
+ title="${escapeHtml(label)}" aria-label="${escapeHtml(label)}">${glyphHtml(idea)}</button>`;
209
+ }
210
+
211
+ // ---- the wells -----------------------------------------------------------
212
+
213
+ // A well is a GOAL THAT HOLDS ONE OF THIS THOUGHT'S TASKS — `wellGoalId`,
214
+ // which idea-state.js derives from `tasks.goal_id` and is emphatic is not
215
+ // `suggested_goal_id` (that one is strictly advisory and would draw a
216
+ // protostar inside a well that never caught it). Nothing is re-derived here;
217
+ // this only groups by the answer.
218
+ //
219
+ // A SATELLITE SITS BESIDE THE THOUGHT IT WAS FOLDED INTO when that thought is
220
+ // in view, because "it orbits that one now" is a statement about POSITION and
221
+ // the object's mark alone cannot make it. When the parent is not in the
222
+ // trail — folded into somebody else's thought — it falls back to its own
223
+ // well, or to open space.
224
+ function group() {
225
+ const byId = new Map(state.ideas.map((i) => [String(i.id), i]));
226
+ const wells = new Map();
227
+ const open = [];
228
+ // The thought a row is DRAWN WITH: itself, unless it was folded into one
229
+ // that is also in view. `hostOf` is the whole of the satellite rule and is
230
+ // asked once, so the well it lands in and the point it is drawn at cannot
231
+ // come from two different answers.
232
+ const hostOf = (idea) => {
233
+ if (idea.merged_into_idea_id == null) return null;
234
+ return byId.get(String(idea.merged_into_idea_id)) || null;
235
+ };
236
+ for (const idea of state.ideas) {
237
+ const host = hostOf(idea);
238
+ const g = ((host || idea).state && (host || idea).state.wellGoalId) || null;
239
+ const entry = { idea, hostId: host ? String(host.id) : null };
240
+ if (!g) { open.push(entry); continue; }
241
+ if (!wells.has(g)) wells.set(g, []);
242
+ wells.get(g).push(entry);
243
+ }
244
+ // Biggest well first, then by goal id — stable, so the field does not
245
+ // re-order itself when a background page lands and the reader loses the
246
+ // thought they were looking at.
247
+ const ordered = [...wells.entries()].sort((a, b) => (b[1].length - a[1].length) || (a[0] < b[0] ? -1 : 1));
248
+ return { wells: ordered, open };
249
+ }
250
+
251
+ // Entries → markup. The spiral index counts only the thoughts that take a
252
+ // slot of their own: a satellite is drawn ON its host, so counting it would
253
+ // push every other thought out by one notch for a body that occupies none.
254
+ // A satellite whose host is in ANOTHER well (its host had no well of its own,
255
+ // or the reverse) falls back to a slot, because the point it would orbit does
256
+ // not exist on this field.
257
+ function fieldHtml(entries) {
258
+ const here = new Set(entries.map((e) => String(e.idea.id)));
259
+ // Anchored = takes a slot. A thought with no host does; so does one whose
260
+ // host is not on this field, and so does a satellite OF a satellite — the
261
+ // chain is followed exactly one hop, which is what makes it impossible for
262
+ // a cycle in the merge ledger to hang the render.
263
+ const anchored = new Set();
264
+ for (const e of entries) if (!e.hostId || !here.has(e.hostId)) anchored.add(String(e.idea.id));
265
+ for (const e of entries) if (e.hostId && !anchored.has(e.hostId)) anchored.add(String(e.idea.id));
266
+
267
+ const slots = new Map();
268
+ let i = 0;
269
+ const n = entries.filter((e) => anchored.has(String(e.idea.id))).length;
270
+ for (const e of entries) {
271
+ if (anchored.has(String(e.idea.id))) slots.set(String(e.idea.id), place(e.idea.id, i++, n));
272
+ }
273
+ return entries.map((e) => {
274
+ const id = String(e.idea.id);
275
+ const p = anchored.has(id) ? slots.get(id) : orbit(slots.get(e.hostId), e.idea.id);
276
+ return thoughtHtml(e.idea, p);
277
+ }).join('');
278
+ }
279
+
280
+ // The rings. One per criterion, innermost first, closing as each is
281
+ // satisfied: a satisfied criterion is drawn solid, an open one faint. A goal
282
+ // whose criteria have not been READ gets no rings at all rather than an empty
283
+ // circle — "we did not look" and "this goal has no criteria" are different
284
+ // answers and the second one would be a lie about the project's own shape.
285
+ function ringsHtml(goalId) {
286
+ const list = state.criteria.get(String(goalId));
287
+ if (!Array.isArray(list) || list.length === 0) return '';
288
+ const n = Math.min(list.length, 6);
289
+ return list.slice(0, n).map((c, i) => {
290
+ const pct = 32 + (92 - 32) * (n === 1 ? 1 : i / (n - 1));
291
+ return `<span class="sky-ring"${c && c.satisfied ? ' data-closed="1"' : ''} style="--r:${pct.toFixed(1)}%" aria-hidden="true"></span>`;
292
+ }).join('');
293
+ }
294
+
295
+ function wellHtml(goalId, entries) {
296
+ const title = state.goals.get(String(goalId)) || `Goal ${goalId}`;
297
+ const crit = state.criteria.get(String(goalId));
298
+ const closed = Array.isArray(crit) ? crit.filter((c) => c && c.satisfied).length : 0;
299
+ const note = Array.isArray(crit) && crit.length > 0
300
+ ? `${closed} of ${crit.length} criteria met`
301
+ : `${entries.length} thought${entries.length === 1 ? '' : 's'}`;
302
+ return `<section class="sky-well" aria-labelledby="sky-w-${escapeHtml(String(goalId))}">
303
+ <h3 class="sky-well__h" id="sky-w-${escapeHtml(String(goalId))}">
304
+ <a href="${goalHref(goalId)}">${escapeHtml(title)}</a>
305
+ </h3>
306
+ <p class="sky-well__note">${escapeHtml(note)}</p>
307
+ <div class="sky-field">
308
+ ${ringsHtml(goalId)}
309
+ ${fieldHtml(entries)}
310
+ </div>
311
+ </section>`;
312
+ }
313
+
314
+ function openHtml(entries) {
315
+ if (entries.length === 0) return '';
316
+ return `<section class="sky-well sky-well--open" aria-labelledby="sky-w-open">
317
+ <h3 class="sky-well__h" id="sky-w-open">Open space</h3>
318
+ <p class="sky-well__note">no goal has claimed ${entries.length === 1 ? 'this one' : 'these'}</p>
319
+ <div class="sky-field">
320
+ ${fieldHtml(entries)}
321
+ </div>
322
+ </section>`;
323
+ }
324
+
325
+ // ---- the panel -----------------------------------------------------------
326
+ //
327
+ // The picked thought in words. It is the page's answer to "never the only
328
+ // path": every fact here names where it is read in full, and the three links
329
+ // out — the record, the well, the descent — are the whole of the escape.
330
+
331
+ function factRow(label, valueHtml) {
332
+ return `<div class="sky-fact"><span class="sky-fact__k">${escapeHtml(label)}</span><span class="sky-fact__v">${valueHtml}</span></div>`;
333
+ }
334
+
335
+ // What a thought became, from the LEDGER facts on the row and never from the
336
+ // state — the same rule the room's `becameHtml` keeps, so a disagreement
337
+ // between the two shows up as a visibly odd row instead of being smoothed
338
+ // into agreement.
339
+ function becameHtml(idea) {
340
+ if (idea.merged_into_idea_id) {
341
+ const t = idea.merged_into_idea_title || `idea ${idea.merged_into_idea_id}`;
342
+ return `folded into <a href="${ideaHref(idea.merged_into_idea_id)}">${escapeHtml(t)}</a>`;
343
+ }
344
+ const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
345
+ const roots = new Set();
346
+ for (const t of lineage) if (t && t.task_id != null) roots.add(String(t.task_id));
347
+ if (roots.size === 0 && idea.promoted_to_task_id != null) roots.add(String(idea.promoted_to_task_id));
348
+ if (roots.size === 0) {
349
+ if (idea.status === 'discarded') return 'declined at triage';
350
+ return '<span class="sky-quiet">nothing yet</span>';
351
+ }
352
+ const bits = [];
353
+ if (roots.size === 1) {
354
+ const id = [...roots][0];
355
+ const named = lineage.find((t) => t && String(t.task_id) === id);
356
+ const t = (named && named.title) || idea.promoted_task_title || `task ${id}`;
357
+ bits.push(`<a href="${taskHref(id)}">${escapeHtml(t)}</a>`);
358
+ } else {
359
+ bits.push(`${roots.size} tasks`);
360
+ }
361
+ const descent = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
362
+ if (descent.length > 0) bits.push(`<span class="sky-quiet">and ${descent.length} more below</span>`);
363
+ return bits.join(' · ');
364
+ }
365
+
366
+ function sizeHtml(idea) {
367
+ const size = idea.size;
368
+ if (!size || typeof size.credits !== 'number') {
369
+ return '<span class="sky-quiet" title="the ledger could not be read">unknown</span>';
370
+ }
371
+ if (size.credits <= 0) return '<span class="sky-quiet">none yet</span>';
372
+ // A size computed from the descent an idea already had, rather than from
373
+ // payouts actually made (spec decision 17). The marker rides wherever the
374
+ // number is shown, or the number quietly claims to be money that was spent.
375
+ const mark = size.historical === true
376
+ ? ' <abbr class="sky-hist" title="computed from the descent this idea already had — not payouts that were made">part historical</abbr>'
377
+ : '';
378
+ return `${escapeHtml(String(size.credits))}${mark}`;
379
+ }
380
+
381
+ function brightHtml(idea) {
382
+ const st = idea.state;
383
+ const word = objects.brightnessWord(st);
384
+ if (!word) return '<span class="sky-quiet">nothing to be alive yet</span>';
385
+ const known = st && st.attributable === true && typeof st.brightness === 'number';
386
+ const pct = known ? Math.round(Number(st.brightness) * 100) : 0;
387
+ // The bar is drawn only when the share was actually read. An unreadable
388
+ // brightness gets the words alone: a bar at zero and a bar that could not
389
+ // be measured look identical, and one of them is a claim nobody checked.
390
+ const bar = known
391
+ ? `<span class="sky-bar" style="--w:${pct}%"${pct === 0 ? ' data-lit="out"' : ''}></span>`
392
+ : '';
393
+ return `${bar}<span class="sky-brightword">${escapeHtml(word)}</span>`;
394
+ }
395
+
396
+ function panelHtml() {
397
+ const idea = state.ideas.find((i) => String(i.id) === String(state.picked));
398
+ if (!idea) {
399
+ return `<p class="sky-pick">Pick a thought to read it. Every one of them is also a row in
400
+ <a href="${roomHref()}">your thinking</a>, which is the same trail as a list.</p>`;
401
+ }
402
+ const st = idea.state;
403
+ const o = objects.objectFor(st && st.state);
404
+ const pos = objects.positionWord(st);
405
+ const well = st && st.wellGoalId
406
+ ? `<a href="${goalHref(st.wellGoalId)}">${escapeHtml(state.goals.get(String(st.wellGoalId)) || `Goal ${st.wellGoalId}`)}</a>`
407
+ : '<span class="sky-quiet">open space</span>';
408
+ return `<div class="sky-panel__head">
409
+ ${glyphHtml(idea, 1.5)}
410
+ <span class="sky-panel__object">${escapeHtml(o.label)}</span>
411
+ </div>
412
+ <p class="sky-panel__title"><a href="${ideaHref(idea.id)}">${escapeHtml(idea.title || `Idea ${idea.id}`)}</a></p>
413
+ <div class="sky-facts">
414
+ ${factRow('gravity', well)}
415
+ ${factRow('became', becameHtml(idea))}
416
+ ${factRow('size — the work it caused', sizeHtml(idea))}
417
+ ${factRow('brightness — still alive', brightHtml(idea))}
418
+ </div>
419
+ ${pos ? `<p class="sky-panel__pos">${escapeHtml(pos)}</p>` : ''}
420
+ <p class="sky-panel__blurb">${escapeHtml(o.blurb)}</p>
421
+ <p class="sky-panel__out"><a class="btn-ghost" href="${ideaHref(idea.id)}">Open its descent</a></p>`;
422
+ }
423
+
424
+ // ---- the legend ----------------------------------------------------------
425
+ //
426
+ // The five objects, in the shared order, with the shared blurbs. It is drawn
427
+ // from idea-objects.js ORDER rather than a list written here, so the page
428
+ // cannot grow a sixth object the resolver does not emit or lose one it does.
429
+ function legendHtml() {
430
+ return objects.ORDER.map((id) => {
431
+ const o = objects.objectFor(id);
432
+ return `<div class="sky-leg">
433
+ <span class="sky-glyph" data-object="${escapeHtml(id)}" style="--d:${id === 'remnant' ? 16 : 11}px;--lit:1" aria-hidden="true"></span>
434
+ <div class="sky-leg__body">
435
+ <span class="sky-leg__name">${escapeHtml(o.label)} — ${escapeHtml(o.noun)}</span>
436
+ <span class="sky-leg__what">${escapeHtml(o.blurb)}</span>
437
+ </div>
438
+ </div>`;
439
+ }).join('');
440
+ }
441
+
442
+ // ---- painting ------------------------------------------------------------
443
+
444
+ function headline() {
445
+ const rows = state.ideas;
446
+ const n = (k) => rows.filter((i) => i.state && i.state.state === k).length;
447
+ const burning = n('star');
448
+ const out = n('remnant');
449
+ if (rows.length === 0) return 'Nothing filed yet';
450
+ return `${burning} burning, ${out} burned out, ${rows.length} in all`;
451
+ }
452
+
453
+ function paint() {
454
+ const { wells, open } = group();
455
+ const sky = $('#sky-map');
456
+ if (wells.length === 0 && open.length === 0) {
457
+ sky.innerHTML = `<p class="sky-empty">You have not filed anything yet, so there is nothing in your sky.
458
+ <a href="${hallBase()}/ideas">File a thought</a> — it takes one line.</p>`;
459
+ } else {
460
+ sky.innerHTML = wells.map(([g, list]) => wellHtml(g, list)).join('') + openHtml(open);
461
+ }
462
+ $('#sky-panel').innerHTML = panelHtml();
463
+ const sub = $('#sky-sub');
464
+ if (sub) sub.textContent = headline();
465
+ const foot = $('#sky-foot');
466
+ const bits = [];
467
+ if (state.loading) bits.push(`Drawing the rest of your trail — ${state.ideas.length} of ${state.total} so far.`);
468
+ else if (state.capped) bits.push(`Showing your newest ${state.ideas.length} of ${state.total}.`);
469
+ bits.push('Size is the work a thought caused and never falls; brightness is how much of that work is still alive.');
470
+ foot.textContent = bits.join(' ');
471
+ foot.hidden = false;
472
+ }
473
+
474
+ // ---- the reads -----------------------------------------------------------
475
+
476
+ async function fetchPage(builderId, offset) {
477
+ const r = await api.request('GET',
478
+ `${API}/inbox/by-builder/${encodeURIComponent(builderId)}?limit=${PAGE}&offset=${offset}`);
479
+ if (r.status === 401) { window.location.assign(`${API}/auth/web/start`); return null; }
480
+ if (!r.ok || !r.data) throw new Error(`idea trail → ${r.status}`);
481
+ return r.data;
482
+ }
483
+
484
+ // The wells' names and their rings, AFTER the field is already on screen.
485
+ // Spec decision 12 — the sky redraws in the background — is about filing, but
486
+ // the same posture applies to the sky's own load: a field drawn with goal ids
487
+ // for labels and no rings is useful, and blocking on two more round trips to
488
+ // avoid showing it for a moment is not.
489
+ async function loadWells(goalIds) {
490
+ try {
491
+ const r = await api.request('GET', `${API}/goals`);
492
+ if (r.ok && r.data && Array.isArray(r.data.goals)) {
493
+ for (const g of r.data.goals) if (g && g.id != null) state.goals.set(String(g.id), g.title || null);
494
+ paint();
495
+ }
496
+ } catch (_) { /* a well keeps its id for a label; nothing else changes */ }
497
+
498
+ const want = goalIds.slice(0, WELL_DETAIL_MAX);
499
+ await Promise.all(want.map(async (id) => {
500
+ try {
501
+ const r = await api.request('GET', `${API}/goals/${encodeURIComponent(id)}`);
502
+ if (r.ok && r.data && Array.isArray(r.data.criteria)) state.criteria.set(String(id), r.data.criteria);
503
+ } catch (_) { /* no rings on this well, which is the honest drawing */ }
504
+ }));
505
+ paint();
506
+ }
507
+
508
+ function fail(msg) {
509
+ $('#sky-map').innerHTML = `<p class="sky-empty">${escapeHtml(msg)}</p>`;
510
+ $('#sky-foot').hidden = true;
511
+ }
512
+
513
+ function wire() {
514
+ const sky = $('#sky-map');
515
+ if (!sky || !sky.addEventListener) return;
516
+ // One delegated listener on the field rather than one per body: a trail of
517
+ // 300 thoughts is 300 buttons, and the field is repainted on every
518
+ // background page.
519
+ sky.addEventListener('click', (ev) => {
520
+ const btn = ev.target && ev.target.closest && ev.target.closest('[data-idea]');
521
+ if (!btn) return;
522
+ const id = btn.getAttribute('data-idea');
523
+ state.picked = String(state.picked) === String(id) ? null : id;
524
+ paint();
525
+ });
526
+ }
527
+
528
+ async function main() {
529
+ // Who is looking. The sky is the AUTHOR's own view, so the id is the
530
+ // viewer's and never a query parameter — the same rule the room keeps.
531
+ const me = await api.request('GET', `${API}/me`);
532
+ if (me.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
533
+ if (!me.ok || !me.data || !me.data.builder) return fail('Could not work out who you are — try signing in again.');
534
+ const builderId = me.data.builder.id;
535
+
536
+ let first;
537
+ try {
538
+ first = await fetchPage(builderId, 0);
539
+ } catch (_) {
540
+ return fail('Could not read your idea trail.');
541
+ }
542
+ if (!first) return;
543
+
544
+ state.ideas = Array.isArray(first.ideas) ? first.ideas : [];
545
+ state.total = Number(first.total) || state.ideas.length;
546
+ state.loading = state.ideas.length < state.total && state.ideas.length < CAP;
547
+ wire();
548
+ paint();
549
+
550
+ while (state.loading) {
551
+ let next;
552
+ try {
553
+ next = await fetchPage(builderId, state.ideas.length);
554
+ } catch (_) {
555
+ state.loading = false;
556
+ state.capped = true;
557
+ break;
558
+ }
559
+ if (!next) return;
560
+ const batch = Array.isArray(next.ideas) ? next.ideas : [];
561
+ state.total = Number(next.total) || state.total;
562
+ if (batch.length === 0) { state.loading = false; break; }
563
+ state.ideas = state.ideas.concat(batch);
564
+ if (state.ideas.length >= CAP && state.ideas.length < state.total) {
565
+ state.capped = true;
566
+ state.loading = false;
567
+ } else if (state.ideas.length >= state.total) {
568
+ state.loading = false;
569
+ }
570
+ paint();
571
+ }
572
+ paint();
573
+
574
+ const goalIds = group().wells.map(([g]) => g);
575
+ if (goalIds.length > 0) await loadWells(goalIds);
576
+ }
577
+
578
+ $('#sky-legend').innerHTML = legendHtml();
579
+ main();
580
+ })();
@@ -0,0 +1,20 @@
1
+ {
2
+ "_": "THE SKY (task 1004105 / BV2.R26) through modules/ui-design/kit/render.js. Like the room, every thought on this page comes from ONE authenticated read, so against the kit's own stub it renders its signed-out / empty state — which is a state the floors must hold in and is rendered here deliberately. To look at the populated field, run it against the hall-preview harness, whose inbox__by-builder.json fixture carries one of each of the five objects plus a historical size, and whose goals.json + goals__<id>.json name the wells and give them their criterion rings:\n\n node scripts/hall-preview/server.js --port 4615 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/sky.states.json --base-in http://localhost:4615 --base-out http://localhost:4615",
3
+ "page": "/builders/sky",
4
+ "surface": "hall-ui",
5
+ "stub": { "prefix": "/builders/" },
6
+ "modeQuery": false,
7
+ "ignoreRequests": ["/api/(gds|bongos)/"],
8
+ "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "Unauthorized", "Not Found", "load failed"],
9
+ "states": {
10
+ "sky": { "auth": true, "actions": [["wait", 1200]], "expect": { "visible": ["#sky-map", "#sky-legend"] } },
11
+ "picked": {
12
+ "auth": true,
13
+ "_": "A thought picked, which is the page's only control and the state the panel exists for. clickIfVisible and not click: against the kit's own stub the field renders empty, so there is no body to press and a bare click would fail the state rather than render it.",
14
+ "actions": [["wait", 1200], ["clickIfVisible", ".sky-body"], ["wait", 200]],
15
+ "expect": { "visible": ["#sky-panel"] }
16
+ },
17
+ "out": { "auth": false, "actions": [["wait", 600]] }
18
+ },
19
+ "reducedMotion": { "auth": true, "actions": [["wait", 1200]] }
20
+ }
@@ -41,7 +41,8 @@
41
41
  <p class="scroll__lede">
42
42
  Your own view of your own thinking. Size is the work a thought caused and
43
43
  never falls; brightness is how much of that work is still alive — that is
44
- the part that can go out.
44
+ the part that can go out. The same trail is also a map, in
45
+ <a href="/builders/sky">your sky</a>.
45
46
  </p>
46
47
 
47
48
  <!-- The figures, the filters and the rows are all written by
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.930",
3
+ "version": "1.19.931",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.930",
9
+ "version": "1.19.931",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.930",
3
+ "version": "1.19.931",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -1160,8 +1160,13 @@ function mountInternalSurfaces(app) {
1160
1160
  // because the page reads the VIEWER's own trail and nobody else's — the id it
1161
1161
  // asks for comes from GET /me, never from the URL, so a rank could protect
1162
1162
  // nothing here that the session does not already.
1163
+ // task 1004105 (BV2.R26): `sky` — THE SKY, the ambient view over the same
1164
+ // trail the room lists — joins that tier for exactly the same reasons. It is
1165
+ // the SAME read (GET /inbox/by-builder/:id with the id from GET /me), drawn
1166
+ // as a map instead of a table, so a floor above sign-in would protect on one
1167
+ // page what it admits on the other.
1163
1168
  const SIGNEDIN_BUILDERS_PAGE_RE =
1164
- /^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1169
+ /^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|sky|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1165
1170
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1166
1171
  // page that embeds them.
1167
1172
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.930'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.931'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');