@bongos/core 1.19.1066 → 1.19.1068

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.
@@ -1,30 +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:
11
- // 1. REVIEW REQUESTS (R04, cascade.js) — a copy/visual ship files a two-step
12
- // artist review at `backlog`, discipline 'artist', source 'cascade'. The
13
- // two steps (coach the grader, then remake/tweak) live on the task record;
14
- // this room names them and opens the door.
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:
28
+ // 1. REVIEW REQUESTS (R04, cascade.js) — RETIRED, draining. A copy/visual
29
+ // ship used to file a two-step artist review at `backlog`, discipline
30
+ // 'artist', source 'cascade'. Tweak Mode removed that rule (task 1004325 /
31
+ // BV2.TW14, ADR 0341 D9), so no new reviews arrive; the ones already filed
32
+ // still show here so they can be answered (they may still hold a deploy
33
+ // under the strict artist gate, ADR 0241). The door goes once none wait.
15
34
  // 2. FLAGGED COPY (R02, copy-desk) — open flags over the words the product
16
- // shows people. Best-effort: an instance without the copy-desk module has
17
- // 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.
18
37
  //
19
38
  // It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.
20
39
  // And it writes NOTHING to any live string or asset — the goal's no-live-CMS
21
- // rule, unchanged. What it DOES write, since task 1003829, is the artist's own
22
- // VERDICT on their own queue: a review is a task in the ledger, and the three
23
- // weights below move that task the way triage always could. The craft itself
24
- // still happens on the surface that owns it (the task record, the copy desk),
25
- // and every change to the product still lands as a claimed, graded, shipped
26
- // task. The allowlist of what may be written is WRITE_ROUTES below, pinned in
27
- // 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.
28
46
  //
29
47
  // THE PICTURE (task 1003829). A review names the shipped task it is about, and
30
48
  // that task may carry a ship-time visual. The room reads it for the card in
@@ -46,9 +64,9 @@
46
64
  let busy = false; // a verdict is in flight — one at a time, so a double-click cannot file twice
47
65
  let tended = false; // at least one verdict landed this sitting, so an emptied garden reads as finished
48
66
 
49
- // Bound the reviews read. cascade.js files a review per qualifying ship and they
50
- // sit at backlog until claimed or dismissed ("dismissable rather than nagging"),
51
- // so this queue grows without bound over the project's life — the unbounded-read
67
+ // Bound the reviews read. cascade.js USED to file a review per qualifying ship
68
+ // (no longer, since TW14: the queue only drains now), and they sit at backlog
69
+ // until claimed or dismissed, so this queue could grow without bound — the unbounded-read
52
70
  // shape modules/hall-ui/CLAUDE.md flags as a repeat bug (the Work board's backlog,
53
71
  // Home's next-up card, the Goals rollup all had it). 50 is far more than a focus
54
72
  // queue tends at once; the footer links to the copy desk and board are the
@@ -92,6 +110,14 @@
92
110
  // travels as the route's own path (the tweak editor's take.path shape).
93
111
  approve: (pageId) => `${API}/copy-desk/pages/${pageId}/approve`,
94
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`,
95
121
  });
96
122
 
97
123
  // The verdict floor, read off /me. Local like goals-render.js's copy — four
@@ -108,14 +134,6 @@
108
134
  // helpers
109
135
  // ---------------------------------------------------------------------------
110
136
 
111
- function greeting() {
112
- const h = new Date().getHours();
113
- if (h < 5) return 'Still up';
114
- if (h < 12) return 'Good morning';
115
- if (h < 18) return 'Good afternoon';
116
- return 'Good evening';
117
- }
118
-
119
137
  // The shipped task a review is ABOUT — cascade.js stamps
120
138
  // source_ref = 'cascade-artist-review-on-ship-<taskId>'.
121
139
  function reviewShippedId(t) {
@@ -240,8 +258,8 @@
240
258
  function emptyCard(done) {
241
259
  const heading = done ? 'You have tended everything waiting' : 'Nothing waits right now';
242
260
  const body = done
243
- ? 'That is the whole garden. Come back when work ships, or start again from the top.'
244
- : 'A quiet moment — either good news or an unread queue. When work with copy or visuals ships, a review appears here for you.';
261
+ ? 'That is the whole garden. Come back when someone flags a line, or start again from the top.'
262
+ : 'A quiet moment. When someone flags a line of copy, it waits here for you.';
245
263
  const restart = done
246
264
  ? '<div class="studio-card__actions studio-empty__actions"><button type="button" class="btn-ghost" data-restart>Start over</button></div>'
247
265
  : '';
@@ -252,13 +270,18 @@
252
270
  // paint
253
271
  // ---------------------------------------------------------------------------
254
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.
255
275
  function paintHead() {
256
- const name = viewer && (viewer.display_name || viewer.github_login);
257
- $('#studio-greet').textContent = name ? `${greeting()}, ${name}` : greeting();
258
- const n = pieces.length;
259
- $('#studio-sub').textContent = n
260
- ? `${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.`
261
- : '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
+ }
262
285
  }
263
286
 
264
287
  function paint() {
@@ -555,10 +578,399 @@
555
578
  approvals.refresh();
556
579
  }
557
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
+
558
969
  (async () => {
559
970
  mountApprovals();
560
971
  const me = await api.request('GET', `${API}/me`);
561
972
  if (me.ok) viewer = me.data.builder || me.data;
973
+ bootHome(me.ok);
562
974
  // Reviews first (the richer craft), then flags — each newest-first within.
563
975
  const [reviews, flags] = await Promise.all([loadReviews(), loadFlags()]);
564
976
  pieces = [...reviews, ...flags];