@bongos/core 1.19.1050 → 1.19.1052

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.
Files changed (51) hide show
  1. package/.bongos-core.json +65 -55
  2. package/docs/copy-inventory.md +85 -79
  3. package/docs/copy-registry.json +212 -138
  4. package/docs/module-api-changelog.md +4 -0
  5. package/modules/hall-ui/public/atlas.html +1 -1
  6. package/modules/hall-ui/public/blockers.html +1 -1
  7. package/modules/hall-ui/public/board-room.html +1 -1
  8. package/modules/hall-ui/public/collab.html +1 -1
  9. package/modules/hall-ui/public/copy-desk.html +1 -1
  10. package/modules/hall-ui/public/deploy.html +1 -1
  11. package/modules/hall-ui/public/diagrams.html +1 -1
  12. package/modules/hall-ui/public/drachmae.html +1 -1
  13. package/modules/hall-ui/public/fleet.html +1 -1
  14. package/modules/hall-ui/public/gate.html +1 -1
  15. package/modules/hall-ui/public/goals.html +1 -1
  16. package/modules/hall-ui/public/government.html +1 -1
  17. package/modules/hall-ui/public/idea.html +1 -1
  18. package/modules/hall-ui/public/ideas.html +1 -1
  19. package/modules/hall-ui/public/index.html +1 -1
  20. package/modules/hall-ui/public/modules.html +1 -1
  21. package/modules/hall-ui/public/primer.html +1 -1
  22. package/modules/hall-ui/public/profile.html +1 -1
  23. package/modules/hall-ui/public/project-settings.html +1 -1
  24. package/modules/hall-ui/public/ranks.html +1 -1
  25. package/modules/hall-ui/public/roadmap.html +1 -1
  26. package/modules/hall-ui/public/roster.html +1 -1
  27. package/modules/hall-ui/public/sessions.html +1 -1
  28. package/modules/hall-ui/public/settings.html +1 -1
  29. package/modules/hall-ui/public/shell.js +77 -1
  30. package/modules/hall-ui/public/sky-data.js +13 -2
  31. package/modules/hall-ui/public/sky-interaction.js +13 -8
  32. package/modules/hall-ui/public/sky-layout.js +6 -1
  33. package/modules/hall-ui/public/studio.html +10 -4
  34. package/modules/hall-ui/public/studio.states.json +76 -1
  35. package/modules/hall-ui/public/style.css +53 -0
  36. package/modules/hall-ui/public/task.html +1 -1
  37. package/modules/hall-ui/public/thinking.css +54 -16
  38. package/modules/hall-ui/public/thinking.html +38 -18
  39. package/modules/hall-ui/public/thinking.states.json +19 -2
  40. package/modules/hall-ui/public/watch.html +1 -1
  41. package/modules/hall-ui/public/work.html +1 -1
  42. package/modules/hall-ui/records/sky.md +9 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/seed-bv2-tweak-mode-tasks.js +277 -0
  47. package/src/module-api.js +1 -1
  48. package/tests/goal_map_page.mjs +5 -3
  49. package/tests/hall_audit.mjs +2 -1
  50. package/tests/hall_fullscreen.mjs +196 -0
  51. package/tests/nav_permission_atoms.mjs +1 -1
