@bongos/core 1.19.1067 → 1.19.1069

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 (54) hide show
  1. package/.bongos-core.json +73 -58
  2. package/docs/copy-inventory.md +74 -53
  3. package/docs/copy-registry.json +310 -101
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +10 -2
  6. package/docs/page-readings.json +566 -536
  7. package/modules/hall-ui/public/atlas.html +1 -1
  8. package/modules/hall-ui/public/blockers.html +1 -1
  9. package/modules/hall-ui/public/board-room.html +1 -1
  10. package/modules/hall-ui/public/collab.html +1 -1
  11. package/modules/hall-ui/public/copy-desk.html +1 -1
  12. package/modules/hall-ui/public/deploy.html +1 -1
  13. package/modules/hall-ui/public/diagrams.html +1 -1
  14. package/modules/hall-ui/public/drachmae.html +1 -1
  15. package/modules/hall-ui/public/fleet.html +1 -1
  16. package/modules/hall-ui/public/gate.html +1 -1
  17. package/modules/hall-ui/public/goals.html +1 -1
  18. package/modules/hall-ui/public/government.html +1 -1
  19. package/modules/hall-ui/public/idea.html +3 -3
  20. package/modules/hall-ui/public/ideas.html +1 -1
  21. package/modules/hall-ui/public/index.html +1 -1
  22. package/modules/hall-ui/public/modules.html +1 -1
  23. package/modules/hall-ui/public/primer.html +1 -1
  24. package/modules/hall-ui/public/profile.html +1 -1
  25. package/modules/hall-ui/public/project-settings.html +1 -1
  26. package/modules/hall-ui/public/ranks.html +1 -1
  27. package/modules/hall-ui/public/roadmap.html +1 -1
  28. package/modules/hall-ui/public/roster.html +1 -1
  29. package/modules/hall-ui/public/sessions.html +1 -1
  30. package/modules/hall-ui/public/settings.html +1 -1
  31. package/modules/hall-ui/public/shell.js +23 -32
  32. package/modules/hall-ui/public/studio-lib.js +207 -0
  33. package/modules/hall-ui/public/studio.css +559 -170
  34. package/modules/hall-ui/public/studio.html +143 -49
  35. package/modules/hall-ui/public/studio.js +442 -33
  36. package/modules/hall-ui/public/studio.states.json +164 -4
  37. package/modules/hall-ui/public/task.html +1 -1
  38. package/modules/hall-ui/public/thinking.html +1 -1
  39. package/modules/hall-ui/public/tweak-editor.html +1 -1
  40. package/modules/hall-ui/public/watch.html +1 -1
  41. package/modules/hall-ui/public/work.html +1 -1
  42. package/modules/hall-ui/records/studio.md +47 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/hall-preview/server.js +5 -0
  47. package/src/module-api.js +1 -1
  48. package/tests/hall_audit.mjs +4 -0
  49. package/tests/hall_nav.mjs +25 -1
  50. package/tests/hall_page_gate_map.mjs +2 -0
  51. package/tests/hall_studio_home.mjs +429 -0
  52. package/tests/hall_studio_world.mjs +7 -1
  53. package/tests/hall_tweak_editor.mjs +1 -1
  54. package/tests/nav_permission_atoms.mjs +1 -1
@@ -1,33 +1,48 @@
1
- // modules/hall-ui/public/studio.js — the Studio (artist mode), /builders/studio.
2
- // R06 of goal 1000074 (the artist's loop), task 1003117.
1
+ // modules/hall-ui/public/studio.js — the studio, /builders/studio.
3
2
  //
