@bongos/core 1.19.928 → 1.19.930
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 +80 -50
- package/docs/copy-inventory.md +36 -15
- package/docs/copy-registry.json +239 -28
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea-objects.js +109 -0
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +11 -0
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +10 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +277 -0
- package/modules/hall-ui/public/thinking.html +74 -0
- package/modules/hall-ui/public/thinking.js +418 -0
- package/modules/hall-ui/public/thinking.states.json +20 -0
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/serve-internal.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/hall_board_world.mjs +32 -0
- package/tests/hall_nav.mjs +1 -1
- package/tests/hall_page_gate_map.mjs +6 -0
- package/tests/hall_thinking_room.mjs +366 -0
- package/tests/helpers.mjs +124 -0
- package/tests/nav_permission_atoms.mjs +1 -1
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Your thinking — {{worldName}}</title>
|
|
7
|
+
<meta name="description" content="Everything you filed and what became of it: what it became, how much work it caused, and whether that work is still alive.">
|
|
8
|
+
<link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
|
|
12
|
+
<link rel="stylesheet" href="/builders/thinking.css">
|
|
13
|
+
</head>
|
|
14
|
+
<!-- THE PAGE THE SPEC CALLS "THE ROOM" (task 1004098 / BV2.R03). The file is
|
|
15
|
+
`thinking` and not `room` because `room.css` is already taken, by the hall's
|
|
16
|
+
READING-ROOM archetype (the primer, diagrams, drachmae — task 1003306,
|
|
17
|
+
ADR 0228), and `board-room` is the government surface. Three unrelated
|
|
18
|
+
rooms in one directory is a collision waiting to be made by whoever next
|
|
19
|
+
links a sheet into a page; the page's own h1 is the design's phrase, so
|
|
20
|
+
nothing about the vocabulary is lost by naming the file after it. -->
|
|
21
|
+
<body data-page="thinking" data-page-title="Your thinking">
|
|
22
|
+
|
|
23
|
+
<div class="app">
|
|
24
|
+
<aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
|
|
25
|
+
<div class="app-scrim" id="app-scrim" hidden></div>
|
|
26
|
+
<div class="app-body">
|
|
27
|
+
<header class="app-topbar" id="app-topbar"></header>
|
|
28
|
+
<div class="app-content">
|
|
29
|
+
<!-- The LABEL voice, like every other working surface: the page's own name
|
|
30
|
+
is the least interesting thing on it (modules/hall-ui/CLAUDE.md, the
|
|
31
|
+
two page-head voices). The room is a working surface, not a document,
|
|
32
|
+
so it takes the board's head and not the reading room's. -->
|
|
33
|
+
<div class="page-head page-head--board">
|
|
34
|
+
<h1 class="page-head__h">Your thinking</h1>
|
|
35
|
+
<p class="page-head__sub" id="tr-sub">Loading what you filed…</p>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<main id="hall-main">
|
|
39
|
+
<section class="scroll" id="tr-scroll" aria-labelledby="tr-h">
|
|
40
|
+
<h2 id="tr-h" class="scroll__h">What you filed, and what became of it</h2>
|
|
41
|
+
<p class="scroll__lede">
|
|
42
|
+
Your own view of your own thinking. Size is the work a thought caused and
|
|
43
|
+
never falls; brightness is how much of that work is still alive — that is
|
|
44
|
+
the part that can go out.
|
|
45
|
+
</p>
|
|
46
|
+
|
|
47
|
+
<!-- The figures, the filters and the rows are all written by
|
|
48
|
+
thinking.js from ONE read, so nothing here can be a second copy of
|
|
49
|
+
a number the server already computed. -->
|
|
50
|
+
<div id="tr-figs" class="tr-figs" role="status" aria-live="polite"></div>
|
|
51
|
+
<div id="tr-filters" class="tr-filters" role="group" aria-label="Show only"></div>
|
|
52
|
+
<div id="tr-body"><p class="tr-note">Loading…</p></div>
|
|
53
|
+
<p class="tr-foot" id="tr-foot" hidden></p>
|
|
54
|
+
</section>
|
|
55
|
+
</main>
|
|
56
|
+
|
|
57
|
+
<footer class="app-foot" id="app-foot"></footer>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
|
|
63
|
+
|
|
64
|
+
<script src="/builders/shell.js?v=2026-09-20-room1"></script>
|
|
65
|
+
<script src="/builders/palette.js" defer></script>
|
|
66
|
+
<script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
|
|
67
|
+
<!-- The filter strip is the kit's tab strip (makeTabs) rather than a fifth
|
|
68
|
+
hand-rolled pill row: it already carries the count pills, the hash sync and
|
|
69
|
+
the narrow-screen scroller this page would otherwise reinvent. -->
|
|
70
|
+
<script src="/builders/hall-kit.js"></script>
|
|
71
|
+
<script src="/builders/idea-objects.js?v=2026-09-20-r03"></script>
|
|
72
|
+
<script src="/builders/thinking.js?v=2026-09-20-r03"></script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// modules/hall-ui/public/thinking.js — THE ROOM (task 1004098 / BV2.R03 of
|
|
2
|
+
// goal 1000095, criterion `ideator-nothing-is-lost`; spec
|
|
3
|
+
// docs/specs/bongos-v2-ideator-surface.md, the agreed shape is
|
|
4
|
+
// docs/design/mocks/ideator-surface/Room.dc.html).
|
|
5
|
+
//
|
|
6
|
+
// "A plain overview of one's own thinking: what was filed, what it became, its
|
|
7
|
+
// size, and whether it still burns." It is deliberately unglamorous — rows, a
|
|
8
|
+
// size, a state — because it is the page a person opens daily and the sky is
|
|
9
|
+
// the ambient one.
|
|
10
|
+
//
|
|
11
|
+
// IT IS THE AUTHOR'S OWN VIEW, and that is spec decision 19 rather than a
|
|
12
|
+
// convenience: the profile's idea trail (profile.js, `activity-ideas`) stays
|
|
13
|
+
// exactly as it is, because it is what SOMEONE ELSE sees on you — three columns,
|
|
14
|
+
// capped, inside an activity log about a person. This page is about the
|
|
15
|
+
// thinking, not about the thinker, so it reads your own trail whole and spends
|
|
16
|
+
// its width on what the profile has no room for: the size and the state. The
|
|
17
|
+
// two surfaces share a READ and nothing else, which is why neither is built out
|
|
18
|
+
// of the other.
|
|
19
|
+
//
|
|
20
|
+
// EVERY NUMBER ON THIS PAGE IS THE SERVER'S. `GET /inbox/by-builder/:id`
|
|
21
|
+
// composes four attachers over each trail row — the descent, what it paid
|
|
22
|
+
// (`size`), which of the five objects it is (`state`) — and this file draws
|
|
23
|
+
// them. It derives no state, compresses no size and counts no brightness:
|
|
24
|
+
// modules/ideas/idea-state.js exists precisely so that the room, the descent
|
|
25
|
+
// and the sky cannot answer the same question three ways (ADR 0322), and the
|
|
26
|
+
// word for each object comes from the one shared table in ./idea-objects.js.
|
|
27
|
+
//
|
|
28
|
+
// WHY IT LOADS THE WHOLE TRAIL AND NOT ONE PAGE OF IT. The four figures over
|
|
29
|
+
// the list — filed / ignited / still burning / burned out — are a claim about a
|
|
30
|
+
// person's whole body of thinking, and computed over a 20-row window they would
|
|
31
|
+
// be a claim about a window while looking exactly like a claim about a life.
|
|
32
|
+
// So the page renders its first window immediately (the read is not cheap: a
|
|
33
|
+
// descent walk and a ledger read per page) and then keeps fetching in the
|
|
34
|
+
// background, re-rendering as it goes, until the trail is exhausted or the cap
|
|
35
|
+
// below is reached. If the cap is reached the foot SAYS the figures cover the
|
|
36
|
+
// newest N — an honest partial beats a silent one.
|
|
37
|
+
|
|
38
|
+
(() => {
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const API = '/api/bongos';
|
|
42
|
+
// baseUrl:'' — this page builds full paths itself through api.request, the
|
|
43
|
+
// way work.js and copy-desk.js do, because the trail read is reached by a
|
|
44
|
+
// path the generated namespaced client does not carry a method for. The two
|
|
45
|
+
// idioms must never be mixed on one page (see idea.js's note on what happens
|
|
46
|
+
// when they are).
|
|
47
|
+
const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
|
|
48
|
+
const { escapeHtml, $, fmtDate } = window.OTB;
|
|
49
|
+
const objects = window.OTBIdeaObjects;
|
|
50
|
+
|
|
51
|
+
const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
|
|
52
|
+
const ideaHref = (id) => `${hallBase()}/idea/${encodeURIComponent(id)}`;
|
|
53
|
+
const taskHref = (id) => `${hallBase()}/task/${encodeURIComponent(id)}`;
|
|
54
|
+
|
|
55
|
+
// The server's own ceiling (modules/ideas/trail.js IDEA_TRAIL_MAX_LIMIT), so
|
|
56
|
+
// the background load takes the fewest round trips the route allows.
|
|
57
|
+
const PAGE = 100;
|
|
58
|
+
// Three pages. A trail longer than this exists — and when it does the foot
|
|
59
|
+
// says so rather than the figures quietly describing the newest 300 as though
|
|
60
|
+
// they described everything.
|
|
61
|
+
const CAP = 300;
|
|
62
|
+
|
|
63
|
+
const state = {
|
|
64
|
+
ideas: [],
|
|
65
|
+
total: 0,
|
|
66
|
+
capped: false,
|
|
67
|
+
loading: false,
|
|
68
|
+
filter: 'everything',
|
|
69
|
+
tabs: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ---- what a row says it became -------------------------------------------
|
|
73
|
+
//
|
|
74
|
+
// The OUTCOME column, and the one place this file composes a sentence rather
|
|
75
|
+
// than printing a field. It is built from the ledger facts the row already
|
|
76
|
+
// carries and never from the state: a satellite says it was folded in because
|
|
77
|
+
// `merged_into_idea_id` is set, not because the resolver called it a
|
|
78
|
+
// satellite — so a disagreement between the two shows up as a visibly odd row
|
|
79
|
+
// instead of being smoothed over into agreement.
|
|
80
|
+
//
|
|
81
|
+
// `descent_tasks` is what the roots went on to cause, and it deliberately
|
|
82
|
+
// EXCLUDES those roots (attachDescentToTrail leaves them out so the first hop
|
|
83
|
+
// is not paid twice), which is why the two counts are added rather than one
|
|
84
|
+
// being read as the whole.
|
|
85
|
+
function becameHtml(idea) {
|
|
86
|
+
if (idea.merged_into_idea_id) {
|
|
87
|
+
const title = idea.merged_into_idea_title || `idea ${idea.merged_into_idea_id}`;
|
|
88
|
+
return `folded into <a href="${ideaHref(idea.merged_into_idea_id)}">${escapeHtml(title)}</a>`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
|
|
92
|
+
const roots = new Set();
|
|
93
|
+
for (const t of lineage) if (t && t.task_id != null) roots.add(String(t.task_id));
|
|
94
|
+
// The legacy column is the only answer for a promotion that predates
|
|
95
|
+
// ideas_task_lineage (the same guard idea.js's descentRows() carries).
|
|
96
|
+
if (roots.size === 0 && idea.promoted_to_task_id != null) roots.add(String(idea.promoted_to_task_id));
|
|
97
|
+
|
|
98
|
+
if (roots.size === 0) {
|
|
99
|
+
if (idea.status === 'discarded') {
|
|
100
|
+
return idea.triage_note
|
|
101
|
+
? `declined — ${escapeHtml(idea.triage_note)}`
|
|
102
|
+
: 'declined at triage';
|
|
103
|
+
}
|
|
104
|
+
return '<span class="tr-quiet">nothing yet</span>';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const bits = [];
|
|
108
|
+
// A single root gets its name and a link, because with one task the title
|
|
109
|
+
// IS the outcome. Several get a count — five titles in a cell is not an
|
|
110
|
+
// overview, which is what this page is.
|
|
111
|
+
if (roots.size === 1) {
|
|
112
|
+
const id = [...roots][0];
|
|
113
|
+
const named = lineage.find((t) => t && String(t.task_id) === id);
|
|
114
|
+
const title = (named && named.title) || idea.promoted_task_title || `task ${id}`;
|
|
115
|
+
bits.push(`<a href="${taskHref(id)}">${escapeHtml(title)}</a>`);
|
|
116
|
+
} else {
|
|
117
|
+
bits.push(`${roots.size} tasks`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const descent = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
|
|
121
|
+
if (descent.length > 0) {
|
|
122
|
+
bits.push(`<span class="tr-quiet">and ${descent.length} more below ${descent.length === 1 ? 'it' : 'them'}</span>`);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The nod is the one ACTIONABLE thing this column can carry: a task sitting
|
|
126
|
+
// at backlog inside a goal is one Metic+ promote from claimable, and the
|
|
127
|
+
// whole point of surfacing position (idea-state.js) was that the queue
|
|
128
|
+
// becomes something a person can see rather than remember to open.
|
|
129
|
+
const pos = objects.positionWord(idea.state);
|
|
130
|
+
if (pos === 'waiting for a nod') bits.push('<span class="tr-nod">waiting for a nod</span>');
|
|
131
|
+
|
|
132
|
+
return bits.join(' · ');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- size ----------------------------------------------------------------
|
|
136
|
+
//
|
|
137
|
+
// The bar's width is the server's `scale` and nothing else. Size is
|
|
138
|
+
// long-tailed, so economy compresses it ONCE at the read
|
|
139
|
+
// (modules/economy/idea-size.js compressIdeaSize) and the direction is
|
|
140
|
+
// explicit that the compressed value is what a drawing uses while the true
|
|
141
|
+
// figure is shown beside it. A second curve here — a log of a log — is
|
|
142
|
+
// exactly how the sky and the room would end up drawing the same thought at
|
|
143
|
+
// two different sizes.
|
|
144
|
+
function sizeHtml(idea) {
|
|
145
|
+
const size = idea.size;
|
|
146
|
+
if (!size || typeof size.credits !== 'number') {
|
|
147
|
+
// `size: null` is "the reward port could not be asked", not "it earned
|
|
148
|
+
// nothing" (trail-size.js). Drawing it as a zero would be a claim.
|
|
149
|
+
return '<span class="tr-quiet" title="the ledger could not be read">unknown</span>';
|
|
150
|
+
}
|
|
151
|
+
if (size.credits <= 0) return '<span class="tr-quiet">—</span>';
|
|
152
|
+
const pct = Math.max(4, Math.round(Number(size.scale || 0) * 100));
|
|
153
|
+
// A size computed from the descent an idea already had, rather than from
|
|
154
|
+
// payouts actually made (spec decision 17). The marker rides wherever the
|
|
155
|
+
// number is shown or the number quietly claims to be money that was spent.
|
|
156
|
+
const mark = size.historical === true
|
|
157
|
+
? '<abbr class="tr-hist" title="computed from the descent this idea already had — not payouts that were made">part historical</abbr>'
|
|
158
|
+
: '';
|
|
159
|
+
// THE BAR KEEPS ITS WIDTH AND LOSES ITS COLOUR WHEN THE WORK IS GONE, which
|
|
160
|
+
// is the agreed shape (Room.dc.html draws the bar in the accent only while
|
|
161
|
+
// `alive > 0`) and the picture the whole two-channel model exists for: a
|
|
162
|
+
// thought that caused a great deal of work the project then tore out reads
|
|
163
|
+
// LARGE AND DEAD, not small. Width is size and never falls; colour is the
|
|
164
|
+
// live channel. Set from the same `state` the object column draws, so the
|
|
165
|
+
// two cells of a row cannot disagree — and only when the removal facts were
|
|
166
|
+
// actually READABLE, because `brightness: null` is "we could not look" and
|
|
167
|
+
// greying a bar on that basis would state something nobody checked.
|
|
168
|
+
const st = idea.state;
|
|
169
|
+
const out = st && st.attributable === true && st.hasWork === true && st.brightness === 0;
|
|
170
|
+
return `<span class="tr-size"${out ? ' data-lit="out"' : ''}>
|
|
171
|
+
<span class="tr-size__bar" style="--w:${pct}%"></span>
|
|
172
|
+
<span class="tr-size__n">${escapeHtml(String(size.credits))}</span>
|
|
173
|
+
</span>${mark}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- the object ----------------------------------------------------------
|
|
177
|
+
//
|
|
178
|
+
// The mark restates in SHAPE what the cell also says in words — the sheet's
|
|
179
|
+
// own rule for the grade badge, and the reason the glyph is not decoration: a
|
|
180
|
+
// mark distinguished only by colour tells a colour-blind reader nothing.
|
|
181
|
+
// Brightness rides the glyph's opacity AND the sentence beside it, so dimness
|
|
182
|
+
// is never the only channel either.
|
|
183
|
+
function objectHtml(idea) {
|
|
184
|
+
const st = idea.state;
|
|
185
|
+
const o = objects.objectFor(st && st.state);
|
|
186
|
+
const bright = objects.brightnessWord(st);
|
|
187
|
+
const pos = objects.positionWord(st);
|
|
188
|
+
// `brightness: null` on a thought that HAS work means the removal facts
|
|
189
|
+
// could not be read. It must not be drawn as a dim star — the glyph stays
|
|
190
|
+
// full and the words carry the doubt.
|
|
191
|
+
const lit = (st && st.hasWork === true && st.attributable === true && typeof st.brightness === 'number')
|
|
192
|
+
? st.brightness
|
|
193
|
+
: 1;
|
|
194
|
+
const sub = bright || pos || o.blurb.split('.')[0];
|
|
195
|
+
return `<span class="tr-obj" data-object="${escapeHtml(String((st && st.state) || 'unknown'))}" style="--lit:${lit.toFixed(2)}">
|
|
196
|
+
<span class="tr-obj__glyph" aria-hidden="true"></span>
|
|
197
|
+
<span class="tr-obj__body">
|
|
198
|
+
<span class="tr-obj__label">${escapeHtml(o.label)}</span>
|
|
199
|
+
<span class="tr-obj__sub">${escapeHtml(sub)}</span>
|
|
200
|
+
</span>
|
|
201
|
+
</span>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function rowHtml(idea) {
|
|
205
|
+
const st = idea.state;
|
|
206
|
+
// NO ID COLUMN, which is the agreed shape (the mock's five columns are the
|
|
207
|
+
// thought, filed, became, size, object) and not only a saving of width: an
|
|
208
|
+
// id is a lookup key, and on the one page where every row is YOURS there is
|
|
209
|
+
// nothing to look up — you know which thought it is by reading it. The id
|
|
210
|
+
// is still one click away, on the record the title links to, and it rides
|
|
211
|
+
// the link's title so it can be copied without leaving.
|
|
212
|
+
return `<tr data-object="${escapeHtml(String((st && st.state) || 'unknown'))}">
|
|
213
|
+
<td class="tr-thought"><a href="${ideaHref(idea.id)}" title="Idea ${escapeHtml(String(idea.id))}">${escapeHtml(idea.title || `Idea ${idea.id}`)}</a></td>
|
|
214
|
+
<td class="ledger__date">${escapeHtml(fmtDate(idea.captured_at))}</td>
|
|
215
|
+
<td class="tr-became">${becameHtml(idea)}</td>
|
|
216
|
+
<td class="tr-sizecell">${sizeHtml(idea)}</td>
|
|
217
|
+
<td class="tr-objcell">${objectHtml(idea)}</td>
|
|
218
|
+
</tr>`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- the figures ---------------------------------------------------------
|
|
222
|
+
//
|
|
223
|
+
// Four counts, each one a plain tally over the rows in hand — no thresholds
|
|
224
|
+
// and no judgement. "filed" is the SERVER's total rather than the loaded
|
|
225
|
+
// length, because that one is knowable in full from the first response even
|
|
226
|
+
// when the rest is still arriving.
|
|
227
|
+
//
|
|
228
|
+
// "ignited" is `hasWork`, which is the resolver's own word for "this has been
|
|
229
|
+
// paid for something" — not a status. An idea can be promoted, sit in a goal,
|
|
230
|
+
// and have ignited nothing; that is the distinction the direction draws and
|
|
231
|
+
// counting statuses here would erase it.
|
|
232
|
+
function figuresHtml() {
|
|
233
|
+
const rows = state.ideas;
|
|
234
|
+
const by = (fn) => rows.filter(fn).length;
|
|
235
|
+
const figs = [
|
|
236
|
+
{ n: state.total, label: 'filed', tone: '' },
|
|
237
|
+
{ n: by((i) => i.state && i.state.hasWork === true), label: 'ignited', tone: '' },
|
|
238
|
+
{ n: by((i) => i.state && i.state.state === 'star'), label: 'still burning', tone: 'warm' },
|
|
239
|
+
{ n: by((i) => i.state && i.state.state === 'remnant'), label: 'burned out', tone: 'dim' },
|
|
240
|
+
];
|
|
241
|
+
return figs.map((f) => `<div class="tr-fig"${f.tone ? ` data-tone="${f.tone}"` : ''}>
|
|
242
|
+
<span class="tr-fig__n">${escapeHtml(String(f.n))}</span>
|
|
243
|
+
<span class="tr-fig__label">${escapeHtml(f.label)}</span>
|
|
244
|
+
</div>`).join('');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ---- the filter strip ----------------------------------------------------
|
|
248
|
+
//
|
|
249
|
+
// The tab ids ARE the five state values, so a deep link is `#remnant` and the
|
|
250
|
+
// strip cannot drift from the vocabulary: the order comes from
|
|
251
|
+
// idea-objects.js ORDER and the labels from its table.
|
|
252
|
+
//
|
|
253
|
+
// A tab whose count is zero is HIDDEN rather than shown empty or disabled.
|
|
254
|
+
// Five dead pills over a new builder's two ideas is noise about objects they
|
|
255
|
+
// do not have; the strip grows as their thinking does. It is done by
|
|
256
|
+
// visibility and not by rebuilding the strip, so the hash of a filter that
|
|
257
|
+
// later empties still resolves instead of throwing.
|
|
258
|
+
function buildTabs() {
|
|
259
|
+
const kit = window.OTBKit;
|
|
260
|
+
const mount = $('#tr-filters');
|
|
261
|
+
if (!kit || !mount || state.tabs) return;
|
|
262
|
+
const tabs = [{ id: 'everything', label: 'everything' }].concat(
|
|
263
|
+
objects.ORDER.map((id) => ({ id, label: objects.objectFor(id).label }))
|
|
264
|
+
);
|
|
265
|
+
state.tabs = kit.makeTabs({
|
|
266
|
+
mount,
|
|
267
|
+
tabs,
|
|
268
|
+
hash: true,
|
|
269
|
+
// syncTabs BEFORE the rows, and not only on a load: the "never hide the
|
|
270
|
+
// filter the reader is looking through" rule is a property of the
|
|
271
|
+
// CURRENT filter, so it has to be re-applied whenever that changes. A
|
|
272
|
+
// filter can be selected without a load — the strip syncs its hash, so
|
|
273
|
+
// `#remnant` on a trail with no remnants arrives here directly — and
|
|
274
|
+
// painting rows alone would leave the reader on an empty list whose
|
|
275
|
+
// control had just been hidden.
|
|
276
|
+
onChange: (id) => { state.filter = id; syncTabs(); paintRows(); },
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function syncTabs() {
|
|
281
|
+
if (!state.tabs) return;
|
|
282
|
+
const counts = { everything: state.ideas.length };
|
|
283
|
+
for (const id of objects.ORDER) counts[id] = 0;
|
|
284
|
+
for (const i of state.ideas) {
|
|
285
|
+
const k = i.state && i.state.state;
|
|
286
|
+
if (k in counts) counts[k] += 1;
|
|
287
|
+
}
|
|
288
|
+
for (const id of Object.keys(counts)) {
|
|
289
|
+
state.tabs.setCount(id, counts[id]);
|
|
290
|
+
const btn = state.tabs.el.querySelector(`[data-tab="${window.CSS.escape(id)}"]`);
|
|
291
|
+
// The active filter is never hidden, even at zero: hiding the control a
|
|
292
|
+
// reader is currently looking through would leave them staring at an
|
|
293
|
+
// empty list with no way back to everything.
|
|
294
|
+
if (btn) btn.hidden = counts[id] === 0 && id !== 'everything' && id !== state.filter;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---- painting ------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
function visibleIdeas() {
|
|
301
|
+
if (state.filter === 'everything') return state.ideas;
|
|
302
|
+
return state.ideas.filter((i) => i.state && i.state.state === state.filter);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function paintRows() {
|
|
306
|
+
const rows = visibleIdeas();
|
|
307
|
+
const body = $('#tr-body');
|
|
308
|
+
if (rows.length === 0) {
|
|
309
|
+
body.innerHTML = state.ideas.length === 0
|
|
310
|
+
? `<p class="tr-empty">You have not filed anything yet. <a href="${hallBase()}/ideas">File a thought</a> — it takes one line.</p>`
|
|
311
|
+
: `<p class="tr-empty">Nothing of yours is ${escapeHtml(objects.objectFor(state.filter).label)} right now.</p>`;
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
body.innerHTML = `<table class="ledger tr-table">
|
|
315
|
+
<thead><tr>
|
|
316
|
+
<th>The thought</th>
|
|
317
|
+
<th class="ledger__date">Filed</th>
|
|
318
|
+
<th>Became</th>
|
|
319
|
+
<th>Size</th>
|
|
320
|
+
<th>Object</th>
|
|
321
|
+
</tr></thead>
|
|
322
|
+
<tbody>${rows.map(rowHtml).join('')}</tbody>
|
|
323
|
+
</table>`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function paint() {
|
|
327
|
+
$('#tr-figs').innerHTML = figuresHtml();
|
|
328
|
+
syncTabs();
|
|
329
|
+
paintRows();
|
|
330
|
+
const foot = $('#tr-foot');
|
|
331
|
+
const bits = [];
|
|
332
|
+
if (state.loading) bits.push(`Reading the rest of your trail — ${state.ideas.length} of ${state.total} so far.`);
|
|
333
|
+
else if (state.capped) bits.push(`Showing your newest ${state.ideas.length} of ${state.total}; the figures above count those.`);
|
|
334
|
+
if (!state.loading && !state.capped && state.ideas.length > 0) {
|
|
335
|
+
bits.push('Size never falls. Brightness is how much of the work a thought caused is still alive, and that is the part that can go out.');
|
|
336
|
+
}
|
|
337
|
+
foot.innerHTML = bits.map((b) => escapeHtml(b)).join(' ');
|
|
338
|
+
foot.hidden = bits.length === 0;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function subLine() {
|
|
342
|
+
const sub = $('#tr-sub');
|
|
343
|
+
if (!sub) return;
|
|
344
|
+
sub.textContent = state.total === 0
|
|
345
|
+
? 'Nothing filed yet'
|
|
346
|
+
: `${state.total} thought${state.total === 1 ? '' : 's'} filed`;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---- the read ------------------------------------------------------------
|
|
350
|
+
|
|
351
|
+
async function fetchPage(builderId, offset) {
|
|
352
|
+
const r = await api.request('GET',
|
|
353
|
+
`${API}/inbox/by-builder/${encodeURIComponent(builderId)}?limit=${PAGE}&offset=${offset}`);
|
|
354
|
+
if (r.status === 401) { window.location.assign(`${API}/auth/web/start`); return null; }
|
|
355
|
+
if (!r.ok || !r.data) throw new Error(`idea trail → ${r.status}`);
|
|
356
|
+
return r.data;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function fail(msg) {
|
|
360
|
+
$('#tr-body').innerHTML = `<p class="tr-empty">${escapeHtml(msg)}</p>`;
|
|
361
|
+
$('#tr-foot').hidden = true;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function main() {
|
|
365
|
+
// Who is looking. The page is the AUTHOR's own view, so the id is the
|
|
366
|
+
// viewer's and never a query parameter: /thinking?builder=41 would be a
|
|
367
|
+
// second, worse profile page, and the profile already exists.
|
|
368
|
+
const me = await api.request('GET', `${API}/me`);
|
|
369
|
+
if (me.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
|
|
370
|
+
if (!me.ok || !me.data || !me.data.builder) return fail('Could not work out who you are — try signing in again.');
|
|
371
|
+
const builderId = me.data.builder.id;
|
|
372
|
+
|
|
373
|
+
let first;
|
|
374
|
+
try {
|
|
375
|
+
first = await fetchPage(builderId, 0);
|
|
376
|
+
} catch (_) {
|
|
377
|
+
return fail('Could not read your idea trail.');
|
|
378
|
+
}
|
|
379
|
+
if (!first) return;
|
|
380
|
+
|
|
381
|
+
state.ideas = Array.isArray(first.ideas) ? first.ideas : [];
|
|
382
|
+
state.total = Number(first.total) || state.ideas.length;
|
|
383
|
+
state.loading = state.ideas.length < state.total && state.ideas.length < CAP;
|
|
384
|
+
subLine();
|
|
385
|
+
buildTabs();
|
|
386
|
+
paint();
|
|
387
|
+
|
|
388
|
+
// The rest, in the background. A failure here leaves the first window
|
|
389
|
+
// rendered and says so in the foot rather than blanking a page that is
|
|
390
|
+
// already useful — the same posture idea.js takes with its /me read.
|
|
391
|
+
while (state.loading) {
|
|
392
|
+
let next;
|
|
393
|
+
try {
|
|
394
|
+
next = await fetchPage(builderId, state.ideas.length);
|
|
395
|
+
} catch (_) {
|
|
396
|
+
state.loading = false;
|
|
397
|
+
state.capped = true;
|
|
398
|
+
break;
|
|
399
|
+
}
|
|
400
|
+
if (!next) return;
|
|
401
|
+
const batch = Array.isArray(next.ideas) ? next.ideas : [];
|
|
402
|
+
state.total = Number(next.total) || state.total;
|
|
403
|
+
if (batch.length === 0) { state.loading = false; break; }
|
|
404
|
+
state.ideas = state.ideas.concat(batch);
|
|
405
|
+
if (state.ideas.length >= CAP && state.ideas.length < state.total) {
|
|
406
|
+
state.capped = true;
|
|
407
|
+
state.loading = false;
|
|
408
|
+
} else if (state.ideas.length >= state.total) {
|
|
409
|
+
state.loading = false;
|
|
410
|
+
}
|
|
411
|
+
subLine();
|
|
412
|
+
paint();
|
|
413
|
+
}
|
|
414
|
+
paint();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
main();
|
|
418
|
+
})();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_": "THE ROOM (task 1004098 / BV2.R03) through modules/ui-design/kit/render.js. Every row 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 page, 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 an unreadable brightness:\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/thinking.states.json --base-in http://localhost:4615 --base-out http://localhost:4615",
|
|
3
|
+
"page": "/builders/thinking",
|
|
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
|
+
"room": { "auth": true, "actions": [["wait", 900]], "expect": { "visible": ["#tr-figs", "#tr-body"] } },
|
|
11
|
+
"burning": {
|
|
12
|
+
"auth": true,
|
|
13
|
+
"_": "The filter strip is the page's only control, and the state it selects is the one a person opens this page for. clickIfVisible and not click: the tab is HIDDEN when the trail carries no stars, which is exactly what the kit's own stub produces, so a bare click would fail the state rather than render it.",
|
|
14
|
+
"actions": [["wait", 900], ["clickIfVisible", "[data-tab=\"star\"]"], ["wait", 200]],
|
|
15
|
+
"expect": { "visible": ["#tr-body"] }
|
|
16
|
+
},
|
|
17
|
+
"out": { "auth": false, "actions": [["wait", 600]] }
|
|
18
|
+
},
|
|
19
|
+
"reducedMotion": { "auth": true, "actions": [["wait", 900]] }
|
|
20
|
+
}
|
|
@@ -162,7 +162,7 @@
|
|
|
162
162
|
|
|
163
163
|
<!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
|
|
164
164
|
chrome exists before the page script runs. -->
|
|
165
|
-
<script src="/builders/shell.js?v=2026-09-20-
|
|
165
|
+
<script src="/builders/shell.js?v=2026-09-20-room1"></script>
|
|
166
166
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
167
167
|
<script src="/builders/palette.js" defer></script>
|
|
168
168
|
<!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
|
|
@@ -151,7 +151,7 @@
|
|
|
151
151
|
|
|
152
152
|
<!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
|
|
153
153
|
time so the chrome exists before the deferred page scripts execute. -->
|
|
154
|
-
<script src="/builders/shell.js?v=2026-09-20-
|
|
154
|
+
<script src="/builders/shell.js?v=2026-09-20-room1"></script>
|
|
155
155
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
156
156
|
<script src="/builders/palette.js" defer></script>
|
|
157
157
|
<!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.930",
|
|
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.930",
|
|
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.
|
|
3
|
+
"version": "1.19.930",
|
|
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",
|
|
@@ -1154,8 +1154,14 @@ function mountInternalSurfaces(app) {
|
|
|
1154
1154
|
// shells — and matched no gate at all. The shell carries no data and every
|
|
1155
1155
|
// read behind it is gated server-side, so this closed an inconsistency rather
|
|
1156
1156
|
// than an exposure; the point is that one URL cannot be two tiers at once.
|
|
1157
|
+
// task 1004098 (BV2.R03): `thinking` — THE ROOM, a builder's own idea trail —
|
|
1158
|
+
// joins the tier `ideas` sits on, for the same settled reason: there is no
|
|
1159
|
+
// permission gate on having a thought. It needs no floor ABOVE sign-in either,
|
|
1160
|
+
// because the page reads the VIEWER's own trail and nobody else's — the id it
|
|
1161
|
+
// asks for comes from GET /me, never from the URL, so a rank could protect
|
|
1162
|
+
// nothing here that the session does not already.
|
|
1157
1163
|
const SIGNEDIN_BUILDERS_PAGE_RE =
|
|
1158
|
-
/^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1164
|
+
/^\/builders\/(?:work|settings|atlas|modules|goals|roadmap|copy-desk|studio|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
|
|
1159
1165
|
// The diagram image assets under /builders/diagrams/ ride the same tier as the
|
|
1160
1166
|
// page that embeds them.
|
|
1161
1167
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.930'; // 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');
|
|
@@ -470,6 +470,38 @@ test('the tab strip is left alone — it scrolls in its own row and never moved
|
|
|
470
470
|
const strip = /\.setup__tabs\s*\{([^}]*)\}/.exec(styleCss) || /\.kit-tabs\s*\{([^}]*)\}/.exec(styleCss);
|
|
471
471
|
assert.ok(strip, 'the tab strip rule is present');
|
|
472
472
|
assert.match(strip[1], /overflow-x:\s*auto/, 'it scrolls itself, which is why it is not the overflow cause');
|
|
473
|
+
|
|
474
|
+
// …and the OTHER axis is named too (task 1004130). Per the overflow spec a
|
|
475
|
+
// non-`visible` value on one axis computes the other to `auto`, so the line
|
|
476
|
+
// above alone made the strip a vertical scroll container as well — and
|
|
477
|
+
// `.setup__tab`'s `margin-bottom: -1px` overflowed it by exactly 1px, so
|
|
478
|
+
// every hall page with tabs grew a vertical scrollbar that could not scroll.
|
|
479
|
+
// The regression is invisible on an overlay-scrollbar platform, which is why
|
|
480
|
+
// it is pinned here rather than left to be spotted in a screenshot.
|
|
481
|
+
assert.match(strip[1], /overflow-y:\s*hidden/,
|
|
482
|
+
'the strip pins overflow-y so the spec cannot promote it to a second scroller');
|
|
483
|
+
|
|
484
|
+
// `visible` would NOT do: beside an `auto` it is the value the spec rewrites,
|
|
485
|
+
// which is the bug itself. Only a value that suppresses the axis is legal.
|
|
486
|
+
assert.doesNotMatch(strip[1], /overflow-y:\s*(visible|auto|scroll)\b/,
|
|
487
|
+
'overflow-y: visible/auto/scroll would put the stray scrollbar straight back');
|
|
488
|
+
|
|
489
|
+
// The 1px that overflows is the underline sitting ON the strip's hairline —
|
|
490
|
+
// the intended look, and the reason the axis is suppressed instead of the
|
|
491
|
+
// margin being removed.
|
|
492
|
+
const tab = rule(styleCss, '.setup__tab');
|
|
493
|
+
assert.ok(tab, '.setup__tab rule present in style.css');
|
|
494
|
+
assert.match(tab, /margin-bottom:\s*-1px/,
|
|
495
|
+
'the tab underline still overlaps the strip hairline — the overflow-y pin is what makes that free');
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
// The page-local workaround the room carried while the shared rule was broken
|
|
499
|
+
// is gone with it (task 1004130, done-when 3). A stale duplicate would be a
|
|
500
|
+
// comment in the tree asserting a defect that no longer exists.
|
|
501
|
+
test('the room no longer carries its own copy of the tab-strip fix', () => {
|
|
502
|
+
const thinkingCss = read('thinking.css');
|
|
503
|
+
assert.doesNotMatch(thinkingCss, /\.kit-tabs\s*\{[^}]*overflow-y/,
|
|
504
|
+
'thinking.css must not restate overflow-y on .kit-tabs — style.css owns it now');
|
|
473
505
|
});
|
|
474
506
|
|
|
475
507
|
// ---- the board's filters drive BOTH queue tabs (task 1003828) ---------------
|
package/tests/hall_nav.mjs
CHANGED
|
@@ -258,7 +258,7 @@ test('sidebar groups read by audience, in order', () => {
|
|
|
258
258
|
// task 1003455 (goal 1000089): 'blockers' joins the same group, after Tasks —
|
|
259
259
|
// its subject is the project (what's stuck on the owner), not a person or a
|
|
260
260
|
// craft, and its one data source (GET /blockers) is public like Roadmap's.
|
|
261
|
-
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'blockers', 'board-room', 'ideas', 'copy-desk', 'studio'],
|
|
261
|
+
assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'blockers', 'board-room', 'ideas', 'thinking', 'copy-desk', 'studio'],
|
|
262
262
|
'the unlabeled group is the project itself (task 1002425 order)');
|
|
263
263
|
assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
|
|
264
264
|
// People (task 1002577): the /scouting directory graduated into the hall.
|
|
@@ -60,6 +60,12 @@ const ORACLE = {
|
|
|
60
60
|
// themselves server-side.
|
|
61
61
|
'/builders/studio': 'requireBuilderPage',
|
|
62
62
|
'/builders/ideas': 'requireBuilderPage',
|
|
63
|
+
// THE ROOM (task 1004098 / BV2.R03) — a builder's own idea trail. Sign-in and
|
|
64
|
+
// no rank floor, the tier /ideas sits on: there is no permission gate on
|
|
65
|
+
// having a thought, and the page reads the VIEWER's own trail (the id comes
|
|
66
|
+
// from GET /me, never from the URL), so a rank could protect nothing the
|
|
67
|
+
// session does not already.
|
|
68
|
+
'/builders/thinking': 'requireBuilderPage',
|
|
63
69
|
'/builders/collab': 'requireBuilderPage',
|
|
64
70
|
// task 1002710: the orientation reading rooms moved DOWN to this tier from a
|
|
65
71
|
// thetes+ gate. They are the onboarding documents, so gating them above xenos
|