@bongos/core 1.21.75 → 1.21.77

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 (43) hide show
  1. package/.bongos-core.json +65 -45
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +50 -3
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +45 -42
  10. package/docs/copy-registry.json +76 -49
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-inventory.json +16 -8
  13. package/docs/page-readings.json +1449 -1428
  14. package/modules/hall-ui/public/craft-mode.js +3 -1
  15. package/modules/hall-ui/public/page-notes.css +150 -0
  16. package/modules/hall-ui/public/page-notes.js +30 -5
  17. package/modules/hall-ui/public/studio.css +15 -0
  18. package/modules/hall-ui/public/studio.html +6 -0
  19. package/modules/hall-ui/public/studio.js +38 -4
  20. package/modules/hall-ui/public/style.css +6 -117
  21. package/modules/page-notes/db.js +19 -0
  22. package/modules/page-notes/migrations/page_notes_002_surface_index.sql +12 -0
  23. package/modules/page-notes/notes.js +25 -2
  24. package/modules/page-notes/routes/page-notes.js +9 -0
  25. package/modules/public-landing/public/account.html +2 -0
  26. package/modules/public-landing/public/assets/artist-review.js +107 -0
  27. package/modules/public-landing/public/contact.html +2 -0
  28. package/modules/public-landing/public/guild.html +2 -0
  29. package/modules/public-landing/public/index.html +2 -0
  30. package/modules/public-landing/public/invitations.html +2 -0
  31. package/modules/public-landing/public/privacy.html +2 -0
  32. package/modules/public-landing/public/projects.html +2 -0
  33. package/modules/public-landing/public/terms.html +2 -0
  34. package/modules/ui-design/kit/serve.js +32 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +12 -0
  38. package/src/module-api.js +1 -1
  39. package/tests/artist_review.mjs +231 -0
  40. package/tests/hall_studio_home.mjs +39 -1
  41. package/tests/landing_legal_pages.mjs +2 -1
  42. package/tests/page_notes_db.mjs +28 -1
  43. package/tests/page_notes_panel.mjs +9 -3
