@bongos/core 1.19.1008 → 1.19.1009

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,962 @@
1
+ // modules/hall-ui/public/ideas.js — the hall "Ideas" tab (/builders/ideas).
2
+ // Task 1002928 / BV1.R152 of goal 1000062 (three grades of idea).
3
+ //
4
+ // WHY THE HALL AT ALL (the owner's principle, recorded on the goal): anything
5
+ // fundamentally simple, that does not need the Claude terminal, should be doable
6
+ // inside the builders hall. Writing down a thought is exactly that — so a Full
7
+ // Idea can be authored and filed here without a terminal, which is this task's
8
+ // first done-when.
9
+ //
10
+ // THE ONE RULE THIS FILE OBEYS: IT CONTAINS NO RULES.
11
+ // Not the five questions, not their character floors, not the scoring
12
+ // arithmetic. The done-when says the meter must reflect the REAL scorer, "not a
13
+ // second copy of the rules", and the cheapest way to violate that is not a
14
+ // re-implemented formula — it is five hand-written <textarea> elements, which
15
+ // quietly fork the template one step before any scoring happens. So:
16
+ //
17
+ // the FORM is rendered from GET /inbox/template (the R141 declaration)
18
+ // the METER is filled from POST /inbox/score (the R142 scorer itself)
19
+ //
20
+ // Both are pure server calls with no DB behind them, which is what makes a
21
+ // debounced call per keystroke reasonable.
22
+ //
23
+ // WHAT THE METER DELIBERATELY DOES NOT SAY: whether this will PASS. The pass bar
24
+ // lives in the economy module with the credit it gates (and the board applies its
25
+ // own), and the ideas scorer is documented as bar-AGNOSTIC. Printing a threshold
26
+ // here would be a second copy of a DIFFERENT rule — the exact mistake this file
27
+ // is organised to avoid — so the gauge reports completeness and stops there.
28
+ //
29
+ // TWO MODES, ONE FORM (task 1002930 / R154). Opened plainly, the composer FILES a
30
+ // new Full Idea. Opened as ?develop=<id> from the spark queue below, it DEVELOPS
31
+ // an existing spark: the spark's own words are shown above the fields, its title
32
+ // becomes a read-only fact rather than an editable input (developing an idea must
33
+ // not quietly rename it out from under its author), and filing calls
34
+ // POST /inbox/:id/develop instead of POST /inbox. The five fields, the meter, the
35
+ // validation and the draft behaviour are identical — which is the point of reusing
36
+ // this surface rather than building a second one that drifts from it.
37
+ //
38
+ // DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
39
+ // and a half-written thought is not something to put in the database: it has no
40
+ // title yet, no grade, and nothing downstream should be able to see it. It lives
41
+ // in localStorage under one key, is restored on load, and is cleared only when
42
+ // the idea is actually filed.
43
+
44
+ (() => {
45
+ 'use strict';
46
+
47
+ // NO baseUrl OVERRIDE — this page calls the GENERATED namespaced methods
48
+ // (api.inbox.*, api.me.*), whose path templates are API-RELATIVE ('/inbox/template').
49
+ // baseUrl:'' means "these paths are already absolute" and passes them through
50
+ // unchanged, which sent every request to the SITE ROOT and 404'd — this page was
51
+ // dead in production from the day it shipped. Letting it default resolves them
52
+ // against '/api/bongos/v1' (api-prefix.js VERSIONED_API_PREFIX), where the router
53
+ // actually mounts. A page that DOES pass baseUrl:'' must build full paths itself
54
+ // via api.request('GET', `${API}/...`) as work.js/copy-desk.js do — never mix the two.
55
+ const api = window.BongosClient.createClient({ credentials: 'same-origin', throwOnError: false });
56
+ const { escapeHtml, $, toast } = window.OTB;
57
+
58
+ // One key per THING BEING WRITTEN. A new idea and a spark being developed are
59
+ // different drafts, and sharing one key would let a half-written new idea
60
+ // overwrite someone's development work (or restore itself into it). Versioned by
61
+ // template key at save time too, so a project that changes its template (R156)
62
+ // cannot restore a draft shaped for the old one.
63
+ const developId = () => {
64
+ const m = /[?&]develop=(\d+)\b/.exec(location.search);
65
+ return m ? m[1] : null;
66
+ };
67
+ const DEVELOP_ID = developId();
68
+ const DRAFT_KEY = DEVELOP_ID ? `hall-full-idea-draft:develop:${DEVELOP_ID}` : 'hall-full-idea-draft';
69
+ const SCORE_DEBOUNCE_MS = 300;
70
+ const DRAFT_DEBOUNCE_MS = 500;
71
+
72
+ let template = null; // { key, label, fields: [{key,label,required,substance_min}] }
73
+ let lastScore = null;
74
+ let scoreTimer = null;
75
+ let draftTimer = null;
76
+ let filing = false;
77
+
78
+ // QUICK FILING (task 1003069 / R08, goal 1000071 "Idea routing"). A second,
79
+ // independent capture path on this page: title + kind + optional body + a
80
+ // goal picker fed by the R07 suggester (GET /goals/suggestions). It lives
81
+ // beside the Full Idea form above rather than inside it — a routed capture
82
+ // must never carry grade='full' (routing.js NEVER_ROUTE_GRADE), so the two
83
+ // cannot share one submit. Quick-mode only: hidden while developing a spark.
84
+ const QUICK_SUGGEST_DEBOUNCE_MS = 300;
85
+ let buildingVersionIds = []; // fetched once — the suggester is scoped per version
86
+ let allOpenGoals = []; // the picker's "browse everything" half
87
+ let allGoalsById = new Map(); // for naming the goal a routed capture landed in
88
+ let goalTouchedByUser = false; // once the builder picks, stop clobbering it with fresh suggestions
89
+ let quickSuggestTimer = null;
90
+ let quickFiling = false;
91
+ // GRAVITY (task 1004083 / spec R02). The landing verdict for the draft as it
92
+ // currently stands, read from POST /inbox/preview-landing. A monotonic token
93
+ // guards against the out-of-order arrival a debounced-but-concurrent read
94
+ // invites: two previews in flight can land in either order, and the older one
95
+ // winning would leave the line describing a draft that no longer exists.
96
+ let gravitySeq = 0;
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // draft persistence
100
+ // ---------------------------------------------------------------------------
101
+
102
+ // Every localStorage access is wrapped: a private window, cleared site data or
103
+ // a browser set to block storage all THROW on read, and losing the draft
104
+ // feature must never take the composer down with it (the shell.js NAV_COLLAPSE
105
+ // precedent, which guards the same way).
106
+ function readDraft() {
107
+ try {
108
+ const raw = window.localStorage.getItem(DRAFT_KEY);
109
+ if (!raw) return null;
110
+ const d = JSON.parse(raw);
111
+ return (d && typeof d === 'object') ? d : null;
112
+ } catch (_) { return null; }
113
+ }
114
+
115
+ function writeDraft() {
116
+ if (!template) return;
117
+ try {
118
+ window.localStorage.setItem(DRAFT_KEY, JSON.stringify({
119
+ template_key: template.key,
120
+ title: titleEl().value,
121
+ field_values: collect(),
122
+ // BV2.R13: a half-written thought includes the scope its author has in
123
+ // mind for it; losing that on navigation is the same loss as losing a
124
+ // paragraph.
125
+ recommendation: collectRecommendation(),
126
+ at: new Date().toISOString(),
127
+ }));
128
+ note('Draft saved in this browser.');
129
+ } catch (_) {
130
+ // Storage refused. Say so once rather than pretending it saved — a builder
131
+ // who believes a draft is safe and navigates away loses the thought.
132
+ note('This browser is not storing drafts — keep the tab open.');
133
+ }
134
+ }
135
+
136
+ function clearDraft() {
137
+ try { window.localStorage.removeItem(DRAFT_KEY); } catch (_) { /* nothing to clear */ }
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // filing receipts (task 1004082 / spec R01 — "the receipt persists")
142
+ // ---------------------------------------------------------------------------
143
+
144
+ // WHAT WAS ACTUALLY BROKEN. Not the keystroke clearing the line — nothing does
145
+ // that, and the task text was amended at build time to say so. The gaps were
146
+ // that only ONE outcome ever existed (the next filing overwrote it), and that
147
+ // it was DOM-only, so a reload lost it. So this is not a guard added to
148
+ // #fq-result; it is the record #fq-result never was.
149
+ //
150
+ // WHY LOCAL, and why that is honest here. A receipt is "what I was told when I
151
+ // filed", which is per-person and per-browser by nature — and the durable
152
+ // record of the ideas themselves already exists server-side on the profile
153
+ // trail. Re-reading the trail here would be a second copy of a read this page
154
+ // does not own (the same reasoning that keeps the scorer out of this file).
155
+ // Guarded exactly like the draft above: storage that throws must never take
156
+ // the composer down, and must never be reported as having saved.
157
+ const RECEIPTS_KEY = 'hall-filing-receipts';
158
+ const RECEIPTS_MAX = 5;
159
+ let receiptsStorageRefused = false;
160
+
161
+ function readReceipts() {
162
+ try {
163
+ const raw = window.localStorage.getItem(RECEIPTS_KEY);
164
+ if (!raw) return [];
165
+ const list = JSON.parse(raw);
166
+ if (!Array.isArray(list)) return [];
167
+ // Shape-check every row rather than trusting the key: a stale or
168
+ // hand-edited value must degrade to "no receipts", never to a thrown
169
+ // render that takes the filing form with it.
170
+ return list.filter((r) => r && typeof r === 'object' && typeof r.title === 'string')
171
+ .slice(0, RECEIPTS_MAX);
172
+ } catch (_) { return []; }
173
+ }
174
+
175
+ function pushReceipt(receipt) {
176
+ const next = [receipt].concat(readReceipts()).slice(0, RECEIPTS_MAX);
177
+ try {
178
+ window.localStorage.setItem(RECEIPTS_KEY, JSON.stringify(next));
179
+ receiptsStorageRefused = false;
180
+ } catch (_) {
181
+ // Same discipline as writeDraft: say it is not being kept rather than
182
+ // letting someone believe a receipt will be there after a reload.
183
+ receiptsStorageRefused = true;
184
+ }
185
+ return next;
186
+ }
187
+
188
+ function receiptLine(r) {
189
+ const when = r.at ? `<time datetime="${escapeHtml(String(r.at))}">${escapeHtml(formatReceiptTime(r.at))}</time> · ` : '';
190
+ const what = `<span class="fi-receipts__what">${escapeHtml(r.title)}</span>`;
191
+ if (r.task_id && r.href) {
192
+ const goal = r.goal_name ? ` under ${escapeHtml(r.goal_name)}` : '';
193
+ return `${when}${what} — filed as <a href="${escapeHtml(r.href)}">task ${escapeHtml(String(r.task_id))}</a>${goal}.`;
194
+ }
195
+ return `${when}${what} — filed to the inbox.`;
196
+ }
197
+
198
+ function formatReceiptTime(iso) {
199
+ const d = new Date(iso);
200
+ if (Number.isNaN(d.getTime())) return '';
201
+ try {
202
+ return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
203
+ } catch (_) { return ''; }
204
+ }
205
+
206
+ function renderReceipts(list) {
207
+ const wrap = $('#fq-receipts');
208
+ const ol = $('#fq-receipts-list');
209
+ const noteEl = $('#fq-receipts-note');
210
+ if (!wrap || !ol) return;
211
+ const receipts = Array.isArray(list) ? list : readReceipts();
212
+ if (!receipts.length) { wrap.hidden = true; ol.innerHTML = ''; return; }
213
+ wrap.hidden = false;
214
+ ol.innerHTML = receipts
215
+ .map((r) => `<li class="fi-receipts__item">${receiptLine(r)}</li>`)
216
+ .join('');
217
+ if (noteEl) {
218
+ noteEl.hidden = !receiptsStorageRefused;
219
+ noteEl.textContent = receiptsStorageRefused
220
+ ? 'This browser is not keeping receipts — they will go when the tab does.'
221
+ : '';
222
+ }
223
+ }
224
+
225
+ function note(msg) {
226
+ const el = $('#fi-draft');
227
+ if (el) el.textContent = msg;
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // the form, rendered from the template declaration
232
+ // ---------------------------------------------------------------------------
233
+
234
+ const titleEl = () => $('#fi-title');
235
+ const fieldEl = (key) => document.getElementById(`fi-f-${key}`);
236
+
237
+ function renderForm(draft) {
238
+ const host = $('#fi-fields');
239
+ host.innerHTML = template.fields.map((f) => `
240
+ <div class="fi-field" data-key="${escapeHtml(f.key)}">
241
+ <label class="fi-label" for="fi-f-${escapeHtml(f.key)}">
242
+ ${escapeHtml(f.label)}${f.required ? '' : ' <span class="fi-hint">(optional)</span>'}
243
+ </label>
244
+ <textarea id="fi-f-${escapeHtml(f.key)}" class="fi-text" rows="4"
245
+ data-key="${escapeHtml(f.key)}"
246
+ placeholder="…"></textarea>
247
+ <p class="fi-fieldnote" id="fi-n-${escapeHtml(f.key)}"></p>
248
+ </div>
249
+ `).join('');
250
+
251
+ // Restore BEFORE wiring listeners, so restoring does not itself fire a save.
252
+ if (draft && draft.field_values) {
253
+ for (const f of template.fields) {
254
+ const v = draft.field_values[f.key];
255
+ if (typeof v === 'string') fieldEl(f.key).value = v;
256
+ }
257
+ }
258
+ if (draft && typeof draft.title === 'string') titleEl().value = draft.title;
259
+
260
+ for (const f of template.fields) {
261
+ fieldEl(f.key).addEventListener('input', onEdit);
262
+ }
263
+ titleEl().addEventListener('input', onEdit);
264
+
265
+ renderRecommendation(draft);
266
+
267
+ $('#fi-meter').hidden = false;
268
+ renderMeterSkeleton();
269
+ }
270
+
271
+ function collect() {
272
+ const out = {};
273
+ for (const f of template.fields) out[f.key] = fieldEl(f.key).value;
274
+ return out;
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // the scope recommendation (task 1004092 / BV2.R13, spec R22)
279
+ // ---------------------------------------------------------------------------
280
+ //
281
+ // NOT A SIXTH TEXTAREA, and deliberately not collected into `field_values`:
282
+ // the five answers are prose the scorer measures, and this is a two-value
283
+ // choice nothing scores. It travels as its own body field, so the meter
284
+ // above never moves when it is picked.
285
+ //
286
+ // THE OPTIONS COME FROM THE SERVER, like the fields. The vocabulary is a
287
+ // database CHECK constraint; hard-coding the pair here would be the "second
288
+ // copy of the rules" defect this whole file is organised against, and would
289
+ // silently omit any value a later migration admits.
290
+ //
291
+ // REQUIRED HERE, OPTIONAL AT THE API — which is not a contradiction. The
292
+ // hall is where the question is actually put to a person, so filing from it
293
+ // without answering would be filing an unanswered question. The route stays
294
+ // permissive because hundreds of already-filed Full Ideas carry no
295
+ // recommendation and a terse CLI filing may carry none either; every reader
296
+ // therefore has to render the absence honestly regardless, and a required
297
+ // body field would 400 every already-shipped client.
298
+
299
+ function recSpec() {
300
+ return (template && template.recommendation) || null;
301
+ }
302
+
303
+ function renderRecommendation(draft) {
304
+ const host = $('#fi-rec');
305
+ if (!host) return;
306
+ const spec = recSpec();
307
+ // An instance whose template read predates this question simply has no
308
+ // control — the composer keeps working and files without one.
309
+ if (!spec || !Array.isArray(spec.options) || !spec.options.length) {
310
+ host.innerHTML = '';
311
+ return;
312
+ }
313
+ const picked = draft && typeof draft.recommendation === 'string' ? draft.recommendation : '';
314
+ host.innerHTML = `
315
+ <fieldset class="fi-rec__set">
316
+ <legend class="fi-label">${escapeHtml(spec.label || 'What do you think this is')}</legend>
317
+ ${spec.hint ? `<p class="fi-hint fi-rec__hint">${escapeHtml(spec.hint)}</p>` : ''}
318
+ ${spec.options.map((o) => `
319
+ <label class="fi-rec__opt">
320
+ <input type="radio" name="fi-rec-choice" value="${escapeHtml(o.value)}"${o.value === picked ? ' checked' : ''}>
321
+ <span>${escapeHtml(o.label || o.value)}</span>
322
+ </label>`).join('')}
323
+ </fieldset>`;
324
+ // onEdit, not a bespoke handler: picking an option has to save the draft
325
+ // and re-evaluate the file button exactly as typing does. It cannot change
326
+ // the score, and scoreNow is a no-op for it beyond one wasted call — which
327
+ // the debounce already collapses.
328
+ for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) {
329
+ el.addEventListener('change', onEdit);
330
+ }
331
+ }
332
+
333
+ function collectRecommendation() {
334
+ const host = $('#fi-rec');
335
+ const picked = host && host.querySelector('input[name="fi-rec-choice"]:checked');
336
+ return picked ? picked.value : '';
337
+ }
338
+
339
+ function clearRecommendation() {
340
+ const host = $('#fi-rec');
341
+ if (!host) return;
342
+ for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) el.checked = false;
343
+ }
344
+
345
+ // Whether the composer may file. Only true when the server actually offered
346
+ // the choice — an instance that serves no `recommendation` spec must not have
347
+ // its file button held hostage by a control that was never rendered.
348
+ function recommendationMissing() {
349
+ const spec = recSpec();
350
+ if (!spec || !Array.isArray(spec.options) || !spec.options.length) return false;
351
+ return !collectRecommendation();
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // the meter — every number in it came from the server
356
+ // ---------------------------------------------------------------------------
357
+
358
+ function renderMeterSkeleton() {
359
+ $('#fi-meter-list').innerHTML = template.fields.map((f) => `
360
+ <li class="fi-mrow" id="fi-m-${escapeHtml(f.key)}">
361
+ <span class="fi-mrow__l">${escapeHtml(f.label)}</span>
362
+ <span class="fi-mrow__bar"><span class="fi-mrow__fill" style="width:0%"></span></span>
363
+ </li>
364
+ `).join('');
365
+ }
366
+
367
+ function renderScore(payload) {
368
+ lastScore = payload;
369
+ const score = payload && Number.isInteger(payload.score) ? payload.score : null;
370
+ const fill = $('#fi-gauge-fill');
371
+ const num = $('#fi-gauge-n');
372
+ fill.style.width = `${score == null ? 0 : score}%`;
373
+ // Three bands, and they describe the TEXT rather than a verdict: this number
374
+ // is not a pass mark (see the header note), so the words must not imply one.
375
+ fill.dataset.band = score == null ? 'none' : (score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin');
376
+ num.textContent = score == null ? '—' : `${score}/100`;
377
+
378
+ const byKey = new Map((payload && payload.fields ? payload.fields : []).map((f) => [f.key, f]));
379
+ for (const f of template.fields) {
380
+ const row = document.getElementById(`fi-m-${f.key}`);
381
+ const s = byKey.get(f.key);
382
+ const pct = s ? Math.round(s.ratio * 100) : 0;
383
+ if (row) row.querySelector('.fi-mrow__fill').style.width = `${pct}%`;
384
+ // Per-field prose under the textarea: how much more text this answer wants
385
+ // before it stops costing the total. `floor` and `substance` are the
386
+ // server's numbers — this only subtracts them.
387
+ const n = document.getElementById(`fi-n-${f.key}`);
388
+ if (!n) continue;
389
+ if (!s || s.substance === 0) { n.textContent = ''; continue; }
390
+ const missing = Math.max(0, s.floor - s.substance);
391
+ n.textContent = missing > 0
392
+ ? `about ${missing} more characters before this answer counts in full`
393
+ : 'counts in full';
394
+ n.dataset.ok = missing > 0 ? '0' : '1';
395
+ }
396
+
397
+ // The file button turns on when every REQUIRED field has something in it —
398
+ // capture validation's actual rule (R143 refuses a missing required field and
399
+ // does NOT enforce the substance floor, because a thin answer is a scoring
400
+ // problem, not a capture error). Matching that exactly means the button never
401
+ // promises an outcome the server will refuse, and never blocks one it accepts.
402
+ //
403
+ // THE SCOPE RECOMMENDATION IS THE ONE PLACE THIS BUTTON IS STRICTER THAN
404
+ // THE ROUTE (BV2.R13). The route accepts a Full Idea without one, because
405
+ // the historical rows have none; the hall does not, because the hall is
406
+ // where the question was actually asked. See renderRecommendation.
407
+ const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
408
+ || recommendationMissing();
409
+ // In develop mode the title is the existing idea's and is not editable, so it
410
+ // cannot be a reason to keep the button off.
411
+ $('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
412
+ }
413
+
414
+ async function scoreNow() {
415
+ const body = { field_values: collect() };
416
+ if (template.key) body.template_key = template.key;
417
+ const r = await api.inbox.postInboxScore({ body });
418
+ // A scoring hiccup must not stop someone writing. The gauge simply stops
419
+ // updating; the composer keeps working and filing still validates server-side.
420
+ if (!r.ok) { note('Could not reach the scorer just now — you can still file.'); return; }
421
+ renderScore(r.data);
422
+ }
423
+
424
+ function onEdit() {
425
+ clearTimeout(scoreTimer);
426
+ clearTimeout(draftTimer);
427
+ scoreTimer = setTimeout(() => { scoreNow(); }, SCORE_DEBOUNCE_MS);
428
+ draftTimer = setTimeout(writeDraft, DRAFT_DEBOUNCE_MS);
429
+ // Re-evaluate the button on the keystroke rather than waiting for the score
430
+ // round-trip: it depends only on what is typed.
431
+ if (lastScore) renderScore(lastScore);
432
+ else $('#fi-submit').disabled = filing
433
+ || (!DEVELOP_ID && !titleEl().value.trim())
434
+ || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
435
+ || recommendationMissing();
436
+ }
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // filing
440
+ // ---------------------------------------------------------------------------
441
+
442
+ function showErr(msg) {
443
+ const el = $('#fi-err');
444
+ el.textContent = msg || '';
445
+ el.dataset.visible = msg ? '1' : '0';
446
+ }
447
+
448
+ // The server names the field it refused (R143's done-when). Render that rather
449
+ // than a generic failure, and point at the field so it can be found.
450
+ function explainFiling(status, data) {
451
+ const err = data && data.error;
452
+ const code = err && typeof err === 'object' ? err.code : err;
453
+ const details = (err && typeof err === 'object' && err.details) || {};
454
+ if (code === 'invalid_field_values') {
455
+ const errors = Array.isArray(details) ? details : (details.errors || []);
456
+ const labelOf = (k) => {
457
+ const f = template.fields.find((x) => x.key === k);
458
+ return f ? f.label : (k || 'the idea');
459
+ };
460
+ return errors.length
461
+ ? `Not filed — ${errors.map((e) => `${labelOf(e.field)}: ${String(e.reason || '').replace(/_/g, ' ')}`).join('; ')}`
462
+ : 'Not filed — the five answers were refused.';
463
+ }
464
+ if (code === 'validation_failed') return 'Not filed — the title is required and must be under 200 characters.';
465
+ if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
466
+ return `Not filed (${status}). Nothing was saved; your draft is still here.`;
467
+ }
468
+
469
+ async function file(ev) {
470
+ ev.preventDefault();
471
+ if (filing) return;
472
+ filing = true;
473
+ showErr('');
474
+ $('#fi-submit').disabled = true;
475
+
476
+ // The one behavioural fork between the two modes. Developing is an act on an
477
+ // EXISTING idea, so it carries no title and no grade: the row already has
478
+ // both, and R146 preserves them along with its author.
479
+ // BV2.R13: the recommendation travels on BOTH paths, and only when one was
480
+ // actually picked — sending '' would be asserting an opinion nobody gave.
481
+ const rec = collectRecommendation();
482
+ const r = DEVELOP_ID
483
+ ? await api.inbox.postInboxIdDevelop({
484
+ id: DEVELOP_ID,
485
+ body: { field_values: collect(), template_key: template.key, ...(rec ? { recommendation: rec } : {}) },
486
+ })
487
+ : await api.inbox.postInbox({
488
+ body: {
489
+ title: titleEl().value.trim(),
490
+ grade: 'full',
491
+ template_key: template.key,
492
+ field_values: collect(),
493
+ ...(rec ? { recommendation: rec } : {}),
494
+ },
495
+ });
496
+
497
+ filing = false;
498
+ if (!r.ok) {
499
+ showErr(explainFiling(r.status, r.data));
500
+ renderScore(lastScore);
501
+ return;
502
+ }
503
+
504
+ // Filed. NOW the draft goes — not before, so a failed POST never costs the text.
505
+ clearDraft();
506
+ const idea = r.data && r.data.idea;
507
+ const score = idea && Number.isInteger(idea.completeness_score) ? idea.completeness_score : null;
508
+ // The capture path is fail-open on grading (R143): the idea lands even if the
509
+ // grade write did not, and says so. Repeat that here instead of claiming a
510
+ // Full Idea was stored when the response says otherwise.
511
+ const landedFull = idea && idea.grade === 'full';
512
+ if (DEVELOP_ID) {
513
+ // The split is the developer's business: they earned a share of it and the
514
+ // author kept theirs, and saying so is how the handoff stops feeling like
515
+ // giving work away (R147 / ADR 0185).
516
+ const credited = r.data && r.data.idea && r.data.idea.ideator_credit;
517
+ toast(`Developed${score == null ? '' : ` — ${score}/100`}${credited ? `. ${credited} credits split with its author.` : '.'}`);
518
+ } else {
519
+ toast(landedFull
520
+ ? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
521
+ : 'Filed, but stored as a quick idea — the grade could not be written.');
522
+ }
523
+
524
+ if (!DEVELOP_ID) titleEl().value = '';
525
+ for (const f of template.fields) fieldEl(f.key).value = '';
526
+ clearRecommendation();
527
+ lastScore = null;
528
+ renderScore(null);
529
+ note('');
530
+ if (idea && idea.id) {
531
+ // The detail PAGE (task 1002929 / R153), not the #/idea/N overlay: the
532
+ // thing just filed has five prose answers, which is what the page is for.
533
+ const base = location.hostname.startsWith('builders.') ? '' : '/builders';
534
+ $('#fi-sub').innerHTML = `Filed <a href="${base}/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
535
+ }
536
+ }
537
+
538
+ function discard() {
539
+ if (!template) return;
540
+ clearDraft();
541
+ titleEl().value = '';
542
+ for (const f of template.fields) fieldEl(f.key).value = '';
543
+ clearRecommendation();
544
+ lastScore = null;
545
+ renderScore(null);
546
+ showErr('');
547
+ note('Draft discarded.');
548
+ toast('Draft discarded.');
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // quick filing — kind + goal picker (task 1003069 / R08)
553
+ // ---------------------------------------------------------------------------
554
+
555
+ const fqTitleEl = () => $('#fq-title');
556
+ const fqKindEl = () => $('#fq-kind');
557
+ const fqBodyEl = () => $('#fq-body');
558
+ const fqGoalEl = () => $('#fq-goal');
559
+
560
+ // The picker's options: "no goal" first (the default — a filer never has to
561
+ // know the goal map, C5), then whatever the suggester found, then every open
562
+ // goal it didn't. Suggested ids are excluded from the second list rather than
563
+ // shown twice.
564
+ function renderGoalOptions(suggested) {
565
+ const sel = fqGoalEl();
566
+ const suggestedIds = new Set((suggested || []).map((g) => String(g.id)));
567
+ const rest = allOpenGoals.filter((g) => !suggestedIds.has(String(g.id)));
568
+ const optionHtml = (g, why) => `<option value="${escapeHtml(String(g.id))}">${escapeHtml(g.title || `goal ${g.id}`)}${why ? ` — matched: ${escapeHtml(why)}` : ''}</option>`;
569
+ let html = `<option value="" selected>No goal (homeless)</option>`;
570
+ if (suggested && suggested.length) {
571
+ html += `<optgroup label="Suggested">${suggested.map((g) => optionHtml(g, (g.matched_terms || []).join(', '))).join('')}</optgroup>`;
572
+ }
573
+ html += `<optgroup label="All open goals">${rest.map((g) => optionHtml(g)).join('')}</optgroup>`;
574
+ sel.innerHTML = html;
575
+ }
576
+
577
+ // The suggester is ranked per version (GET /goals/suggestions requires one),
578
+ // so a filer whose instance has more than one version building gets both
579
+ // consulted and merged by score. An outage — or no building version at all —
580
+ // degrades to "no suggestions"; the homeless option and the full goal list
581
+ // are still there either way (goal-suggest.js's own fail-open contract).
582
+ async function refreshGoalSuggestions() {
583
+ if (goalTouchedByUser) return; // never yank a manual pick out from under someone
584
+ const title = fqTitleEl().value.trim();
585
+ if (!title || !buildingVersionIds.length) { renderGoalOptions([]); return; }
586
+ const kind = fqKindEl().value;
587
+ try {
588
+ const results = await Promise.all(buildingVersionIds.map((versionId) => api.goals.getGoalsSuggestions({
589
+ query: { version: versionId, title, kind, limit: 5 },
590
+ })));
591
+ const byId = new Map();
592
+ for (const r of results) {
593
+ if (!r.ok || !r.data || !Array.isArray(r.data.suggestions)) continue;
594
+ for (const g of r.data.suggestions) {
595
+ const prev = byId.get(String(g.id));
596
+ if (!prev || g.score > prev.score) byId.set(String(g.id), g);
597
+ }
598
+ }
599
+ const merged = Array.from(byId.values()).sort((a, b) => b.score - a.score).slice(0, 5);
600
+ renderGoalOptions(merged);
601
+ } catch (_) {
602
+ renderGoalOptions([]);
603
+ }
604
+ }
605
+
606
+ // ---------------------------------------------------------------------------
607
+ // gravity — which goal will catch this thought, said before it is released
608
+ // (task 1004083 / spec R02)
609
+ // ---------------------------------------------------------------------------
610
+
611
+ const fqGravityEl = () => $('#fq-gravity');
612
+
613
+ // THE SENTENCES, AND NOTHING ELSE. Every branch below READS a field of the
614
+ // server's verdict; not one of them decides a landing. That split is the whole
615
+ // point of the endpoint: modules/ideas/routing.js `decideLanding` stays the one
616
+ // place the matrix lives, and a second copy of it in this file is precisely the
617
+ // failure the #fq-kind comment above already records ("this markup used to
618
+ // spell the whole vocabulary and had gone stale ... a third copy nobody was
619
+ // watching"). If a sentence here ever needs an `if` over kind or rank, the
620
+ // verdict is missing a field and the fix belongs on the server.
621
+ //
622
+ // Returns null for "say nothing" — an unscorable draft, or a read that failed.
623
+ // Silence is the honest degraded state: it leaves the filer exactly where they
624
+ // were before this feature, rather than replacing a quiet lie with a loud one.
625
+ function gravitySentence(v) {
626
+ if (!v || !v.outcome) return null;
627
+ const goalName = v.goal ? (v.goal.title || `goal ${v.goal.id}`) : null;
628
+ const claimable = v.status === 'ready';
629
+
630
+ if (v.outcome === 'refused') {
631
+ // The protected-scope wall. The server wrote the reason for a human; it is
632
+ // the one branch that already reads as a sentence, so it is passed through.
633
+ return { tone: 'refused', text: v.refusal && v.refusal.reason ? v.refusal.reason : `${goalName} will not take this filing.` };
634
+ }
635
+ if (v.outcome === 'routed') {
636
+ return {
637
+ tone: 'lands',
638
+ text: claimable
639
+ ? `This lands in ${goalName} as a task anyone can claim.`
640
+ : `This lands in ${goalName}, waiting for a nod before anyone can claim it.`,
641
+ };
642
+ }
643
+ if (v.outcome === 'routed_home') {
644
+ // The label on the picker says "homeless"; since ADR 0262 that is not true
645
+ // of a bug, and this line is the correction. Naming the version rather than
646
+ // the goal is deliberate — the maintenance goal is created by the capture,
647
+ // so the preview has no row to name without making one (landing-preview.js).
648
+ return {
649
+ tone: 'lands',
650
+ text: claimable
651
+ ? `A bug always lands. With no goal picked this goes to ${v.version_id}'s maintenance goal, claimable straight away.`
652
+ : `A bug always lands. With no goal picked this goes to ${v.version_id}'s maintenance goal, waiting for a nod.`,
653
+ };
654
+ }
655
+ // outcome === 'floats' — the inbox. Two ways to get here, and they are
656
+ // different facts: nobody named a goal, or a goal was named and the matrix
657
+ // declined it. The second is the one the picker currently lies about.
658
+ if (v.reason === 'judgment_kind') {
659
+ return { tone: 'floats', text: `${goalName} will not catch this — this kind is judged, not queued. It waits in the inbox for triage.` };
660
+ }
661
+ if (v.reason === 'full_idea_never_routes') {
662
+ return { tone: 'floats', text: 'A Full Idea is ratified by the board rather than routed. It waits in the inbox.' };
663
+ }
664
+ return { tone: 'floats', text: 'This floats in the inbox until someone triages it.' };
665
+ }
666
+
667
+ function renderGravity(v) {
668
+ const el = fqGravityEl();
669
+ if (!el) return;
670
+ const said = gravitySentence(v);
671
+ // dataset, not setAttribute: it writes the same data-tone the sheet selects
672
+ // on, and it is the half of the element a DOM the page is rendered into and
673
+ // a test stub both really carry.
674
+ if (!said) { el.hidden = true; el.textContent = ''; el.dataset.tone = ''; return; }
675
+ el.textContent = said.text;
676
+ el.dataset.tone = said.tone;
677
+ el.hidden = false;
678
+ }
679
+
680
+ // The read. Shares the suggester's debounce rather than adding a second clock:
681
+ // the two answer the same question at the same moment ("given what is typed so
682
+ // far, where does this go?"), and spec decision 12 — filing never gets slower —
683
+ // is a reason not to grow the number of things a keystroke sets running.
684
+ //
685
+ // A draft with no title is not previewed: it cannot be filed either, so there
686
+ // is no outcome to state.
687
+ async function refreshGravity() {
688
+ const seq = ++gravitySeq;
689
+ const title = fqTitleEl().value.trim();
690
+ if (!title) { renderGravity(null); return; }
691
+ const goalId = fqGoalEl().value;
692
+ const body = { kind: fqKindEl().value };
693
+ if (goalId) body.goal_id = Number(goalId);
694
+ let r;
695
+ try {
696
+ r = await api.inbox.postInboxPreviewLanding({ body });
697
+ } catch (_) {
698
+ r = null;
699
+ }
700
+ // A stale answer never paints. See gravitySeq above.
701
+ if (seq !== gravitySeq) return;
702
+ renderGravity(r && r.ok ? r.data : null);
703
+ }
704
+
705
+ function onQuickTitleEdit() {
706
+ clearTimeout(quickSuggestTimer);
707
+ quickSuggestTimer = setTimeout(() => { refreshGoalSuggestions(); refreshGravity(); }, QUICK_SUGGEST_DEBOUNCE_MS);
708
+ }
709
+
710
+ function showQuickErr(msg) {
711
+ $('#fq-err').textContent = msg || '';
712
+ }
713
+
714
+ // The server names why a routed capture was refused (task 1003066's landing
715
+ // gate) or names the refusal code when it doesn't — read it, never re-derive
716
+ // the landing matrix here (routing.js stays the one place that decides).
717
+ function explainQuickFiling(status, data) {
718
+ const err = data && data.error;
719
+ const code = err && (typeof err === 'object' ? err.code : err);
720
+ const details = (err && typeof err === 'object' && err.details) || {};
721
+ if (details.reason) return `Not filed — ${details.reason}`;
722
+ if (code) return `Not filed — ${code}.`;
723
+ if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
724
+ return `Not filed (${status}). Nothing was saved — try again.`;
725
+ }
726
+
727
+ async function fileQuick(ev) {
728
+ ev.preventDefault();
729
+ if (quickFiling) return;
730
+ const title = fqTitleEl().value.trim();
731
+ showQuickErr('');
732
+ $('#fq-result').innerHTML = '';
733
+ if (!title) { showQuickErr('Not filed — a title is required.'); return; }
734
+ quickFiling = true;
735
+ $('#fq-submit').disabled = true;
736
+
737
+ const goalId = fqGoalEl().value;
738
+ const body = { title, body_md: fqBodyEl().value, kind: fqKindEl().value };
739
+ if (goalId) body.goal_id = Number(goalId);
740
+
741
+ const r = await api.inbox.postInbox({ body });
742
+ quickFiling = false;
743
+ $('#fq-submit').disabled = false;
744
+
745
+ if (!r.ok) {
746
+ showQuickErr(explainQuickFiling(r.status, r.data));
747
+ return;
748
+ }
749
+
750
+ // The routing verdict travels on idea.routing; a routed capture also
751
+ // carries the task it became at the top level (routed_task) — see
752
+ // routes/inbox.js. Not routed (no goal picked, or the matrix declined
753
+ // one) is exactly today's inbox behaviour.
754
+ const routedTask = r.data && r.data.routed_task;
755
+ const routing = r.data && r.data.idea && r.data.idea.routing;
756
+ if (routedTask && routedTask.id && routing && routing.routed) {
757
+ const href = window.OTBTaskDetail && typeof window.OTBTaskDetail.href === 'function'
758
+ ? window.OTBTaskDetail.href(routedTask.id)
759
+ : `/task/${encodeURIComponent(routedTask.id)}`;
760
+ const goal = allGoalsById.get(String(routing.goal_id));
761
+ const goalName = goal ? goal.title : `goal ${routing.goal_id}`;
762
+ $('#fq-result').innerHTML = `Filed as <a href="${href}">task ${escapeHtml(String(routedTask.id))}</a> under ${escapeHtml(goalName)}.`;
763
+ renderReceipts(pushReceipt({ title, task_id: routedTask.id, goal_name: goalName, href, at: new Date().toISOString() }));
764
+ } else {
765
+ $('#fq-result').textContent = 'Filed to the inbox.';
766
+ renderReceipts(pushReceipt({ title, task_id: null, goal_name: null, href: null, at: new Date().toISOString() }));
767
+ }
768
+
769
+ fqTitleEl().value = '';
770
+ fqBodyEl().value = '';
771
+ fqKindEl().value = 'bug';
772
+ goalTouchedByUser = false;
773
+ renderGoalOptions([]);
774
+ // The form is empty again, so the line has nothing to describe. The RECEIPT
775
+ // above is what survives the filing (task 1004082); gravity is a statement
776
+ // about a draft, and this draft is gone.
777
+ renderGravity(null);
778
+ }
779
+
780
+ async function bootQuickComposer() {
781
+ fqKindEl().value = 'bug';
782
+ renderGoalOptions([]);
783
+ fqTitleEl().addEventListener('input', onQuickTitleEdit);
784
+ fqKindEl().addEventListener('change', onQuickTitleEdit);
785
+ // Changing the goal is the one edit that changes gravity WITHOUT changing
786
+ // what the suggester would return, so it redraws the line directly rather
787
+ // than going through onQuickTitleEdit. Deliberately undebounced: a select is a
788
+ // deliberate single act, not a stream of keystrokes.
789
+ fqGoalEl().addEventListener('change', () => { goalTouchedByUser = true; refreshGravity(); });
790
+ $('#fq-form').addEventListener('submit', fileQuick);
791
+
792
+ // Before any network call: the receipts are local, so a builder who reloads
793
+ // sees what became of earlier thoughts even if every read below fails.
794
+ renderReceipts();
795
+
796
+ try {
797
+ const [versionsRes, goalsRes] = await Promise.all([
798
+ api.versions.getVersions({}),
799
+ api.goals.getGoals({ query: { status: 'open' } }),
800
+ ]);
801
+ buildingVersionIds = (versionsRes.ok && versionsRes.data && Array.isArray(versionsRes.data.versions))
802
+ ? versionsRes.data.versions.filter((v) => v.status === 'building').map((v) => v.id)
803
+ : [];
804
+ allOpenGoals = (goalsRes.ok && goalsRes.data && Array.isArray(goalsRes.data.goals)) ? goalsRes.data.goals : [];
805
+ allGoalsById = new Map(allOpenGoals.map((g) => [String(g.id), g]));
806
+ renderGoalOptions([]);
807
+ } catch (_) {
808
+ // The picker degrades to "homeless only" — filing itself never depends
809
+ // on this read succeeding.
810
+ }
811
+
812
+ // task 1003453 — the kind vocabulary, so this select is not a third hardcoded
813
+ // copy. Its OWN try, deliberately not folded into the Promise.all above: the
814
+ // goal picker is what this form is FOR and the kind list is cosmetic, so a
815
+ // vocabulary read must never be able to take the picker down with it. (It can:
816
+ // put it in that Promise.all and one rejection strands buildingVersionIds and
817
+ // allOpenGoals in the shared catch — two suites caught exactly that.)
818
+ // keepFirst 0 clears the markup's fallback and rebuilds with the served list,
819
+ // keeping 'bug' as the default this form has always opened on.
820
+ try {
821
+ const meRes = await api.me.getMe({});
822
+ window.OTB.fillKindFilter?.(fqKindEl(), meRes.ok && meRes.data && meRes.data.task_kinds, {
823
+ keepFirst: 0, label: window.OTB.kindLabel, selected: 'bug',
824
+ });
825
+ } catch (_) { /* the markup's Bug option stands — a report can still be filed */ }
826
+ }
827
+
828
+ // ---------------------------------------------------------------------------
829
+ // boot
830
+ // ---------------------------------------------------------------------------
831
+
832
+ // ---------------------------------------------------------------------------
833
+ // the spark queue (task 1002930 / R154)
834
+ // ---------------------------------------------------------------------------
835
+
836
+ const hallBase = () => (location.hostname.startsWith('builders.') ? '' : '/builders');
837
+
838
+ function renderQueue(sparks, count) {
839
+ const host = $('#fi-queue');
840
+ if (!sparks || sparks.length === 0) {
841
+ // An answer, not a blank space — and it says how one gets here, since the
842
+ // only way to fill this queue is for an author to offer something up.
843
+ host.innerHTML = '<p class="fi-queue-empty">Nothing is waiting. A spark appears here when its author offers a quick idea up for development.</p>';
844
+ return;
845
+ }
846
+ const total = Number.isInteger(count) ? count : sparks.length;
847
+ const more = total > sparks.length ? `<p class="fi-note">Showing ${sparks.length} of ${total}.</p>` : '';
848
+ host.innerHTML = `<div class="fi-queue">${sparks.map((sp) => {
849
+ const who = sp.filed_by_login ? `@${escapeHtml(sp.filed_by_login)}` : 'an unnamed builder';
850
+ const when = sp.captured_at ? ` · ${escapeHtml(String(sp.captured_at).slice(0, 10))}` : '';
851
+ return `
852
+ <div class="fi-spark">
853
+ <p class="fi-spark__title"><a href="${hallBase()}/idea/${encodeURIComponent(sp.id)}">${escapeHtml(sp.title || `Idea ${sp.id}`)}</a></p>
854
+ <span class="fi-spark__who">had by ${who}${when}</span>
855
+ <a class="fi-spark__go btn btn--ghost" href="?develop=${encodeURIComponent(sp.id)}">Develop this →</a>
856
+ </div>`;
857
+ }).join('')}</div>${more}`;
858
+ }
859
+
860
+ async function loadQueue() {
861
+ const r = await api.inbox.getInboxSparks({});
862
+ // The queue is secondary to the composer: an outage here must not stop
863
+ // someone writing, so it degrades to a line rather than an error state.
864
+ if (!r.ok || !r.data) {
865
+ $('#fi-queue').innerHTML = '<p class="fi-queue-empty">Could not load the queue just now.</p>';
866
+ return;
867
+ }
868
+ renderQueue(r.data.sparks, r.data.count);
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // develop mode
873
+ // ---------------------------------------------------------------------------
874
+
875
+ // Show the spark's own words above the fields, and make the title a FACT rather
876
+ // than an input. Returns false when the idea cannot be developed, so boot can
877
+ // say why instead of presenting a form that will 409.
878
+ function enterDevelopMode(idea) {
879
+ if (idea.grade !== 'spark') {
880
+ $('#fi-sub').textContent = idea.grade === 'full'
881
+ ? `Idea ${idea.id} has already been developed into a Full Idea.`
882
+ : `Idea ${idea.id} is not a Big Idea Spark — only its author can offer it up for development.`;
883
+ $('#fi-form').hidden = true;
884
+ return false;
885
+ }
886
+ $('#fi-develop').hidden = false;
887
+ $('#fi-develop-title').textContent = idea.title || `Idea ${idea.id}`;
888
+ $('#fi-develop-body').textContent = String(idea.body_md || '').trim();
889
+ $('#fi-develop-who').textContent = idea.filed_by_login
890
+ ? `had by @${idea.filed_by_login}${idea.captured_at ? ` · ${String(idea.captured_at).slice(0, 10)}` : ''} — they keep the credit for the thought`
891
+ : 'they keep the credit for the thought';
892
+ // The title is the existing idea's. Shown, not editable: developing an idea
893
+ // must not quietly rename it out from under its author.
894
+ const t = titleEl();
895
+ t.value = idea.title || '';
896
+ t.readOnly = true;
897
+ t.tabIndex = -1;
898
+ $('#fi-title-label').hidden = true;
899
+ t.hidden = true;
900
+ $('#fi-submit').textContent = 'Develop this idea';
901
+ $('#fi-sub').textContent = 'Answer the five questions and this spark becomes a Full Idea.';
902
+ return true;
903
+ }
904
+
905
+ async function main() {
906
+ // THE QUEUE IS LOADED FIRST, AND UNCONDITIONALLY. The composer and the spark
907
+ // queue are independent surfaces that happen to share a page: the queue needs
908
+ // no template. Loading it after the template check meant one failed fetch took
909
+ // BOTH down — the reported symptom was "Could not load the idea template" AND a
910
+ // queue stuck on "Loading…" forever, which read as two bugs and was one.
911
+ // A surface that does not depend on the failure must not be hostage to it.
912
+ loadQueue();
913
+
914
+ // Quick filing (R08) is a THIRD independent surface on this page, offered
915
+ // only outside develop mode — developing a spark always produces a Full
916
+ // Idea, never a fresh quick report (see the section's own header comment).
917
+ if (DEVELOP_ID) {
918
+ const scroll = $('#fq-compose-scroll');
919
+ if (scroll) scroll.hidden = true;
920
+ } else {
921
+ bootQuickComposer();
922
+ }
923
+
924
+ const r = await api.inbox.getInboxTemplate({});
925
+ if (!r.ok || !r.data || !r.data.template) {
926
+ $('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
927
+ return;
928
+ }
929
+ template = r.data.template;
930
+
931
+ const draft = readDraft();
932
+ // A draft written against a different template is not restorable — the keys
933
+ // may not exist any more. Drop it rather than half-restoring it.
934
+ const usable = draft && (!draft.template_key || draft.template_key === template.key) ? draft : null;
935
+
936
+ $('#fi-sub').textContent = 'Five questions, and a score that counts how much of each you actually answered.';
937
+ renderForm(usable);
938
+ $('#fi-form').addEventListener('submit', file);
939
+ $('#fi-clear').addEventListener('click', discard);
940
+
941
+ if (DEVELOP_ID) {
942
+ const d = await api.inbox.getInboxId({ id: DEVELOP_ID });
943
+ if (!d.ok || !d.data || !d.data.idea) {
944
+ $('#fi-sub').textContent = `Could not load idea ${DEVELOP_ID}.`;
945
+ $('#fi-form').hidden = true;
946
+ return;
947
+ }
948
+ if (!enterDevelopMode(d.data.idea)) return;
949
+ // Re-run the button rule now that the title requirement is lifted.
950
+ onEdit();
951
+ }
952
+
953
+ if (usable) {
954
+ note('Draft restored.');
955
+ scoreNow();
956
+ } else {
957
+ renderScore(null);
958
+ }
959
+ }
960
+
961
+ main();
962
+ })();