@bongos/core 1.19.1006 → 1.19.1008

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.
@@ -264,7 +264,11 @@
264
264
 
265
265
  renderRecommendation(draft);
266
266
 
267
- $('#fi-meter').hidden = false;
267
+ formRendered = true;
268
+ // The meter is unhidden through the disclosure, which owns the rule for both
269
+ // directions; setting it here as well would be a second source of truth for
270
+ // the same visibility and is what let the reopen case rot unnoticed.
271
+ setDisclosure(fullOpen);
268
272
  renderMeterSkeleton();
269
273
  }
270
274
 
@@ -902,24 +906,78 @@
902
906
  return true;
903
907
  }
904
908
 
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();
909
+ // ---------------------------------------------------------------------------
910
+ // the disclosure (task 1004087 / spec R04, decision 18)
911
+ // ---------------------------------------------------------------------------
912
+ //
913
+ // THE FULL IDEA FORM IS CLOSED UNTIL SOMEONE ASKS FOR IT, and this is the
914
+ // whole point of the fold. Decision 18 put the five questions on the same
915
+ // surface as the one-line composer and named that the riskiest call in the
916
+ // spec: a spark's value is that it costs nothing to have, and a five-field
917
+ // form standing permanently open under the fast path is exactly how a page
918
+ // starts feeling like something to be filled in.
919
+ //
920
+ // So "on the same surface" is spent on ONE press, and nothing else. While the
921
+ // form is shut this page does not fetch the template, does not wire the
922
+ // per-keystroke scorer, and does not hold the rail reserve open — the one-line
923
+ // path is the first thing under the heading at full width, and it is ready
924
+ // before any of the Full Idea machinery has run.
925
+ //
926
+ // Closing again HIDES, it does not discard: the draft lives in localStorage
927
+ // exactly as it did before, so the toggle is reversible in both directions and
928
+ // the button can say so without lying.
929
+
930
+ let fullBooted = false; // the template read was STARTED once
931
+ let formRendered = false; // ...and actually produced a form (and so a meter)
932
+ let fullOpen = false;
933
+
934
+ // The rail reserve belongs to the meter, so it arrives and leaves with it.
935
+ // style.css reserves `--rail-right-w` from `.app-content--railed > main` at the
936
+ // wide breakpoint whether or not anything is in the rail, which is why this is
937
+ // a class toggle and not simply `#fi-meter.hidden`.
938
+ function setRail(on) {
939
+ const c = document.getElementById('fi-content');
940
+ if (!c || !c.classList) return;
941
+ if (on) c.classList.add('app-content--railed');
942
+ else c.classList.remove('app-content--railed');
943
+ }
913
944
 
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();
945
+ function setDisclosure(open) {
946
+ fullOpen = open;
947
+ const form = $('#fi-form');
948
+ const btn = $('#fi-open');
949
+ const note = $('#fi-disclose-note');
950
+ if (form) form.hidden = !open;
951
+ if (btn) {
952
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
953
+ btn.textContent = open ? 'Put the five questions away' : 'Answer the five questions';
954
+ }
955
+ if (note) {
956
+ note.textContent = open
957
+ ? 'Putting them away keeps your draft — it is saved as you type.'
958
+ : 'Nothing you have typed above is lost by opening this.';
922
959
  }
960
+ // The meter is the form's readout, so it follows the form BOTH WAYS — and the
961
+ // condition is written as one expression rather than a hide-only branch
962
+ // because the hide-only version was a real bug: `renderForm` is the only
963
+ // thing that ever unhid the meter, `bootFullIdea` runs it at most once, so
964
+ // opening, closing and opening again brought the form and the rail back with
965
+ // the readout gone for good — the rail reserve held open for a panel that
966
+ // was no longer there, which is the exact anti-pattern the fold removes.
967
+ //
968
+ // `formRendered`, not `fullBooted`: the boot flag is set BEFORE the template
969
+ // read, so it is true even when that read failed and there is no meter to
970
+ // show. An empty meter is a readout claiming a score of nothing.
971
+ const meter = $('#fi-meter');
972
+ if (meter) meter.hidden = !(open && formRendered);
973
+ setRail(open);
974
+ }
975
+
976
+ // Everything the Full Idea half needs, run at most once and never on the fast
977
+ // path. Returns nothing: its failures are stated on the page, as before.
978
+ async function bootFullIdea() {
979
+ if (fullBooted) return;
980
+ fullBooted = true;
923
981
 
924
982
  const r = await api.inbox.getInboxTemplate({});
925
983
  if (!r.ok || !r.data || !r.data.template) {
@@ -958,5 +1016,58 @@
958
1016
  }
959
1017
  }