@@ -0,0 +1,107 @@
1
+ /* assets/artist-review.js — THE ARTIST REVIEW BUTTON on the public site (task
2
+ 1004499, goal 1000095 WA6; owner decisions 2026-10-04).
3
+
4
+ A signed-in builder looking at a live public page (the landing, /projects,
5
+ /account and the footer pages) writes UI feedback about it without leaving it.
6
+ The button sits in the hub bar beside the identity chip and opens the hall's
7
+ own feedback panel (modules/hall-ui/public/page-notes.js, the Artistry
8
+ palette), so a review written here is the same doc as one written in the hall:
9
+ split by an AI into ideas filed under the reviewer, and kept as that page's
10
+ design reference. Its page id is site:<page>; the Studio lists them.
11
+
12
+ Signed out there is no button: GET /me answers 401 and nothing is drawn. Any
13
+ signed-in builder sees it, in any craft mode — the button is cosmetic, the
14
+ panel's routes hold the walls (ADR 0016).
15
+
16
+ The page names the hall's origin on this tag (data-hall="{{buildersOrigin}}",
17
+ stamped server-side), so the ideas the panel links to open in the hall, which
18
+ on the hub is another host. A ?note=N link opens that doc in place. */
19
+ (function () {
20
+ 'use strict';
21
+ var PANEL = '/modules/hall-ui/public/';
22
+ // The hall palette's own icon (craft-mode.js ICONS.artistry), built as nodes:
23
+ // this surface writes no innerHTML.
24
+ var SVG_NS = 'http://www.w3.org/2000/svg';
25
+ var ICON_PATHS = [
26
+ ['M8 2a6 6 0 1 0 0 12c.9 0 1.4-.6 1.4-1.3 0-.8-.6-1-.6-1.7 0-.6.5-1 1.2-1h1.6A2.4 2.4 0 0 0 14 7.6C14 4.5 11.3 2 8 2Z', null],
27
+ ['M5 6.2h.01M7.6 4.6h.01M10.4 5.2h.01M4.6 9h.01', '2.2'],
28
+ ];
29
+ function icon() {
30
+ var svg = document.createElementNS(SVG_NS, 'svg');
31
+ [['viewBox', '0 0 16 16'], ['fill', 'none'], ['stroke', 'currentColor'], ['stroke-width', '1.4'], ['stroke-linecap', 'round'], ['stroke-linejoin', 'round'], ['aria-hidden', 'true']]
32
+ .forEach(function (a) { svg.setAttribute(a[0], a[1]); });
33
+ ICON_PATHS.forEach(function (p) {
34
+ var path = document.createElementNS(SVG_NS, 'path');
35
+ path.setAttribute('d', p[0]);
36
+ if (p[1]) path.setAttribute('stroke-width', p[1]);
37
+ svg.appendChild(path);
38
+ });
39
+ return svg;
40
+ }
41
+ var tag = document.currentScript;
42
+ var hall = (tag && tag.getAttribute('data-hall')) || '';
43
+
44
+ function linkSheet() {
45
+ if (document.getElementById('arv-sheet')) return;
46
+ var l = document.createElement('link');
47
+ l.id = 'arv-sheet';
48
+ l.rel = 'stylesheet';
49
+ l.href = PANEL + 'page-notes.css';
50
+ document.head.appendChild(l);
51
+ }
52
+ var loading = null;
53
+ // The panel learns it is on the public site, and where the hall is, from here.
54
+ function ready(api) { api.configure({ surface: 'site', hall: hall }); return api; }
55
+ function loadPanel() {
56
+ if (window.OTBPageNotes) return Promise.resolve(ready(window.OTBPageNotes));
57
+ if (loading) return loading;
58
+ loading = new Promise(function (resolve, reject) {
59
+ var s = document.createElement('script');
60
+ s.src = PANEL + 'page-notes.js';
61
+ s.onload = function () { if (window.OTBPageNotes) resolve(ready(window.OTBPageNotes)); else reject(new Error('panel did not load')); };
62
+ s.onerror = function () { loading = null; reject(new Error('panel did not load')); };
63
+ document.head.appendChild(s);
64
+ });
65
+ return loading;
66
+ }
67
+ // The hall's palette (craft-mode.js TOOLS.artistry) wears the same two names;
68
+ // the two pages share no script, so tests/artist_review.mjs holds them equal.
69
+ var LABEL = 'Artist review: write feedback on this page';
70
+ var SHORT = 'Review';
71
+ function drawButton() {
72
+ var bar = document.querySelector('.barRight');
73
+ if (!bar || document.getElementById('arv-btn')) return null;
74
+ var b = document.createElement('button');
75
+ b.type = 'button';
76
+ b.id = 'arv-btn';
77
+ b.className = 'arv-btn';
78
+ b.setAttribute('aria-label', LABEL);
79
+ b.title = LABEL;
80
+ var label = document.createElement('span');
81
+ label.className = 'arv-btn__label';
82
+ label.textContent = SHORT;
83
+ b.appendChild(icon());
84
+ b.appendChild(label);
85
+ b.addEventListener('click', function () {
86
+ loadPanel().then(function (api) { api.open(); }, function () { b.setAttribute('aria-disabled', 'true'); });
87
+ });
88
+ var who = document.getElementById('who');
89
+ if (who && who.parentNode === bar) bar.insertBefore(b, who);
90
+ else bar.appendChild(b);
91
+ return b;
92
+ }
93
+
94
+ function signedIn(me) {
95
+ if (!me) return;
96
+ linkSheet();
97
+ drawButton();
98
+ var note = /[?&]note=([1-9]\d{0,17})(?:&|$)/.exec(window.location.search || '');
99
+ if (note) loadPanel().then(function (api) { api.openNote(note[1]); }, function () { /* the page still works */ });
100
+ }
101
+
102
+ fetch('/api/bongos/me', { method: 'GET', credentials: 'same-origin', headers: { Accept: 'application/json' } })
103
+ .then(function (r) { return r.ok ? r.json().catch(function () { return null; }) : null; })
104
+ .then(function (d) { signedIn(d ? (d.builder || d.me || d) : null); }, function () { /* signed out or offline: no button */ });
105
+
106
+ window.OTBArtistReview = { signedIn: signedIn };
107
+ })();
@@ -120,5 +120,7 @@
120
120
 
121
121
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
122
122
  <script src="/assets/hub-bar.js"></script>
123
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
124
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
123
125
  </body>
124
126
  </html>
@@ -629,5 +629,7 @@
629
629
  load();
630
630
  })();
631
631
  </script>
632
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
633
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
632
634
  </body>
633
635
  </html>
@@ -1261,5 +1261,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
1261
1261
  });
1262
1262
  })();
1263
1263
  </script>
1264
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
1265
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
1264
1266
  </body>
1265
1267
  </html>
@@ -961,5 +961,7 @@
961
961
  });
962
962
  })();
963
963
  </script>
964
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
965
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
964
966
  </body>
965
967
  </html>
@@ -129,5 +129,7 @@
129
129
 
130
130
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy" aria-current="page">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
131
131
  <script src="/assets/hub-bar.js"></script>
132
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
133
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
132
134
  </body>
133
135
  </html>
@@ -11174,5 +11174,7 @@ summary{min-height:24px;padding:3px 0;}
11174
11174
  .then(loadSelfShipped, loadSelfShipped);
11175
11175
  })();
11176
11176
  </script>
11177
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
11178
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
11177
11179
  </body>
11178
11180
  </html>
@@ -134,5 +134,7 @@
134
134
 
135
135
  <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms" aria-current="page">Terms</a><a href="/contact">Contact</a><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a></nav></footer>
136
136
  <script src="/assets/hub-bar.js"></script>
137
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
138
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
137
139
  </body>
138
140
  </html>
@@ -439,6 +439,33 @@ function handlePatch(res, body) {
439
439
  return json(res, 200, { handle: h, previous_handle: previous || null });
440
440
  }
441
441
 
