@bongos/core 1.21.75 → 1.21.76

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 +2 -0
  12. package/docs/page-inventory.json +16 -8
  13. package/docs/page-readings.json +1542 -1522
  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 +1 -116
  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 +6 -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
@@ -206,7 +206,9 @@
206
206
  // routes behind a tool hold its walls (ADR 0016).
207
207
  var TOOLS = {
208
208
  artistry: [
209
- { id: 'palette', icon: 'artistry', short: 'Feedback', label: 'Write feedback on this page', script: 'page-notes.js', api: 'OTBPageNotes' },
209
+ // Named "Artist review" since task 1004499: the public site's button
210
+ // (public-landing assets/artist-review.js) opens this same panel.
211
+ { id: 'palette', icon: 'artistry', short: 'Review', label: 'Artist review: write feedback on this page', script: 'page-notes.js', api: 'OTBPageNotes' },
210
212
  // Task 1004497: this page's words, edited where they stand as a proposed wording change.
211
213
  { id: 'paintbrush', icon: 'brush', short: 'Edit words', label: 'Edit this page\'s words', script: 'paintbrush.js', api: 'OTBPaintbrush' },
212
214
  ],
@@ -0,0 +1,150 @@
1
+ /* page-notes.css — the page feedback doc's sheet (task 1004496), moved out of
2
+ style.css by task 1004499 so the PUBLIC site's Artist review button can wear
3
+ the same panel: style.css imports it for the hall, and the public pages'
4
+ artist-review.js links it at /modules/hall-ui/public/page-notes.css. Every
5
+ token the public sheets may lack carries its fallback in place. The
6
+ lightbulb (lightbulb.js) rides these classes too. */
7
+
8
+ /* The fallbacks live as custom properties, never in a declaration value, so
9
+ the hall's colour-literal guard (tests/hall_audit.mjs) still holds: in the
10
+ hall each one resolves to its token, on the public site to its fallback. */
11
+ .pn, .arv-btn {
12
+ --pn-scrim: var(--scrim, rgba(0, 0, 0, .55));
13
+ --pn-shadow-pop: var(--shadow-pop, 0 24px 60px rgba(0, 0, 0, .35));
14
+ --pn-danger: var(--danger, #e5484d);
15
+ --pn-accent-on: var(--accent-on, #1a0b04);
16
+ --pn-glass: var(--glass, rgba(255, 255, 255, .08));
17
+ }
18
+
19
+ /* The page feedback doc: a sheet from the right over a scrim. */
20
+ html.pn-open { overflow: hidden; }
21
+ .pn {
22
+ position: fixed;
23
+ inset: 0;
24
+ z-index: var(--z-modal, 1000);
25
+ display: flex;
26
+ justify-content: flex-end;
27
+ background: var(--pn-scrim);
28
+ }
29
+ .pn__panel {
30
+ position: relative;
31
+ width: min(560px, 100%);
32
+ height: 100%;
33
+ box-sizing: border-box;
34
+ display: flex;
35
+ flex-direction: column;
36
+ background: var(--bg-card);
37
+ color: var(--ink);
38
+ box-shadow: 0 0 0 1px var(--glass-edge), var(--pn-shadow-pop);
39
+ }
40
+ .pn__head { position: relative; padding: 22px 64px 14px 24px; border-bottom: 1px solid var(--rule-soft); }
41
+ .pn__eyebrow {
42
+ margin: 0 0 6px;
43
+ font-family: var(--label-face);
44
+ font-size: 11px;
45
+ letter-spacing: .12em;
46
+ text-transform: uppercase;
47
+ color: var(--craft, var(--ink-faint));
48
+ }
49
+ .pn__title { margin: 0; font-family: var(--font-display); font-weight: 400; font-size: 22px; letter-spacing: -0.02em; }
50
+ .pn__close {
51
+ position: absolute; top: 14px; right: 14px;
52
+ display: inline-flex; align-items: center; justify-content: center;
53
+ width: 40px; height: 40px; padding: 0; border: 0; border-radius: var(--radius-pill);
54
+ background: none; color: var(--ink-soft); cursor: pointer;
55
+ }
56
+ .pn__close svg { width: 18px; height: 18px; }
57
+ .pn__close:hover { color: var(--ink); background: var(--glass); }
58
+ .pn__body { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 24px 28px; display: flex; flex-direction: column; gap: 12px; }
59
+ .pn__label { font-size: 13px; color: var(--ink-soft); }
60
+ .pn__text {
61
+ width: 100%;
62
+ min-height: 260px;
63
+ box-sizing: border-box;
64
+ padding: 14px 16px;
65
+ border: 1px solid var(--rule);
66
+ border-radius: var(--radius-sm);
67
+ background: var(--bg);
68
+ color: var(--ink);
69
+ font: inherit;
70
+ font-size: 15px;
71
+ line-height: 1.55;
72
+ resize: vertical;
73
+ }
74
+ .pn__text:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
75
+ .pn__tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
76
+ .pn__add {
77
+ position: relative;
78
+ display: inline-flex; align-items: center;
79
+ min-height: 34px; padding: 0 14px;
80
+ border-radius: var(--radius-pill);
81
+ box-shadow: inset 0 0 0 1px var(--rule);
82
+ font-size: 13.5px; color: var(--ink); cursor: pointer;
83
+ }
84
+ .pn__add:hover { background: var(--glass); }
85
+ .pn__add:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
86
+ .pn__add input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
87
+ .pn__quiet { margin: 0; font-size: 13px; color: var(--ink-faint); }
88
+ .pn__saved { margin-left: auto; }
89
+ .pn__thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 8px; }
90
+ .pn__thumb { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; background: var(--bg-deep); }
91
+ .pn__error { margin: 0; font-size: 13.5px; color: var(--pn-danger); }
92
+ .pn__error:empty { display: none; }
93
+ .pn__foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; padding-top: 4px; }
94
+ .pn__hint { margin: 0; font-size: 13px; line-height: 1.45; color: var(--ink-soft); }
95
+ .pn__foot .pn__hint { flex: 1 1 240px; }
96
+ .pn__submit {
97
+ min-height: 40px; padding: 0 20px; border: 0; border-radius: var(--radius-pill);
98
+ background: var(--accent); color: var(--pn-accent-on); font: inherit; font-weight: 500; cursor: pointer;
99
+ }
100
+ .pn__submit:hover { background: var(--accent-lift, var(--accent)); }
101
+ .pn__submit:disabled { opacity: .55; cursor: default; }
102
+ .pn__submit:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
103
+ .pn__list { margin-top: 14px; padding-top: 16px; border-top: 1px solid var(--rule-soft); }
104
+ .pn__list-h {
105
+ margin: 0 0 8px;
106
+ font-family: var(--label-face); font-size: var(--label-size, 11.5px); letter-spacing: var(--label-space, .1em);
107
+ text-transform: var(--label-case, uppercase); color: var(--ink-faint); font-weight: 400;
108
+ }
109
+ .pn__rows, .pn__ideas { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
110
+ .pn__row {
111
+ display: grid; gap: 2px; width: 100%; padding: 10px 12px; border: 0; border-radius: var(--radius-sm);
112
+ background: none; color: var(--ink); font: inherit; text-align: left; cursor: pointer;
113
+ }
114
+ a.pn__row { text-decoration: none; } /* the lightbulb's page ideas are links */
115
+ .pn__row:hover { background: var(--glass); }
116
+ .pn__row:focus-visible, .pn__back:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
117
+ .pn__row-meta { font-size: 12px; color: var(--ink-faint); }
118
+ .pn__row-text { font-size: 14px; color: var(--ink-soft); }
119
+ .pn__ideas a { display: inline-block; padding: 6px 0; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
120
+ .pn__back {
121
+ align-self: flex-start; min-height: 32px; padding: 0 12px; margin-left: -12px; border: 0;
122
+ border-radius: var(--radius-pill); background: none; color: var(--ink-soft); font: inherit; font-size: 13.5px; cursor: pointer;
123
+ }
124
+ .pn__back:hover { color: var(--ink); background: var(--glass); }
125
+ .pn__done { margin: 0; font-size: 16px; }
126
+ .pn-doc { font-size: 15px; line-height: 1.6; overflow-wrap: anywhere; }
127
+ .pn-doc p, .pn-doc ul { margin: 0 0 12px; }
128
+ .pn-doc__h { margin: 16px 0 6px; font-size: 16px; font-weight: 500; }
129
+ .pn-doc__img { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
130
+ @media (max-width: 640px) {
131
+ .pn__head { padding: 18px 60px 12px 16px; }
132
+ .pn__body { padding: 14px 16px 24px; }
133
+ }
134
+
135
+ /* The public site's Artist review button (task 1004499, artist-review.js): a
136
+ pill in the hub bar, beside the identity chip. */
137
+ .arv-btn {
138
+ display: inline-flex; align-items: center; gap: 7px;
139
+ min-height: 36px; padding: 0 14px 0 11px;
140
+ border: 0; border-radius: var(--radius-pill, 999px);
141
+ background: none; color: var(--ink); font: inherit; font-size: 14px; cursor: pointer;
142
+ box-shadow: inset 0 0 0 1px var(--rule);
143
+ }
144
+ .arv-btn svg { width: 17px; height: 17px; flex: none; }
145
+ .arv-btn:hover { background: var(--pn-glass); }
146
+ .arv-btn:focus-visible { outline: 2px solid var(--focus, var(--accent)); outline-offset: 2px; }
147
+ @media (max-width: 640px) {
148
+ .arv-btn { width: 36px; padding: 0; justify-content: center; }
149
+ .arv-btn__label { display: none; }
150
+ }
@@ -26,12 +26,35 @@
26
26
  return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
27
27
  });
28
28
  }
29
+ // Where the panel is open. The hall needs no setting; the public site's
30
+ // Artist review button (task 1004499, artist-review.js) calls
31
+ // configure({ surface: 'site', hall: <the hall's origin> }) before it opens.
32
+ var HALL_ORIGIN_RE = /^https?:\/\/[A-Za-z0-9.:-]+$/;
33
+ var cfg = { surface: 'builders', hall: '' };
34
+ function configure(o) {
35
+ o = o || {};
36
+ cfg.surface = o.surface === 'site' ? 'site' : 'builders';
37
+ cfg.hall = HALL_ORIGIN_RE.test(String(o.hall || '')) ? String(o.hall).replace(/\/+$/, '') : '';
38
+ }
39
+ function onSite() { return cfg.surface === 'site'; }
40
+ // The page a doc is about. In the hall, the page inventory id builders:<data-page>.
41
+ // On the public site, site:<first path segment>, so / is site:home and
42
+ // /projects is site:projects. Those docs are read in the Studio.
29
43
  function page() {
30
44
  var b = document.body || { dataset: {} };
31
- var slug = String((b.dataset && b.dataset.page) || 'home').toLowerCase().replace(/[^a-z0-9_-]/g, '-').slice(0, 64) || 'home';
32
- var title = (b.dataset && b.dataset.pageTitle) || String(document.title || '').split(/\s[—-]\s/)[0] || slug;
33
45
  var path = String((window.location && window.location.pathname) || '/').replace(/[^A-Za-z0-9/_.-]/g, '').slice(0, 200) || '/';
34
- return { id: 'builders:' + slug, path: path, title: title.slice(0, 120) };
46
+ var raw = onSite() ? (path.split('/')[1] || 'home').replace(/\.html$/, '') : ((b.dataset && b.dataset.page) || 'home');
47
+ var slug = String(raw).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 64) || 'home';
48
+ // A public page's title leads with the product name ("Cloud Bongos — projects"),
49
+ // so it is named by its path instead: Home, Projects, Account.
50
+ var title = onSite() ? slug.charAt(0).toUpperCase() + slug.slice(1).replace(/[-_]+/g, ' ')
51
+ : (b.dataset && b.dataset.pageTitle) || String(document.title || '').split(/\s[—·|-]\s/)[0] || slug;
52
+ return { id: (onSite() ? 'site:' : 'builders:') + slug, path: path, title: title.slice(0, 120) };
53
+ }
54
+ // An idea's own page. The hall reads it at /#/idea/N; from the public site the
55
+ // hall is another host on the hub, the origin configure() was given.
56
+ function ideaHref(id) {
57
+ return onSite() ? cfg.hall + '/builders/idea/' + id : '/#/idea/' + id;
35
58
  }