960
1018
 
1019
+ async function openFullIdea() {
1020
+ setDisclosure(true);
1021
+ await bootFullIdea();
1022
+ }
1023
+
1024
+ async function toggleFullIdea() {
1025
+ if (fullOpen) { setDisclosure(false); return; }
1026
+ await openFullIdea();
1027
+ }
1028
+
1029
+ async function main() {
1030
+ // THE QUEUE IS LOADED FIRST, AND UNCONDITIONALLY. The composer and the spark
1031
+ // queue are independent surfaces that happen to share a page: the queue needs
1032
+ // no template. Loading it after the template check meant one failed fetch took
1033
+ // BOTH down — the reported symptom was "Could not load the idea template" AND a
1034
+ // queue stuck on "Loading…" forever, which read as two bugs and was one.
1035
+ // A surface that does not depend on the failure must not be hostage to it.
1036
+ loadQueue();
1037
+
1038
+ // Quick filing (R08) is a THIRD independent surface on this page, offered
1039
+ // only outside develop mode — developing a spark always produces a Full
1040
+ // Idea, never a fresh quick report (see the section's own header comment).
1041
+ if (DEVELOP_ID) {
1042
+ const scroll = $('#fq-compose-scroll');
1043
+ if (scroll) scroll.hidden = true;
1044
+ } else {
1045
+ bootQuickComposer();
1046
+ }
1047
+
1048
+ const openBtn = $('#fi-open');
1049
+ if (openBtn) openBtn.addEventListener('click', toggleFullIdea);
1050
+ setDisclosure(false);
1051
+
1052
+ // THE THREE CASES A SHUT FORM WOULD STRAND (task 1004087 / spec R04). The
1053
+ // done-when is "no dead links", and a link that lands on a page whose
1054
+ // relevant half is folded away is dead in the only sense that matters to
1055
+ // whoever followed it:
1056
+ //
1057
+ // ?develop=<id> the spark queue's own rows, and /idea/:id — the whole
1058
+ // request is "develop this", so arriving at a shut form
1059
+ // would be the page ignoring it.
1060
+ // #full the deep link anything else uses to mean "the composer",
1061
+ // including /idea/:id's fallback action.
1062
+ // a saved draft half-written work already in localStorage. Leaving it
1063
+ // behind a press nobody knows to make is how "a partial
1064
+ // draft is not lost on navigation" (R152) quietly stops
1065
+ // being true — the draft would still be there and no one
1066
+ // would ever see it again.
1067
+ if (DEVELOP_ID || location.hash === '#full' || readDraft()) {
1068
+ await openFullIdea();
1069
+ }
1070
+ }
1071
+
961
1072
  main();
962
1073
  })();
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The hall's IDEA COMPOSER through modules/ui-design/kit/render.js (task 1003305). The five questions are rendered from GET /inbox/template and the spark queue from GET /inbox/sparks, so the populated form needs the hall-preview harness; the kit's own stub answers neither and renders the honest `could not load the idea template` line instead, which is the `no-template` state below.\n\n node scripts/hall-preview/server.js --port 4612 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/ideas.states.json --base-in http://builders.localhost:4612 --base-out http://builders.localhost:4612\n\nThe right rail is the shell's `.app-content--railed` reserve, so the 62rem breakpoint is exercised at 1440 and stacked at 390/320 — which is the whole reason all three widths are rendered.",
2
+ "_": "The hall's IDEA COMPOSER through modules/ui-design/kit/render.js (task 1003305). The five questions are rendered from GET /inbox/template and the spark queue from GET /inbox/sparks, so the populated form needs the hall-preview harness; the kit's own stub answers neither and renders the honest `could not load the idea template` line instead, which is the `no-template` state below.\n\n node scripts/hall-preview/server.js --port 4612 --fixture-me\n node modules/ui-design/kit/render.js --states modules/hall-ui/public/ideas.states.json --base-in http://builders.localhost:4612 --base-out http://builders.localhost:4612\n\nTHE FOLD (task 1004087 / spec R04) is why there are now three states rather than two. `compose` is what a builder actually lands on — the one-line composer at the top, the Full Idea form folded away behind one press, and NO rail reserve, which is the whole reason that state is rendered at 1440 as well as at 390/320. `full` is the same page one press later: the five questions, the meter, and the rail. Rendering only one of them would measure half the page, and the half that is measured on every arrival is the first.",
3
3
  "page": "/ideas",