4
- // The criterion (artist-mode): "a place an artist wants to be, not a form. A
5
- // focus environment on the Cloud Bongos screen — a lofi-girl-style zen garden of
6
- // focus and creativity — rather than another table of rows."
3
+ // THE STUDIO HOME (task 1003971 / BV2.TW16, goal 1000095; ADR 0341 D2, D5, D8
4
+ // and D11). The artist's lofi home in Tweak Mode, built 1:1 on the master
5
+ // design (docs/design/mocks/tweak-mode/Main.dc.html): the night room, a
6
+ // greeting whose words are editable where they stand, the tally top right
7
+ // (GET /copy-desk/tally), Resume back into the page the artist holds (GET
8
+ // /copy-desk/next's resume, into /builders/tweak-editor?page=<id>), and three
9
+ // quick actions that open glass panels in place: the Tweak/CopyWrite Flow (GET
10
+ // /copy-desk/next: the recommendation, take it, pick another), the Approval
11
+ // queue (approval-queue.js, mounted below) and Artist Review Status (GET
12
+ // /copy-desk/pages: three surface lines, "N of M tweaked"). The words and rules
13
+ // are studio-lib.js.
7
14
  //
8
- // So this is NOT a queue table. It aggregates the artist's real work — the two
9
- // surfaces the rest of goal 1000074 built — and presents it ONE PIECE AT A TIME,
10
- // a stone to tend in a calm garden, with the rest waiting quietly:
15
+ // THE STUDIO'S OWN WORDS are a tweak of THIS page, builders:studio (ADR 0341
16
+ // D2), never a live edit. A first click on them offers to take the studio page
17
+ // (the claim route, D5); once the artist holds it, the lines the studio's own
18
+ // reading lists are typed over in place and autosaved to the round's draft
19
+ // (PUT /copy-desk/pages/builders:studio/draft, the whole draft each time, the
20
+ // tweak editor's rule), and the page itself keeps its words until the round is
21
+ // submitted, applied, approved and shipped like any other. Submit is the
22
+ // editor's: the strip under the words links there.
23
+ //
24
+ // THE OLDER REVIEWS (R06 of goal 1000074, task 1003117). Below the home sits
25
+ // the garden this page used to be, behind a quiet door that shows only while
26
+ // reviews filed before Tweak Mode still wait. It presents the artist's older
27
+ // work ONE PIECE AT A TIME, a stone to tend, with the rest waiting quietly:
11
28
  // 1. REVIEW REQUESTS (R04, cascade.js) — RETIRED, draining. A copy/visual
12
29
  // ship used to file a two-step artist review at `backlog`, discipline
13
30
  // 'artist', source 'cascade'. Tweak Mode removed that rule (task 1004325 /
14
31
  // BV2.TW14, ADR 0341 D9), so no new reviews arrive; the ones already filed
15
32
  // still show here so they can be answered (they may still hold a deploy
16
- // under the strict artist gate, ADR 0241). TW16 rebuilds this room as the
17
- // Tweak Mode home, and this queue goes with it once it is empty.
33
+ // under the strict artist gate, ADR 0241). The door goes once none wait.
18
34
  // 2. FLAGGED COPY (R02, copy-desk) — open flags over the words the product
19
- // shows people. Best-effort: an instance without the copy-desk module has
20
- // no queue, and the room degrades to reviews alone rather than erroring.
35
+ // shows people, tended beside the reviews while the door is open.
36
+ // Best-effort: an instance without the copy-desk module has no queue.
21
37
  //
22
38
  // It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.
23
39
  // And it writes NOTHING to any live string or asset — the goal's no-live-CMS
24
- // rule, unchanged. What it DOES write, since task 1003829, is the artist's own
25
- // VERDICT on their own queue: a review is a task in the ledger, and the three
26
- // weights below move that task the way triage always could. The craft itself
27
- // still happens on the surface that owns it (the task record, the copy desk),
28
- // and every change to the product still lands as a claimed, graded, shipped
29
- // task. The allowlist of what may be written is WRITE_ROUTES below, pinned in
30
- // tests/hall_studio_world.mjs.
40
+ // rule, unchanged. What it DOES write is named in WRITE_ROUTES below, pinned in
41
+ // tests/hall_studio_world.mjs: the artist's own VERDICT on their own older
42
+ // reviews (since task 1003829), the Approval queue's two decisions (TW12), and
43
+ // the page claim and draft save that make a page, the studio included, the
44
+ // artist's to rewrite (TW16). Every change to the product still lands as a
45
+ // claimed, graded, shipped task.
31
46
  //
