@bongos/core 1.19.299 → 1.19.301

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,89 @@
1
+ /* modules/hall-ui/public/idea.css — the idea-detail page (task 1002929 / R153
2
+ of goal 1000062).
3
+
4
+ Promoted out of an inline <style> because tests/hall_audit.mjs budgets an inline
5
+ block at 15 lines and this is ~60: past that the documented pattern (task 2176)
6
+ is a per-page stylesheet. The exceptions in that budget map are grandfathered or
7
+ deliberate; a brand-new page has no business claiming one.
8
+
9
+ Built on the hall's tokens. No --line/--panel: neither is defined in style.css,
10
+ so a fallback is all you would ever get and both assume a dark ground the hall
11
+ does not have by default (--bg is #f5f6f8). */
12
+
13
+ /* Page-local, like task.html (task 751) — this page adds a handful of shapes
14
+ and reuses the hall's tokens for everything else. No --line/--panel here:
15
+ neither is defined in style.css, so a fallback is all you would ever get
16
+ and both assume a dark ground the hall does not have by default. */
17
+
18
+ .idea-detail__back { font-size: 13px; color: var(--ink-soft); text-decoration: none; display: inline-block; margin-bottom: 10px; }
19
+ .idea-detail__back:hover { color: var(--ink); text-decoration: underline; }
20
+
21
+ /* The grade is the first thing to read: it decides what the rest of the page
22
+ even contains. Three states, distinguished by colour AND by word — a badge
23
+ that only differs by hue is unreadable to a chunk of readers. */
24
+ .ig-grade {
25
+ display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 10px;
26
+ border-radius: 999px; font-size: 12px; font-weight: 600;
27
+ background: var(--surface-2); border: 1px solid var(--border); color: var(--ink-soft);
28
+ }
29
+ .ig-grade--full { background: var(--ok-weak); color: var(--ok); border-color: color-mix(in srgb, var(--ok) 30%, transparent); }
30
+ .ig-grade--spark { background: var(--warn-weak); color: var(--warn); border-color: color-mix(in srgb, var(--warn) 30%, transparent); }
31
+ .ig-grade--quick { background: var(--accent-weak); color: var(--accent-text); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
32
+
33
+ .ig-head { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: center; margin-bottom: 4px; }
34
+ .ig-title { margin: 0; font-size: 1.15rem; text-wrap: balance; }
35
+
36
+ .ig-meta {
37
+ font-size: 13px; color: var(--ink-soft); margin: 0 0 16px;
38
+ display: flex; flex-wrap: wrap; gap: 4px 14px; align-items: baseline;
39
+ }
40
+ .ig-meta strong { color: var(--ink); font-weight: 600; }
41
+
42
+ /* Attribution is its own block, not a line in the meta run: on a developed
43
+ idea it carries TWO people and the whole point of R146 was that both
44
+ survive. Burying that in a comma list is how one of them gets lost. */
45
+ .ig-credit {
46
+ display: grid; gap: 6px;
47
+ padding: 12px 14px; margin: 0 0 18px;
48
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
49
+ background: var(--surface-2); font-size: 13px;
50
+ }
51
+ .ig-credit__row { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
52
+ .ig-credit__who { color: var(--ink); font-weight: 600; }
53
+ .ig-credit__role { color: var(--ink-faint); }
54
+
55
+ /* The five answers, laid out to be READ: one column, generous leading, a
56
+ measure that stops around 70 characters. */
57
+ .ig-answers { display: grid; gap: 18px; margin: 4px 0 0; }
58
+ .ig-answer__q { margin: 0 0 4px; font-size: 0.92rem; font-weight: 600; }
59
+ .ig-answer__a { margin: 0; max-width: 70ch; line-height: 1.65; white-space: pre-wrap; }
60
+ .ig-answer__a[data-empty="1"] { color: var(--ink-faint); font-style: italic; }
61
+
62
+ .ig-score { display: flex; align-items: center; gap: 10px; margin: 0 0 6px; }
63
+ .ig-score__track { width: 140px; height: 8px; border-radius: 4px; background: var(--border); overflow: hidden; }
64
+ .ig-score__fill { height: 100%; border-radius: 4px; background: var(--accent); }
65
+ .ig-score__fill[data-band="full"] { background: var(--ok); }
66
+ .ig-score__fill[data-band="part"] { background: var(--warn); }
67
+ .ig-score__fill[data-band="thin"] { background: var(--danger); }
68
+ .ig-score__n { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink-soft); }
69
+
70
+ .ig-body { max-width: 70ch; line-height: 1.65; white-space: pre-wrap; margin: 4px 0 0; }
71
+ .ig-empty { color: var(--ink-faint); font-size: 0.9rem; }
72
+ .ig-note { font-size: 0.82rem; color: var(--ink-faint); line-height: 1.55; margin: 14px 0 0; }
73
+ .ig-sub { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); }
74
+ .ig-sub__h { margin: 0 0 8px; font-size: 0.95rem; }
75
+
76
+ /* ---- the mark control (task 1002930 / R154) -----------------------------
77
+ Author-only, and rendered as its own block rather than a button in a toolbar:
78
+ offering your idea up for someone else to develop is a decision, and the one
79
+ sentence explaining what it means to you is the part that makes it one. */
80
+ .ig-mark {
81
+ margin-top: 20px;
82
+ padding: 14px;
83
+ border: 1px solid var(--border);
84
+ border-radius: var(--radius-sm);
85
+ background: var(--surface-2);
86
+ }
87
+ .ig-mark__lede { margin: 0 0 10px; font-size: 13px; color: var(--ink-soft); max-width: 62ch; line-height: 1.55; }
88
+ .ig-mark__err { margin: 8px 0 0; font-size: 13px; color: var(--danger); }
89
+ .ig-mark__err:empty { display: none; }
@@ -0,0 +1,55 @@
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>Idea — {{worldName}}</title>
7
+ <meta name="description" content="One idea, read end to end: its grade, its five answers, how complete they are, who had the thought and who developed it.">
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 href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
12
+ <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
13
+ <link rel="stylesheet" href="/builders/idea.css">
14
+ </head>
15
+ <body data-page="idea" data-page-title="Idea">
16
+
17
+ <div class="app">
18
+ <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
19
+ <div class="app-scrim" id="app-scrim" hidden></div>
20
+ <div class="app-body">
21
+ <header class="app-topbar" id="app-topbar"></header>
22
+ <div class="app-content">
23
+ <div class="page-head">
24
+ <!-- This page is deliberately not in the nav (it is always reached from a
25
+ link), so a deep-linked visitor needs a way back before the body loads
26
+ — the task.html precedent. -->
27
+ <a class="idea-detail__back" href="/ideas">← Back to Ideas</a>
28
+ <p class="page-head__sub" id="idea-sub">Loading idea…</p>
29
+ </div>
30
+
31
+ <main>
32
+ <section class="scroll" id="idea-scroll" aria-labelledby="idea-h">
33
+ <div id="idea-body">
34
+ <p class="deeplink-card__title" id="idea-h">Loading…</p>
35
+ </div>
36
+ </section>
37
+ </main>
38
+
39
+ <footer class="app-foot" id="app-foot"></footer>
40
+ </div>
41
+ </div>
42
+ </div>
43
+
44
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
45
+
46
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
47
+ <script src="/builders/palette.js" defer></script>
48
+ <script src="/builders/dom-utils.js?v=2026-06-23-contrib" defer></script>
49
+ <!-- task-detail.js still owns the #/idea/N and #/blocker/N overlay, so a ref
50
+ written inside an idea's own prose keeps resolving from this page. -->
51
+ <script src="/builders/task-detail.js?v=2026-07-03-taskdetail2" defer></script>
52
+ <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
53
+ <script src="/builders/idea.js?v=2026-08-22-r153" defer></script>
54
+ </body>
55
+ </html>
@@ -0,0 +1,289 @@
1
+ // modules/hall-ui/public/idea.js — the idea-detail PAGE (/idea/:id).
2
+ // Task 1002929 / BV1.R153 of goal 1000062 (three grades of idea).
3
+ //
4
+ // A real page rather than a wider modal, following task 751: that task replaced
5
+ // the #/task/N overlay with /task/:id so a record got a shareable URL and working
6
+ // back/forward. A Full Idea is five prose answers — the thing an overlay is worst
7
+ // at — so it earns the same treatment.
8
+ //
9
+ // THE OVERLAY IS NOT REPLACED. task-detail.js keeps owning #/idea/N, because ~320
10
+ // of its ~650 lines are the Metic+ triage panel (promote → POST /tasks, merge,
11
+ // discard, with goal hints and version pickers). Moving that machinery is its own
12
+ // task; breaking a shipped triage flow to make a read prettier would be a bad
13
+ // trade. So the overlay stays the fast peek and links here, and this page stays a
14
+ // READ — the summary→detail relationship the hall already has between the Work
15
+ // board and /task/:id.
16
+ //
17
+ // WHAT THE GRADE DECIDES. The grade is not decoration on this page, it selects
18
+ // what the page contains:
19
+ // full → the five answers, each under its own question, plus the score
20
+ // spark → the body, plus "someone is invited to develop this"
21
+ // quick → the body, and NOTHING about the five fields
22
+ // The done-when names that last case explicitly: a quick idea must render with no
23
+ // empty five-field skeleton. Five headings over five dashes is not an empty
24
+ // state, it is a page telling a builder they did something wrong by filing a
25
+ // quick idea — which is the opposite of what a quick idea is for.
26
+ //
27
+ // The five QUESTIONS come from GET /inbox/template, not from this file, for the
28
+ // same reason the composer's do (R152): the labels live in the R141 declaration,
29
+ // and a copy here would silently ignore a per-project template.
30
+ //
31
+ // THE MARK CONTROL (task 1002930 / R154) is the one WRITE on this page, and it is
32
+ // AUTHOR-ONLY — a done-when, and the server enforces it regardless (R144 checks
33
+ // captured_by and no rank overrides it). Hiding it from a non-author is therefore
34
+ // cosmetic, exactly like the hall's nav gates: the wall is the route. It renders
35
+ // only on an OPEN quick-or-spark idea, because those are the only states R144
36
+ // accepts — showing a button that is guaranteed to 409 teaches the rule wrong.
37
+
38
+ (() => {
39
+ 'use strict';
40
+
41
+ const API = '/api/gds';
42
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
43
+ const { escapeHtml, $, fmtDate } = window.OTB;
44
+
45
+ const ideaId = () => {
46
+ const m = /\/idea\/(\d+)/.exec(location.pathname);
47
+ return m ? m[1] : null;
48
+ };
49
+
50
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // pieces
54
+ // ---------------------------------------------------------------------------
55
+
56
+ // Colour AND word. A badge distinguished only by hue tells a colour-blind
57
+ // reader nothing, and this badge decides how to read the whole page.
58
+ const GRADE_WORD = { full: 'Full Idea', spark: 'Big Idea Spark', quick: 'Quick idea' };
59
+
60
+ function gradeBadge(grade) {
61
+ const g = GRADE_WORD[grade] ? grade : 'quick';
62
+ return `<span class="ig-grade ig-grade--${escapeHtml(g)}">${escapeHtml(GRADE_WORD[g])}</span>`;
63
+ }
64
+
65
+ function scoreRow(score) {
66
+ if (!Number.isInteger(score)) {
67
+ // A Full Idea with no score is a real state (R142 returns null for
68
+ // anything it cannot score, and that is stored as-is). Say so rather than
69
+ // drawing an empty bar that reads as zero.
70
+ return '<p class="ig-empty">Not scored.</p>';
71
+ }
72
+ const band = score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin';
73
+ return `
74
+ <div class="ig-score">
75
+ <span class="ig-score__track"><span class="ig-score__fill" data-band="${band}" style="width:${score}%"></span></span>
76
+ <span class="ig-score__n">${score}/100 complete</span>
77
+ </div>`;
78
+ }
79
+
80
+ // Who had the thought, and who wrote it out. Two rows when they are two people
81
+ // — R146's whole point is that the author survives being developed by someone
82
+ // else, so this never collapses them into one line.
83
+ function credit(idea) {
84
+ const who = (login, id) => (login ? `@${escapeHtml(login)}` : (id ? `builder ${escapeHtml(String(id))}` : 'someone'));
85
+ const rows = [];
86
+ rows.push(`
87
+ <div class="ig-credit__row">
88
+ <span class="ig-credit__who">${who(idea.filed_by_login, idea.captured_by)}</span>
89
+ <span class="ig-credit__role">had the thought${idea.captured_at ? ` · ${escapeHtml(fmtDate(idea.captured_at))}` : ''}</span>
90
+ </div>`);
91
+ if (idea.developed_by) {
92
+ const same = String(idea.developed_by) === String(idea.captured_by);
93
+ rows.push(`
94
+ <div class="ig-credit__row">
95
+ <span class="ig-credit__who">${who(idea.developed_by_login, idea.developed_by)}</span>
96
+ <span class="ig-credit__role">${same ? 'developed it themselves' : 'developed it into a Full Idea'}${idea.developed_at ? ` · ${escapeHtml(fmtDate(idea.developed_at))}` : ''}</span>
97
+ </div>`);
98
+ }
99
+ return `<div class="ig-credit">${rows.join('')}</div>`;
100
+ }
101
+
102
+ function prose(text) {
103
+ const s = String(text == null ? '' : text).trim();
104
+ return s
105
+ ? `<p class="ig-body">${escapeHtml(s)}</p>`
106
+ : '<p class="ig-body" data-empty="1">No description.</p>';
107
+ }
108
+
109
+ // The five answers, in TEMPLATE order, each under its own question. Missing
110
+ // answers are marked rather than hidden: on a Full Idea a blank answer is
111
+ // information (it is why the score is what it is).
112
+ function answers(idea, template) {
113
+ const values = (idea.field_values && typeof idea.field_values === 'object') ? idea.field_values : {};
114
+ return `<div class="ig-answers">${template.fields.map((f) => {
115
+ const v = String(values[f.key] == null ? '' : values[f.key]).trim();
116
+ return `
117
+ <div class="ig-answer">
118
+ <p class="ig-answer__q">${escapeHtml(f.label)}</p>
119
+ <p class="ig-answer__a"${v ? '' : ' data-empty="1"'}>${v ? escapeHtml(v) : 'Not answered.'}</p>
120
+ </div>`;
121
+ }).join('')}</div>`;
122
+ }
123
+
124
+ // ---- the R154 mark control -------------------------------------------------
125
+
126
+ // Rendered only when the viewer IS the author. The server re-checks; this is
127
+ // the same cosmetic-gate posture as shell.js's nav.
128
+ function markControl(idea, viewer) {
129
+ const isAuthor = viewer && idea.captured_by != null
130
+ && String(viewer.id) === String(idea.captured_by);
131
+ if (!isAuthor) return '';
132
+ if (idea.status !== 'open') return '';
133
+ if (idea.grade === 'full') return '';
134
+ const sparked = idea.grade === 'spark';
135
+ return `
136
+ <div class="ig-mark">
137
+ <p class="ig-mark__lede">${sparked
138
+ ? 'You have offered this up for development. Anyone can turn it into a Full Idea; you keep the credit for having had it.'
139
+ : 'Not sure you have the full picture? Offer it up and someone else can develop it into a Full Idea — you keep the credit for the thought.'}</p>
140
+ <button type="button" class="btn${sparked ? ' btn--ghost' : ''}" id="ig-mark-btn">${sparked ? 'Withdraw the spark mark' : 'Mark as a Big Idea Spark'}</button>
141
+ <p class="ig-mark__err" id="ig-mark-err" role="alert"></p>
142
+ </div>`;
143
+ }
144
+
145
+ async function toggleMark(idea) {
146
+ const btn = $('#ig-mark-btn');
147
+ const err = $('#ig-mark-err');
148
+ if (!btn) return;
149
+ // Which direction to go is read from the IDEA, not from a data- attribute on
150
+ // the button. The idea is already in hand, so a DOM round-trip would be a
151
+ // second copy of the same fact — and the one that goes stale first, since the
152
+ // grade is what the server actually returns.
153
+ const sparked = idea.grade === 'spark';
154
+ btn.disabled = true;
155
+ err.textContent = '';
156
+ const r = sparked
157
+ ? await api.inbox.deleteInboxIdSpark({ id: idea.id })
158
+ : await api.inbox.postInboxIdSpark({ id: idea.id, body: {} });
159
+ if (!r.ok) {
160
+ btn.disabled = false;
161
+ // The server's own words, per code — a generic failure here would hide the
162
+ // one thing worth knowing (that the rule is about authorship, or state).
163
+ const e = r.data && r.data.error;
164
+ const code = e && typeof e === 'object' ? e.code : e;
165
+ err.textContent = code === 'not_your_idea'
166
+ ? 'Only the builder who filed this idea can offer it up for development.'
167
+ : code === 'not_a_quick_idea'
168
+ ? 'This is already a Full Idea — there is nothing to hand off.'
169
+ : code === 'idea_not_open'
170
+ ? 'This idea is no longer open, so it cannot be marked.'
171
+ : `Could not change the mark (${r.status}).`;
172
+ return;
173
+ }
174
+ // Re-render from what the server returned rather than toggling a local flag:
175
+ // the response is the authority on the new grade, and R144 is idempotent so a
176
+ // no-op comes back as the same truth.
177
+ const next = (r.data && r.data.idea) ? r.data.idea : idea;
178
+ window.OTB.toast(next.grade === 'spark' ? 'Offered up for development.' : 'Mark withdrawn.');
179
+ boot({ ...idea, ...next });
180
+ }
181
+
182
+ function statusLine(idea) {
183
+ const bits = [];
184
+ bits.push(`<span><strong>${escapeHtml(idea.status || 'open')}</strong></span>`);
185
+ if (idea.kind) bits.push(`<span>${escapeHtml(idea.kind)}</span>`);
186
+ if (idea.promoted_to_task_id) {
187
+ bits.push(`<span>became <a href="${hallBase()}/task/${encodeURIComponent(idea.promoted_to_task_id)}">task ${escapeHtml(String(idea.promoted_to_task_id))}</a></span>`);
188
+ }
189
+ if (idea.merged_into_idea_id) {
190
+ bits.push(`<span>merged into <a href="${hallBase()}/idea/${encodeURIComponent(idea.merged_into_idea_id)}">idea ${escapeHtml(String(idea.merged_into_idea_id))}</a></span>`);
191
+ }
192
+ return `<p class="ig-meta">${bits.join('')}</p>`;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // render
197
+ // ---------------------------------------------------------------------------
198
+
199
+ // Cached across a re-render: the template and the viewer do not change when the
200
+ // grade does, and re-fetching them on every mark toggle would be two needless
201
+ // requests plus a visible flicker.
202
+ const state = { template: null, viewer: null };
203
+
204
+ function render(idea, template, viewer) {
205
+ const grade = GRADE_WORD[idea.grade] ? idea.grade : 'quick';
206
+ const parts = [];
207
+
208
+ parts.push(`<div class="ig-head">${gradeBadge(grade)}</div>`);
209
+ parts.push(`<h1 class="ig-title" id="idea-h">${escapeHtml(idea.title || `Idea ${idea.id}`)}</h1>`);
210
+ parts.push(statusLine(idea));
211
+ parts.push(credit(idea));
212
+
213
+ if (grade === 'full') {
214
+ parts.push(scoreRow(idea.completeness_score));
215
+ if (template) {
216
+ parts.push(answers(idea, template));
217
+ } else {
218
+ // The template read failed. Show the raw answers rather than nothing —
219
+ // but WITHOUT inventing an order, since jsonb key order is not the
220
+ // template's order and pretending otherwise would misreport the idea.
221
+ parts.push('<p class="ig-empty">Could not load the question labels; showing the answers as stored.</p>');
222
+ parts.push(`<div class="ig-answers">${Object.keys(idea.field_values || {}).map((k) =>
223
+ `<div class="ig-answer"><p class="ig-answer__q">${escapeHtml(k)}</p><p class="ig-answer__a">${escapeHtml(String(idea.field_values[k] || ''))}</p></div>`
224
+ ).join('')}</div>`);
225
+ }
226
+ // A Full Idea's body_md is the original quick-idea text it grew out of, when
227
+ // it had one. Shown after the answers, subordinate to them.
228
+ if (String(idea.body_md || '').trim()) {
229
+ parts.push(`<div class="ig-sub"><h2 class="ig-sub__h">As first filed</h2>${prose(idea.body_md)}</div>`);
230
+ }
231
+ } else {
232
+ // quick AND spark: the body, and NOT a five-field skeleton.
233
+ parts.push(prose(idea.body_md));
234
+ if (grade === 'spark') {
235
+ parts.push(`<p class="ig-note">Its author marked this as needing development — anyone can turn it into a Full Idea, and they keep the credit for having had it. <a href="${hallBase()}/ideas">Write one</a>.</p>`);
236
+ } else {
237
+ parts.push('<p class="ig-note">A quick idea is a thought filed fast. It is not an unfinished Full Idea — only its author can offer it up for development.</p>');
238
+ }
239
+ }
240
+
241
+ parts.push(markControl(idea, viewer));
242
+
243
+ $('#idea-body').innerHTML = parts.join('');
244
+ $('#idea-sub').textContent = `Idea ${idea.id}`;
245
+ document.title = `${idea.title || `Idea ${idea.id}`} — idea ${idea.id}`;
246
+ }
247
+
248
+ // render + wire. Separate from render() so the mark toggle can re-render from
249
+ // the server's response without re-fetching anything.
250
+ function boot(idea) {
251
+ render(idea, state.template, state.viewer);
252
+ const btn = $('#ig-mark-btn');
253
+ if (btn) btn.addEventListener('click', () => { toggleMark(idea); });
254
+ }
255
+
256
+ function fail(msg) {
257
+ $('#idea-body').innerHTML = `<p class="deeplink-card__title" id="idea-h">${escapeHtml(msg)}</p>`;
258
+ $('#idea-sub').textContent = '';
259
+ }
260
+
261
+ async function main() {
262
+ const id = ideaId();
263
+ if (!id) return fail('No idea id in the address.');
264
+
265
+ const r = await api.inbox.getInboxId({ id });
266
+ if (r.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
267
+ if (r.status === 404) return fail(`No idea ${id}.`);
268
+ if (!r.ok || !r.data || !r.data.idea) return fail('Could not load this idea.');
269
+ const idea = r.data.idea;
270
+
271
+ // The labels are only needed for a Full Idea, so a quick idea costs one
272
+ // request for them, not two — and a template outage cannot stop a quick idea
273
+ // rendering.
274
+ if (idea.grade === 'full') {
275
+ const key = idea.template_key;
276
+ const t = await api.inbox.getInboxTemplate(key ? { query: { template_key: key } } : {});
277
+ if (t.ok && t.data && t.data.template) state.template = t.data.template;
278
+ }
279
+ // Who is looking — for the author-only mark control (R154). Best-effort: the
280
+ // page is a READ first, so a /me hiccup must hide the button, never blank the
281
+ // idea. The route is the wall regardless.
282
+ const me = await api.me.getMe({});
283
+ if (me.ok && me.data && me.data.builder) state.viewer = me.data.builder;
284
+
285
+ boot(idea);
286
+ }
287
+
288
+ main();
289
+ })();
@@ -118,3 +118,30 @@
118
118
  .fi-mrow__fill { display: block; height: 100%; width: 0; border-radius: 0.2rem; background: var(--accent); transition: width 0.18s ease-out; }
119
119
 
120
120
  .fi-meter__note { margin: 0.9rem 0 0; font-size: 0.76rem; opacity: 0.65; line-height: 1.5; }
121
+
122
+ /* ---- the spark queue + develop mode (task 1002930 / R154) --------------- */
123
+
124
+ .fi-queue { display: grid; gap: 10px; }
125
+ .fi-spark {
126
+ display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: baseline;
127
+ padding: 12px 14px;
128
+ border: 1px solid var(--border); border-radius: var(--radius-sm);
129
+ background: var(--surface-2);
130
+ }
131
+ .fi-spark__title { margin: 0; font-weight: 600; font-size: 0.92rem; flex: 1 1 20rem; }
132
+ .fi-spark__title a { color: inherit; }
133
+ .fi-spark__who { font-size: 0.8rem; color: var(--ink-faint); }
134
+ .fi-spark__go { font-size: 0.84rem; white-space: nowrap; }
135
+ .fi-queue-empty { font-size: 0.88rem; color: var(--ink-faint); }
136
+
137
+ /* The spark being developed. Bordered on the leading edge rather than boxed: it
138
+ is context for the fields below it, not a competing card. */
139
+ .fi-develop {
140
+ margin: 0 0 6px;
141
+ padding: 2px 0 2px 14px;
142
+ border-left: 3px solid var(--warn);
143
+ }
144
+ .fi-develop__label { margin: 0; font-size: 0.76rem; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--warn); }
145
+ .fi-develop__title { margin: 4px 0 0; font-weight: 600; }
146
+ .fi-develop__body { margin: 4px 0 0; max-width: 70ch; line-height: 1.6; white-space: pre-wrap; color: var(--ink-soft); font-size: 0.9rem; }
147
+ .fi-develop__who { margin: 6px 0 0; font-size: 0.8rem; color: var(--ink-faint); }
@@ -23,11 +23,22 @@
23
23
  <div class="page-head"><p class="page-head__sub" id="fi-sub">Loading the idea template…</p></div>
24
24
 
25
25
  <main>
26
- <!-- The composer. ONE section, deliberately: this page's whole job is
27
- "write the thing down", and the second panel that would explain the
28
- grades belongs in the Primer, not above the field you came to fill in.
29
- The idea detail view (R153) and the spark queue (R154) are separate
30
- tasks and will add their own sections here. -->
26
+ <!-- The spark queue (task 1002930 / R154). ABOVE the composer, because
27
+ "what is already waiting" is the thing to scan before starting
28
+ something new — and because a queue nobody sees is not a pickup
29
+ surface. Collapsed to a single line when empty so it never pushes the
30
+ composer down the page for nothing. -->
31
+ <section class="scroll" id="fi-queue-scroll" aria-labelledby="fi-queue-h">
32
+ <h2 id="fi-queue-h" class="scroll__h">Waiting to be developed</h2>
33
+ <p class="scroll__lede">
34
+ Ideas whose author has said "I had the thought but not the full picture".
35
+ Anyone can develop one — the author keeps the credit for having had it,
36
+ and you are recorded as its developer.
37
+ </p>
38
+ <div id="fi-queue"><p class="fi-note">Loading…</p></div>
39
+ </section>
40
+
41
+ <!-- The composer. The idea detail view lives on its own page (R153). -->
31
42
  <section class="scroll" id="fi-compose-scroll" aria-labelledby="fi-compose-h">
32
43
  <h2 id="fi-compose-h" class="scroll__h">Write a Full Idea</h2>
33
44
  <p class="scroll__lede">
@@ -37,7 +48,18 @@
37
48
  </p>
38
49
 
39
50
  <form id="fi-form" novalidate>
40
- <label class="fi-label" for="fi-title">
51
+ <!-- DEVELOP MODE (R154): when the composer was opened from a spark,
52
+ this block carries the spark's own words and the title becomes a
53
+ read-only fact rather than a field — developing an idea must not
54
+ quietly rename it out from under its author. -->
55
+ <div class="fi-develop" id="fi-develop" hidden>
56
+ <p class="fi-develop__label">Developing a Big Idea Spark</p>
57
+ <p class="fi-develop__title" id="fi-develop-title"></p>
58
+ <p class="fi-develop__body" id="fi-develop-body"></p>
59
+ <p class="fi-develop__who" id="fi-develop-who"></p>
60
+ </div>
61
+
62
+ <label class="fi-label" for="fi-title" id="fi-title-label">
41
63
  Title
42
64
  <span class="fi-hint">One line. This is what the idea is called everywhere else.</span>
43
65
  </label>
@@ -90,6 +112,6 @@
90
112
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
91
113
  <script src="/builders/palette.js" defer></script>
92
114
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
93
- <script src="/builders/ideas.js?v=2026-08-22-r152"></script>
115
+ <script src="/builders/ideas.js?v=2026-08-22-r154"></script>
94
116
  </body>
95
117
  </html>