@@ -0,0 +1,277 @@
1
+ // scripts/gds/seed-bv2-tweak-mode-tasks.js — seed the Tweak Mode chain into goal 1000095 (planning session, task 1004312)
2
+ //
3
+ // WHY. The 2026-09-27 planning session settled Tweak Mode — an artist takes a
4
+ // whole page from the lofi studio, rewrites every word of it as one document
5
+ // (or offline in Word and uploads it back), submits, and approves the applied
6
+ // page before it ships — as a dependency CHAIN inside the existing
7
+ // working-area-6 goal. Tweak Mode REPLACES the per-ship artist review (same
8
+ // payouts), text only for now. Source of truth: docs/specs/bongos-v2-tweak-mode.md
9
+ // (final); the approved screens: docs/design/mocks/tweak-mode/.
10
+ //
11
+ // Lands: one criterion, the scope widening the chain needs, the new tasks, the
12
+ // rescope of 1003971 (1004238 merged into it), 1004078 merged into TW14, a
13
+ // split-off backlog task for ARTIST MODE's photo half, and every edge.
14
+ //
15
+ // Idempotent: tasks on (source, source_ref) — a reused ref returns 200 with the
16
+ // FIRST row, so a 200 whose title is not ours is reported, not trusted; the
17
+ // criterion on criterion_id (409 = already seeded); scope is a set-union; edges
18
+ // and criterion links are idempotent server-side; merge reports already_merged.
19
+ //
20
+ // Ends with verifySeedCriteriaLinks. The goal already carries other criteria, so
21
+ // the expected counts are the goal's PRE-seed linked_tasks plus this seed's plan
22
+ // (re-running after a full success therefore reports a mismatch on wa6-tweak-mode:
23
+ // that is the plan being counted twice, not a mislink).
24
+ //
25
+ // Run: node scripts/gds/seed-bv2-tweak-mode-tasks.js [--dry-run]
26
+ const os = require('os');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const { verifySeedCriteriaLinks } = require('./seed-verify-criteria');
30
+ // cliExit, never process.exit after a request: a hard exit mid-teardown aborts
31
+ // natively on Windows + Node 24 and reports a false exit code (task 1002734).
32
+ const { cliExit } = require('./cli-lib');
33
+
34
+ function loadSession() {
35
+ const candidates = [
36
+ path.join(os.homedir(), '.config', 'cloudbongos', 'gds-session.json'),
37
+ path.join(os.homedir(), '.config', 'otb', 'gds-session.json'),
38
+ ];
39
+ for (const p of candidates) if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8'));
40
+ throw new Error('no Bongos session found — run /builder-setup');
41
+ }
42
+ const sess = loadSession();
43
+ const BASE = (process.env.GDS_API_BASE || sess.api_base).replace(/\/$/, '');
44
+ const H = { Authorization: `Bearer ${sess.token}`, 'Content-Type': 'application/json' };
45
+ const DRY = process.argv.includes('--dry-run');
46
+
47
+ const VER = 'BONGOS-V2';
48
+ const GOAL = 1000095;
49
+ const SRC = 'planning-session';
50
+ const REF = 'bv2-tweak-mode';
51
+ const CRIT = 'wa6-tweak-mode';
52
+ const SPEC = 'docs/specs/bongos-v2-tweak-mode.md';
53
+ const MOCKS = 'docs/design/mocks/tweak-mode/';
54
+ const ARTIST_MODE = 1003971; // rescoped into TW16
55
+ const LOFI_PAGE = 1004238; // merged into 1003971
56
+ const STUDIO_COPY = 1004078; // merged into TW14
57
+ const SCOPE_ADD = ['copy-desk', 'ui-design', 'public-landing', 'status-ui', 'economy'];
58
+
59
+ const CRITERION_MD = `**An artist can take any page of their project from untweaked to tweaked without leaving the studio or touching a terminal.**
60
+
61
+ Today an artist's work arrives as one review task per ship and one flag per string, which cannot keep up with how much the project ships; most of it was abandoned unread. When this is true, the page is the unit of an artist's work, and it all happens from one calm place:
62
+
63
+ - The studio is a lofi home with four doors: Resume, Tweak/CopyWrite Flow, Approval queue and Artist Review Status. The last three open in place as glass panels, and the studio's own words can be rewritten where they stand. The artist's credit tally sits in the corner.
64
+ - Every page on every surface (landing, builders hall, status) has a tweak status with a count ("text tweaked 2x"), a changelog and a changed-since-last-tweak marker. Artist Review Status sums it per surface in three lines.
65
+ - The Tweak/CopyWrite Flow recommends the next page (asked-for first, then changed, then untweaked) and lets the artist pick another; taking it claims the page for them alone.
66
+ - The artist sees every word the rendered page shows as one document and types over it, or downloads it as a .docx, works offline, and uploads it back into the draft. The draft survives closing the tab. One submit, then "next up".
67
+ - The applied page is rendered in the Approval queue, flipped between before and after with the changed words highlighted; nothing ships until the artist approves or sends it back with a note.
68
+ - Text only: nothing structural can change, and shared text changes only on the page being tweaked unless it is literally the same UI element. Text the tool cannot place in the source goes to an engineer as its own task.
69
+ - Tweak Mode replaces the per-ship artist review, and the artist is paid for an approved page what the reviews paid.
70
+ - It works on any Bongos project.
71
+
72
+ *Written 2026-09-27 by the area owner (planning session, task 1004312). Spec: ${SPEC}. Screens: ${MOCKS}.*`;
73
+
74
+ const S = `Spec: ${SPEC} (Tweak Mode chain, goal 1000095). Prototype: ${MOCKS} (README maps each screen).`;
75
+ // The prototype is the MASTER: screens are built 1:1 on it, and the proof is a
76
+ // side-by-side, not a claim.
77
+ const PIXEL = (board, part) => `\n\nPIXEL CONTRACT (1:1): ${MOCKS}${board}${part ? ` (${part})` : ''} is the master design, approved by the owner. Build it 1:1: the same layout, words, sizes, colours, glass and states. Before shipping, render the built page with modules/ui-design/kit at 1440 and 390 wide, in light and dark, and put each render beside the artboard with scripts/gds/review-sheet.js. Every difference is either fixed or listed in the ship notes with the gate or route that forced it. An unlisted difference is a defect.`;
78
+
79
+ const TASKS = [
80
+ { ref: 'TW01', kind: 'infra', disc: 'engineer', prio: 5, est: 30, deps: [],
81
+ title: 'BV2.TW01 · Turn the artist gate off on the Cloud Bongos instance',
82
+ desc: `Owner decision 2026-09-27: the artist gate (ADR 0241) is off for now; approval of page tweaks moves into the studio's Approval queue (TW12).\n\nSet the Cloud Bongos instance's artistGate to 'off' through the provisioning settings path (modules/provisioning, the artistGate vocabulary key), not by hand-editing a host file. Record the before/after value on this task. Task 1004107 (the gate's contradiction) stays open for when the gate returns.\n\nDONE WHEN: the live instance reports artistGate 'off' and a deploy is no longer held by an unresolved artist review. ${S}` },
83
+ { ref: 'TW02', kind: 'feature', disc: 'engineer', prio: 5, est: 150, deps: [],
84
+ title: 'BV2.TW02 · Page inventory: every page of every surface gets a stable id',
85
+ desc: `The PAGE is Tweak Mode's unit, and pages are implicit today (body[data-page] in the hall, file names on landing and status). Generate an inventory: for each surface (builders hall, landing, status) every page with a stable id, title, live url, the files that render it, and its .states.json states where present. Portable: derived from the modules an instance has enabled. Freshness-checked like the copy registry (a --check the ship pipeline runs).\n\nDONE WHEN: the inventory lists every hall, landing and status page with stable ids and a --check fails on drift. ${S}` },
86
+ { ref: 'TW03', kind: 'feature', disc: 'engineer', prio: 5, est: 120, deps: ['TW02'],
87
+ title: 'BV2.TW03 · ADR: write down the page tweak model',
88
+ desc: `The owner has already made these decisions (spec, "Design decisions"); this task WRITES THEM DOWN as one ADR so every later task builds on the same model. It is not a design pass: where the spec is genuinely silent, file a blocker for the owner rather than choosing. The ADR records:\n- page status DERIVED from the ledger (approved, shipped tweak tasks per page id; text and ui counts), the changelog, drift ("changed since last tweak"), and the per-surface Artist Review Status sums;\n- one active tweak per page, the claim, page asks, and the recommendation order (asked-for, then changed, then untweaked);\n- the draft kept on the tweak task and the page-batch proposal (amends ADR 0233), with copy_no_cms.mjs staying green;\n- the .docx round trip: the file's shape, how an upload maps back to lines, and what an upload that changes structure is refused with;\n- the waiting-for-artist state and send-back;\n- REPLACING the artist-review-on-ship cascade (lifecycle/cascade.js), and paying an approved page what the reviews paid.\nConfirm or re-cut TW04–TW16 against it.\n\nDONE WHEN: the ADR is merged and TW04–TW16 match it. ${S}` },
89
+ { ref: 'TW04', kind: 'feature', disc: 'engineer', prio: 5, est: 240, deps: ['TW02'],
90
+ title: 'BV2.TW04 · Page reader: every string a rendered page shows',
91
+ desc: `The copy registry (docs/copy-registry.json) is keyed by FILE, includes comment text no one sees, and misses runtime-built and server-sent text. The page reader answers "what does this page actually show": render each inventory page in each state (scripts/hall-preview/server.js fixtures + modules/ui-design/kit; landing and status the same way) and list every visible string in page order, grouped by section. Map each to a registry entry (file + line), or mark it UNPLACED; mark shared-shell text (top bar, footer) as a SHARED ELEMENT.\n\nDONE WHEN: every inventory page has a reading with placed / unplaced / shared marked, and a test proves an unplaced string is listed, not dropped. ${S}` },
92
+ { ref: 'TW05', kind: 'feature', disc: 'engineer', prio: 5, est: 180, deps: ['TW03', 'TW04'],
93
+ title: 'BV2.TW05 · Page tweak status, Artist Review Status and the artist tally (the reads)',
94
+ desc: `Per TW03, derived from the ledger, no status table:\n- per page: status (Untweaked / Text tweaked / UI tweaked / Both), counts ("2x"), changelog (artist, date, lines, before/after), and changed-since-last-tweak lines;\n- per surface (Artist Review Status): "N of M tweaked";\n- per artist (the studio's corner tally): credits earned from page tweaks, pages tweaked, and this week's gain.\n\nDONE WHEN: all three reads are correct for a fixture with an untweaked page, a page tweaked twice, and a tweaked page whose text changed afterwards. ${S}` },
95
+ { ref: 'TW06', kind: 'feature', disc: 'engineer', prio: 5, est: 120, deps: ['TW03'],
96
+ title: 'BV2.TW06 · Claim a page, and ask for one',
97
+ desc: `A builder holding the Artist role claims a page's tweak in one call, which creates (or reuses) the page's tweak task and claims it for them; a second claim while one is held is refused and names the holder. Anyone can ask for a page with a one-line reason; asks feed the recommendation (TW07). The page ask replaces per-string flags as the main signal.\n\nDONE WHEN: an artist claims a page; a second artist is refused with the holder named; a non-artist can ask but not claim. ${S}` },
98
+ { ref: 'TW07', kind: 'feature', disc: 'engineer', prio: 5, est: 90, deps: ['TW05', 'TW06'],
99
+ title: 'BV2.TW07 · The Tweak/CopyWrite Flow: recommend the next page',
100
+ desc: `The chain the owner asked for: finish a page, get the next one. One read returns the recommended next page for this artist and why ("2 people asked for it"), plus a short list of alternatives for "pick another". Order per TW03: asked-for first, then changed since last tweak, then untweaked; never a page someone else holds. Taking it is TW06's claim.\n\nDONE WHEN: the recommendation follows the order on a fixture, skips held pages, and "take next" claims the recommended page in one call. ${S}` },
101
+ { ref: 'TW08', kind: 'feature', disc: 'engineer', prio: 5, est: 180, deps: ['TW03', 'TW04', 'TW06'],
102
+ title: 'BV2.TW08 · Page-batch proposal with a saved draft',
103
+ desc: `Extend the copy-desk proposal (ADR 0233, amended by TW03) from one occurrence to a whole page. The artist's draft saves onto the page's tweak task as they write; submit freezes it into the page-batch patch and queues it for /tweak. A rewrite of an UNPLACED line files its own engineer task carrying the wording and does not block the page. Targets resolve server-side from the page reading, never from the request body.\n\nDONE WHEN: draft save, submit and the unplaced split work end to end, and modules/copy-desk/tests/copy_no_cms.mjs stays green. ${S}` },
104
+ { ref: 'TW09', kind: 'feature', disc: 'engineer', prio: 5, est: 180, deps: ['TW08'],
105
+ title: 'BV2.TW09 · Work offline: download a page as .docx and upload it back',
106
+ desc: `Owner decision: an artist can download the page's words as a Word document, work offline, and upload it back into the draft. Download renders the page reading as a .docx (sections as headings, one paragraph per line, shared and engineer lines marked). Upload maps paragraphs back to lines per TW03 and merges them into the draft (never straight to the site). An upload that adds, drops or reorders lines is refused with a plain explanation of which lines moved. Cheapest viable library, no paid service.\n\nDONE WHEN: a round trip with edits lands those edits in the draft, and a structure-changing upload is refused by name. ${S}` },
107
+ { ref: 'TW10', kind: 'feature', disc: 'ui', prio: 5, est: 240, deps: ['TW04', 'TW06', 'TW08', 'TW09'],
108
+ title: 'BV2.TW10 · The tweak editor: the whole page as one document',
109
+ desc: `The artist's own flow, not Claude's. Over the dimmed studio: a glass top bar (back to studio, page name, lines rewritten + saved, open the page in a new tab, download .docx, upload .docx, submit page) and one paper document holding every word of the page in page order, typed over in place, autosaving (TW08). A changed line carries a small accent mark and a faint "was:"; shared and engineer lines carry a margin note. Submit opens "<page> is in" with the next recommended page (TW07): take next, pick another, back to the studio. No terminal anywhere; /design floors apply.\n\nDONE WHEN: an artist rewrites a page, closes the tab, returns to the draft, round-trips it through Word, submits, and takes the next page, at desktop and phone width in light and dark. ${S}${PIXEL('Editor.dc.html')}` },
110
+ { ref: 'TW11', kind: 'feature', disc: 'engineer', prio: 5, est: 240, deps: ['TW08'],
111
+ title: 'BV2.TW11 · /tweak skill + batch applier',
112
+ desc: `A new skill, /tweak: a builder or agent session takes the next submitted page, claims it, and applies every line via scripts/gds/copy-apply.js in a new --batch mode (keeps \${expr} holes; names each refusal). It renders the applied page before and after (desktop + phone, light + dark) through the ui-design kit, attaches both to the task, and parks it at waiting-for-artist (TW03). It never ships without the artist's approval (TW12).\n\nDONE WHEN: /tweak applies a multi-line page batch, attaches the renders and parks; a refused line is named, not skipped. ${S}` },
113
+ { ref: 'TW12', kind: 'feature', disc: 'ui', prio: 5, est: 180, deps: ['TW11'],
114
+ title: 'BV2.TW12 · The Approval queue: the applied page, rendered, before and after',
115
+ desc: `A glass panel opened from the studio. It shows the applied page ITSELF, rendered with its new words (not a list of strings), with a before/after switch, the changed words highlighted, and the queue position ("1 of 2"). Approve and ship (the ship proceeds, the page's count goes up, the next page in the queue is named), or send it back with a one-line note (it returns to the tweak queue). Added to the studio's WRITE_ROUTES allowlist; still no live string write.\n\nDONE WHEN: approve ships the page and advances its status; send-back re-queues it with the note; both from the studio with no terminal. ${S}${PIXEL('Main.dc.html', 'its Approval queue panel')}` },
116
+ { ref: 'TW13', kind: 'feature', disc: 'engineer', prio: 4, est: 150, deps: ['TW03', 'TW11'],
117
+ title: 'BV2.TW13 · Pay the artist for an approved page, as the artist reviews paid',
118
+ desc: `Owner decision: Tweak Mode replaces the per-ship artist review with the same payouts. On an approved page tweak's ship, the artist who wrote it is paid what completing the replaced artist review paid them; the applying session keeps its normal build reward. Payees resolved server-side from the task (the ADR 0185 pattern in modules/economy/credits.js), never from the caller; a self-applied tweak is one payee; the total is conserved. Feeds the studio tally (TW05).\n\nDONE WHEN: a shipped page tweak pays the artist and the applier as above, with tests for the self-applied and rounding cases. ${S}` },
119
+ { ref: 'TW14', kind: 'refactor', disc: 'engineer', prio: 4, est: 150, deps: ['TW05'],
120
+ title: 'BV2.TW14 · Tweak Mode replaces the per-ship artist review',
121
+ desc: `The artist-review-on-ship cascade (modules/lifecycle/cascade.js) files one task per ship; 34 of the 39 it filed were abandoned, 33 with no note. Per TW03: a ship that changes a page's words no longer files a review task. It marks that page "changed since last tweak", which raises it in the Tweak/CopyWrite Flow and shows in Artist Review Status. Task 1004078 (the studio's review card never showing the copy) is merged into this one: with no per-ship review cards, it has nothing left to fix.\n\nDONE WHEN: a ship changing a page's text files no artist review and the page reads changed-since; the cascade's tests are updated to the new rule. ${S}` },
122
+ { ref: 'TW15', kind: 'cleanup', disc: 'ui', prio: 3, est: 60, deps: ['TW16'],
123
+ title: 'BV2.TW15 · Archive the Copy desk and Ideas tabs',
124
+ desc: `Owner decision 2026-09-27: the Copy desk and the Ideas board come off the hall's tab groups. ARCHIVED, not deleted: remove both entries from the nav (modules/hall-ui/public/shell.js), keep the pages and routes reachable by link so old bookmarks and links still land, and delete no data. Ideas are still filed from Your thinking (its filing form covers quick and Full Ideas); artist work lives in the studio (TW16). Update the page-gate map and any test that pins the nav list.\n\nDONE WHEN: neither tab shows in the nav, both URLs still load, and the hall tests are green. ${S}` },
125
+ { ref: 'TW20', kind: 'feature', disc: 'ui', prio: 5, est: 180, deps: [],
126
+ title: 'BV2.TW20 · The studio and Your thinking sit in the hall, with a full-screen switch',
127
+ desc: `Owner decision 2026-09-27: the Studio (/studio) and Your thinking (/thinking) join the normal hall chrome, the sidebar and the top bar, like every other hall page. Your thinking deliberately carries none today (its header comment says so). Each page gets one button that goes FULL SCREEN, hiding the sidebar and top bar so the page fills the window (the prototype's studio is what full screen looks like), and a button in full screen to come back. Shared, not per page: one switch in shell.js that any page can opt into, remembered per page in localStorage (try/catch), reachable by keyboard, with Esc leaving full screen. Both pages must still pass the kit floors in both states (header, main, 24px targets, reduced motion).\n\nDONE WHEN: both pages render inside the hall chrome, the switch hides and restores it, the choice survives a reload, and the kit floors pass in both states at 1440 and 390, light and dark. ${S}` },
128
+ { ref: 'TW21', kind: 'refactor', disc: 'ui', prio: 4, est: 150, deps: ['TW20'],
129
+ title: 'BV2.TW21 · Your thinking is only your thinking; the project views live on the project map',
130
+ desc: `Owner decision 2026-09-27. Today /thinking and /goal-map are one sky with a lens switcher ("simple", "your thinking", "the whole project"); "the whole project" hands off to /goal-map, and "my ideas" lays your thoughts over the project map. Disconnect them. /thinking keeps only YOUR thinking and its own tools. The whole-project views and features, including the "the whole project" lens and the "my ideas" overlay, live only on the goals/project map page (/goal-map), which stops offering "your thinking" as a lens. Keep the shared sky-*.js code shared where it is only drawing. It is the lens and the features that separate, not the renderer.\n\nCONFIRMED BY THE OWNER (2026-09-27): "the your projects tab" means the whole-project lens, the "the whole project" switch and the project-map features on Your thinking. They move to the goals/project map page only.\n\nDONE WHEN: /thinking shows no whole-project lens or project-map features, /goal-map holds them all, neither links to the other as a lens, and the sky tests are updated to the split. ${S}` },
131
+ { ref: 'TW17', kind: 'feature', disc: 'engineer', prio: 2, est: 60, deps: ['TW16'],
132
+ title: 'BV2.TW17 · The artist pack teaches Tweak Mode',
133
+ desc: `docs/packs/artist.md leads with the studio: Resume, the Tweak/CopyWrite Flow, the editor and the Word round trip, the Approval queue. The per-ship review and the copy-desk flag flow are gone from the artist's loop. Keep the pack inside its CI budget.\n\nDONE WHEN: an artist session reading only the pack can take a page through Tweak Mode. ${S}` },
134
+ { ref: 'TW18', kind: 'verify', disc: 'artist', prio: 4, est: 90, deps: ['TW09', 'TW10', 'TW12', 'TW13', 'TW16'],
135
+ title: 'BV2.TW18 · Proof: an artist tweaks a landing page and a hall page end to end',
136
+ desc: `The live demonstration that wa6-tweak-mode holds. A non-technical artist, with no terminal, from the studio: takes a landing page from the Tweak/CopyWrite Flow and rewrites it in the editor, takes next and rewrites a hall page offline in Word and uploads it; a session runs /tweak; the artist approves both in the Approval queue. Both pages then read "text tweaked 1x", Artist Review Status moves, and the tally shows the payout.\n\nDONE WHEN: the above happens on the live instance and is recorded (screens + the two shipped tasks) on this task. ${S}` },
137
+ { ref: 'TW19', kind: 'spike', disc: 'engineer', prio: 2, est: 120, deps: ['TW18'],
138
+ title: 'BV2.TW19 · Auto-pickup: an agent takes submitted tweaks with no session started',
139
+ desc: `"Queue now, auto later" (owner, 2026-09-27). Today agents fire only on task.shipped and may only report (modules/agents/CLAUDE.md, the event path), and the autonomy runner is off. Design an agent that claims and applies a submitted page on its own: the trigger, who pays (spend caps), the loop guard, the rank it runs at. Output: an ADR plus the tasks to build it.\n\nDONE WHEN: the ADR is written and its build tasks are filed in this goal. ${S}` },
140
+ ];
141
+
142
+ // Existing task folded into the chain at TW16.
143
+ const TW16_TITLE = 'BV2.TW16 · ARTIST MODE: the lofi studio home, with Tweak Mode as its four doors';
144
+ const TW16_DESC = `RESCOPED 2026-09-27 (planning session, task 1004312; spec ${SPEC}). Task 1004238 ("the artist page is simple and lofi") is merged into this one. The photo-to-asset half split out to its own backlog task, because the core has no image path yet (see the correction in the original text below).
145
+
146
+ **Now:** the Studio (/builders/studio) becomes a lofi home an artist wants to be in. The owner likes the prototype's lofi design for now: a night window, a lamp and a desk, over the project's Chrome World tokens. On it:
147
+ - the studio's own words are editable in place (that edit is itself a tweak of the studio page, through TW08);
148
+ - the artist's tally in the top right: credits, pages tweaked, this week (TW05);
149
+ - **Resume** goes back into the page the artist holds (TW10);
150
+ - three quick actions open glass panels in place, so the artist never flips pages: **Tweak/CopyWrite Flow** (TW07), **Approval queue** (TW12) and **Artist Review Status** (TW05's three surface lines).
151
+ The old review/flag queue on this page goes: Tweak Mode replaces it (TW14).
152
+
153
+ DONE WHEN: an artist opening the studio can do all of Tweak Mode from it, approved by the owner at desktop and phone width in light and dark.${PIXEL('Main.dc.html')}
154
+
155
+ ---
156
+ ORIGINAL DESCRIPTION (kept for provenance):
157
+
158
+ `;
159
+
160
+ const PHOTO_TASK = {
161
+ kind: 'feature', disc: 'artist', prio: 2, est: 240,
162
+ title: 'ARTIST MODE part 2: from a photo of physical work to a usable asset',
163
+ desc: `Split from task 1003971 at the 2026-09-27 planning session (task 1004312). An artist uploads photos of physical work and digitises them into whatever they are meant for, keeping their personal style continuous with the project's design rules.\n\nBlocked in substance: the core has no image-generation path (modules/art-pipeline is excluded from the published core). First decide between bringing an image path into the core cleanly (its own ADR) or scoping to the parts that need no model. See 1003971's original description.\n\nNot part of the Tweak Mode chain.`,
164
+ };
165
+
166
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
167
+ let dryId = 0;
168
+ async function api(method, p, body, tries = 0) {
169
+ if (DRY && method !== 'GET') {
170
+ console.log(` [dry] ${method} ${p}`);
171
+ return { ok: true, status: 0, json: { task: { id: `dry${++dryId}`, title: body && body.title } } };
172
+ }
173
+ await sleep(400);
174
+ const res = await fetch(`${BASE}/api/bongos${p}`, { method, headers: H, body: body ? JSON.stringify(body) : undefined });
175
+ const text = await res.text();
176
+ let json; try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 300) }; }
177
+ if (res.status === 429 && tries < 8) {
178
+ const wait = ((json.error && json.error.details && json.error.details.retry_after_seconds) || 30) + 1;
179
+ console.log(` …rate-limited, waiting ${wait}s`);
180
+ await sleep(wait * 1000);
181
+ return api(method, p, body, tries + 1);
182
+ }
183
+ return { status: res.status, ok: res.ok, json };
184
+ }
185
+ const note = (r) => JSON.stringify(r.json).slice(0, 200);
186
+
187
+ (async () => {
188
+ let failed = 0;
189
+ const pre = await api('GET', `/goals/${GOAL}`);
190
+ if (!pre.ok) { console.log(`cannot read goal ${GOAL}: ${pre.status}`); cliExit(1); return; }
191
+ const baseline = {};
192
+ for (const c of pre.json.criteria || []) baseline[c.criterion_id] = Number(c.linked_tasks) || 0;
193
+
194
+ console.log('Criterion…');
195
+ const c = await api('POST', `/versions/${VER}/done-when`, { criterion_id: CRIT, criterion_md: CRITERION_MD, goal_id: GOAL });
196
+ console.log(` ${CRIT}: ${c.ok ? 'created' : c.status === 409 ? 'already seeded' : 'FAILED ' + note(c)}`);
197
+ if (!c.ok && c.status !== 409) { cliExit(1); return; }
198
+
199
+ console.log('Scope…');
200
+ const sc = await api('POST', `/goals/${GOAL}/scope`, { add: SCOPE_ADD });
201
+ console.log(` +${SCOPE_ADD.join(', ')}: ${sc.ok ? 'ok' : 'FAILED ' + note(sc)}`);
202
+ if (!sc.ok) failed++;
203
+
204
+ console.log(`Tasks (${TASKS.length})…`);
205
+ const idByRef = { TW16: ARTIST_MODE };
206
+ const plan = [];
207
+ for (const t of TASKS) {
208
+ const r = await api('POST', '/tasks', {
209
+ version_id: VER, goal_id: GOAL, title: t.title, description: t.desc, kind: t.kind,
210
+ discipline: t.disc, priority: t.prio, est_minutes: t.est, criterion_ids: [CRIT],
211
+ source: SRC, source_ref: `${REF}-${t.ref}`,
212
+ });
213
+ const task = r.json.task || r.json;
214
+ if (!r.ok || !task.id) { console.log(` ${t.ref}: FAILED ${r.status} ${note(r)}`); failed++; continue; }
215
+ if (!DRY && task.title !== t.title) { console.log(` ${t.ref}: source_ref collision — got task ${task.id} "${task.title}"`); failed++; continue; }
216
+ idByRef[t.ref] = task.id;
217
+ plan.push([CRIT]);
218
+ console.log(` ${t.ref} → task ${task.id} (${r.status})`);
219
+ }
220
+
221
+ console.log('Existing tasks…');
222
+ const am = await api('GET', `/tasks/${ARTIST_MODE}`);
223
+ const amTask = am.json.task || am.json;
224
+ if (!String(amTask.title || '').startsWith('BV2.TW16')) {
225
+ const r = await api('PATCH', `/tasks/${ARTIST_MODE}`, { title: TW16_TITLE, description: TW16_DESC + (amTask.description || ''), priority: 4 });
226
+ console.log(` 1003971 rescoped: ${r.ok ? 'ok' : 'FAILED ' + note(r)}`); if (!r.ok) failed++;
227
+ } else console.log(' 1003971 already rescoped');
228
+ const link = await api('POST', `/tasks/${ARTIST_MODE}/criteria`, { criterion_ids: [CRIT] });
229
+ console.log(` 1003971 → ${CRIT}: ${link.ok ? 'linked' : 'FAILED ' + note(link)}`);
230
+ if (link.ok) plan.push([CRIT]); else failed++;
231
+ for (const [from, into, why] of [
232
+ [LOFI_PAGE, ARTIST_MODE, 'Merged into 1003971 at the Tweak Mode planning session (task 1004312): the lofi artist page IS the rescoped ARTIST MODE studio home.'],
233
+ [STUDIO_COPY, idByRef.TW14, 'Merged into BV2.TW14 at the Tweak Mode planning session (task 1004312): Tweak Mode replaces the per-ship artist review, so there are no review cards left whose copy needs showing.'],
234
+ ]) {
235
+ if (!into) { console.log(` ${from}: merge target missing`); failed++; continue; }
236
+ const mg = await api('POST', `/tasks/${from}/merge`, { into_task_id: Number(into) || into, reason: why });
237
+ console.log(` ${from} merged into ${into}: ${mg.ok ? 'ok' : mg.status === 409 ? 'already merged' : 'FAILED ' + note(mg)}`);
238
+ if (!mg.ok && mg.status !== 409) failed++;
239
+ }
240
+
241
+ console.log('Dependencies…');
242
+ const EDGES = [...TASKS.map((t) => [t.ref, t.deps]), ['TW16', ['TW05', 'TW07', 'TW10', 'TW12', 'TW20']]];
243
+ for (const [ref, deps] of EDGES) {
244
+ if (!deps.length) continue;
245
+ const id = idByRef[ref];
246
+ const depIds = deps.map((d) => idByRef[d]).filter(Boolean);
247
+ if (!id || depIds.length !== deps.length) { console.log(` ${ref}: dep resolve gap`); failed++; continue; }
248
+ const r = await api('POST', `/tasks/${id}/dependencies`, { depends_on_task_ids: depIds });
249
+ console.log(` ${ref} ← ${deps.join(', ')}: ${r.ok ? 'ok' : 'FAILED ' + note(r)}`); if (!r.ok) failed++;
250
+ }
251
+
252
+ console.log('Split-off task…');
253
+ const ph = await api('POST', '/tasks', {
254
+ version_id: VER, goal_id: GOAL, title: PHOTO_TASK.title, description: PHOTO_TASK.desc, kind: PHOTO_TASK.kind,
255
+ discipline: PHOTO_TASK.disc, priority: PHOTO_TASK.prio, est_minutes: PHOTO_TASK.est,
256
+ source: SRC, source_ref: `${REF}-photo-split`,
257
+ });
258
+ console.log(` photo half → ${ph.ok ? 'task ' + (ph.json.task || ph.json).id : 'FAILED ' + note(ph)}`); if (!ph.ok) failed++;
259
+
260
+ console.log('Opening the zero-dep layer…');
261
+ for (const ref of ['TW01', 'TW02', 'TW20']) {
262
+ const id = idByRef[ref]; if (!id) continue;
263
+ const r = await api('POST', `/tasks/${id}/promote`, {});
264
+ console.log(` ${ref} (task ${id}): ${r.ok ? 'ready' : 'note ' + note(r)}`);
265
+ }
266
+
267
+ console.log('\nref→id:', JSON.stringify(idByRef));
268
+ if (DRY) return;
269
+ const expected = { ...baseline };
270
+ for (const ids of plan) for (const s of ids) expected[s] = (expected[s] || 0) + 1;
271
+ // A merge abandons its task, and 1004078 was linked to other criteria on this
272
+ // goal; if the goal's linked_tasks counts only live tasks, that criterion drops
273
+ // by one. Report it rather than fail on it.
274
+ await verifySeedCriteriaLinks({ goalId: GOAL, expectedCounts: expected, fetchGoal: (id) => api('GET', `/goals/${id}`),
275
+ exit: (code) => { if (code) console.log('(check the mismatch above: a drop of 1 on a criterion 1004078 or 1004238 was linked to is the merge, not a mislink)'); failed += code ? 1 : 0; } });
276
+ if (failed) { console.log(`${failed} step(s) FAILED — see above.`); cliExit(1); }
277
+ })();
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.1050'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1052'; // 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');
@@ -401,12 +401,14 @@ await test('the page is the sky\'s own surface — the same sheet and the same s
401
401
  assert.match(html, /<body data-page="goal-map" data-page-title="The sky" data-sky-lens="all">/);
402
402
  assert.match(html, /<title>The sky — \{\{worldName\}\}<\/title>/);
403
403
  assert.match(html, /<h1>the sky <em id="lensWord">— the whole project<\/em><\/h1>/, 'the h1 is in static markup, so every state has one');
404
- assert.equal(/shell\.js|style\.css/.test(html), false, 'no hall chrome, like /thinking');
404
+ assert.equal(/shell\.js|style\.css/.test(html), false, 'no hall chrome yet — /thinking joined it (task 1004327), the project sky did not');
405
405
  const scripts = (h) => [...h.matchAll(/<script src="\/builders\/([^"?]+)\?v=([^"]+)"/g)].map((m) => [m[1], m[2]]);
406
406
  assert.deepEqual(scripts(html).map((s) => s[0]), ['idea-objects.js', 'sky-data.js', 'sky-layout.js', 'sky-draw.js', 'sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']);
407
- assert.deepEqual(scripts(html), scripts(thinking), 'the two doors load the same files at the same stamp');
407
+ // /thinking also loads the shell (task 1004327); the SKY's files are the same.
408
+ const skyScripts = (h) => scripts(h).filter((s) => s[0] !== 'shell.js');
409
+ assert.deepEqual(skyScripts(html), skyScripts(thinking), 'the two doors load the same sky files at the same stamp');
408
410
  assert.equal(html.match(/thinking\.css\?v=([^"]+)/)[1], thinking.match(/thinking\.css\?v=([^"]+)/)[1]);
409
- assert.match(read('thinking.css'), /body\[data-page="thinking"\], body\[data-page="goal-map"\] \{/, 'the sheet names both bodies');
411
+ assert.match(read('thinking.css'), /body\[data-page="goal-map"\], body\[data-page="thinking"\] \.sky-stage \{/, 'the sheet names both doors: the project sky\'s body, and the stage /thinking draws in inside the hall');
410
412
  for (const f of ['goal-map.css', 'goal-map.js', 'goal-map-lib.js']) assert.equal(fs.existsSync(path.join(HALL, f)), false, `${f} is retired`);
411
413
  assert.equal(fs.existsSync(path.join(ROOT, 'tests/goal_map_lib.mjs')), false, 'and so is the lib\'s test');
412
414
  assert.equal(/goal-map-lib|goal-map\.js|goal-map\.css/.test(html), false);
@@ -362,8 +362,9 @@ test('the module doc still records where the 2179 density pass landed', () => {
362
362
  // private copy of the hall's, it is a different surface's own, pinned by
363
363
  // tests/hall_sky.mjs against the template. It still stamps the branding token
364
364
  // and the pack's font links like every hall page (the server injects them).
365
+ // /thinking left this list in task 1004327 (owner decision 2026-09-27): it sits
366
+ // in the hall chrome now, with the shell's full-screen switch to hide it.
365
367
  const STANDALONE_EXEMPT = {
366
- 'thinking.html': 'the sky (task 1004231, ADR 0331) — the design of record is its own surface, not the hall chrome',
367
368
  'goal-map.html': 'the project sky (task 1004232) — the same surface as thinking.html, opened in its whole-project lens',
368
369
  };
369
370
 
@@ -0,0 +1,196 @@
1
+ // tests/hall_fullscreen.mjs — the hall's shared full-screen switch (task 1004327,
2
+ // BV2.TW20, goal 1000095; owner decision 2026-09-27).
3
+ //
4
+ // The studio and Your thinking sit in the hall chrome, and each carries ONE
5
+ // switch that hides the sidebar and the top bar so the page fills the window.
6
+ // The switch is the shell's, not a page's: a page opts in with
7
+ // <body data-fullscreen-switch>, and the shell renders the button, keeps the
8
+ // state on html[data-fullscreen="on"], remembers it PER PAGE in localStorage and
9
+ // lets Escape leave. These cases boot the REAL shell.js in a DOM stub and drive
10
+ // it the way a reader does — press the button, reload, press Escape — so a
11
+ // switch that renders but does not remember, or an Escape that fights the sky's
12
+ // own use of the key, fails here. The two source assertions at the end are
13
+ // labelled as such: the CSS half (what "hidden" means) is proven by the kit
14
+ // renders in the ship notes, not by a regex.
15
+ import assert from 'node:assert/strict';
16
+ import { test } from 'node:test';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import vm from 'node:vm';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
24
+ const SHELL = fs.readFileSync(path.join(HALL, 'shell.js'), 'utf8');
25
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
26
+
27
+ // Boot the shell once — one page load. `store` is the browser's localStorage and
28
+ // is SHARED across boots on purpose: a second boot over the same store is a reload.
29
+ function boot({ page = 'thinking', optIn = true, store = {}, storageThrows = false } = {}) {
30
+ const els = new Map();
31
+ const windowListeners = {};
32
+ const timers = [];
33
+ const makeEl = (id) => {
34
+ const attrs = {};
35
+ return {
36
+ id, dataset: {}, style: {}, textContent: '', hidden: false, _html: '', title: '',
37
+ classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } },
38
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; }, removeAttribute(k) { delete attrs[k]; },
39
+ addEventListener(ev, fn) { (this._on ||= {})[ev] = fn; },
40
+ querySelector() { return null; }, querySelectorAll() { return []; },
41
+ appendChild() {}, focus() {}, click() { if (this._on && this._on.click) this._on.click({ target: this }); },
42
+ insertAdjacentElement() {}, remove() {},
43
+ set innerHTML(v) { this._html = String(v); }, get innerHTML() { return this._html; },
44
+ };
45
+ };
46
+ // The topbar's innerHTML is the shell's own markup; an id the shell rendered
47
+ // into it resolves only if that markup really carries it, so a button the
48
+ // shell forgot to render cannot be clicked into existence by the stub.
49
+ const topbar = makeEl('app-topbar');
50
+ const getElementById = (id) => {
51
+ if (id === 'app-topbar') return topbar;
52
+ if (/^(fullscreen-toggle|theme-toggle|zoom-toggle|topbar-menu|topbar-title|topbar-jump)$/.test(id)
53
+ && !topbar._html.includes(`id="${id}"`)) return null;
54
+ if (!els.has(id)) els.set(id, makeEl(id));
55
+ return els.get(id);
56
+ };
57
+ const body = {
58
+ dataset: { page },
59
+ hasAttribute: (k) => optIn && k === 'data-fullscreen-switch',
60
+ };
61
+ const documentObj = {
62
+ body,
63
+ documentElement: { dataset: {}, style: {}, classList: { add() {}, remove() {}, toggle() { return false; }, contains() { return false; } } },
64
+ getElementById,
65
+ querySelectorAll() { return []; },
66
+ createElement() { return makeEl(null); },
67
+ addEventListener() {}, dispatchEvent() {},
68
+ get cookie() { return ''; }, set cookie(_v) {},
69
+ };
70
+ const location = { hostname: 'h', origin: 'https://h', pathname: '/' + page, hash: '', search: '', assign() {}, replace() {} };
71
+ const localStorage = {
72
+ getItem: (k) => { if (storageThrows) throw new Error('SecurityError'); return k in store ? store[k] : null; },
73
+ setItem: (k, v) => { if (storageThrows) throw new Error('SecurityError'); store[k] = String(v); },
74
+ };
75
+ let resizes = 0;
76
+ const windowObj = {
77
+ __BRANDING__: {}, __MODULES__: { enabled: {} },
78
+ BongosClient: { createClient: () => ({ request: () => new Promise(() => {}) }) },
79
+ matchMedia: () => ({ matches: false, addEventListener() {} }),
80
+ localStorage, location,
81
+ requestAnimationFrame() {}, scrollTo() {},
82
+ addEventListener(type, fn) { (windowListeners[type] ||= []).push(fn); },
83
+ dispatchEvent(ev) { if (ev && ev.type === 'resize') resizes += 1; return true; },
84
+ setTimeout: (fn) => { timers.push(fn); return timers.length; },
85
+ };
86
+ const sandbox = {
87
+ window: windowObj, document: documentObj, location, console, clearTimeout() {}, queueMicrotask,
88
+ setTimeout: windowObj.setTimeout,
89
+ Event: class { constructor(t) { this.type = t; } },
90
+ CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } },
91
+ };
92
+ sandbox.globalThis = sandbox;
93
+ vm.createContext(sandbox);
94
+ vm.runInContext(SHELL, sandbox, { filename: 'shell.js' });
95
+ const root = documentObj.documentElement;
96
+ return {
97
+ store,
98
+ button: () => getElementById('fullscreen-toggle'),
99
+ on: () => root.dataset.fullscreen === 'on',
100
+ resizes: () => resizes,
101
+ // Press a key the way the browser dispatches it: every window listener,
102
+ // then the page may mark the press used, then the queued timers run.
103
+ press(key, { target = { tagName: 'CANVAS' }, pageUses = false } = {}) {
104
+ const ev = { key, target, defaultPrevented: false, preventDefault() { this.defaultPrevented = true; } };
105
+ for (const fn of windowListeners.keydown || []) fn(ev);
106
+ if (pageUses) ev.preventDefault();
107
+ while (timers.length) timers.shift()();
108
+ },
109
+ };
110
+ }
111
+
112
+ test('a page that does not opt in gets no switch, and never goes full screen', () => {
113
+ const store = { 'hall-fullscreen:work': 'on' };
114
+ const p = boot({ page: 'work', optIn: false, store });
115
+ assert.equal(p.button(), null, 'no button rendered');
116
+ assert.equal(p.on(), false, 'a stored value for a page with no switch is ignored');
117
+ });
118
+
119
+ test('an opted-in page renders ONE switch in the top bar, off by default', () => {
120
+ const p = boot();
121
+ const btn = p.button();
122
+ assert.ok(btn, 'the switch is in the top bar');
123
+ assert.equal(p.on(), false);
124
+ assert.equal(btn.getAttribute('aria-pressed'), 'false');
125
+ assert.equal(btn.getAttribute('aria-label'), 'Full screen');
126
+ });
127
+
128
+ test('the switch hides the chrome, the same button brings it back', () => {
129
+ const p = boot();
130
+ p.button().click();
131
+ assert.equal(p.on(), true, 'html[data-fullscreen="on"]');
132
+ assert.equal(p.button().getAttribute('aria-pressed'), 'true');
133
+ assert.equal(p.button().getAttribute('aria-label'), 'Leave full screen');
134
+ assert.ok(p.resizes() >= 1, 'a page that sizes to its box is told to re-measure');
135
+ p.button().click();
136
+ assert.equal(p.on(), false, 'and back');
137
+ });
138
+
139
+ test('THE CHOICE SURVIVES A RELOAD — and it is remembered per page', () => {
140
+ const store = {};
141
+ boot({ page: 'thinking', store }).button().click();
142
+ assert.equal(store['hall-fullscreen:thinking'], 'on');
143
+ assert.equal(boot({ page: 'thinking', store }).on(), true, 'the reload opens in full screen');
144
+ assert.equal(boot({ page: 'studio', store }).on(), false, 'the studio did not follow the sky');
145
+ boot({ page: 'thinking', store }).button().click();
146
+ assert.equal(boot({ page: 'thinking', store }).on(), false, 'leaving is remembered too');
147
+ });
148
+
149
+ test('Escape leaves full screen', () => {
150
+ const p = boot({ store: { 'hall-fullscreen:thinking': 'on' } });
151
+ assert.equal(p.on(), true);
152
+ p.press('Escape');
153
+ assert.equal(p.on(), false);
154
+ });
155
+
156
+ test('an Escape the PAGE used (the sky\'s tour, menu or pick) does not also leave', () => {
157
+ const p = boot({ store: { 'hall-fullscreen:thinking': 'on' } });
158
+ p.press('Escape', { pageUses: true });
159
+ assert.equal(p.on(), true, 'the page marked the press used, after the shell heard it');
160
+ });
161
+
162
+ test('an Escape inside a text field belongs to the field', () => {
163
+ const p = boot({ store: { 'hall-fullscreen:thinking': 'on' } });
164
+ p.press('Escape', { target: { tagName: 'INPUT' } });
165
+ assert.equal(p.on(), true);
166
+ p.press('x');
167
+ assert.equal(p.on(), true, 'other keys are not the switch\'s');
168
+ });
169
+
170
+ test('storage that throws (a private window) leaves the chrome showing and the switch working', () => {
171
+ const p = boot({ storageThrows: true });
172
+ assert.equal(p.on(), false);
173
+ p.button().click();
174
+ assert.equal(p.on(), true, 'the switch still works for this page load');
175
+ });
176
+
177
+ test('the studio and Your thinking opt in, inside the hall chrome; the project sky does not (yet)', () => {
178
+ for (const f of ['studio.html', 'thinking.html']) {
179
+ const html = read(f);
180
+ assert.match(html, /<body[^>]*\bdata-fullscreen-switch\b/, `${f} opts in`);
181
+ for (const id of ['app-sidebar', 'app-topbar']) assert.match(html, new RegExp(`id="${id}"`), `${f} carries #${id}`);
182
+ assert.match(html, /<main id="hall-main"/, `${f}: the skip link's target`);
183
+ assert.equal((html.replace(/<!--[\s\S]*?-->/g, '').match(/<header\b/g) || []).length, 1, `${f}: the top bar is the one <header>`);
184
+ }
185
+ assert.doesNotMatch(read('goal-map.html'), /data-fullscreen-switch|shell\.js/, '/goal-map keeps its standalone form');
186
+ });
187
+
188
+ // SOURCE ASSERTION (the CSS half): full screen hides the sidebar and the foot
189
+ // but keeps the top bar IN the document, collapsed to its switch — so the page
190
+ // keeps one <header> and the way back is where the way in was.
191
+ test('source: full screen hides the sidebar and foot, and collapses the top bar rather than removing it', () => {
192
+ const css = read('style.css');
193
+ assert.match(css, /html\[data-fullscreen="on"\] \.app-sidebar,\s*html\[data-fullscreen="on"\] \.app-scrim,\s*html\[data-fullscreen="on"\] \.app-foot \{ display: none; \}/);
194
+ assert.match(css, /html\[data-fullscreen="on"\] \.topbar-side > :not\(\.fullscreen-toggle\)/);
195
+ assert.doesNotMatch(css, /html\[data-fullscreen="on"\] \.app-topbar \{ display: none/);
196
+ });
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = '6b28297270d1e1f0';
90
+ const SHELL_SHA = '7f66604d74d544e8';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);