4
4
  "surface": "hall-ui",
5
5
  "stub": { "prefix": "/builders/" },
@@ -8,7 +8,8 @@
8
8
  "_console": "The 501 is the harness, not the page: the meter re-scores through POST /inbox/score on every keystroke and scripts/hall-preview/server.js answers every write with preview_readonly by design. Declared rather than hidden.",
9
9
  "ignoreConsole": ["/api/(gds|bongos)/", "\\b401\\b", "\\b404\\b", "\\b501\\b", "Unauthorized", "Not Found", "Not Implemented", "load failed"],
10
10
  "states": {
11
- "compose": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#fi-form", "#fi-fields", "#fi-queue"] } },
11
+ "compose": { "auth": true, "actions": [["wait", 700]], "expect": { "visible": ["#fq-form", "#fi-queue", "#fi-open"] } },
12
+ "full": { "auth": true, "actions": [["wait", 700], ["click", "#fi-open"], ["wait", 700]], "expect": { "visible": ["#fi-form", "#fi-fields", "#fi-meter"] } },
12
13
  "develop": { "auth": true, "url": "/ideas?develop=1000884", "actions": [["wait", 700]], "expect": { "visible": ["#fi-develop"] } }
13
14
  },
14
15
  "reducedMotion": { "auth": true, "actions": [["wait", 700]] }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1006",
3
+ "version": "1.19.1008",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1006",
9
+ "version": "1.19.1008",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1006",
3
+ "version": "1.19.1008",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1006'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1008'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -105,9 +105,30 @@ test('the shell owns BOTH halves of the rail, measured from one value', () => {
105
105
  'the panel is placed from --topbar-h; ideas.css hand-copied it as 6.5rem and nothing kept the two in step');
106
106
  });
107
107
 