442
+ // The page feedback panel's routes (task 1004499: the public site's Artist review
443
+ // button opens modules/hall-ui/public/page-notes.js), so a render can picture the
444
+ // panel open. One draft per page for the run; nothing is ever split or filed.
445
+ // SIMPLIFIED: drafts are keyed by page alone, where the real route keys them by
446
+ // (author, page) — the stub has one fixture builder, so the two agree here.
447
+ const pnDrafts = new Map();
448
+ function pageNotesRoute(req, res, rest, u) {
449
+ if (!rest.startsWith('/page-notes')) return false;
450
+ if (!AUTH) { json(res, 401, UNAUTH); return true; }
451
+ if (rest === '/page-notes/site') { json(res, 200, { notes: [] }); return true; }
452
+ if (rest === '/page-notes' && req.method === 'GET') { json(res, 200, { page_id: u.searchParams.get('page_id') || '', notes: [] }); return true; }
453
+ if (rest === '/page-notes' && req.method === 'POST') {
454
+ readBody(req, (body) => {
455
+ const key = String(body.page_id || '');
456
+ if (!pnDrafts.has(key)) pnDrafts.set(key, { id: String(pnDrafts.size + 1), page_id: key, page_path: body.page_path || '/', page_title: body.page_title || '', body_md: '', status: 'draft', idea_ids: [], author_name: 'builder' });
457
+ json(res, 200, { note: pnDrafts.get(key), images: [] });
458
+ });
459
+ return true;
460
+ }
461
+ const one = rest.match(/^\/page-notes\/(\d+)$/);
462
+ if (one && req.method === 'PATCH') {
463
+ readBody(req, (body) => { for (const n of pnDrafts.values()) if (n.id === one[1]) n.body_md = String(body.body_md || ''); json(res, 200, { ok: true }); });
464
+ return true;
465
+ }
466
+ return false;
467
+ }
468
+
442
469
  // The hub's guild page (task 1002301): GET /guilds/:slug (public), GET /guilds/:slug/members
443
470
  // and PATCH /guilds/:slug/membership (own-scope), answered from ONE set of guilds held for the
444
471
  // run (guilds.json, or the file a states file's stub.fixtures names), keyed by slug, each with