32
47
  // THE PICTURE (task 1003829). A review names the shipped task it is about, and
33
48
  // that task may carry a ship-time visual. The room reads it for the card in
@@ -95,6 +110,14 @@
95
110
  // travels as the route's own path (the tweak editor's take.path shape).
96
111
  approve: (pageId) => `${API}/copy-desk/pages/${pageId}/approve`,
97
112
  sendBack: (pageId) => `${API}/copy-desk/pages/${pageId}/send-back`,
113
+ // The home's two (task 1003971 / BV2.TW16, ADR 0341 D5 and D11). claim
114
+ // takes a page's tweak round for the artist: "take it" in the Tweak/
115
+ // CopyWrite Flow, and "take the studio page" before the studio's own words
116
+ // are typed over. draft saves the WHOLE draft of the studio page the artist
117
+ // holds (the holder only; the route decides). Neither writes a live string:
118
+ // a round's words reach its page only as a shipped, approved tweak.
119
+ claim: (pageId) => `${API}/copy-desk/pages/${pageId}/claim`,
120
+ draft: (pageId) => `${API}/copy-desk/pages/${pageId}/draft`,
98
121
  });
99
122
 
100
123
  // The verdict floor, read off /me. Local like goals-render.js's copy — four
@@ -111,14 +134,6 @@
111
134
  // helpers
112
135
  // ---------------------------------------------------------------------------
113
136
 
114
- function greeting() {
115
- const h = new Date().getHours();
116
- if (h < 5) return 'Still up';
117
- if (h < 12) return 'Good morning';
118
- if (h < 18) return 'Good afternoon';
119
- return 'Good evening';
120
- }
121
-
122
137
  // The shipped task a review is ABOUT — cascade.js stamps
123
138
  // source_ref = 'cascade-artist-review-on-ship-<taskId>'.