36
59
  function api(method, path, body) {
37
60
  var opts = { method: method, credentials: 'same-origin', headers: {} };
@@ -81,7 +104,7 @@
81
104
  function shellHtml(p) {
82
105
  return '<div class="pn__panel" role="dialog" aria-modal="true" aria-labelledby="pn-title">' +
83
106
  '<div class="pn__head">' +
84
- '<p class="pn__eyebrow">Page feedback · ' + esc(p.title) + '</p>' +
107
+ '<p class="pn__eyebrow">Artist review · ' + esc(p.title) + '</p>' +
85
108
  '<h2 class="pn__title" id="pn-title">What should this page be?</h2>' +
86
109
  '<button class="pn__close" type="button" data-pn-close aria-label="Close">' +
87
110
  '<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M5 5l8 8M13 5l-8 8"/></svg></button>' +
@@ -123,7 +146,7 @@
123
146
  function ideasHtml(ideas) {
124
147
  if (!ideas || !ideas.length) return '';
125
148
  return '<ul class="pn__ideas">' + ideas.map(function (i) {
126
- return '<li><a href="/#/idea/' + esc(i.id) + '">' + esc(i.title) + '</a></li>';
149
+ return '<li><a href="' + esc(ideaHref(i.id)) + '">' + esc(i.title) + '</a></li>';
127
150
  }).join('') + '</ul>';
128
151
  }
129
152
  function readHtml(doc) {
@@ -314,6 +337,8 @@
314
337
  open: function () { if (mountSheet()) openDraft(); },
315
338
  openNote: function (id) { if (mountSheet()) refreshList().then(function () { return showNote(id); }); },
316
339
  close: close,
340
+ configure: configure,
341
+ ideaHref: ideaHref,
317
342
  renderBody: renderBody,
318
343
  page: page,
319
344
  };
@@ -501,6 +501,21 @@ html.st-open { overflow: hidden; }
501
501
  .st-surface__fill { height: 4px; border-radius: 999px; background: var(--st-accent); }
502
502
  .st-asked { margin: 0; font-size: 14px; line-height: 1.5; color: var(--st-ink-faint); }
503
503
 
504
+ /* From the live site (task 1004499): one row per review, the page then who and
505
+ when, then its opening words. Each row opens the doc in the palette's panel. */
506
+ .st-sites { display: flex; flex-direction: column; gap: 6px; margin: 0; padding: 0; list-style: none; }
507
+ .st-site {
508
+ display: flex; flex-direction: column; gap: 4px;
509
+ margin: 0 -12px; padding: 10px 12px; border-radius: 12px;
510
+ color: var(--st-ink); text-decoration: none;
511
+ }
512
+ .st-site:hover { background: var(--st-glass); }
513
+ .st-site:focus-visible { outline: 2px solid var(--st-accent); outline-offset: 1px; }
514
+ .st-site__head { display: flex; align-items: baseline; gap: 4px 12px; flex-wrap: wrap; }
515
+ .st-site__page { font-size: 17px; font-weight: 300; }
516
+ .st-site__meta { font-size: 13px; color: var(--st-ink-dim); }
517
+ .st-site__text { font-size: 14px; line-height: 1.45; color: var(--st-ink-soft); overflow-wrap: anywhere; }
518
+
504
519
  /* ---------------------------------------------------------------------------
505
520
  The phone. The room keeps its night: the window shrinks to the top right
506
521
  (scaled whole, so its moon and lights keep their places), the desk sits on a
@@ -125,6 +125,12 @@
125
125
  <span class="st-action__label">Artist Review Status</span>
126
126
  <span class="st-action__detail" id="st-status-detail">how far each surface has come</span>
127
127
  </button>
128
+ <!-- Task 1004499: the reviews builders wrote on the public site with
129
+ its Artist review button. Each opens here, in the palette's panel. -->
130
+ <button type="button" class="st-action" id="st-site" aria-haspopup="dialog">
131
+ <span class="st-action__label">From the live site</span>
132
+ <span class="st-action__detail" id="st-site-detail">reviews written on the live site</span>
133
+ </button>
128
134
  <!-- The retired review queue (task 1004325 / TW14), a quiet door that
129
135
  shows only while reviews filed before Tweak Mode still wait. -->
130
136
  <button type="button" class="st-older" id="st-older" aria-haspopup="dialog" hidden>
@@ -666,9 +666,20 @@
666
666
  setText('st-status-detail', lib.statusDetail(home.pages));
667
667
  }
668
668
 
669
- // ---- the two small glass panels -------------------------------------------
669
+ // The public site's reviews (task 1004499): docs written with its Artist
670
+ // review button, newest first. Each row is the doc's own link, which opens it
671
+ // in the palette's panel right here (craft-mode.js reads ?note=).
672
+ async function loadSite() {
673
+ const res = await api.request('GET', `${API}/page-notes/site`);
674
+ if (!res.ok || !res.data) { home.siteFailed = true; return; }
675
+ home.site = res.data.notes || [];
676
+ const n = home.site.length;
677
+ setText('st-site-detail', n ? `${n === 20 ? 'the newest 20' : n} ${n === 1 ? 'review' : 'reviews'} from the live site` : 'no reviews from the live site yet');
678
+ }
679
+
680
+ // ---- the small glass panels -----------------------------------------------
670
681
 
671
- const TITLES = Object.freeze({ flow: 'Tweak/CopyWrite Flow', status: 'Artist Review Status' });
682
+ const TITLES = Object.freeze({ flow: 'Tweak/CopyWrite Flow', status: 'Artist Review Status', site: 'From the live site' });
672
683
 
673
684
  function flowHtml() {
674
685
  if (!home.next) {
@@ -712,10 +723,31 @@
712
723
  return `<ul class="st-surfaces">${rows}</ul>${asked ? `<p class="st-asked">${escapeHtml(asked)}</p>` : ''}`;
713
724
  }
714
725
 
726
+ function siteHtml() {
727
+ if (!home.site) {
728
+ return `<p class="st-panel__line">${home.siteFailed ? 'The reviews could not be read just now. Try again in a moment.' : 'Reading the reviews…'}</p>`;
729
+ }
730
+ if (!home.site.length) {
731
+ return '<p class="st-panel__line">None yet. A signed-in builder on the live site sees a Review button in its top bar; what they write lands here.</p>';
732
+ }
733
+ const day = (iso) => { const d = iso ? new Date(iso) : null; return d && !isNaN(d) ? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : ''; };
734
+ const rows = home.site.map((r) => {
735
+ const ideas = r.status === 'filed' ? `${r.idea_count} ${r.idea_count === 1 ? 'idea' : 'ideas'}` : 'splitting';
736
+ const whole = String(r.excerpt || '').replace(/!\[[^\]\n]*\]\([^)\n]*\)/g, '[image]').replace(/\s+/g, ' ').trim();
737
+ const text = whole.length > 140 ? `${whole.slice(0, 139).replace(/\s+\S*$/, '')}…` : whole;
738
+ return `
739
+ <li><a class="st-site" href="${escapeHtml(r.link)}">
740
+ <span class="st-site__head"><span class="st-site__page">${escapeHtml(r.page_title || r.page_path || r.page_id)}</span><span class="st-site__meta">${escapeHtml(r.author_name)} · ${escapeHtml(day(r.submitted_at))} · ${escapeHtml(ideas)}</span></span>
741
+ <span class="st-site__text">${escapeHtml(text)}</span>
742
+ </a></li>`;
743
+ }).join('');
744
+ return `<ul class="st-sites">${rows}</ul>`;
745
+ }
746
+
715
747
  function paintPanel() {
716
748
  const body = byId('st-panel-body');
717
749
  if (!body || !home.panel) return;
718
- body.innerHTML = home.panel === 'flow' ? flowHtml() : statusHtml();
750
+ body.innerHTML = home.panel === 'flow' ? flowHtml() : home.panel === 'site' ? siteHtml() : statusHtml();
719
751
  }
720
752
 
721
753
  function openPanel(kind, opener) {
@@ -902,6 +934,8 @@
902
934
  if (flow) flow.addEventListener('click', () => openPanel('flow', flow));
903
935
  const status = byId('st-status');
904
936
  if (status) status.addEventListener('click', () => openPanel('status', status));
937
+ const site = byId('st-site');
938
+ if (site) site.addEventListener('click', () => openPanel('site', site));
905
939
  const older = byId('st-older');
906
940
  if (older) older.addEventListener('click', () => openReviews(older));
907
941
 
@@ -962,7 +996,7 @@
962
996
  if (!hasDom || !lib) return;
963
997
  wireHome();
964
998
  if (!signedIn) return;
965
- await Promise.all([loadTally(), loadNext(), loadPages()]);
999
+ await Promise.all([loadTally(), loadNext(), loadPages(), loadSite()]);
966
1000
  if (home.panel) paintPanel();
967
1001
  }
968
1002
 
@@ -56,6 +56,7 @@
56
56
  here. It lives on the shell rules below and on a page's own body, because a
57
57
  body declaration beats an inherited :root value at every width and a token
58
58
  tier that carries a breakpoint silently defeats its own media query. */
59
+ @import url("page-notes.css");
59
60
 
60
61
  :root {
61
62
  color-scheme: light;
@@ -1569,122 +1570,6 @@ html[data-theme="light"][data-craft="ideating"] .app-sidebar {
1569
1570
  .topbar-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1570
1571
  }
1571
1572
 
1572
- /* The page feedback doc: a sheet from the right over a scrim. */
1573
- html.pn-open { overflow: hidden; }
1574
- .pn {
1575
- position: fixed;
1576
- inset: 0;
1577
- z-index: var(--z-modal);
1578
- display: flex;
1579
- justify-content: flex-end;
1580
- background: var(--scrim);
1581
- }
1582
- .pn__panel {
1583
- position: relative;
1584
- width: min(560px, 100%);
1585
- height: 100%;
1586
- box-sizing: border-box;
1587
- display: flex;
1588
- flex-direction: column;
1589
- background: var(--bg-card);
1590
- color: var(--ink);
1591
- box-shadow: 0 0 0 1px var(--glass-edge), var(--shadow-pop);
1592
- }
1593
- .pn__head { position: relative; padding: 22px 64px 14px 24px; border-bottom: 1px solid var(--rule-soft); }
1594
- .pn__eyebrow {
1595
- margin: 0 0 6px;
1596
- font-family: var(--label-face);
1597
- font-size: 11px;
1598
- letter-spacing: .12em;
1599
- text-transform: uppercase;
1600
- color: var(--craft, var(--ink-faint));
1601
- }
1602
- .pn__title { margin: 0; font-family: var(--font-display); font-weight: 400; font-size: 22px; letter-spacing: -0.02em; }
1603
- .pn__close {
1604
- position: absolute; top: 14px; right: 14px;
1605
- display: inline-flex; align-items: center; justify-content: center;
1606
- width: 40px; height: 40px; padding: 0; border: 0; border-radius: var(--radius-pill);
1607
- background: none; color: var(--ink-soft); cursor: pointer;
1608
- }
1609
- .pn__close svg { width: 18px; height: 18px; }
1610
- .pn__close:hover { color: var(--ink); background: var(--glass); }
1611
- .pn__body { flex: 1; min-height: 0; overflow-y: auto; padding: 18px 24px 28px; display: flex; flex-direction: column; gap: 12px; }
1612
- .pn__label { font-size: 13px; color: var(--ink-soft); }
1613
- .pn__text {
1614
- width: 100%;
1615
- min-height: 260px;
1616
- box-sizing: border-box;
1617
- padding: 14px 16px;
1618
- border: 1px solid var(--rule);
1619
- border-radius: var(--radius-sm);
1620
- background: var(--bg);
1621
- color: var(--ink);
1622
- font: inherit;
1623
- font-size: 15px;
1624
- line-height: 1.55;
1625
- resize: vertical;
1626
- }
1627
- .pn__text:focus { outline: 2px solid var(--focus); outline-offset: 1px; }
1628
- .pn__tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; }
1629
- .pn__add {
1630
- position: relative;
1631
- display: inline-flex; align-items: center;
1632
- min-height: 34px; padding: 0 14px;
1633
- border-radius: var(--radius-pill);
1634
- box-shadow: inset 0 0 0 1px var(--rule);
1635
- font-size: 13.5px; color: var(--ink); cursor: pointer;
1636
- }
1637
- .pn__add:hover { background: var(--glass); }
1638
- .pn__add:focus-within { outline: 2px solid var(--focus); outline-offset: 2px; }
1639
- .pn__add input { position: absolute; inset: 0; opacity: 0; cursor: pointer; width: 100%; }
1640
- .pn__quiet { margin: 0; font-size: 13px; color: var(--ink-faint); }
1641
- .pn__saved { margin-left: auto; }
1642
- .pn__thumbs { display: grid; grid-template-columns: repeat(auto-fill, minmax(88px, 1fr)); gap: 8px; }
1643
- .pn__thumb { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 8px; background: var(--bg-deep); }
1644
- .pn__error { margin: 0; font-size: 13.5px; color: var(--danger); }
1645
- .pn__error:empty { display: none; }
1646
- .pn__foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; padding-top: 4px; }
1647
- .pn__hint { margin: 0; font-size: 13px; line-height: 1.45; color: var(--ink-soft); }
1648
- .pn__foot .pn__hint { flex: 1 1 240px; }
1649
- .pn__submit {
1650
- min-height: 40px; padding: 0 20px; border: 0; border-radius: var(--radius-pill);
1651
- background: var(--accent); color: var(--accent-on); font: inherit; font-weight: 500; cursor: pointer;
1652
- }
1653
- .pn__submit:hover { background: var(--accent-lift); }
1654
- .pn__submit:disabled { opacity: .55; cursor: default; }
1655
- .pn__submit:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
1656
- .pn__list { margin-top: 14px; padding-top: 16px; border-top: 1px solid var(--rule-soft); }
1657
- .pn__list-h {
1658
- margin: 0 0 8px;
1659
- font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space);
1660
- text-transform: var(--label-case); color: var(--ink-faint); font-weight: 400;
1661
- }
1662
- .pn__rows, .pn__ideas { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
1663
- .pn__row {
1664
- display: grid; gap: 2px; width: 100%; padding: 10px 12px; border: 0; border-radius: var(--radius-sm);
1665
- background: none; color: var(--ink); font: inherit; text-align: left; cursor: pointer;
1666
- }
1667
- a.pn__row { text-decoration: none; } /* the lightbulb's page ideas are links */
1668
- .pn__row:hover { background: var(--glass); }
1669
- .pn__row:focus-visible, .pn__back:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
1670
- .pn__row-meta { font-size: 12px; color: var(--ink-faint); }
1671
- .pn__row-text { font-size: 14px; color: var(--ink-soft); }
1672
- .pn__ideas a { display: inline-block; padding: 6px 0; color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
1673
- .pn__back {
1674
- align-self: flex-start; min-height: 32px; padding: 0 12px; margin-left: -12px; border: 0;
1675
- border-radius: var(--radius-pill); background: none; color: var(--ink-soft); font: inherit; font-size: 13.5px; cursor: pointer;
1676
- }
1677
- .pn__back:hover { color: var(--ink); background: var(--glass); }
1678
- .pn__done { margin: 0; font-size: 16px; }
1679
- .pn-doc { font-size: 15px; line-height: 1.6; overflow-wrap: anywhere; }
1680
- .pn-doc p, .pn-doc ul { margin: 0 0 12px; }
1681
- .pn-doc__h { margin: 16px 0 6px; font-size: 16px; font-weight: 500; }
1682
- .pn-doc__img { display: block; max-width: 100%; height: auto; margin: 8px 0; border-radius: 8px; }
1683
- @media (max-width: 640px) {
1684
- .pn__head { padding: 18px 60px 12px 16px; }
1685
- .pn__body { padding: 14px 16px 24px; }
1686
- }
1687
-
1688
1573
  /* THE PAINTBRUSH (task 1004497, paintbrush.js): the page's own words, edited
1689
1574
  where they stand. Not modal: the page stays live under it, so the bar docks
1690
1575
  at the foot and the words carry the mode's colour as a dashed underline. A
@@ -52,6 +52,24 @@ async function listForPage(pageId, { limit = 20 } = {}, executor) {
52
52
  return r.rows;
53
53
  }
54
54
 
55
+ // Every submitted doc on one surface, newest first (task 1004499: the Studio's
56
+ // list of the public site's reviews). The surface is a fixed literal from
57
+ // notes.js, never request input; LIKE's own wildcards cannot appear in it.
58
+ async function listForSurface(surface, { limit = 20 } = {}, executor) {
59
+ const q = exec(executor);
60
+ const r = await q(
61
+ `SELECT n.id, n.page_id, n.page_path, n.page_title, n.status, n.submitted_at, n.idea_ids,
62
+ left(n.body_md, 400) AS excerpt,
63
+ COALESCE(NULLIF(b.display_name, ''), b.github_login) AS author_name
64
+ FROM page_notes_docs n JOIN builders b ON b.id = n.author_id
65
+ WHERE n.page_id LIKE $1 AND n.status <> 'draft'
66
+ ORDER BY n.submitted_at DESC NULLS LAST, n.id DESC
67
+ LIMIT $2`,
68
+ [`${surface}:%`, limit],
69
+ );
70
+ return r.rows;
71
+ }
72
+
55
73
  async function saveBody(id, authorId, bodyMd, executor) {
56
74
  const q = exec(executor);
57
75
  const r = await q(
@@ -170,6 +188,7 @@ module.exports = {
170
188
  openDraft,
171
189
  getNote,
172
190
  listForPage,
191
+ listForSurface,
173
192
  saveBody,
174
193
  countImages,
175
194
  addImage,
@@ -0,0 +1,12 @@
1
+ -- page_notes_002_surface_index.sql — the Studio's list of the public site's
2
+ -- reviews (task 1004499) reads every submitted doc on ONE surface, newest first:
3
+ -- `page_id LIKE 'site:%'`. page_notes_by_page (001) is ordered by the database's
4
+ -- collation, which a prefix LIKE cannot seek on; text_pattern_ops orders by
5
+ -- bytes, so the prefix is an index range and the list stays a seek as the table
6
+ -- grows. Partial on submitted docs, like page_notes_by_page: drafts are never
7
+ -- listed.
8
+ CREATE INDEX IF NOT EXISTS page_notes_by_surface
9
+ ON page_notes_docs (page_id text_pattern_ops, submitted_at DESC) WHERE status <> 'draft';
10
+
11
+ INSERT INTO schema_migrations (version) VALUES ('page_notes_002_surface_index')
12
+ ON CONFLICT DO NOTHING;
@@ -148,12 +148,31 @@ function wholeDocIdea({ pageTitle, pageId, bodyMd }) {
148
148
  return { title, body: str(bodyMd).replace(IMAGE_REF_RE, (_m, alt) => `[image${alt ? ': ' + alt : ''}]`).trim().slice(0, MAX_IDEA_BODY) };
149
149
  }
150
150
 
151
- // Where a doc is read: its page, opened on the doc.
151
+ // The public site's pages (task 1004499, the Artist review button): their docs
152
+ // are page ids on the 'site:' surface. Their page_path is a path on the PUBLIC
153
+ // host, which a link inside the hall cannot reach (the hall is its own host on
154
+ // the hub), so a site doc is read in the Studio, which lists them.
155
+ const SITE_SURFACE = 'site';
156
+ const STUDIO_PATH = '/builders/studio';
157
+ function isSiteNote(note) { return str(note && note.page_id).startsWith(`${SITE_SURFACE}:`); }
158
+
159
+ // Where a doc is read: its page, opened on the doc (a site doc: the Studio).
152
160
  function noteLink(note) {
153
- const path = str(note.page_path) || '/';
161
+ const path = isSiteNote(note) ? STUDIO_PATH : (str(note.page_path) || '/');
154
162
  return `${path}${path.includes('?') ? '&' : '?'}note=${note.id}`;
155
163
  }
156
164
 
165
+ // One row of the Studio's "From the live site" list (GET /page-notes/site),
166
+ // from a listForSurface row. The one mapper, so the Studio's test renders what
167
+ // the route answers.
168
+ function siteListItem(r) {
169
+ return {
170
+ id: String(r.id), page_id: r.page_id, page_path: r.page_path, page_title: r.page_title, status: r.status,
171
+ submitted_at: r.submitted_at, idea_count: (r.idea_ids || []).length, author_name: r.author_name,
172
+ excerpt: r.excerpt, link: noteLink(r),
173
+ };
174
+ }
175
+
157
176
  // The idea body: the split's words, then the way back to the full doc.
158
177
  function ideaBody(idea, note, authorName) {
159
178
  const who = str(authorName) || 'An artist';
@@ -216,5 +235,9 @@ module.exports = {
216
235
  parseSplitReply,
217
236
  wholeDocIdea,
218
237
  noteLink,
238
+ SITE_SURFACE,
239
+ STUDIO_PATH,
240
+ isSiteNote,
241
+ siteListItem,
219
242
  ideaBody,
220
243
  };
@@ -3,6 +3,7 @@
3
3
  //
4
4
  // POST /page-notes open (or resume) my draft for a page
5
5
  // GET /page-notes?page_id= my draft for the page + the page's reference docs
6
+ // GET /page-notes/site the public site's reviews, for the Studio (task 1004499)
6
7
  // GET /page-notes/:id one doc, its images and the ideas it became
7
8
  // PATCH /page-notes/:id save my draft's text
8
9
  // POST /page-notes/:id/images add a reference image to my draft (base64 JSON)
@@ -156,6 +157,14 @@ module.exports = function buildPageNotesRouter() {
156
157
  });
157
158
  }));
158
159
 
160
+ // The public site's reviews (task 1004499), for the Studio's list. Mounted
161
+ // before /page-notes/:id so 'site' is never read as a doc id.
162
+ router.get('/page-notes/site', api.requireBuilder, api.asyncHandler('GET /page-notes/site', async (req, res) => {
163
+ const rows = await db.listForSurface(N.SITE_SURFACE, { limit: 20 });
164
+ res.set('Cache-Control', 'no-store');
165
+ res.json({ notes: rows.map(N.siteListItem) });
166
+ }));
167
+
159
168
  router.get('/page-notes/images/:id', api.requireBuilder, api.asyncHandler('GET /page-notes/images/:id', async (req, res) => {
160
169
  const id = String(req.params.id || '');
161
170
  if (!ID_RE.test(id)) return res.fail('invalid_image_id', 400);
@@ -624,5 +624,7 @@
624
624
  });
625
625
  })();
626
626
  </script>
627
+ <!-- the Artist review button: signed-in builders only (task 1004499) -->
628
+ <script src="/assets/artist-review.js" data-hall="{{buildersOrigin}}"></script>
627
629
  </body>
628
630
  </html>