@@ -661,6 +688,7 @@ const server = http.createServer((req, res) => {
661
688
  starter-bundles.js declaration, so the picture the kit renders and the
662
689
  payload the route serves cannot drift apart silently. */
663
690
  if (rest === '/provisioning/starter-bundles') return json(res, 200, STARTER_BUNDLES_FIXTURE);
691
+ if (pageNotesRoute(req, res, rest, u)) return;
664
692
  if (rest === '/auth/logout') return json(res, 200, { ok: true });
665
693
  if (rest.startsWith('/auth/web/start')) { res.writeHead(302, { location: PREFIX }); return res.end(); }
666
694
  if (rest === '/instance') { const b = pages.size ? pages.values().next().value.brand : {}; return json(res, 200, { brand: { productName: (b && b.productName) || '', project: {} }, modules: {} }); }
@@ -672,6 +700,10 @@ const server = http.createServer((req, res) => {
672
700
  res.writeHead(200, { 'content-type': MIME['.js'], 'cache-control': 'no-store' });
673
701
  return res.end(bundle ? fs.readFileSync(bundle, 'utf8') : '/* stub: no client bundle in this checkout */');
674
702
  }
703
+ // an enabled module's browser assets, as serve-internal.js serves them on every host
704
+ // (task 1004499: the public site loads the hall's feedback panel from here)
705
+ const modAsset = p.match(/^\/modules\/([a-z][a-z0-9-]*)\/public\/(.+)$/);
706
+ if (modAsset) { const abs = fileUnder(path.join(ROOT, 'modules', modAsset[1], 'public'), modAsset[2]); if (abs) return sendFile(abs, res); }
675
707
  // docs/ raw (a mock's relative links resolve) and the surface dir by its checkout path
676
708
  if (p.startsWith('/docs/')) { const abs = fileUnder(DOCS, p.slice('/docs/'.length)); if (abs) return sendFile(abs, res); }
677
709
  const relSurface = path.relative(ROOT, DIR).split(path.sep).join('/');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.75",
3
+ "version": "1.21.77",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.75",
9
+ "version": "1.21.77",
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.75",
3
+ "version": "1.21.77",
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",
@@ -9260,5 +9260,17 @@
9260
9260
  "id": "1003807",
9261
9261
  "text": "The module store now only shows modules that are safe and good enough: each must pass the security check, and once it has real-world data it needs a score of at least 50 out of 100. Brand-new modules show with a 'New' label un"
9262
9262
  }
9263
+ ],
9264
+ "1.21.76": [
9265
+ {
9266
+ "id": "1004499",
9267
+ "text": "Artists can now review the public website while looking at it. A signed-in builder on cloudbongos.com's public pages (home, projects, account, guilds, invitations, privacy, terms, contact) sees a Review button in the top bar,"
9268
+ }
9269
+ ],
9270
+ "1.21.77": [
9271
+ {
9272
+ "id": "1004638",
9273
+ "text": "The hall sidebar now has a solid background, so the page title no longer shows through the Builders' Hall name in light mode."
9274
+ }
9263
9275
  ]
9264
9276
  }
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.75'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.77'; // 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');
@@ -0,0 +1,231 @@
1
+ // tests/artist_review.mjs — the Artist review button on the public site (task
2
+ // 1004499, goal 1000095 WA6). Owner decisions 2026-10-04: the public pages plus
3
+ // the Studio; any signed-in builder sees it; it sits in the hub bar; the
4
+ // reviews are listed in the Studio.
5
+ //
6
+ // Four altitudes: the pure rules (a site doc is read in the Studio), the SQL,
7
+ // the route over a stubbed db through real HTTP, and the browser half — the
8
+ // REAL artist-review.js drawing the button (or nothing) and loading the REAL
9
+ // page-notes.js, whose page id and idea links follow the public surface.
10
+ import { test } from 'node:test';
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import http from 'node:http';
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 require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const N = require('../modules/page-notes/notes.js');
22
+ const LOADER = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public/assets/artist-review.js'), 'utf8');
23
+ const NOTES = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/page-notes.js'), 'utf8');
24
+ const realListForSurface = require('../modules/page-notes/db.js').listForSurface;
25
+ const PAGES = ['index', 'projects', 'account', 'privacy', 'terms', 'contact', 'guild', 'invitations'];
26
+
27
+ // ---------- pure ----------
28
+
29
+ test('a site doc is read in the Studio; a hall doc still on its own page', () => {
30
+ assert.equal(N.noteLink({ id: '5', page_id: 'site:projects', page_path: '/projects' }), '/builders/studio?note=5');
31
+ assert.equal(N.noteLink({ id: '6', page_id: 'builders:studio', page_path: '/studio' }), '/studio?note=6');
32
+ assert.equal(N.isSiteNote({ page_id: 'site:home' }), true);
33
+ assert.equal(N.isSiteNote({ page_id: 'builders:site' }), false, 'the surface, not a page named site');
34
+ assert.equal(N.normalizeDraft({ page_id: 'site:home', page_path: '/' }).ok, true, 'the site surface is a valid page id');
35
+ const body = N.ideaBody({ body: 'The hero is too dark.' }, { id: '5', page_id: 'site:home', page_title: 'Home' }, 'Ana');
36
+ assert.match(body, /Full reference doc: \[Home feedback\]\(\/builders\/studio\?note=5\)$/, 'every idea it becomes links into the hall');
37
+ });
38
+
39
+ test('the Studio list row: counts the ideas, carries the link, never the body', () => {
40
+ const r = N.siteListItem({ id: 9, page_id: 'site:terms', page_path: '/terms', page_title: 'Terms', status: 'filed', submitted_at: 't', idea_ids: [1, 2, 3], author_name: 'Bo', excerpt: 'x', body_md: 'SECRET' });
41
+ assert.deepEqual(r, { id: '9', page_id: 'site:terms', page_path: '/terms', page_title: 'Terms', status: 'filed', submitted_at: 't', idea_count: 3, author_name: 'Bo', excerpt: 'x', link: '/builders/studio?note=9' });
42
+ });
43
+
44
+ // ---------- SQL ----------
45
+
46
+ test('listForSurface reads submitted docs on that surface only, newest first', async () => {
47
+ let seen = null;
48
+ await realListForSurface('site', { limit: 20 }, async (text, params) => { seen = { text, params }; return { rows: [] }; });
49
+ assert.deepEqual(seen.params, ['site:%', 20]);
50
+ assert.match(seen.text, /n\.page_id LIKE \$1 AND n\.status <> 'draft'/);
51
+ assert.match(seen.text, /ORDER BY n\.submitted_at DESC/);
52
+ assert.doesNotMatch(seen.text, /\bn\.body_md\b(?!,)/, 'only an excerpt leaves the table');
53
+ });
54
+
55
+ // ---------- the route ----------
56
+
57
+ const db = require('../modules/page-notes/db.js');
58
+ const api = require('../src/module-api.js');
59
+ let viewer = { id: 9, rank: 'thetes' };
60
+ let asked = null;
61
+ db.listForSurface = async (surface, opts) => {
62
+ asked = { surface, opts };
63
+ return [{ id: 41, page_id: 'site:projects', page_path: '/projects', page_title: 'Projects', status: 'filed', submitted_at: '2026-10-04', idea_ids: [7], author_name: 'Ana', excerpt: 'Labels overlap.' }];
64
+ };
65
+ db.getNote = async () => null;
66
+ api.requireBuilder = (req, res, next) => { if (!viewer) return res.status(401).json({ error: { code: 'unauthenticated' } }); req.builder = viewer; next(); };
67
+ const express = require('express');
68
+ const app = express();
69
+ app.use((req, res, next) => { res.fail = (code, s) => res.status(typeof s === 'number' ? s : 400).json({ error: { code } }); next(); });
70
+ app.use(express.json());
71
+ app.use('/api/bongos', require('../modules/page-notes/routes/page-notes.js')());
72
+ const server = app.listen(0);
73
+ await new Promise((r) => server.once('listening', r));
74
+ test.after(() => server.close());
75
+ const get = (p) => new Promise((resolve, reject) => {
76
+ http.get(`http://127.0.0.1:${server.address().port}/api/bongos${p}`, (res) => {
77
+ let s = '';
78
+ res.on('data', (c) => { s += c; });
79
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(s || 'null'), headers: res.headers }));
80
+ }).on('error', reject);
81
+ });
82
+
83
+ test('GET /page-notes/site: any signed-in builder reads the list; a visitor nothing', async () => {
84
+ viewer = { id: 9, rank: 'thetes' };
85
+ const r = await get('/page-notes/site');
86
+ assert.equal(r.status, 200, 'answered by the list, never read as doc id "site"');
87
+ assert.deepEqual(asked, { surface: 'site', opts: { limit: 20 } });
88
+ assert.equal(r.headers['cache-control'], 'no-store');
89
+ assert.deepEqual(r.body.notes.map((n) => [n.id, n.idea_count, n.link]), [['41', 1, '/builders/studio?note=41']]);
90
+ viewer = null;
91
+ assert.equal((await get('/page-notes/site')).status, 401);
92
+ });
93
+
94
+ // ---------- the browser half ----------
95
+
96
+ function el(tag, id, cls) {
97
+ const attrs = {};
98
+ const listeners = {};
99
+ const e = {
100
+ tagName: tag, id: id || '', className: cls || '', children: [], parentNode: null, innerHTML: '', title: '', type: '', src: '', href: '', rel: '',
101
+ dataset: {},
102
+ setAttribute(k, v) { attrs[k] = String(v); if (k.startsWith('data-')) e.dataset[k.slice(5).replace(/-(\w)/g, (_m, c) => c.toUpperCase())] = String(v); },
103
+ getAttribute(k) { return k in attrs ? attrs[k] : null; },
104
+ appendChild(c) { c.parentNode = e; e.children.push(c); if (e.onAppend) e.onAppend(c); return c; },
105
+ insertBefore(c, ref) { c.parentNode = e; const at = e.children.indexOf(ref); if (at === -1) e.children.push(c); else e.children.splice(at, 0, c); return c; },
106
+ addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
107
+ click() { (listeners.click || []).forEach((fn) => fn({ target: e })); },
108
+ };
109
+ return e;
110
+ }
111
+ const tree = (r) => r.children.flatMap((c) => [c, ...tree(c)]);
112
+ const flush = async () => { for (let i = 0; i < 6; i++) await new Promise((r) => setTimeout(r, 0)); };
113
+
114
+ function bootSite({ me = { builder: { id: 9, github_login: 'ana' } }, search = '', pathname = '/projects', hall = 'https://builders.example.com' } = {}) {
115
+ const body = el('BODY');
116
+ const head = el('HEAD');
117
+ const bar = body.appendChild(el('DIV', '', 'barRight'));
118
+ bar.appendChild(el('BUTTON', 'projTrig', 'trig'));
119
+ const who = bar.appendChild(el('BUTTON', 'who', 'trig who'));
120
+ const script = el('SCRIPT');
121
+ script.setAttribute('data-hall', hall);
122
+ const fetched = [];
123
+ const loaded = [];
124
+ const opened = [];
125
+ const configured = [];
126
+ const all = () => [head, body, ...tree(head), ...tree(body)];
127
+ const document = {
128
+ body, head, currentScript: script, title: 'Projects — Example',
129
+ getElementById: (id) => all().find((n) => n.id === id) || null,
130
+ querySelector: (sel) => (sel === '.barRight' ? bar : null),
131
+ createElement: (t) => el(t.toUpperCase()),
132
+ createElementNS: (_ns, t) => el(t),
133
+ };
134
+ const window = { location: { search, pathname }, document };
135
+ head.onAppend = (tag) => {
136
+ if (tag.tagName !== 'SCRIPT') return;
137
+ loaded.push(tag.src);
138
+ window.OTBPageNotes = { configure: (c) => configured.push(JSON.parse(JSON.stringify(c))), open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id) };
139
+ tag.onload();
140
+ };
141
+ const fetch = async (url, opts) => {
142
+ fetched.push([url, opts.credentials]);
143
+ return me ? { ok: true, json: async () => me } : { ok: false, status: 401, json: async () => ({}) };
144
+ };
145
+ const sandbox = { window, document, fetch, Promise, setTimeout };
146
+ vm.createContext(sandbox);
147
+ vm.runInContext(LOADER, sandbox, { filename: 'artist-review.js' });
148
+ return { window, document, body, head, bar, who, fetched, loaded, opened, configured, sandbox };
149
+ }
150
+
151
+ test('SIGNED OUT: no button, no sheet, no panel — the page is untouched', async () => {
152
+ const s = bootSite({ me: null });
153
+ await flush();
154
+ assert.deepEqual(s.fetched, [['/api/bongos/me', 'same-origin']]);
155
+ assert.equal(s.document.getElementById('arv-btn'), null);
156
+ assert.equal(s.head.children.length, 0);
157
+ assert.deepEqual(s.configured, [], 'the panel is never told anything');
158
+ });
159
+
160
+ test('SIGNED IN: the Review button sits in the hub bar right before the identity chip, and opens the hall\'s panel', async () => {
161
+ const s = bootSite();
162
+ await flush();
163
+ const b = s.document.getElementById('arv-btn');
164
+ assert.ok(b, 'drawn');
165
+ assert.equal(b.parentNode, s.bar);
166
+ assert.equal(s.bar.children[s.bar.children.indexOf(b) + 1], s.who, 'beside the identity chip');
167
+ assert.equal(b.getAttribute('aria-label'), 'Artist review: write feedback on this page', 'the same name as the hall\'s palette');
168
+ assert.equal(b.innerHTML, '', 'built as nodes: this surface writes no innerHTML');
169
+ assert.equal(b.children[0].tagName, 'svg');
170
+ assert.deepEqual([b.children[1].className, b.children[1].textContent], ['arv-btn__label', 'Review']);
171
+ assert.doesNotMatch(LOADER, /\.innerHTML\s*=/);
172
+ assert.deepEqual(s.head.children.map((t) => t.href), ['/modules/hall-ui/public/page-notes.css'], 'the panel\'s own sheet, served from the hall module');
173
+ assert.deepEqual(s.loaded, [], 'the panel is not loaded until it is wanted');
174
+ b.click(); await flush();
175
+ b.click(); await flush();
176
+ assert.deepEqual(s.loaded, ['/modules/hall-ui/public/page-notes.js'], 'loaded once');
177
+ assert.deepEqual(s.opened, ['open', 'open']);
178
+ assert.deepEqual(s.configured[0], { surface: 'site', hall: 'https://builders.example.com' }, 'the panel is told, explicitly, it is on the public site and where the hall is');
179
+ });
180
+
181
+ test('a ?note=N link opens that doc in place; a hall origin that is not one is dropped', async () => {
182
+ const s = bootSite({ search: '?note=41', hall: 'javascript:alert(1)' });
183
+ await flush();
184
+ assert.deepEqual(s.opened, ['note 41']);
185
+ assert.equal(s.body.dataset.pnHall, undefined);
186
+ });
187
+
188
+ test('the panel names a public page as site:<first path segment>, and its ideas open in the hall', async () => {
189
+ const run = (pathname, hall = 'https://builders.example.com') => {
190
+ const s = bootSite({ pathname, hall });
191
+ return flush().then(() => {
192
+ vm.runInContext(NOTES, s.sandbox, { filename: 'page-notes.js' });
193
+ s.window.OTBPageNotes.configure({ surface: 'site', hall });
194
+ return s;
195
+ });
196
+ };
197
+ for (const [p, id] of [['/', 'site:home'], ['/projects', 'site:projects'], ['/account', 'site:account'], ['/privacy.html', 'site:privacy']]) {
198
+ const s = await run(p);
199
+ assert.equal(s.window.OTBPageNotes.page().id, id, p);
200
+ assert.equal(N.PAGE_ID_RE.test(id), true, 'the server takes it');
201
+ }
202
+ const s = await run('/projects');
203
+ s.document.title = 'Example — projects';
204
+ assert.equal(s.window.OTBPageNotes.page().title, 'Projects', 'named by its path, not the product name the title leads with');
205
+ assert.equal((await run('/')).window.OTBPageNotes.page().title, 'Home');
206
+ assert.match(NOTES, /esc\(ideaHref\(i\.id\)\)/, 'idea links go through the surface-aware href');
207
+ assert.equal(s.window.OTBPageNotes.ideaHref('7'), 'https://builders.example.com/builders/idea/7', 'an idea opens in the hall');
208
+ const bad = await run('/projects', 'javascript:alert(1)');
209
+ assert.equal(bad.window.OTBPageNotes.ideaHref('7'), '/builders/idea/7', 'a hall origin that is not one is dropped');
210
+ s.window.OTBPageNotes.configure({});
211
+ assert.equal(s.window.OTBPageNotes.page().id, 'builders:home', 'unconfigured, it is the hall panel it always was');
212
+ assert.equal(s.window.OTBPageNotes.ideaHref('7'), '/#/idea/7');
213
+ });
214
+
215
+ test('the public button and the hall palette wear one name', () => {
216
+ const craft = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/craft-mode.js'), 'utf8');
217
+ const tool = /id: 'palette', icon: 'artistry', short: '([^']+)', label: '([^']+)'/.exec(craft);
218
+ assert.ok(tool, 'the palette tool');
219
+ assert.ok(LOADER.includes("var SHORT = '" + tool[1] + "';"), 'the short name');
220
+ assert.ok(LOADER.includes("var LABEL = '" + tool[2] + "';"), 'the full name');
221
+ });
222
+
223
+ test('every public page carries the loader last, with the hall origin stamped server-side', () => {
224
+ for (const f of PAGES) {
225
+ const html = fs.readFileSync(path.join(ROOT, 'modules/public-landing/public', f + '.html'), 'utf8').replace(/\r\n/g, '\n');
226
+ assert.match(html, /<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, f);
227
+ }
228
+ const serve = fs.readFileSync(path.join(ROOT, 'src/bongos/serve-internal.js'), 'utf8');
229
+ assert.match(serve, /\.split\('\{\{buildersOrigin\}\}'\)/, 'the token the tag carries is one the server fills');
230
+ assert.match(serve, /const MODULE_ASSET_RE = \/\^\\\/modules\\\/\(\[a-z\]\[a-z0-9-\]\*\)\\\/public\\\/\(\.\+\)\$\//, 'the panel is served at /modules/hall-ui/public/');
231
+ });
@@ -418,12 +418,50 @@ try {
418
418
  assert.equal(none.el('st-older').hidden, true, 'no review waiting: no door');
419
419
  });
