@bongos/core 1.19.929 → 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.
- package/.bongos-core.json +45 -20
- package/docs/copy-inventory.md +35 -7
- package/docs/copy-registry.json +352 -9
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/sky.css +365 -0
- package/modules/hall-ui/public/sky.html +79 -0
- package/modules/hall-ui/public/sky.js +580 -0
- package/modules/hall-ui/public/sky.states.json +20 -0
- package/modules/hall-ui/public/style.css +10 -0
- package/modules/hall-ui/public/thinking.css +0 -9
- package/modules/hall-ui/public/thinking.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +6 -1
- package/src/module-api.js +1 -1
- package/tests/hall_board_world.mjs +32 -0
- package/tests/hall_page_gate_map.mjs +5 -0
- package/tests/hall_sky.mjs +458 -0
- package/tests/helpers.mjs +121 -0
|
@@ -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
|
+
}
|
|
@@ -3163,10 +3163,20 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
3163
3163
|
/* tab strip — the .setup__tab idiom in the .setup__toggle frame, promoted */
|
|
3164
3164
|
/* A tab strip is hairlines, not a segmented box: one rule under the row and a
|
|
3165
3165
|
2px accent underline on the open tab, the lit-step grammar again. */
|
|
3166
|
+
/* Both axes are named on purpose, and the pair is load-bearing — do not
|
|
3167
|
+
"simplify" this back to the single `overflow-x`. Per the overflow spec a
|
|
3168
|
+
non-`visible` value on one axis computes the OTHER axis to `auto` too, so
|
|
3169
|
+
`overflow-x: auto` alone made the strip a scroll container vertically as
|
|
3170
|
+
well; `.setup__tab`'s `margin-bottom: -1px` (the underline sitting on the
|
|
3171
|
+
strip's hairline, below) then overflowed it by exactly 1px and every hall
|
|
3172
|
+
page with tabs grew a vertical scrollbar it could never scroll. `hidden` is
|
|
3173
|
+
the fix and `visible` is not: `visible` beside an `auto` is the value the
|
|
3174
|
+
spec rewrites, which would put the bug straight back (task 1004130). */
|
|
3166
3175
|
.kit-tabs {
|
|
3167
3176
|
display: flex;
|
|
3168
3177
|
max-width: 100%;
|
|
3169
3178
|
overflow-x: auto;
|
|
3179
|
+
overflow-y: hidden;
|
|
3170
3180
|
border: 0;
|
|
3171
3181
|
border-bottom: 1px solid var(--rule);
|
|
3172
3182
|
border-radius: 0;
|
|
@@ -61,15 +61,6 @@
|
|
|
61
61
|
the kit's own margin assumes a panel directly beneath and here a table
|
|
62
62
|
follows. */
|
|
63
63
|
.tr-filters { margin: 0 0 var(--sp-sm); }
|
|
64
|
-
/* `.kit-tabs` sets `overflow-x: auto`, which per spec makes the OTHER axis
|
|
65
|
-
`auto` too — and the strip's own `.setup__tab { margin-bottom: -1px }` leaves
|
|
66
|
-
it exactly 1px taller than its box, so a vertical scrollbar appears beside a
|
|
67
|
-
horizontal strip that cannot scroll vertically. It is the shared component's
|
|
68
|
-
defect rather than this page's, and it is not fixed in style.css from here:
|
|
69
|
-
that rule is on every page carrying a tab strip, and quietly changing the
|
|
70
|
-
hall's chrome inside a page's task is how a sheet stops being reviewable.
|
|
71
|
-
Scoped to this page, with the shared fix filed separately. */
|
|
72
|
-
.tr-filters .kit-tabs { overflow-y: hidden; }
|
|
73
64
|
|
|
74
65
|
/* ---- the rows ------------------------------------------------------------ */
|
|
75
66
|
/* The four short columns are pinned and the two prose ones share what is left,
|
|
@@ -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.
|
|
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.
|
|
9
|
+
"version": "1.19.931",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|