124
139
  function reviewShippedId(t) {
@@ -255,13 +270,18 @@
255
270
  // paint
256
271
  // ---------------------------------------------------------------------------
257
272
 
273
+ // The quiet door to the older reviews: shown only while a review filed before
274
+ // Tweak Mode still waits (TW14), since that is the one reason the garden stays.
258
275
  function paintHead() {
259
- const name = viewer && (viewer.display_name || viewer.github_login);
260
- $('#studio-greet').textContent = name ? `${greeting()}, ${name}` : greeting();
261
- const n = pieces.length;
262
- $('#studio-sub').textContent = n
263
- ? `${n} ${n === 1 ? 'piece' : 'pieces'} of the product's voice ${n === 1 ? 'waits' : 'wait'} for you. Tend them one at a time — or let them be.`
264
- : 'Nothing waits right now. Enjoy the quiet.';
276
+ const reviews = pieces.filter((p) => p.type === 'review').length;
277
+ const door = $('#st-older');
278
+ if (door) door.hidden = reviews === 0;
279
+ const label = $('#st-older-label');
280
+ if (label) {
281
+ label.textContent = reviews === 1
282
+ ? '1 review from before Tweak Mode still waits'
283
+ : `${reviews} reviews from before Tweak Mode still wait`;
284
+ }
265
285
  }
266
286
 
267
287
  function paint() {
@@ -558,10 +578,399 @@
558
578
  approvals.refresh();
559
579
  }
560
580
 
581
+ // ===========================================================================
582
+ // THE HOME (task 1003971 / BV2.TW16): the tally, Resume, the three quick
583
+ // actions and their panels, and the studio's own words. It needs a real DOM
584
+ // and the two pure libs; without either (the vm tests of the garden, a
585
+ // stale cache) it draws nothing and the garden still works.
586
+ // ===========================================================================
587
+ const lib = window.OTBStudioLib;
588
+ const editorLib = window.OTBTweakEditor;
589
+ const hasDom = typeof document.getElementById === 'function';
590
+ const byId = (id) => (hasDom ? document.getElementById(id) : null);
591
+ const STUDIO = lib ? lib.STUDIO_PAGE : 'builders:studio';
592
+ const SAVE_DEBOUNCE_MS = 700;
593
+ const editorHref = (pageId) => `/builders/tweak-editor?page=${pageId}`;
594
+
595
+ const home = {
596
+ next: null, nextFailed: false,
597
+ pages: null, pagesFailed: false,
598
+ pick: null, picking: false,
599
+ panel: null, opener: null,
600
+ };
601
+
602
+ // The one claim call site, for a page id the inventory's shape allows (the
603
+ // server validates it against the inventory again).
604
+ async function claimPage(pageId) {
605
+ if (!lib || !lib.PAGE_ID_RE.test(String(pageId || ''))) return { ok: false, data: {} };
606
+ return api.request('POST', WRITE_ROUTES.claim(pageId), {});
607
+ }
608
+
609
+ function refusalOf(res) {
610
+ const d = (window.OTB.errDetails && window.OTB.errDetails(res.data)) || {};
611
+ const code = (window.OTB.errCode && window.OTB.errCode(res.data)) || (res.data && res.data.error && res.data.error.code) || '';
612
+ return lib.claimWords(code, d);
613
+ }
614
+
615
+ function setText(id, text) { const el = byId(id); if (el) el.textContent = text; }
616
+
617
+ // ---- the tally, Resume, the details ---------------------------------------
618
+
619
+ async function loadTally() {
620
+ const res = await api.request('GET', `${API}/copy-desk/tally`);
621
+ const parts = res.ok ? lib.tallyParts(res.data) : null;
622
+ const pill = byId('st-tally');
623
+ if (!pill || !parts) return;
624
+ setText('st-tally-credits', parts.credits);
625
+ setText('st-tally-pages', parts.pages);
626
+ setText('st-tally-week', parts.week);
627
+ pill.hidden = false;
628
+ }
629
+
630
+ async function loadNext() {
631
+ const res = await api.request('GET', `${API}/copy-desk/next`);
632
+ if (!res.ok || !res.data) { home.nextFailed = true; return; }
633
+ home.next = res.data;
634
+ setText('st-flow-detail', lib.flowDetail(home.next));
635
+ const resume = home.next.resume;
636
+ if (!resume || !lib.PAGE_ID_RE.test(String(resume.page_id || ''))) return;
637
+ // One more read, for "12 of 38 lines": the editor's own counter.
638
+ const ed = await api.request('GET', `${API}/copy-desk/pages/${resume.page_id}/draft`);
639
+ const counter = ed.ok && ed.data ? ed.data.counter : null;
640
+ const link = byId('st-resume');
641
+ if (link) {
642
+ link.href = editorHref(resume.page_id);
643
+ link.hidden = false;
644
+ }
645
+ setText('st-resume-detail', lib.resumeDetail(resume, counter));
646
+ paintSubLine(lib.subParts(resume, counter));
647
+ }
648
+
649
+ // The greeting's sub-line, written here (not in studio-lib.js) so the copy
650
+ // registry places it at this assignment: it is one of the studio's own lines,
651
+ // and an in-place tweak of it names this string.
652
+ function paintSubLine(p) {
653
+ const sub = byId('studio-sub');
654
+ if (!sub || !p || wedit.editing) return;
655
+ if (p.counted) {
656
+ sub.textContent = `${p.title}, on the ${p.surface} page, is where you left it. ${p.changed} of its ${p.total} lines are yours so far.`;
657
+ } else {
658
+ sub.textContent = `${p.title}, on the ${p.surface} page, is where you left it.`;
659
+ }
660
+ }
661
+
662
+ async function loadPages() {
663
+ const res = await api.request('GET', `${API}/copy-desk/pages`);
664
+ if (!res.ok || !res.data) { home.pagesFailed = true; return; }
665
+ home.pages = res.data;
666
+ setText('st-status-detail', lib.statusDetail(home.pages));
667
+ }
668
+
669
+ // ---- the two small glass panels -------------------------------------------
670
+
671
+ const TITLES = Object.freeze({ flow: 'Tweak/CopyWrite Flow', status: 'Artist Review Status' });
672
+
673
+ function flowHtml() {
674
+ if (!home.next) {
675
+ return `<p class="st-panel__line">${home.nextFailed ? 'The next page could not be read just now. Try again in a moment.' : 'Finding the next page…'}</p>`;
676
+ }
677
+ const all = [home.next.recommended, ...(home.next.alternatives || [])].filter(Boolean);
678
+ const cur = (home.pick && all.find((c) => c.page_id === home.pick)) || all[0];
679
+ if (!cur) return '<p class="st-panel__line">Every page is taken or on its way to the site right now. Come back soon.</p>';
680
+ const others = all.filter((c) => c !== cur);
681
+ const pick = others.length
682
+ ? `<button type="button" class="st-pick" data-pick aria-expanded="${home.picking ? 'true' : 'false'}" aria-controls="st-picks">pick another</button>`
683
+ : '';
684
+ const chips = others.map((o) => `<button type="button" class="st-chip" data-pick-id="${escapeHtml(o.page_id)}">${escapeHtml(lib.pickLabel(o))}</button>`).join('');
685
+ return `
686
+ <div class="st-next">
687
+ <span class="st-next__page">${escapeHtml(lib.titleOf(cur))}</span>
688
+ <span class="st-next__why">${escapeHtml(cur.why_line || cur.why || '')}</span>
689
+ </div>
690
+ <div class="st-next__acts">
691
+ <button type="button" class="st-take" data-take="${escapeHtml(cur.page_id)}">take it</button>
692
+ ${pick}
693
+ </div>
694
+ <p class="st-panel__err" id="st-take-err" role="alert" hidden></p>
695
+ ${others.length ? `<div class="st-picks" id="st-picks"${home.picking ? '' : ' hidden'}>${chips}</div>` : ''}`;
696
+ }
697
+
698
+ function statusHtml() {
699
+ if (!home.pages) {
700
+ return `<p class="st-panel__line">${home.pagesFailed ? 'The pages could not be read just now. Try again in a moment.' : 'Reading the pages…'}</p>`;
701
+ }
702
+ const rows = lib.surfaceRows(home.pages, lib.landingHost(byId('hall-main') && byId('hall-main').getAttribute('data-apex'), window.location && window.location.host)).map((s) => `
703
+ <li class="st-surface">
704
+ <div class="st-surface__head">
705
+ <span class="st-surface__name">${escapeHtml(s.name)}</span>
706
+ <span class="st-surface__where">${escapeHtml(s.where)}</span>
707
+ <span class="st-surface__count">${escapeHtml(s.count)}</span>
708
+ </div>
709
+ <div class="st-surface__track"><div class="st-surface__fill" style="width: ${Number(s.pct)}%"></div></div>
710
+ </li>`).join('');
711
+ const asked = lib.askedLine(home.next && home.next.asked_pages);
712
+ return `<ul class="st-surfaces">${rows}</ul>${asked ? `<p class="st-asked">${escapeHtml(asked)}</p>` : ''}`;
713
+ }
714
+
715
+ function paintPanel() {
716
+ const body = byId('st-panel-body');
717
+ if (!body || !home.panel) return;
718
+ body.innerHTML = home.panel === 'flow' ? flowHtml() : statusHtml();
719
+ }
720
+
721
+ function openPanel(kind, opener) {
722
+ const overlay = byId('st-panel');
723
+ if (!overlay || !TITLES[kind]) return;
724
+ home.panel = kind;
725
+ home.opener = opener || null;
726
+ home.picking = false;
727
+ setText('st-panel-title', TITLES[kind]);
728
+ paintPanel();
729
+ overlay.hidden = false;
730
+ document.documentElement.classList.add('st-open');
731
+ const title = byId('st-panel-title');
732
+ if (title) title.focus();
733
+ }
734
+
735
+ function closePanel() {
736
+ const overlay = byId('st-panel');
737
+ if (!overlay || overlay.hidden) return;
738
+ overlay.hidden = true;
739
+ home.panel = null;
740
+ document.documentElement.classList.remove('st-open');
741
+ if (home.opener && home.opener.focus) home.opener.focus();
742
+ }
743
+
744
+ async function takePage(pageId, button) {
745
+ if (button) button.disabled = true;
746
+ const res = await claimPage(pageId);
747
+ if (res.ok) { window.location.assign(editorHref(pageId)); return; }
748
+ if (button) button.disabled = false;
749
+ const err = byId('st-take-err');
750
+ if (err) { err.textContent = refusalOf(res); err.hidden = false; }
751
+ }
752
+
753
+ // ---- the older reviews' dialog --------------------------------------------
754
+
755
+ function openReviews(opener) {
756
+ const box = byId('st-reviews');
757
+ if (!box) return;
758
+ home.opener = opener || null;
759
+ box.hidden = false;
760
+ document.documentElement.classList.add('st-open');
761
+ const title = byId('st-reviews-title');
762
+ if (title) title.focus();
763
+ }
764
+
765
+ function closeReviews() {
766
+ const box = byId('st-reviews');
767
+ if (!box || box.hidden) return;
768
+ box.hidden = true;
769
+ document.documentElement.classList.remove('st-open');
770
+ const door = byId('st-older');
771
+ const back = home.opener && !home.opener.hidden ? home.opener : (door && !door.hidden ? door : byId('st-flow'));
772
+ if (back && back.focus) back.focus();
773
+ }
774
+
775
+ // ---- the studio's own words, typed over in place ---------------------------
776
+
777
+ const wedit = {
778
+ editing: false, busy: false,
779
+ lines: [], readingHash: null, values: {}, keys: new Map(),
780
+ save: 'saved', timer: null, inflight: null, again: false, lastSent: null, error: '',
781
+ };
782
+
783
+ function wordEls() {
784
+ const box = byId('st-words');
785
+ return box ? Array.from(box.querySelectorAll('[data-words]')) : [];
786
+ }
787
+
788
+ function paintEditing() {
789
+ const strip = byId('st-editing');
790
+ if (!strip || !wedit.editing) return;
791
+ const changed = editorLib.changedCount(wedit.lines, wedit.values);
792
+ const counter = editorLib.counterText(changed, wedit.lines.length, wedit.save);
793
+ const unread = wedit.keys.size === 0;
794
+ strip.classList.toggle('st-editing--failed', wedit.save === 'failed');
795
+ strip.innerHTML = unread
796
+ ? `The studio page is yours, but its reading does not list these words yet. <a href="${editorHref(STUDIO)}">Rewrite it in the editor</a>`
797
+ : `The studio page is yours · ${escapeHtml(counter)}${wedit.error ? ` · ${escapeHtml(wedit.error)}` : ''} · <a href="${editorHref(STUDIO)}">submit it in the editor</a>`;
798
+ strip.hidden = false;
799
+ }
800
+
801
+ function enterEditing(ed, focusEl) {
802
+ const reading = (ed && ed.reading) || {};
803
+ wedit.lines = reading.lines || [];
804
+ wedit.readingHash = reading.reading_hash || null;
805
+ wedit.values = editorLib.initialValues(wedit.lines, ed && ed.draft);
806
+ wedit.lastSent = JSON.stringify(editorLib.draftBody(wedit.readingHash, wedit.lines, wedit.values));
807
+ wedit.keys = new Map();
808
+ for (const el of wordEls()) {
809
+ const line = lib.matchLine(el.textContent, wedit.lines);
810
+ if (!line) { el.setAttribute('data-unread', ''); continue; }
811
+ wedit.keys.set(el, line.key);
812
+ el.textContent = wedit.values[line.key];
813
+ el.setAttribute('contenteditable', 'true');
814
+ el.setAttribute('spellcheck', 'false');
815
+ el.setAttribute('aria-label', `The studio's words: ${line.text}`);
816
+ }
817
+ wedit.editing = true;
818
+ const hint = byId('st-hint');
819
+ if (hint) hint.hidden = true;
820
+ const offer = byId('st-offer');
821
+ if (offer) offer.hidden = true;
822
+ paintEditing();
823
+ const target = (focusEl && wedit.keys.has(focusEl)) ? focusEl : wordEls().find((el) => wedit.keys.has(el));
824
+ if (target) target.focus();
825
+ }
826
+
827
+ async function readStudio() {
828
+ const res = await api.request('GET', `${API}/copy-desk/pages/${STUDIO}/draft`);
829
+ return res.ok && res.data && res.data.reading ? res.data : null;
830
+ }
831
+
832
+ // A first click on the words: straight into editing when the artist already
833
+ // holds the studio page, the offer to take it otherwise.
834
+ async function beginWords(focusEl) {
835
+ if (wedit.editing || wedit.busy || !lib || !editorLib) return;
836
+ wedit.busy = true;
837
+ const ed = await readStudio();
838
+ wedit.busy = false;
839
+ if (ed && ed.round && ed.round.held_by_you) { enterEditing(ed, focusEl); return; }
840
+ wedit.focusAfter = focusEl || null;
841
+ const offer = byId('st-offer');
842
+ const err = byId('st-offer-err');
843
+ if (err) err.hidden = true;
844
+ if (offer) offer.hidden = false;
845
+ const take = byId('st-offer-take');
846
+ if (take) take.focus();
847
+ }
848
+
849
+ async function takeStudio() {
850
+ const take = byId('st-offer-take');
851
+ const err = byId('st-offer-err');
852
+ if (take) take.disabled = true;
853
+ const res = await claimPage(STUDIO);
854
+ if (!res.ok) {
855
+ if (take) take.disabled = false;
856
+ if (err) { err.textContent = refusalOf(res); err.hidden = false; }
857
+ return;
858
+ }
859
+ const ed = await readStudio();
860
+ if (take) take.disabled = false;
861
+ if (!ed) {
862
+ if (err) { err.textContent = 'The studio page is yours, but its words could not be read just now. Open it in the editor.'; err.hidden = false; }
863
+ return;
864
+ }
865
+ enterEditing(ed, wedit.focusAfter);
866
+ }
867
+
868
+ function scheduleSave() {
869
+ clearTimeout(wedit.timer);
870
+ wedit.timer = setTimeout(saveWords, SAVE_DEBOUNCE_MS);
871
+ }
872
+
873
+ // The WHOLE draft, every changed line of the studio page (the ones typed here
874
+ // and any the editor saved), one save in flight at a time.
875
+ async function saveWords() {
876
+ clearTimeout(wedit.timer);
877
+ if (!wedit.editing) return;
878
+ if (wedit.inflight) { wedit.again = true; return; }
879
+ const body = editorLib.draftBody(wedit.readingHash, wedit.lines, wedit.values);
880
+ const sent = JSON.stringify(body);
881
+ if (sent === wedit.lastSent) return;
882
+ wedit.save = 'saving';
883
+ wedit.error = '';
884
+ paintEditing();
885
+ wedit.inflight = api.request('PUT', WRITE_ROUTES.draft(STUDIO), body);
886
+ const res = await wedit.inflight;
887
+ wedit.inflight = null;
888
+ if (res.ok) {
889
+ wedit.lastSent = sent;
890
+ wedit.save = 'saved';
891
+ } else {
892
+ wedit.save = 'failed';
893
+ const d = (window.OTB.errDetails && window.OTB.errDetails(res.data)) || {};
894
+ wedit.error = d.what_this_means || errMessage(res.data) || 'the words could not be saved';
895
+ }
896
+ paintEditing();
897
+ if (wedit.again) { wedit.again = false; saveWords(); }
898
+ }
899
+
900
+ function wireHome() {
901
+ const flow = byId('st-flow');
902
+ if (flow) flow.addEventListener('click', () => openPanel('flow', flow));
903
+ const status = byId('st-status');
904
+ if (status) status.addEventListener('click', () => openPanel('status', status));
905
+ const older = byId('st-older');
906
+ if (older) older.addEventListener('click', () => openReviews(older));
907
+
908
+ const overlay = byId('st-panel');
909
+ if (overlay) {
910
+ overlay.addEventListener('click', (ev) => {
911
+ if (ev.target === overlay || ev.target.closest('[data-close-panel]')) { closePanel(); return; }
912
+ const take = ev.target.closest('[data-take]');
913
+ if (take) { takePage(take.getAttribute('data-take'), take); return; }
914
+ if (ev.target.closest('[data-pick]')) { home.picking = !home.picking; paintPanel(); const b = overlay.querySelector('[data-pick]'); if (b) b.focus(); return; }
915
+ const chip = ev.target.closest('[data-pick-id]');
916
+ if (chip) { home.pick = chip.getAttribute('data-pick-id'); home.picking = false; paintPanel(); const t = overlay.querySelector('[data-take]'); if (t) t.focus(); }
917
+ });
918
+ }
919
+ const reviews = byId('st-reviews');
920
+ if (reviews) {
921
+ reviews.addEventListener('click', (ev) => {
922
+ if (ev.target === reviews || ev.target.closest('[data-close-reviews]')) closeReviews();
923
+ });
924
+ }
925
+ document.addEventListener('keydown', (ev) => {
926
+ if (ev.key !== 'Escape') return;
927
+ if (home.panel) { closePanel(); return; }
928
+ const box = byId('st-reviews');
929
+ if (box && !box.hidden) closeReviews();
930
+ });
931
+
932
+ const hint = byId('st-hint');
933
+ if (hint) hint.addEventListener('click', () => beginWords(null));
934
+ const no = byId('st-offer-no');
935
+ if (no) no.addEventListener('click', () => { const o = byId('st-offer'); if (o) o.hidden = true; if (hint) hint.focus(); });
936
+ const take = byId('st-offer-take');
937
+ if (take) take.addEventListener('click', takeStudio);
938
+ for (const el of wordEls()) {
939
+ el.addEventListener('click', () => { if (!wedit.editing) beginWords(el); });
940
+ el.addEventListener('keydown', (ev) => {
941
+ if (ev.key === 'Enter' && wedit.editing) { ev.preventDefault(); el.blur(); }
942
+ });
943
+ el.addEventListener('paste', (ev) => {
944
+ if (!wedit.editing) return;
945
+ ev.preventDefault();
946
+ const text = ((ev.clipboardData && ev.clipboardData.getData('text/plain')) || '').replace(/\s+/g, ' ');
947
+ document.execCommand('insertText', false, text);
948
+ });
949
+ el.addEventListener('input', () => {
950
+ const key = wedit.keys.get(el);
951
+ if (!key) return;
952
+ wedit.values[key] = el.textContent;
953
+ paintEditing();
954
+ scheduleSave();
955
+ });
956
+ el.addEventListener('blur', () => { if (wedit.keys.has(el)) saveWords(); });
957
+ }
958
+ window.addEventListener('pagehide', () => { if (wedit.editing) saveWords(); });
959
+ }
960
+
961
+ async function bootHome(signedIn) {
962
+ if (!hasDom || !lib) return;
963
+ wireHome();
964
+ if (!signedIn) return;
965
+ await Promise.all([loadTally(), loadNext(), loadPages()]);
966
+ if (home.panel) paintPanel();
967
+ }
968
+
561
969
  (async () => {
562
970
  mountApprovals();
563
971
  const me = await api.request('GET', `${API}/me`);
564
972
  if (me.ok) viewer = me.data.builder || me.data;
973
+ bootHome(me.ok);
565
974
  // Reviews first (the richer craft), then flags — each newest-first within.
566
975
  const [reviews, flags] = await Promise.all([loadReviews(), loadFlags()]);
567
976
  pieces = [...reviews, ...flags];