@bongos/core 1.21.44 → 1.21.45

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 (50) hide show
  1. package/.bongos-core.json +64 -54
  2. package/docs/copy-inventory.md +23 -11
  3. package/docs/copy-registry.json +131 -13
  4. package/docs/module-api-changelog.md +2 -0
  5. package/docs/page-readings.json +197 -197
  6. package/modules/hall-ui/public/atlas.html +3 -3
  7. package/modules/hall-ui/public/blockers.html +3 -3
  8. package/modules/hall-ui/public/board-room.html +3 -3
  9. package/modules/hall-ui/public/city.html +3 -3
  10. package/modules/hall-ui/public/collab.html +3 -3
  11. package/modules/hall-ui/public/commands.html +3 -3
  12. package/modules/hall-ui/public/copy-desk.html +3 -3
  13. package/modules/hall-ui/public/craft-mode.js +8 -1
  14. package/modules/hall-ui/public/deploy.html +3 -3
  15. package/modules/hall-ui/public/diagrams.html +3 -3
  16. package/modules/hall-ui/public/drachmae.html +3 -3
  17. package/modules/hall-ui/public/fleet.html +3 -3
  18. package/modules/hall-ui/public/gate.html +3 -3
  19. package/modules/hall-ui/public/goal-map.html +3 -3
  20. package/modules/hall-ui/public/goals.html +3 -3
  21. package/modules/hall-ui/public/government.html +3 -3
  22. package/modules/hall-ui/public/idea.html +3 -3
  23. package/modules/hall-ui/public/ideas.html +3 -3
  24. package/modules/hall-ui/public/index.html +3 -3
  25. package/modules/hall-ui/public/modules.html +3 -3
  26. package/modules/hall-ui/public/paintbrush.js +398 -0
  27. package/modules/hall-ui/public/primer.html +3 -3
  28. package/modules/hall-ui/public/profile.html +3 -3
  29. package/modules/hall-ui/public/project-settings.html +3 -3
  30. package/modules/hall-ui/public/ranks.html +3 -3
  31. package/modules/hall-ui/public/roadmap.html +3 -3
  32. package/modules/hall-ui/public/roster.html +3 -3
  33. package/modules/hall-ui/public/sessions.html +3 -3
  34. package/modules/hall-ui/public/settings.html +3 -3
  35. package/modules/hall-ui/public/street.html +3 -3
  36. package/modules/hall-ui/public/studio.html +3 -3
  37. package/modules/hall-ui/public/style.css +78 -0
  38. package/modules/hall-ui/public/task.html +3 -3
  39. package/modules/hall-ui/public/thinking.html +3 -3
  40. package/modules/hall-ui/public/tweak-editor.html +3 -3
  41. package/modules/hall-ui/public/watch.html +3 -3
  42. package/modules/hall-ui/public/work.html +3 -3
  43. package/modules/hall-ui/records/craft-mode.md +9 -0
  44. package/package-lock.json +2 -2
  45. package/package.json +1 -1
  46. package/release-notes.json +6 -0
  47. package/src/module-api.js +1 -1
  48. package/tests/hall_tweak_editor.mjs +1 -1
  49. package/tests/page_notes_panel.mjs +4 -3
  50. package/tests/paintbrush.mjs +351 -0
@@ -138,9 +138,9 @@
138
138
  </div>
139
139
  </div>
140
140
 
141
- <script src="/builders/shell-trail.js?v=2026-10-04-nolegacy"></script>
142
- <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
143
- <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
141
+ <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
142
+ <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
143
+ <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
144
144
  <script src="/builders/palette.js" defer></script>
145
145
 
146
146
  <script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
@@ -142,9 +142,9 @@
142
142
 
143
143
  <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
144
144
 
145
- <script src="/builders/shell-trail.js?v=2026-10-04-nolegacy"></script>
146
- <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
147
- <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
145
+ <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
146
+ <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
147
+ <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
148
148
  <script src="/builders/palette.js" defer></script>
149
149
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
150
150
  <script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
@@ -162,9 +162,9 @@
162
162
 
163
163
  <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
164
164
  chrome exists before the page script runs. -->
165
- <script src="/builders/shell-trail.js?v=2026-10-04-nolegacy"></script>
166
- <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
167
- <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
165
+ <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
166
+ <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
167
+ <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
168
168
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
169
169
  <script src="/builders/palette.js" defer></script>