108
- test('the ideas composer OPTS IN from its markup', () => {
109
- assert.match(ideasHtml, /class="app-content app-content--railed"/, 'the content column reserves the room');
110
- assert.match(ideasHtml, /class="fi-meter app-rail"/, 'and the meter takes the shell\'s rail frame');
108
+ test('the ideas composer OPTS IN, and since the fold it opts in WITH THE METER', () => {
109
+ // The rule this case has always guarded is that a page takes the rail from the
110
+ // shell's own opt-in and never reaches into shell layout from its own sheet.
111
+ // Task 1004087 (spec R04) folded the Full Idea form away by default, so the
112
+ // opt-in moved from the markup to the moment the meter appears: the reserve
113
+ // narrows the one-line composer at every wide width, and holding it open for a
114
+ // panel nobody asked for is the same defect as hand-copying its offset was.
115
+ const ideasJs = read('ideas.js');
116
+ assert.match(ideasHtml, /class="fi-meter app-rail"/, 'the meter still takes the shell\'s rail frame');
117
+ // Matched on a class ATTRIBUTE, not anywhere in the file: the markup carries a
118
+ // comment naming the class to say why it is not there, and a bare substring
119
+ // search cannot tell an explanation from the thing it explains.
120
+ assert.doesNotMatch(ideasHtml, /class="[^"]*app-content--railed/,
121
+ 'and the reserve is no longer held open in the markup');
122
+ assert.match(ideasJs, /classList\.add\('app-content--railed'\)/, 'ideas.js grants the reserve');
123
+ assert.match(ideasJs, /classList\.remove\('app-content--railed'\)/,
124
+ 'and hands it back — a reserve only ever added is a static one written the long way');
125
+ // The half that would otherwise rot: the page must still not style shell
126
+ // layout. Read with the comments STRIPPED — the sheet's header explains at
127
+ // length which override it used to carry and where that override went, and a
128
+ // check that cannot tell a rule from the note about it fails on the note.
129
+ const ideasRules = ideasCss.replace(/\/\*[\s\S]*?\*\//g, '');
130
+ assert.doesNotMatch(ideasRules, /(^|[\s,>])\.app-(content|rail)\b/m,
131
+ 'ideas.css says what the meter LOOKS like, never where the shell puts it');
111
132
  });
112
133
 
113
134
  // ---- 2. the button vocabulary that had no rule -----------------------------
@@ -65,6 +65,13 @@ function boot({
65
65
  // `{ outcome: null }`, which is the server saying "nothing to state yet"; the
66
66
  // line must go quiet for both, and there is a case below for each.
67
67
  landing = undefined,
68
+ // task 1004087 (spec R04) — the #full deep link, which auto-opens the folded
69
+ // Full Idea form. Part of boot() because the fold reads it once, at boot.
70
+ hash = '',
71
+ // task 1004087: a template read that FAILS. bootFullIdea returns early, so no
72
+ // form and no meter — distinct from "folded", and the distinction is the
73
+ // reason the meter follows formRendered rather than the boot flag.
74
+ templateOk = true,
68
75
  // OUT-OF-ORDER READS (task 1004108). Names of fakes whose answers are PARKED
69
76
  // instead of returned: the call is recorded and its response computed at once,
70
77
  // but the caller's `await` does not resolve until the test releases it. That is
@@ -79,8 +86,14 @@ function boot({
79
86
  const makeEl = () => ({
80
87
  dataset: {}, style: {}, textContent: '', value: '', hidden: false, disabled: false,
81
88
  readOnly: false, tabIndex: 0, _html: '', _handlers: {},
82
- classList: { add() {}, remove() {} },
83
- setAttribute() {}, getAttribute() { return null; },
89
+ // task 1004087: attributes and classes are RECORDED rather than swallowed.
90
+ // The fold's two tells are an aria-expanded on the toggle and the rail class
91
+ // on the content wrapper, and a no-op stub agrees with every implementation
92
+ // including a broken one.
93
+ _attrs: {}, _classes: new Set(),
94
+ get classList() { const s = this._classes; return { add: (c) => s.add(c), remove: (c) => s.delete(c), contains: (c) => s.has(c) }; },
95
+ setAttribute(k, v) { this._attrs[String(k)] = String(v); },
96
+ getAttribute(k) { const v = this._attrs[String(k)]; return v === undefined ? null : v; },
84
97
  addEventListener(t, fn) { this._handlers[t] = fn; },
85
98
  querySelector() { return makeEl(); }, querySelectorAll() { return []; }, appendChild() {},
86
99
  set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
@@ -114,7 +127,10 @@ function boot({
114
127
 
115
128
  const client = {
116
129
  inbox: {
117
- getInboxTemplate: async () => ({ status: 200, ok: true, data: { template: TEMPLATE } }),
130
+ // RECORDED (task 1004087): the fold's cost claim is 'the template is not
131
+ // read until someone asks, and is read once'. A fake that does not count
132
+ // its calls cannot tell either half from a page that reads it every time.
133
+ getInboxTemplate: async () => { calls.push(['template', null]); return templateOk ? { status: 200, ok: true, data: { template: TEMPLATE } } : { status: 500, ok: false, data: null }; },
118
134
  getInboxSparks: async () => { calls.push(['sparks', null]); return { status: 200, ok: true, data: { sparks, count: count == null ? sparks.length : count } }; },
119
135
  getInboxId: async (a) => { calls.push(['idea', a]); return { status: ideaOk ? 200 : 404, ok: ideaOk, data: ideaOk ? { idea } : null }; },
120
136
  postInboxScore: async (a) => { calls.push(['score', a]); return { status: 200, ok: true, data: { score: 60, fields: TEMPLATE.fields.map((f) => ({ key: f.key, substance: 24, floor: 40, ratio: 0.6 })) } }; },
@@ -186,7 +202,7 @@ function boot({
186
202
  setItem: (k, v) => { store[k] = String(v); },
187
203
  removeItem: (k) => { delete store[k]; },
188
204
  },
189
- location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search, assign() {} },
205
+ location: { hostname: 'builders.cloudbongos.com', pathname: '/ideas', search, hash, assign() {} },
190
206
  addEventListener() {},
191
207
  };
192
208
 
@@ -200,6 +216,10 @@ function boot({
200
216
  calls, toasts, store, el, deferred,
201
217
  queueHtml: () => el('fi-queue').innerHTML,
202
218
  fill: () => { for (const f of TEMPLATE.fields) el(`fi-f-${f.key}`).value = `an answer for ${f.key} long enough to count`; },
219
+ // task 1004087 (spec R04) — the one press that unfolds the Full Idea form.
220
+ // Every case below that reaches the five questions now goes through it,
221
+ // because a real builder does too.
222
+ openFull: async () => { el('fi-open')._handlers.click(); await settle(); await settle(); },
203
223
  submit: () => el('fi-form')._handlers.submit({ preventDefault() {} }),
204
224
  submitQuick: () => el('fq-form')._handlers.submit({ preventDefault() {} }),
205
225
  };
@@ -345,6 +365,10 @@ await test('the plain composer is unaffected — develop mode is never entered',
345
365
  // tests/idea_hall_composer.mjs pins over the HTML source; what matters at
346
366
  // runtime is that nothing reveals it, and the tell is that no idea was fetched.
347
367
  assert.ok(!r.calls.some((c) => c[0] === 'idea'), 'no idea is loaded without ?develop=');
368
+ // task 1004087 (spec R04): in plain mode the five questions are FOLDED, so a
369
+ // builder who wants them presses for them. That press is the whole cost of
370
+ // decision 18, and it is spent here rather than assumed away.
371
+ await r.openFull();
348
372
  assert.equal(r.el('fi-title').readOnly, false, 'the title stays editable');
349
373
  r.el('fi-title').value = 'a new idea';
350
374
  r.fill();
@@ -789,5 +813,172 @@ await test('a slow preview answering after a faster one never repaints the stale
789
813
  // page rather than pin it. That is a production bug to file, not a test to
790
814
  // write, and the harness is now able to reach it the day it is fixed.
791
815
 
816
+
817
+ // --- the fold (task 1004087 / spec R04, decision 18) ----------------------
818
+ //
819
+ // Decision 18 put the five questions on the same surface as the one-line
820
+ // composer and called it the riskiest decision in the spec, because a spark's
821
+ // whole value is that it costs nothing to have. The cases below are what
822
+ // "folded" has to mean for that to stay true, and each one is written as a
823
+ // property of the FAST path, not of the form: a page that opened the five
824
+ // questions eagerly would pass a test that only asked "is the form present".
825
+
826
+ await test('the fast path costs nothing: no template read, no scorer, no rail', async () => {
827
+ const r = boot();
828
+ await settle(); await settle();
829
+ assert.ok(!r.calls.some((c) => c[0] === 'template'), 'the template is not read at all until someone asks for the questions');
830
+ assert.ok(!r.calls.some((c) => c[0] === 'score'), 'nothing is scored before anyone asks to write');
831
+ assert.ok(!r.el('fi-fields').innerHTML.includes(TEMPLATE.fields[0].label),
832
+ 'and the five questions are not rendered — the template read has not happened at all');
833
+ assert.equal(r.el('fi-form').hidden, true, 'the five questions are folded away');
834
+ assert.equal(r.el('fi-meter').hidden, true, 'and so is their readout');
835
+ assert.equal(r.el('fi-content')._classes.has('app-content--railed'), false,
836
+ 'the rail reserve is NOT held open — that reserve narrows the one-line form at every wide width');
837
+ assert.equal(r.el('fi-open').getAttribute('aria-expanded'), 'false');
838
+ // The quick composer is live at the same moment, so the fold costs the fast
839
+ // path nothing in readiness either.
840
+ assert.equal(typeof r.el('fq-form')._handlers.submit, 'function', 'the one-line path is already submittable');
841
+ });
842
+
843
+ await test('one press unfolds it, and only then does the page fetch the template', async () => {
844
+ const r = boot();
845
+ await settle(); await settle();
846
+ await r.openFull();
847
+ assert.equal(r.el('fi-form').hidden, false);
848
+ assert.equal(r.el('fi-meter').hidden, false, 'the meter arrives with the form it reads');
849
+ assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true, 'and the rail reserve arrives with the meter');
850
+ assert.equal(r.el('fi-open').getAttribute('aria-expanded'), 'true');
851
+ assert.ok(r.el('fi-fields').innerHTML.includes(TEMPLATE.fields[0].label), 'the five questions rendered from the template read');
852
+ });
853
+
854
+ await test('putting them away hides them and KEEPS the draft — it is not a discard', async () => {
855
+ const r = boot();
856
+ await settle(); await settle();
857
+ await r.openFull();
858
+ r.el('fi-title').value = 'half a thought';
859
+ r.fill();
860
+ r.el(`fi-f-${TEMPLATE.fields[0].key}`)._handlers.input();
861
+ await new Promise((res) => setTimeout(res, 700));
862
+ const saved = JSON.stringify(r.store);
863
+ r.el('fi-open')._handlers.click();
864
+ await settle();
865
+ assert.equal(r.el('fi-form').hidden, true, 'the form folds away again');
866
+ assert.equal(r.el('fi-content')._classes.has('app-content--railed'), false, 'and hands the width back');
867
+ assert.equal(r.el('fi-meter').hidden, true);
868
+ assert.equal(JSON.stringify(r.store), saved, 'nothing written by the draft was thrown away');
869
+ });
870
+
871
+ await test('the template is read ONCE however many times it is folded', async () => {
872
+ const r = boot();
873
+ await settle(); await settle();
874
+ await r.openFull();
875
+ r.el('fi-open')._handlers.click(); await settle();
876
+ await r.openFull();
877
+ // The template fake does not push to `calls`, so the tell is the render: a
878
+ // second boot would replace the rendered fields with the loading line first.
879
+ assert.equal(r.calls.filter((c) => c[0] === 'template').length, 1,
880
+ 'the template is fetched on the FIRST open and never again — a re-boot per open would also re-wire every listener');
881
+ assert.ok(r.el('fi-fields').innerHTML.includes(TEMPLATE.fields[0].label), 'and the form survives a fold and an unfold');
882
+ });
883
+
884
+ // --- the three things a shut form would strand ----------------------------
885
+
886
+ await test('?develop= opens it without being asked — the whole request was "develop this"', async () => {
887
+ const r = boot({ search: '?develop=77' });
888
+ await settle(); await settle();
889
+ assert.equal(r.el('fi-form').hidden, false, 'a develop link that landed on a folded form would be a dead link');
890
+ assert.equal(r.el('fi-develop').hidden, false);
891
+ assert.equal(r.el('fi-open').getAttribute('aria-expanded'), 'true');
892
+ });
893
+
894
+ await test('the #full deep link opens it — /idea/:id sends people here by that name', async () => {
895
+ const r = boot({ hash: '#full' });
896
+ await settle(); await settle();
897
+ assert.equal(r.el('fi-form').hidden, false);
898
+ assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true);
899
+ });
900
+
901
+ await test('a saved draft opens it — half-written work is never left behind a press', async () => {
902
+ const r = boot({ store: { 'hall-full-idea-draft': JSON.stringify({ title: 'a thought from yesterday', values: {} }) } });
903
+ await settle(); await settle();
904
+ assert.equal(r.el('fi-form').hidden, false, 'a draft nobody can see is a draft that was lost');
905
+ assert.equal(r.el('fi-title').value, 'a thought from yesterday', 'and it is restored, not merely revealed');
906
+ });
907
+
908
+ await test('an UNRELATED stored key does not open it — only a real draft does', async () => {
909
+ const r = boot({ store: { 'hall-full-idea-draft:develop:99': JSON.stringify({ title: 'someone else', values: {} }) } });
910
+ await settle(); await settle();
911
+ assert.equal(r.el('fi-form').hidden, true, 'a develop-scoped draft is not this page\'s draft');
912
+ });
913
+
914
+ // --- the markup half ------------------------------------------------------
915
+ //
916
+ // The stub DOM does not parse ideas.html, so every `hidden` above is the one
917
+ // ideas.js set at runtime. These two read the real file, for the same reason
918
+ // the receipts and gravity cases in this suite do: the runtime assertions
919
+ // would all still pass against a page that shipped the form open.
920
+
921
+ await test('ideas.html ships the form hidden and the fast path FIRST', async () => {
922
+ const html = fs.readFileSync(path.join(HALL, 'ideas.html'), 'utf8');
923
+ assert.match(html, /<form id="fi-form" novalidate hidden>/, 'the five questions ship folded');
924
+ assert.match(html, /id="fi-open"[^>]*aria-expanded="false"/, 'and the toggle ships saying so');
925
+ assert.ok(html.indexOf('id="fq-compose-scroll"') < html.indexOf('id="fi-compose-scroll"'),
926
+ 'the one-line composer comes before the five questions in the DOM, not merely on screen');
927
+ assert.ok(html.indexOf('id="fq-compose-scroll"') < html.indexOf('id="fi-queue-scroll"'),
928
+ 'and before the spark queue, which feeds the form rather than the fast path');
929
+ assert.doesNotMatch(html, /class="app-content app-content--railed"/,
930
+ 'the rail reserve is no longer held open in the markup — ideas.js grants it with the meter');
931
+ });
932
+
933
+ await test('/idea/:id sends people to a form that will be open when they arrive', async () => {
934
+ const src = fs.readFileSync(path.join(HALL, 'idea.js'), 'utf8');
935
+ assert.match(src, /\/ideas\?develop=\$\{encodeURIComponent\(idea\.id\)\}/,
936
+ 'the spark page develops THAT spark, rather than dropping someone on a bare composer');
937
+ assert.match(src, /\/ideas#full/, 'and the fallback action names the composer the fold answers to');
938
+ assert.doesNotMatch(src, /href="\$\{hallBase\(\)\}\/ideas"/,
939
+ 'no link is left pointing at the page with no word about which half it meant');
940
+ });
941
+
942
+
943
+
944
+ // --- REOPENING, which is the case the first round of this task missed --------
945
+ //
946
+ // The fold's own interaction is open → close → open, and the first version of
947
+ // it shipped a meter that never came back: `renderForm` was the only thing that
948
+ // ever unhid the meter, `bootFullIdea` runs it at most once, so the second open
949
+ // restored the form AND the rail reserve with the readout gone for good. Every
950
+ // close case below passed, because none of them opened again.
951
+ //
952
+ // The lesson is the one in the harness's own header about the parked reads: a
953
+ // probe that reds nothing is not proof the code is right, and neither is a case
954
+ // that stops one step short of the interaction it is named after.
955
+
956
+ await test('reopening brings the METER back, not just the form and the rail', async () => {
957
+ const r = boot();
958
+ await settle(); await settle();
959
+ await r.openFull();
960
+ assert.equal(r.el('fi-meter').hidden, false, 'shown on the first open');
961
+ r.el('fi-open')._handlers.click(); await settle();
962
+ assert.equal(r.el('fi-meter').hidden, true, 'away with the form');
963
+ await r.openFull();
964
+ assert.equal(r.el('fi-form').hidden, false, 'the form comes back');
965
+ assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true, 'and so does the rail reserve');
966
+ assert.equal(r.el('fi-meter').hidden, false,
967
+ 'and so does the meter — a rail held open for a panel that never returns is the anti-pattern the fold removes');
968
+ });
969
+
970
+ await test('a template that never loaded shows NO meter, however often it is folded', async () => {
971
+ const r = boot({ templateOk: false });
972
+ await settle(); await settle();
973
+ await r.openFull();
974
+ assert.equal(r.el('fi-meter').hidden, true, 'there is no form, so there is nothing to read out');
975
+ r.el('fi-open')._handlers.click(); await settle();
976
+ await r.openFull();
977
+ assert.equal(r.el('fi-meter').hidden, true,
978
+ 'and reopening must not reveal an empty gauge — a readout showing nothing is a score of nothing');
979
+ assert.equal(r.el('fi-content')._classes.has('app-content--railed'), true,
980
+ 'the rail still follows the OPEN state, so this is a real distinction and not both flags moving together');
981
+ });
982
+
792
983
  console.log(`\nidea_spark_hall: ${passed} passed, ${failed} failed`);
793
984
  if (failed > 0) process.exit(1);