420
420
 
421
+ await test('From the live site (task 1004499): the public pages\' reviews, each opening its doc here', async () => {
422
+ const N = require('../modules/page-notes/notes.js');
423
+ // the route's own mapper over two listForSurface rows
424
+ const rows = [
425
+ { id: 31, page_id: 'site:projects', page_path: '/projects', page_title: 'Projects', status: 'filed', submitted_at: day(1), idea_ids: [1, 2], author_name: 'Ana', excerpt: 'The map labels <b>overlap</b>.\n![shot](/api/bongos/page-notes/images/4)' },
426
+ { id: 32, page_id: 'site:home', page_path: '/', page_title: '', status: 'splitting', submitted_at: day(0), idea_ids: [], author_name: 'Bo', excerpt: 'Hero is too dark.' },
427
+ ].map(N.siteListItem);
428
+ const s = bootStudio({ answers: { ...HOME, 'GET /page-notes/site': ok({ notes: rows }) } });
429
+ await s.settle();
430
+ assert.equal(s.el('st-site-detail').textContent, '2 reviews from the live site');
431
+ await s.click('st-site');
432
+ assert.equal(s.el('st-panel-title').textContent, 'From the live site');
433
+ const body = s.el('st-panel-body').innerHTML;
434
+ assert.deepEqual([...body.matchAll(/class="st-site" href="([^"]*)"/g)].map((m) => m[1]), ['/builders/studio?note=31', '/builders/studio?note=32'], 'each row opens its doc in the Studio');
435
+ assert.match(body, /st-site__page">Projects</);
436
+ assert.match(body, /st-site__page">\/</, 'an untitled page falls back to its path');
437
+ assert.match(body, /Ana · [^<]* · 2 ideas/);
438
+ assert.match(body, /Bo · [^<]* · splitting/);
439
+ assert.match(body, /The map labels &lt;b&gt;overlap&lt;\/b&gt;\. \[image\]</, 'the excerpt is escaped and its picture named, not shown');
440
+ const long = bootStudio({ answers: { ...HOME, 'GET /page-notes/site': ok({ notes: [{ ...rows[1], excerpt: 'word '.repeat(60) }] }) } });
441
+ await long.settle();
442
+ await long.click('st-site');
443
+ const cut = /st-site__text">([^<]*)</.exec(long.el('st-panel-body').innerHTML)[1];
444
+ assert.ok(cut.length <= 140 && /word…$/.test(cut), `a long excerpt ends on a whole word and says it goes on: ${cut}`);
445
+ await s.clickIn('st-panel', '[data-close-panel]');
446
+
447
+ const none = bootStudio({ answers: { ...HOME, 'GET /page-notes/site': ok({ notes: [] }) } });
448
+ await none.settle();
449
+ assert.equal(none.el('st-site-detail').textContent, 'no reviews from the live site yet');
450
+ await none.click('st-site');
451
+ assert.match(none.el('st-panel-body').innerHTML, /None yet\./);
452
+
453
+ const down = bootStudio({ answers: HOME });
454
+ await down.settle();
455
+ await down.click('st-site');
456
+ assert.match(down.el('st-panel-body').innerHTML, /could not be read just now/);
457
+ });
458
+
421
459
  await test('the markup: one h1 (the greeting), the tally and four doors, the libs before the page, the full-screen switch', () => {
422
460
  const nc = HTML.replace(/<!--[\s\S]*?-->/g, '');
423
461
  assert.equal((nc.match(/<h1\b/g) || []).length, 1);
424
462
  assert.match(nc, /<h1[^>]*id="studio-greet"[^>]*data-words/);
425
463
  assert.match(nc, /<p[^>]*id="studio-sub"[^>]*data-words/);
426
- for (const id of ['st-tally', 'st-resume', 'st-flow', 'studio-approvals', 'st-status', 'st-older']) assert.match(nc, new RegExp(`id="${id}"`));
464
+ for (const id of ['st-tally', 'st-resume', 'st-flow', 'studio-approvals', 'st-status', 'st-site', 'st-older']) assert.match(nc, new RegExp(`id="${id}"`));
427
465
  assert.match(nc, /<body[^>]*data-fullscreen-switch/);
428
466
  const at = (f) => nc.indexOf(f);
429
467
  assert.ok(at('tweak-editor-lib.js') < at('studio-lib.js') && at('studio-lib.js') < at('/studio.js'), 'the libs, then the page');
@@ -206,7 +206,8 @@ for (const p of PAGES) {
206
206
  const html = read(p.file);
207
207
  const styles = [...html.matchAll(/<style>([\s\S]*?)<\/style>/g)].map((m) => m[1]).join('\n');
208
208
  assert.doesNotMatch(styles, /\.(shell|bar|barMark|barLeft|barRight|trig|who|signinDoor|menuBand|band[A-Z]\w*)\b[^{}]*\{/, 'no bar or band rule in the page');
209
- assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<\/body>/, 'one shared script, after the markup it drives');
209
+ // then the Artist review button's loader (task 1004499), the page's last tag
210
+ assert.match(html, /<\/footer>\n<script src="\/assets\/hub-bar\.js"><\/script>\n<!-- [^\n]*-->\n<script src="\/assets\/artist-review\.js" data-hall="\{\{buildersOrigin\}\}"><\/script>\n<\/body>/, 'one shared script, after the markup it drives');
210
211
  assert.doesNotMatch(html, /<script>/, 'and no inline copy of the bands');
211
212
  assert.match(html, /<a data-link-hall href="\{\{buildersOrigin\}\}">Builders' hall<\/a><\/nav><\/footer>/, 'the hall keeps a door in the footer row now the bar has no Hall pill, as on /account');
212
213
  });
@@ -54,8 +54,11 @@ await pool.query('INSERT INTO builders (id, github_login, display_name) VALUES (
54
54
 
55
55
  const MIGRATION = fs.readFileSync(path.join(ROOT, 'modules/page-notes/migrations/page_notes_001_notes.sql'), 'utf8');
56
56
  await pool.query(MIGRATION);
57
+ // task 1004499: the Studio's site list reads by page_id prefix
58
+ const MIGRATION_2 = fs.readFileSync(path.join(ROOT, 'modules/page-notes/migrations/page_notes_002_surface_index.sql'), 'utf8');
59
+ await pool.query(MIGRATION_2);
57
60
  const PAGE = 'builders:pn-test-page';
58
- const clean = () => pool.query('DELETE FROM page_notes_docs WHERE page_id LIKE $1', ['builders:pn-test-%']);
61
+ const clean = () => pool.query('DELETE FROM page_notes_docs WHERE page_id LIKE $1 OR page_id LIKE $2', ['builders:pn-test-%', 'pntest:%']);
59
62
  await clean();
60
63
 
61
64
  const PNG = Buffer.from('89504e470d0a1a0a0000000d4948445200000001000000010806000000', 'hex');
@@ -135,6 +138,30 @@ await test('deleting a doc takes its images with it', async () => {
135
138
 
136
139
  await clean();
137
140
  await pool.query('DELETE FROM builders WHERE id = ANY($1::bigint[])', [[A, B]]).catch(() => {});
141
+ await test('THE SITE LIST (task 1004499): one surface, submitted only, newest first, read through its own index', async () => {
142
+ await pool.query(MIGRATION_2);
143
+ const mk = async (author, page, at) => {
144
+ const n = await db.openDraft({ authorId: author, pageId: page, pagePath: '/', pageTitle: page }, q);
145
+ await pool.query("UPDATE page_notes_docs SET status = 'filed', submitted_at = $2, body_md = 'words' WHERE id = $1", [n.id, at]);
146
+ return String(n.id);
147
+ };
148
+ const older = await mk(A, 'pntest:home', '2026-10-01T00:00:00Z');
149
+ const newer = await mk(B, 'pntest:projects', '2026-10-02T00:00:00Z');
150
+ await db.openDraft({ authorId: A, pageId: 'pntest:terms', pagePath: '/terms', pageTitle: 'Terms' }, q);
151
+ await mk(A, 'builders:pn-test-other', '2026-10-03T00:00:00Z');
152
+ const rows = await db.listForSurface('pntest', { limit: 20 }, q);
153
+ assert.deepEqual(rows.map((r) => String(r.id)), [newer, older], 'the surface only, drafts left out, newest first');
154
+ const c = await pool.connect();
155
+ try {
156
+ await c.query('BEGIN');
157
+ await c.query('SET LOCAL enable_seqscan = off');
158
+ const plan = (await c.query("EXPLAIN SELECT id FROM page_notes_docs n WHERE n.page_id LIKE 'site:%' AND n.status <> 'draft'")).rows.map((r) => r['QUERY PLAN']).join('\n');
159
+ assert.match(plan, /page_notes_by_surface/, plan);
160
+ await c.query('ROLLBACK');
161
+ } finally { c.release(); }
162
+ await clean();
163
+ });
164
+
138
165
  await pool.end();
139
166
  console.log(`page_notes_db: ${passed} passed, ${failed} failed`);
140
167
  process.exit(failed ? 1 : 0);