@bongos/core 1.19.297 → 1.19.299

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,120 @@
1
+ /* modules/hall-ui/public/ideas.css — the Ideas tab (task 1002928 / BV1.R152 of
2
+ goal 1000062).
3
+
4
+ Built on the hall's existing tokens (style.css), not a new palette. Only the
5
+ shapes this page needs are here: a wide-screen two-column composer with the
6
+ meter alongside, collapsing to one column where there is no room for it.
7
+
8
+ USES THE REAL TOKEN NAMES — --border / --surface-2 / --accent / --ok / --warn /
9
+ --danger / --radius. Note for anyone copying a sibling sheet: copy-desk.css
10
+ reaches for `var(--line, rgba(255,255,255,.12))` and `var(--panel, …)`, and
11
+ NEITHER --line NOR --panel is defined in style.css — so those fallbacks apply,
12
+ and they assume a dark ground the hall does not have by default (--bg is
13
+ #f5f6f8). Borders drawn that way are invisible on the light theme. This sheet
14
+ deliberately does not inherit that. The hall's dark theme flips only the
15
+ neutrals, so every token below re-derives on its own and this page needs no
16
+ theme-specific block at all. */
17
+
18
+ /* The composer and its meter sit side by side once there is room. Below that
19
+ width the meter stacks ABOVE the fields — a readout you cannot see while
20
+ typing is not a live meter. */
21
+ @media (min-width: 62rem) {
22
+ .app-content > main { padding-right: 20rem; }
23
+ .fi-meter {
24
+ position: fixed;
25
+ top: 6.5rem;
26
+ right: 1.5rem;
27
+ width: 17rem;
28
+ max-height: calc(100vh - 8rem);
29
+ overflow-y: auto;
30
+ }
31
+ }
32
+
33
+ .fi-label {
34
+ display: block;
35
+ margin: 1.1rem 0 0.3rem;
36
+ font-size: 0.9rem;
37
+ font-weight: 600;
38
+ }
39
+ .fi-hint { font-weight: 400; font-size: 0.8rem; opacity: 0.7; }
40
+
41
+ .fi-input,
42
+ .fi-text {
43
+ width: 100%;
44
+ padding: 0.55rem 0.7rem;
45
+ border: 1px solid var(--border);
46
+ border-radius: var(--radius-sm);
47
+ background: var(--surface-2);
48
+ color: inherit;
49
+ font: inherit;
50
+ line-height: 1.55;
51
+ }
52
+ .fi-text { resize: vertical; min-height: 5.5rem; }
53
+ .fi-input:focus,
54
+ .fi-text:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
55
+
56
+ .fi-fields { margin-top: 0.5rem; }
57
+ .fi-field + .fi-field { margin-top: 0.35rem; }
58
+
59
+ .fi-fieldnote { margin: 0.3rem 0 0; font-size: 0.78rem; opacity: 0.7; min-height: 1.1em; }
60
+ .fi-fieldnote[data-ok="1"] { opacity: 0.85; color: var(--ok); }
61
+
62
+ .fi-note { font-size: 0.85rem; opacity: 0.75; }
63
+
64
+ .fi-err {
65
+ margin: 0.9rem 0 0;
66
+ font-size: 0.87rem;
67
+ line-height: 1.5;
68
+ color: var(--danger);
69
+ }
70
+ .fi-err[data-visible="0"] { display: none; }
71
+
72
+ .fi-actions {
73
+ display: flex;
74
+ flex-wrap: wrap;
75
+ gap: 0.6rem 0.75rem;
76
+ align-items: center;
77
+ margin-top: 1.2rem;
78
+ }
79
+ .fi-draft { font-size: 0.8rem; opacity: 0.7; }
80
+
81
+ /* ---- the meter ---------------------------------------------------------- */
82
+
83
+ .fi-meter {
84
+ border: 1px solid var(--border);
85
+ border-radius: var(--radius);
86
+ background: var(--surface-2);
87
+ padding: 0.9rem 1rem 1rem;
88
+ margin: 1.5rem;
89
+ }
90
+ .fi-meter__h { margin: 0 0 0.6rem; font-size: 0.95rem; }
91
+
92
+ .fi-gauge { display: flex; align-items: center; gap: 0.6rem; }
93
+ .fi-gauge__track {
94
+ flex: 1;
95
+ height: 0.55rem;
96
+ border-radius: 0.3rem;
97
+ background: var(--border);
98
+ overflow: hidden;
99
+ }
100
+ .fi-gauge__fill {
101
+ height: 100%;
102
+ width: 0;
103
+ border-radius: 0.3rem;
104
+ transition: width 0.18s ease-out;
105
+ background: var(--accent);
106
+ }
107
+ /* Three bands describing the TEXT, not a verdict — the pass bar lives in the
108
+ economy module and this readout deliberately does not know it. */
109
+ .fi-gauge__fill[data-band="thin"] { background: var(--danger); }
110
+ .fi-gauge__fill[data-band="part"] { background: var(--warn); }
111
+ .fi-gauge__fill[data-band="full"] { background: var(--ok); }
112
+ .fi-gauge__n { margin: 0; font-size: 0.9rem; font-variant-numeric: tabular-nums; min-width: 4.2rem; text-align: right; }
113
+
114
+ .fi-meter__list { list-style: none; margin: 0.85rem 0 0; padding: 0; display: grid; gap: 0.45rem; }
115
+ .fi-mrow { display: grid; gap: 0.2rem; }
116
+ .fi-mrow__l { font-size: 0.76rem; opacity: 0.8; line-height: 1.3; }
117
+ .fi-mrow__bar { display: block; height: 0.3rem; border-radius: 0.2rem; background: var(--border); overflow: hidden; }
118
+ .fi-mrow__fill { display: block; height: 100%; width: 0; border-radius: 0.2rem; background: var(--accent); transition: width 0.18s ease-out; }
119
+
120
+ .fi-meter__note { margin: 0.9rem 0 0; font-size: 0.76rem; opacity: 0.65; line-height: 1.5; }
@@ -0,0 +1,95 @@
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>Ideas — {{worldName}}</title>
7
+ <meta name="description" content="Write a Full Idea: five questions that turn a thought into a baseline scope of work someone else can deliver against, with a live completeness score.">
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/ideas.css">
14
+ </head>
15
+ <body data-page="ideas" data-page-title="Ideas">
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"><p class="page-head__sub" id="fi-sub">Loading the idea template…</p></div>
24
+
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. -->
31
+ <section class="scroll" id="fi-compose-scroll" aria-labelledby="fi-compose-h">
32
+ <h2 id="fi-compose-h" class="scroll__h">Write a Full Idea</h2>
33
+ <p class="scroll__lede">
34
+ A Full Idea answers five questions, so that someone else can deliver against it
35
+ without having to interview you first. Open to every role — the score below is
36
+ the only filter, and it counts substance, not who you are.
37
+ </p>
38
+
39
+ <form id="fi-form" novalidate>
40
+ <label class="fi-label" for="fi-title">
41
+ Title
42
+ <span class="fi-hint">One line. This is what the idea is called everywhere else.</span>
43
+ </label>
44
+ <input type="text" id="fi-title" class="fi-input" maxlength="200" autocomplete="off"
45
+ placeholder="what the idea is, in one line">
46
+
47
+ <!-- The five fields are rendered from GET /inbox/template — the R141
48
+ declaration — never hand-written here. A hard-coded form is the
49
+ same "second copy of the rules" defect as a hard-coded scorer,
50
+ one step earlier, and it would silently ignore a per-project
51
+ template (R156). -->
52
+ <div id="fi-fields" class="fi-fields">
53
+ <p class="fi-note">Loading the five questions…</p>
54
+ </div>
55
+
56
+ <p class="fi-err" id="fi-err" role="alert"></p>
57
+
58
+ <div class="fi-actions">
59
+ <button type="submit" class="btn" id="fi-submit" disabled>File this idea</button>
60
+ <button type="button" class="btn btn--ghost" id="fi-clear">Discard draft</button>
61
+ <span class="fi-draft" id="fi-draft" role="status" aria-live="polite"></span>
62
+ </div>
63
+ </form>
64
+ </section>
65
+ </main>
66
+
67
+ <footer class="app-foot" id="app-foot"></footer>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ <!-- The meter rides along beside the form on a wide screen and above it on a
73
+ narrow one (CSS decides). It is a READOUT, never an input: every number in
74
+ it comes from the server's own scorer. -->
75
+ <aside class="fi-meter" id="fi-meter" aria-labelledby="fi-meter-h" hidden>
76
+ <h2 class="fi-meter__h" id="fi-meter-h">Completeness</h2>
77
+ <div class="fi-gauge">
78
+ <div class="fi-gauge__track"><div class="fi-gauge__fill" id="fi-gauge-fill"></div></div>
79
+ <p class="fi-gauge__n" id="fi-gauge-n" role="status" aria-live="polite">—</p>
80
+ </div>
81
+ <ul class="fi-meter__list" id="fi-meter-list"></ul>
82
+ <p class="fi-meter__note">
83
+ Counted, not judged: each question is worth the same, and earns its share in
84
+ proportion to how much real text it carries. Whitespace and padding score nothing.
85
+ </p>
86
+ </aside>
87
+
88
+ <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
89
+
90
+ <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
91
+ <script src="/builders/palette.js" defer></script>
92
+ <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
93
+ <script src="/builders/ideas.js?v=2026-08-22-r152"></script>
94
+ </body>
95
+ </html>
@@ -0,0 +1,335 @@
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
+ // DRAFTS ARE LOCAL. "A partial draft is not lost on navigation" is a done-when,
30
+ // and a half-written thought is not something to put in the database: it has no
31
+ // title yet, no grade, and nothing downstream should be able to see it. It lives
32
+ // in localStorage under one key, is restored on load, and is cleared only when
33
+ // the idea is actually filed.
34
+
35
+ (() => {
36
+ 'use strict';
37
+
38
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
39
+ const { escapeHtml, $, toast } = window.OTB;
40
+
41
+ // One key, this page only. Versioned by template key at save time so a project
42
+ // that changes its template (R156) cannot restore a draft shaped for the old one.
43
+ const DRAFT_KEY = 'hall-full-idea-draft';
44
+ const SCORE_DEBOUNCE_MS = 300;
45
+ const DRAFT_DEBOUNCE_MS = 500;
46
+
47
+ let template = null; // { key, label, fields: [{key,label,required,substance_min}] }
48
+ let lastScore = null;
49
+ let scoreTimer = null;
50
+ let draftTimer = null;
51
+ let filing = false;
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // draft persistence
55
+ // ---------------------------------------------------------------------------
56
+
57
+ // Every localStorage access is wrapped: a private window, cleared site data or
58
+ // a browser set to block storage all THROW on read, and losing the draft
59
+ // feature must never take the composer down with it (the shell.js NAV_COLLAPSE
60
+ // precedent, which guards the same way).
61
+ function readDraft() {
62
+ try {
63
+ const raw = window.localStorage.getItem(DRAFT_KEY);
64
+ if (!raw) return null;
65
+ const d = JSON.parse(raw);
66
+ return (d && typeof d === 'object') ? d : null;
67
+ } catch (_) { return null; }
68
+ }
69
+
70
+ function writeDraft() {
71
+ if (!template) return;
72
+ try {
73
+ window.localStorage.setItem(DRAFT_KEY, JSON.stringify({
74
+ template_key: template.key,
75
+ title: titleEl().value,
76
+ field_values: collect(),
77
+ at: new Date().toISOString(),
78
+ }));
79
+ note('Draft saved in this browser.');
80
+ } catch (_) {
81
+ // Storage refused. Say so once rather than pretending it saved — a builder
82
+ // who believes a draft is safe and navigates away loses the thought.
83
+ note('This browser is not storing drafts — keep the tab open.');
84
+ }
85
+ }
86
+
87
+ function clearDraft() {
88
+ try { window.localStorage.removeItem(DRAFT_KEY); } catch (_) { /* nothing to clear */ }
89
+ }
90
+
91
+ function note(msg) {
92
+ const el = $('#fi-draft');
93
+ if (el) el.textContent = msg;
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // the form, rendered from the template declaration
98
+ // ---------------------------------------------------------------------------
99
+
100
+ const titleEl = () => $('#fi-title');
101
+ const fieldEl = (key) => document.getElementById(`fi-f-${key}`);
102
+
103
+ function renderForm(draft) {
104
+ const host = $('#fi-fields');
105
+ host.innerHTML = template.fields.map((f) => `
106
+ <div class="fi-field" data-key="${escapeHtml(f.key)}">
107
+ <label class="fi-label" for="fi-f-${escapeHtml(f.key)}">
108
+ ${escapeHtml(f.label)}${f.required ? '' : ' <span class="fi-hint">(optional)</span>'}
109
+ </label>
110
+ <textarea id="fi-f-${escapeHtml(f.key)}" class="fi-text" rows="4"
111
+ data-key="${escapeHtml(f.key)}"
112
+ placeholder="…"></textarea>
113
+ <p class="fi-fieldnote" id="fi-n-${escapeHtml(f.key)}"></p>
114
+ </div>
115
+ `).join('');
116
+
117
+ // Restore BEFORE wiring listeners, so restoring does not itself fire a save.
118
+ if (draft && draft.field_values) {
119
+ for (const f of template.fields) {
120
+ const v = draft.field_values[f.key];
121
+ if (typeof v === 'string') fieldEl(f.key).value = v;
122
+ }
123
+ }
124
+ if (draft && typeof draft.title === 'string') titleEl().value = draft.title;
125
+
126
+ for (const f of template.fields) {
127
+ fieldEl(f.key).addEventListener('input', onEdit);
128
+ }
129
+ titleEl().addEventListener('input', onEdit);
130
+
131
+ $('#fi-meter').hidden = false;
132
+ renderMeterSkeleton();
133
+ }
134
+
135
+ function collect() {
136
+ const out = {};
137
+ for (const f of template.fields) out[f.key] = fieldEl(f.key).value;
138
+ return out;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // the meter — every number in it came from the server
143
+ // ---------------------------------------------------------------------------
144
+
145
+ function renderMeterSkeleton() {
146
+ $('#fi-meter-list').innerHTML = template.fields.map((f) => `
147
+ <li class="fi-mrow" id="fi-m-${escapeHtml(f.key)}">
148
+ <span class="fi-mrow__l">${escapeHtml(f.label)}</span>
149
+ <span class="fi-mrow__bar"><span class="fi-mrow__fill" style="width:0%"></span></span>
150
+ </li>
151
+ `).join('');
152
+ }
153
+
154
+ function renderScore(payload) {
155
+ lastScore = payload;
156
+ const score = payload && Number.isInteger(payload.score) ? payload.score : null;
157
+ const fill = $('#fi-gauge-fill');
158
+ const num = $('#fi-gauge-n');
159
+ fill.style.width = `${score == null ? 0 : score}%`;
160
+ // Three bands, and they describe the TEXT rather than a verdict: this number
161
+ // is not a pass mark (see the header note), so the words must not imply one.
162
+ fill.dataset.band = score == null ? 'none' : (score >= 80 ? 'full' : score >= 40 ? 'part' : 'thin');
163
+ num.textContent = score == null ? '—' : `${score}/100`;
164
+
165
+ const byKey = new Map((payload && payload.fields ? payload.fields : []).map((f) => [f.key, f]));
166
+ for (const f of template.fields) {
167
+ const row = document.getElementById(`fi-m-${f.key}`);
168
+ const s = byKey.get(f.key);
169
+ const pct = s ? Math.round(s.ratio * 100) : 0;
170
+ if (row) row.querySelector('.fi-mrow__fill').style.width = `${pct}%`;
171
+ // Per-field prose under the textarea: how much more text this answer wants
172
+ // before it stops costing the total. `floor` and `substance` are the
173
+ // server's numbers — this only subtracts them.
174
+ const n = document.getElementById(`fi-n-${f.key}`);
175
+ if (!n) continue;
176
+ if (!s || s.substance === 0) { n.textContent = ''; continue; }
177
+ const missing = Math.max(0, s.floor - s.substance);
178
+ n.textContent = missing > 0
179
+ ? `about ${missing} more characters before this answer counts in full`
180
+ : 'counts in full';
181
+ n.dataset.ok = missing > 0 ? '0' : '1';
182
+ }
183
+
184
+ // The file button turns on when every REQUIRED field has something in it —
185
+ // capture validation's actual rule (R143 refuses a missing required field and
186
+ // does NOT enforce the substance floor, because a thin answer is a scoring
187
+ // problem, not a capture error). Matching that exactly means the button never
188
+ // promises an outcome the server will refuse, and never blocks one it accepts.
189
+ const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
190
+ $('#fi-submit').disabled = filing || missingRequired || !titleEl().value.trim();
191
+ }
192
+
193
+ async function scoreNow() {
194
+ const body = { field_values: collect() };
195
+ if (template.key) body.template_key = template.key;
196
+ const r = await api.inbox.postInboxScore({ body });
197
+ // A scoring hiccup must not stop someone writing. The gauge simply stops
198
+ // updating; the composer keeps working and filing still validates server-side.
199
+ if (!r.ok) { note('Could not reach the scorer just now — you can still file.'); return; }
200
+ renderScore(r.data);
201
+ }
202
+
203
+ function onEdit() {
204
+ clearTimeout(scoreTimer);
205
+ clearTimeout(draftTimer);
206
+ scoreTimer = setTimeout(() => { scoreNow(); }, SCORE_DEBOUNCE_MS);
207
+ draftTimer = setTimeout(writeDraft, DRAFT_DEBOUNCE_MS);
208
+ // Re-evaluate the button on the keystroke rather than waiting for the score
209
+ // round-trip: it depends only on what is typed.
210
+ if (lastScore) renderScore(lastScore);
211
+ else $('#fi-submit').disabled = filing
212
+ || !titleEl().value.trim()
213
+ || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
214
+ }
215
+
216
+ // ---------------------------------------------------------------------------
217
+ // filing
218
+ // ---------------------------------------------------------------------------
219
+
220
+ function showErr(msg) {
221
+ const el = $('#fi-err');
222
+ el.textContent = msg || '';
223
+ el.dataset.visible = msg ? '1' : '0';
224
+ }
225
+
226
+ // The server names the field it refused (R143's done-when). Render that rather
227
+ // than a generic failure, and point at the field so it can be found.
228
+ function explainFiling(status, data) {
229
+ const err = data && data.error;
230
+ const code = err && typeof err === 'object' ? err.code : err;
231
+ const details = (err && typeof err === 'object' && err.details) || {};
232
+ if (code === 'invalid_field_values') {
233
+ const errors = Array.isArray(details) ? details : (details.errors || []);
234
+ const labelOf = (k) => {
235
+ const f = template.fields.find((x) => x.key === k);
236
+ return f ? f.label : (k || 'the idea');
237
+ };
238
+ return errors.length
239
+ ? `Not filed — ${errors.map((e) => `${labelOf(e.field)}: ${String(e.reason || '').replace(/_/g, ' ')}`).join('; ')}`
240
+ : 'Not filed — the five answers were refused.';
241
+ }
242
+ if (code === 'validation_failed') return 'Not filed — the title is required and must be under 200 characters.';
243
+ if (status === 401 || status === 403) return 'Not filed — your session may have expired. Reload and try again.';
244
+ return `Not filed (${status}). Nothing was saved; your draft is still here.`;
245
+ }
246
+
247
+ async function file(ev) {
248
+ ev.preventDefault();
249
+ if (filing) return;
250
+ filing = true;
251
+ showErr('');
252
+ $('#fi-submit').disabled = true;
253
+
254
+ const r = await api.inbox.postInbox({
255
+ body: {
256
+ title: titleEl().value.trim(),
257
+ grade: 'full',
258
+ template_key: template.key,
259
+ field_values: collect(),
260
+ },
261
+ });
262
+
263
+ filing = false;
264
+ if (!r.ok) {
265
+ showErr(explainFiling(r.status, r.data));
266
+ renderScore(lastScore);
267
+ return;
268
+ }
269
+
270
+ // Filed. NOW the draft goes — not before, so a failed POST never costs the text.
271
+ clearDraft();
272
+ const idea = r.data && r.data.idea;
273
+ const score = idea && Number.isInteger(idea.completeness_score) ? idea.completeness_score : null;
274
+ // The capture path is fail-open on grading (R143): the idea lands even if the
275
+ // grade write did not, and says so. Repeat that here instead of claiming a
276
+ // Full Idea was stored when the response says otherwise.
277
+ const landedFull = idea && idea.grade === 'full';
278
+ toast(landedFull
279
+ ? `Filed as a Full Idea${score == null ? '' : ` — ${score}/100`}.`
280
+ : 'Filed, but stored as a quick idea — the grade could not be written.');
281
+
282
+ titleEl().value = '';
283
+ for (const f of template.fields) fieldEl(f.key).value = '';
284
+ lastScore = null;
285
+ renderScore(null);
286
+ note('');
287
+ if (idea && idea.id) {
288
+ $('#fi-sub').innerHTML = `Filed <a href="/#/idea/${encodeURIComponent(idea.id)}">idea ${escapeHtml(String(idea.id))}</a>. Write another below.`;
289
+ }
290
+ }
291
+
292
+ function discard() {
293
+ if (!template) return;
294
+ clearDraft();
295
+ titleEl().value = '';
296
+ for (const f of template.fields) fieldEl(f.key).value = '';
297
+ lastScore = null;
298
+ renderScore(null);
299
+ showErr('');
300
+ note('Draft discarded.');
301
+ toast('Draft discarded.');
302
+ }
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // boot
306
+ // ---------------------------------------------------------------------------
307
+
308
+ async function main() {
309
+ const r = await api.inbox.getInboxTemplate({});
310
+ if (!r.ok || !r.data || !r.data.template) {
311
+ $('#fi-sub').textContent = 'Could not load the idea template. Reload, or file from the terminal with capture.js.';
312
+ return;
313
+ }
314
+ template = r.data.template;
315
+
316
+ const draft = readDraft();
317
+ // A draft written against a different template is not restorable — the keys
318
+ // may not exist any more. Drop it rather than half-restoring it.
319
+ const usable = draft && (!draft.template_key || draft.template_key === template.key) ? draft : null;
320
+
321
+ $('#fi-sub').textContent = 'Five questions, and a score that counts how much of each you actually answered.';
322
+ renderForm(usable);
323
+ $('#fi-form').addEventListener('submit', file);
324
+ $('#fi-clear').addEventListener('click', discard);
325
+
326
+ if (usable) {
327
+ note('Draft restored.');
328
+ scoreNow();
329
+ } else {
330
+ renderScore(null);
331
+ }
332
+ }
333
+
334
+ main();
335
+ })();
@@ -130,6 +130,14 @@
130
130
  // craft rather than a rank, so hiding this behind one would hide the
131
131
  // artist's own queue from the artist. The module gate drops the tab on an
132
132
  // instance that disables copy-desk.
133
+ // task 1002928 (R152 of goal 1000062): the Full Idea composer. In the FIRST
134
+ // group — this is the project's own work-intake, beside Goals and Tasks,
135
+ // not one of "your numbers". gate 'signed' and NOT a craft or rank gate, on
136
+ // the goal's own settled decision: there is no permission gate on Full
137
+ // Ideas, completeness scoring and triage do the filtering. Hiding the page
138
+ // behind the ideator craft would hide the ideator's own surface from
139
+ // everyone else who has a thought, which is most of the value.
140
+ { id: 'ideas', label: 'Ideas', icon: 'goals', href: '/ideas', gate: 'signed' },
133
141
  { id: 'copy-desk', label: 'Copy desk', icon: 'docs', href: '/copy-desk', gate: 'signed', module: 'copy-desk' },
134
142
  ] },
135
143
  // Your own numbers. task 2174 folded Achievements into Standing and this task