170
170
  <!-- shared frontend toolkit (window.OTB) — loaded before watch.js, which pulls from it. -->
@@ -171,9 +171,9 @@
171
171
 
172
172
  <!-- The app shell (sidebar + top bar + footer). Dependency-free; runs at parse
173
173
  time so the chrome exists before the deferred page scripts execute. -->
174
- <script src="/builders/shell-trail.js?v=2026-10-04-nolegacy"></script>
175
- <script src="/builders/craft-mode.js?v=2026-10-04-nolegacy"></script>
176
- <script src="/builders/shell.js?v=2026-10-04-nolegacy"></script>
174
+ <script src="/builders/shell-trail.js?v=2026-10-04-paintbrush"></script>
175
+ <script src="/builders/craft-mode.js?v=2026-10-04-paintbrush"></script>
176
+ <script src="/builders/shell.js?v=2026-10-04-paintbrush"></script>
177
177
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
178
178
  <script src="/builders/palette.js" defer></script>
179
179
  <!-- shared frontend toolkit (window.OTB) — loaded FIRST so work.js can pull from it. -->
@@ -35,3 +35,12 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
35
35
  - **Two pieces are drawn by `craft-mode.js` `applyLook()`, on every mode change:** the eyebrow beside `#topbar-title` (`LOOKS`: "the studio · night", "the sky", "the city"; hidden at <=640px, where it overflowed phones) and the scene plate under `.app-sidebar__brand` (40px closed rail, 64px open). The plate's markup IS `artHtml(mode)`, the picker's door art, exported rather than copied; light mode recolours it to each scene's day (the city is the dark one through `invert(1) hue-rotate(180deg)`). It does not animate.
36
36
  - **Always-present tabs widened** (owner, 2026-10-03): `ALWAYS_TABS` is Builders, Collab and Profile, so every mode keeps the You and Community groups.
37
37
  - **Governing's header tallies** ("needs you · moving") are task 1004563, not this look.
38
+
39
+ ## The paintbrush (task 1004497)
40
+
41
+ - **Artistry carries two tools**: the palette (a feedback doc, `page-notes.js`, record `page-notes.md`) and the paintbrush (`paintbrush.js`). Both are `TOOLS` entries in `craft-mode.js` and load their own script on first press.
42
+ - **The paintbrush edits the page's own words where they stand, as a REAL wording change** (owner, 2026-10-04): it is the copy desk's page round, the same one the tweak editor and the studio's own words write (ADR 0341). Pressing it reads `GET /copy-desk/pages/builders:<page>/draft`; a page nobody holds is OFFERED, never taken silently (taking it is the copy desk's artists-only claim, and the page shows as being tweaked while held). Typing saves the whole draft; "Review" lists every change as was/now; "Send for approval" submits the round. It never writes `page-notes` (owner: kept separate from the feedback docs).
43
+ - **Only a leaf element inside `<main>` whose words are one reading line becomes editable.** Shared chrome, a line with a `{…}` hole, controls (`button`, form fields, `code`), and anything hidden stay as they are; the bar counts the lines it cannot reach and links to the tweak editor for them. A click on edited words inside a link does not navigate.
44
+ - **Nobody else's view changes, and neither does yours once editing ends**: "Stop editing" and "Send" both put the page's own words back. The draft stays on the round.
45
+ - **The top bar fits two tools at 390px** because, at phone width, the side packs to a 4px gap, a tool is 28px, the side and the menu do not shrink, and a long page title trims with an ellipsis instead. All 34 hall pages measure 0px of sideways scroll in Artistry at 390px.
46
+ - `tests/paintbrush.mjs` runs the real script and both shared libs over a small page.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.44",
3
+ "version": "1.21.45",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.44",
9
+ "version": "1.21.45",
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.21.44",
3
+ "version": "1.21.45",
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",
@@ -9028,5 +9028,11 @@
9028
9028
  "id": "1004569",
9029
9029
  "text": "The street's pins no longer pile up or talk over each other. Each building now has one pin, and its colour and size show how much it matters: largest and pulsing orange when something needs you within a day, orange when it nee"
9030
9030
  }
9031
+ ],
9032
+ "1.21.45": [
9033
+ {
9034
+ "id": "1004497",
9035
+ "text": "Artists can now edit any hall page's words right where they stand. In Artistry mode, \"Edit words\" offers to take the page; the words get an underline and can be typed over, a review lists each change as was/now, and \"Send for"
9036
+ }
9031
9037
  ]
9032
9038
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.44'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.45'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -635,7 +635,7 @@ try {
635
635
  assert.match(HTML, /<body data-page="tweak-editor"[^>]*data-fullscreen-switch/);
636
636
  assert.equal((HTML.match(/<h1\b/g) || []).length, 1);
637
637
  assert.ok(HTML.indexOf('tweak-editor-lib.js') < HTML.indexOf('tweak-editor.js?'), 'the lib loads first');
638
- assert.match(HTML, /shell\.js\?v=2026-10-04-nolegacy/);
638
+ assert.match(HTML, /shell\.js\?v=2026-10-04-paintbrush/);
639
639
  assert.equal(/fonts\.googleapis\.com\/css/.test(HTML), false);
640
640
  for (const id of ['tw-bar', 'tw-counter', 'tw-open', 'tw-open-more', 'tw-divider', 'tw-openwrap', 'tw-open-menu', 'tw-open-tab', 'tw-open-split', 'tw-frame', 'tw-frame-view', 'tw-split-close', 'tw-download', 'tw-upload', 'tw-file', 'tw-submit', 'tw-paper', 'tw-doc', 'tw-notice', 'tw-done', 'tw-take', 'tw-pick', 'tw-picks', 'tw-refused', 'tw-refused-title', 'tw-refused-list', 'tw-refused-next', 'tw-refused-download', 'tw-refused-close']) {
641
641
  assert.ok(new RegExp(`id="${id}"`).test(HTML), `#${id} is in the markup`);
@@ -76,8 +76,9 @@ const flush = () => new Promise((r) => setTimeout(r, 0));
76
76
 
77
77
  test('THE PALETTE: in the top bar in Artistry alone, right before the switch, and gone when the mode changes', () => {
78
78
  const p = boot({ session: { 'hall-craft': 'artistry' } });
79
- assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette']);
80
- assert.equal(p.tools()[0].nextSibling, p.sw, 'it sits right before the mode switch');
79
+ assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette', 'craft-tool-paintbrush'], 'the palette, then the paintbrush (task 1004497)');
80
+ assert.equal(p.tools()[0].nextSibling, p.tools()[1], 'the palette comes first');
81
+ assert.equal(p.tools()[1].nextSibling, p.sw, 'and the pair sits right before the mode switch');
81
82
  assert.equal(p.tools()[0].getAttribute('aria-label'), 'Write feedback on this page');
82
83
  for (const m of ['building', 'ideating', 'governing']) {
83
84
  p.craft.choose(m);
@@ -85,7 +86,7 @@ test('THE PALETTE: in the top bar in Artistry alone, right before the switch, an
85
86
  }
86
87
  p.craft.choose('artistry');
87
88
  p.craft.choose('artistry');
88
- assert.equal(p.tools().length, 1, 'choosing Artistry twice draws one palette');
89
+ assert.equal(p.tools().length, 2, 'choosing Artistry twice draws one palette and one paintbrush');
89
90
  assert.equal(boot().tools().length, 0, 'and none with no mode at all');
90
91
  });
91
92
 
@@ -0,0 +1,351 @@
1
+ // tests/paintbrush.mjs — the Artistry paintbrush (task 1004497): a page's own
2
+ // words, edited where they stand, as a proposed wording change.
3
+ //
4
+ // Runs the REAL paintbrush.js, with the REAL tweak-editor-lib.js and
5
+ // studio-lib.js it shares with the editor and the studio, over a small page
6
+ // (there is no jsdom here; the fake-DOM idiom of tests/page_notes_panel.mjs).
7
+ // The task's done-when, as cases: an artist toggles editing, changes the words
8
+ // a page shows, reviews the changes and sends them as one proposal; nobody
9
+ // else's view changes — the page's words come back when editing ends, and the
10
+ // only writes are the copy desk's own page-round routes (owner, 2026-10-04: the
11
+ // edits are real wording changes, kept apart from the palette's feedback docs).
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
21
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
22
+ const BRUSH = read('paintbrush.js');
23
+ const EDITOR_LIB = read('tweak-editor-lib.js');
24
+ const STUDIO_LIB = read('studio-lib.js');
25
+ const CRAFT = read('craft-mode.js');
26
+ const CSS = read('style.css');
27
+
28
+ // ---------- a small DOM: enough for the selectors paintbrush.js asks ----------
29
+ function matchesOne(el, sel) {
30
+ sel = sel.trim();
31
+ const m = /^([a-z]+)?(?:#([\w-]+))?(?:\.([\w-]+))?(?:\[([\w-]+)(?:="([^"]*)")?\])?$/i.exec(sel);
32
+ if (!m) throw new Error('fake DOM cannot match ' + sel);
33
+ const [, tag, id, cls, attr, val] = m;
34
+ if (tag && el.tagName !== tag.toUpperCase()) return false;
35
+ if (id && el.id !== id) return false;
36
+ if (cls && !el.classList.contains(cls)) return false;
37
+ if (attr) {
38
+ if (attr === 'hidden') return !!el.hidden;
39
+ if (!el.hasAttribute(attr)) return false;
40
+ if (val !== undefined && el.getAttribute(attr) !== val) return false;
41
+ }
42
+ return true;
43
+ }
44
+ const matches = (el, sel) => sel.split(',').some((s) => matchesOne(el, s));
45
+
46
+ class El {
47
+ constructor(tag, text) {
48
+ this.tagName = tag.toUpperCase();
49
+ this.attrs = {};
50
+ this.children = [];
51
+ this.parentNode = null;
52
+ this.id = '';
53
+ this.hidden = false;
54
+ this.dataset = {};
55
+ this.listeners = {};
56
+ this._text = text == null ? '' : String(text);
57
+ this._html = '';
58
+ const cls = new Set();
59
+ this.classList = {
60
+ add: (...c) => c.forEach((x) => cls.add(x)), remove: (...c) => c.forEach((x) => cls.delete(x)),
61
+ contains: (c) => cls.has(c), toggle: (c, on) => { if (on === undefined ? !cls.has(c) : on) cls.add(c); else cls.delete(c); },
62
+ };
63
+ }
64
+ add(child) { child.parentNode = this; this.children.push(child); return child; }
65
+ appendChild(c) { return this.add(c); }
66
+ removeChild(c) { this.children.splice(this.children.indexOf(c), 1); c.parentNode = null; return c; }
67
+ get childElementCount() { return this.children.length; }
68
+ get textContent() { return this.children.length ? this.children.map((c) => c.textContent).join('') : this._text; }
69
+ set textContent(v) { this.children = []; this._text = String(v); }
70
+ get innerHTML() { return this._html; }
71
+ set innerHTML(v) { this._html = String(v); }
72
+ set className(v) { String(v).split(/\s+/).filter(Boolean).forEach((c) => this.classList.add(c)); }
73
+ setAttribute(k, v) { this.attrs[k] = String(v); if (k === 'contenteditable') this.contentEditable = String(v); }
74
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
75
+ hasAttribute(k) { return k in this.attrs; }
76
+ removeAttribute(k) { delete this.attrs[k]; if (k === 'contenteditable') this.contentEditable = 'inherit'; }
77
+ closest(sel) { for (let n = this; n; n = n.parentNode) if (n.tagName && matches(n, sel)) return n; return null; }
78
+ querySelectorAll(sel) {
79
+ const out = [];
80
+ const walk = (n) => n.children.forEach((c) => { if (sel === '*' || matches(c, sel)) out.push(c); walk(c); });
81
+ walk(this);
82
+ return out;
83
+ }
84
+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
85
+ addEventListener(t, fn) { (this.listeners[t] ||= []).push(fn); }
86
+ focus() { this.ownerDocument.activeElement = this; }
87
+ blur() {}
88
+ }
89
+
90
+ // A page with words the reading lists, and words it must leave alone.
91
+ function page() {
92
+ const body = new El('body');
93
+ body.dataset.page = 'roadmap';
94
+ body.dataset.pageTitle = 'Roadmap';
95
+ const main = body.add(new El('main'));
96
+ const h1 = main.add(new El('h1', 'Where the build is going'));
97
+ const lede = main.add(new El('p', 'Every version, from first idea to shipped.'));
98
+ const link = main.add(new El('a'));
99
+ link.setAttribute('href', '/goals');
100
+ const linkWords = link.add(new El('span', 'See every goal'));
101
+ const count = main.add(new El('p', '12 versions shipped')); // a {…} hole line: the editor's
102
+ const btn = main.add(new El('button', 'Star this version')); // a control: never editable
103
+ const stray = main.add(new El('p', 'Words the reading has never seen'));
104
+ const hiddenBox = main.add(new El('div'));
105
+ hiddenBox.hidden = true;
106
+ const hiddenWords = hiddenBox.add(new El('p', 'Nothing in flight right now.'));
107
+ const footer = body.add(new El('footer', 'Skip to content')); // outside main
108
+ return { body, main, h1, lede, link, linkWords, count, btn, stray, hiddenWords, footer };
109
+ }
110
+ const READING = {
111
+ reading_hash: 'abc123',
112
+ read: true,
113
+ lines: [
114
+ { key: 'L0001', section: 'skip link', text: 'Skip to content', placement: 'shared' },
115
+ { key: 'L0002', section: 'page top', text: 'Where the build is going', placement: 'placed' },
116
+ { key: 'L0003', section: 'page top', text: 'Every version, from first idea to shipped.', placement: 'placed' },
117
+ { key: 'L0004', section: 'versions', text: 'See every goal', placement: 'placed' },
118
+ { key: 'L0005', section: 'versions', text: '{…} versions shipped', placement: 'placed' },
119
+ { key: 'L0006', section: 'versions', text: 'Star this version', placement: 'placed' },
120
+ { key: 'L0007', section: 'versions', text: 'Nothing in flight right now.', placement: 'unplaced' },
121
+ ],
122
+ };
123
+
124
+ function boot({ round = null, draft = null, claim = { status: 200, body: { ok: true } }, submit = { status: 200, body: { ok: true, outcome: 'submitted' } } } = {}) {
125
+ const p = page();
126
+ const head = new El('head');
127
+ const calls = [];
128
+ const docListeners = {};
129
+ const document = {
130
+ body: p.body, head, documentElement: new El('html'), activeElement: null,
131
+ currentScript: { src: 'https://h/builders/paintbrush.js?v=2026-10-04-test' },
132
+ querySelector: (sel) => p.body.querySelector(sel),
133
+ getElementById: (id) => p.body.querySelectorAll('*').find((n) => n.id === id) || null,
134
+ createElement: (t) => { const e = new El(t); e.ownerDocument = document; return e; },
135
+ addEventListener: (t, fn) => { (docListeners[t] ||= []).push(fn); },
136
+ removeEventListener: (t, fn) => { docListeners[t] = (docListeners[t] || []).filter((f) => f !== fn); },
137
+ execCommand() { return true; },
138
+ title: 'Roadmap — Builders hall',
139
+ };
140
+ for (const n of [p.body, ...p.body.querySelectorAll('*')]) n.ownerDocument = document;
141
+ let state = { round, draft };
142
+ const respond = (status, body) => Promise.resolve({ ok: status < 400, status, json: () => Promise.resolve(body) });
143
+ const fetch = (url, opts = {}) => {
144
+ const method = opts.method || 'GET';
145
+ calls.push({ method, url, body: opts.body ? JSON.parse(opts.body) : undefined, keepalive: !!opts.keepalive });
146
+ if (!url.startsWith('/api/bongos/copy-desk/pages/builders%3Aroadmap/')) return respond(404, {});
147
+ const rest = url.slice('/api/bongos/copy-desk/pages/builders%3Aroadmap/'.length);
148
+ if (method === 'GET' && rest === 'draft') return respond(200, { ok: true, page: { id: 'builders:roadmap', title: 'Roadmap' }, reading: READING, round: state.round, draft: state.draft });
149
+ if (method === 'POST' && rest === 'claim') {
150
+ if (claim.status < 400) state = { round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } }, draft: null };
151
+ return respond(claim.status, claim.body);
152
+ }
153
+ if (method === 'PUT' && rest === 'draft') return respond(200, { ok: true, outcome: 'saved' });
154
+ if (method === 'POST' && rest === 'submit') return respond(submit.status, submit.body);
155
+ return respond(404, {});
156
+ };
157
+ const winListeners = {};
158
+ const window = { location: { pathname: '/roadmap' }, addEventListener: (t, fn) => { (winListeners[t] ||= []).push(fn); }, removeEventListener: (t, fn) => { winListeners[t] = (winListeners[t] || []).filter((f) => f !== fn); } };
159
+ const sandbox = { window, document, fetch, Promise, setTimeout, clearTimeout, JSON, Math, String, Array, Object };
160
+ window.window = window;
161
+ vm.createContext(sandbox);
162
+ // The two shared libs, as the page would have them once the tool loads them.
163
+ vm.runInContext(EDITOR_LIB, sandbox, { filename: 'tweak-editor-lib.js' });
164
+ vm.runInContext(STUDIO_LIB, sandbox, { filename: 'studio-lib.js' });
165
+ vm.runInContext(BRUSH, sandbox, { filename: 'paintbrush.js' });
166
+ const bar = () => p.body.querySelector('#paintbrush');
167
+ const fire = (type, ev) => (docListeners[type] || []).slice().forEach((fn) => fn(ev));
168
+ // Press a bar button by its data attribute.
169
+ const press = (attr) => {
170
+ const html = bar().innerHTML;
171
+ assert.match(html, new RegExp(attr), `the bar offers [${attr}]`);
172
+ const btn = new El('button');
173
+ btn.setAttribute(attr, '');
174
+ btn.parentNode = bar();
175
+ bar().listeners.click.forEach((fn) => fn({ target: btn }));
176
+ };
177
+ const type = (el, text) => { el.textContent = text; fire('input', { target: el }); };
178
+ const editable = () => p.body.querySelectorAll('*').filter((n) => n.hasAttribute('contenteditable'));
179
+ return { ...p, leave: () => (winListeners.pagehide || []).forEach((fn) => fn()), brush: window.OTBPaintbrush, calls, bar, press, type, fire, editable, html: document.documentElement, writes: () => calls.filter((c) => c.method !== 'GET') };
180
+ }
181
+ const settle = () => new Promise((r) => setTimeout(r, 5));
182
+
183
+ test('craft-mode.js puts the paintbrush beside the palette in Artistry, loading its own script', () => {
184
+ assert.match(CRAFT, /\{ id: 'paintbrush', icon: 'brush', short: 'Edit words', label: 'Edit this page\\'s words', script: 'paintbrush.js', api: 'OTBPaintbrush' \}/);
185
+ const tools = /var TOOLS = \{([\s\S]*?)\n \};/.exec(CRAFT)[1];
186
+ assert.match(tools, /^\s*artistry: \[/m, 'it is an Artistry tool');
187
+ assert.doesNotMatch(tools, /building:|ideating:|governing:/, 'and no other mode has one');
188
+ });
189
+
190
+ test('pressing it on a page nobody holds OFFERS the page — nothing is editable and nothing is written until the artist takes it', async () => {
191
+ const p = boot();
192
+ p.brush.open();
193
+ await settle();
194
+ assert.match(p.bar().innerHTML, /Take this page/);
195
+ assert.match(p.bar().innerHTML, /Nobody else sees them until then/);
196
+ assert.equal(p.editable().length, 0);
197
+ assert.deepEqual(p.writes(), [], 'reading the page writes nothing');
198
+ assert.equal(p.calls[0].url, '/api/bongos/copy-desk/pages/builders%3Aroadmap/draft', 'it reads this page by its reading id');
199
+ });
200
+
201
+ test('taking the page makes the words the reading lists editable where they stand — not shared chrome, holes, controls, hidden or unknown words', async () => {
202
+ const p = boot();
203
+ p.brush.open();
204
+ await settle();
205
+ p.press('data-pb-take');
206
+ await settle();
207
+ assert.deepEqual(p.writes().map((c) => c.method + ' ' + c.url.split('/').pop()), ['POST claim'], 'one claim, through the copy desk');
208
+ assert.deepEqual(p.editable().map((n) => n.textContent), ['Where the build is going', 'Every version, from first idea to shipped.', 'See every goal']);
209
+ for (const n of [p.count, p.btn, p.stray, p.hiddenWords, p.footer]) assert.ok(!n.hasAttribute('contenteditable'), `"${n.textContent}" stays as it is`);
210
+ assert.equal(p.h1.getAttribute('data-pb-key'), 'L0002');
211
+ assert.ok(p.html.classList.contains('pb-editing'));
212
+ assert.match(p.bar().innerHTML, /0 changes/);
213
+ assert.match(p.bar().innerHTML, /tweak editor/, 'the lines it cannot reach point to the editor');
214
+ });
215
+
216
+ test('a refused claim says why, in the artist\'s words, and leaves the page alone', async () => {
217
+ const p = boot({ claim: { status: 403, body: { ok: false, error: { code: 'not_an_artist', message: 'no' } } } });
218
+ p.brush.open();
219
+ await settle();
220
+ p.press('data-pb-take');
221
+ await settle();
222
+ assert.match(p.bar().innerHTML, /Only an artist can take a page/);
223
+ assert.equal(p.editable().length, 0);
224
+ });
225
+
226
+ test('a page another artist holds is not offered, and one already on its way is not either', async () => {
227
+ const held = boot({ round: { task_id: '9', state: 'writing', held_by_you: false, holder: { login: 'mira' } } });
228
+ held.brush.open();
229
+ await settle();
230
+ assert.match(held.bar().innerHTML, /mira is tweaking this page right now/);
231
+ assert.doesNotMatch(held.bar().innerHTML, /Take this page/);
232
+ const flight = boot({ round: { task_id: '9', state: 'submitted', held_by_you: false, holder: null } });
233
+ flight.brush.open();
234
+ await settle();
235
+ assert.match(flight.bar().innerHTML, /on its way to the site/);
236
+ assert.deepEqual([...held.writes(), ...flight.writes()], []);
237
+ });
238
+
239
+ test('typing saves the WHOLE draft to the page round, and a click on words inside a link does not follow it', async () => {
240
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
241
+ p.brush.open();
242
+ await settle();
243
+ assert.equal(p.editable().length, 3, 'a page you already hold opens straight into editing');
244
+ p.type(p.lede, 'Every version, from the first spark to shipped.');
245
+ p.fire('focusout', { target: p.lede });
246
+ await settle();
247
+ const put = p.writes().find((c) => c.method === 'PUT');
248
+ assert.ok(put, 'a save went out');
249
+ assert.match(put.url, /builders%3Aroadmap\/draft$/);
250
+ assert.deepEqual(JSON.parse(JSON.stringify(put.body)), { reading_hash: 'abc123', lines: [{ key: 'L0003', after: 'Every version, from the first spark to shipped.' }] });
251
+ assert.ok(p.lede.classList.contains('pb-word--changed'));
252
+ let prevented = false;
253
+ p.fire('click', { target: p.linkWords, preventDefault() { prevented = true; } });
254
+ assert.ok(prevented, 'the link around the words does not navigate while editing');
255
+ });
256
+
257
+ test('REVIEW lists every change as was/now, and SEND submits the page once and puts its own words back', async () => {
258
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
259
+ p.brush.open();
260
+ await settle();
261
+ p.type(p.h1, 'Where we are going');
262
+ p.type(p.linkWords, 'Every goal <b>here</b>');
263
+ p.press('data-pb-review');
264
+ await settle();
265
+ const html = p.bar().innerHTML;
266
+ assert.match(html, /Was<\/span> Where the build is going/);
267
+ assert.match(html, /Now<\/span> Where we are going/);
268
+ assert.match(html, /Every goal &lt;b&gt;here&lt;\/b&gt;/, 'what the artist typed is escaped in the review');
269
+ p.press('data-pb-send');
270
+ await settle();
271
+ const submits = p.writes().filter((c) => c.method === 'POST' && c.url.endsWith('/submit'));
272
+ assert.equal(submits.length, 1, 'one proposal for the page');
273
+ const lastPut = p.writes().filter((c) => c.method === 'PUT').pop();
274
+ assert.deepEqual(JSON.parse(JSON.stringify(lastPut.body.lines)), [{ key: 'L0002', after: 'Where we are going' }, { key: 'L0004', after: 'Every goal <b>here</b>' }], 'the saved draft held both changes before the submit');
275
+ assert.match(p.bar().innerHTML, /Sent for approval: 2 changes/);
276
+ assert.equal(p.h1.textContent, 'Where the build is going', 'the page shows its own words again');
277
+ assert.equal(p.linkWords.textContent, 'See every goal');
278
+ assert.equal(p.editable().length, 0);
279
+ assert.ok(!p.html.classList.contains('pb-editing'));
280
+ });
281
+
282
+ test('STOP EDITING keeps the draft saved and puts the page\'s words back', async () => {
283
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
284
+ p.brush.open();
285
+ await settle();
286
+ p.type(p.h1, 'A new heading');
287
+ p.press('data-pb-stop');
288
+ await settle();
289
+ assert.ok(p.writes().some((c) => c.method === 'PUT'), 'the change was saved first');
290
+ assert.ok(!p.writes().some((c) => c.url.endsWith('/submit')), 'stopping sends nothing');
291
+ assert.equal(p.h1.textContent, 'Where the build is going');
292
+ assert.equal(p.editable().length, 0);
293
+ assert.equal(p.bar(), null, 'the bar is gone');
294
+ });
295
+
296
+ test('a draft saved earlier comes back: the words show the artist\'s rewrite when editing resumes', async () => {
297
+ const p = boot({
298
+ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } },
299
+ draft: { reading_hash: 'abc123', lines: [{ key: 'L0002', after: 'Where it all goes' }] },
300
+ });
301
+ p.brush.open();
302
+ await settle();
303
+ assert.equal(p.h1.textContent, 'Where it all goes');
304
+ assert.ok(p.h1.classList.contains('pb-word--changed'));
305
+ assert.match(p.bar().innerHTML, /1 change/);
306
+ });
307
+
308
+ test('the only routes it writes are the copy desk\'s page round — never page-notes, never anything else', () => {
309
+ const routes = [...BRUSH.matchAll(/request\('(POST|PUT|PATCH|DELETE)', [^,]*?'\/([a-z-]+)'/g)].map((m) => m[1] + ' ' + m[2]);
310
+ assert.deepEqual(routes.sort(), ['POST claim', 'POST submit', 'PUT draft']);
311
+ assert.match(BRUSH, /var API = '\/api\/bongos\/copy-desk\/pages\/';/);
312
+ assert.doesNotMatch(BRUSH, /page-notes/);
313
+ });
314
+
315
+ test('its styles live in style.css, on the token tier', () => {
316
+ for (const sel of ['.pb {', '.pb__btn--go', '.pb-word {', '.pb-word--changed', '.pb__changes']) assert.ok(CSS.includes(sel), `style.css styles ${sel}`);
317
+ const block = CSS.slice(CSS.indexOf('/* THE PAINTBRUSH'), CSS.indexOf('/* THE BACK LINK'));
318
+ assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|rgba?\(/i, 'no colour literals: tokens only');
319
+ });
320
+
321
+ test('leaving the page mid-edit sends the last save with keepalive, so closing the tab does not lose it', async () => {
322
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
323
+ p.brush.open();
324
+ await settle();
325
+ p.type(p.h1, 'Typed, then the tab closed');
326
+ p.leave();
327
+ await settle();
328
+ const put = p.writes().find((c) => c.method === 'PUT');
329
+ assert.ok(put && put.keepalive, 'the save on the way out is a keepalive request');
330
+ assert.equal(put.body.lines[0].after, 'Typed, then the tab closed');
331
+ });
332
+
333
+ test('words only: bold, italic and underline keys do nothing in the words; elsewhere they are left alone', async () => {
334
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
335
+ p.brush.open();
336
+ await settle();
337
+ let prevented = 0;
338
+ for (const key of ['b', 'i', 'u']) p.fire('keydown', { key, ctrlKey: true, target: p.h1, preventDefault() { prevented++; } });
339
+ assert.equal(prevented, 3);
340
+ p.fire('keydown', { key: 'b', ctrlKey: true, target: p.stray, preventDefault() { prevented++; } });
341
+ assert.equal(prevented, 3, 'a key outside the edited words is not touched');
342
+ });
343
+
344
+ test('a word on the page matches its line however its spaces wrap, and one table serves every element', async () => {
345
+ const p = boot({ round: { task_id: '9', state: 'writing', held_by_you: true, holder: { login: 'me' } } });
346
+ p.lede.textContent = ' Every version,\n from first idea to shipped. ';
347
+ p.brush.open();
348
+ await settle();
349
+ assert.equal(p.lede.getAttribute('data-pb-key'), 'L0003');
350
+ assert.doesNotMatch(BRUSH, /matchLine\(/, 'no per-element pattern matching: the hole-free lines are looked up by their words');
351
+ });