PageSourceSearch

https://app.replicants.ai/js/course-config.js?v=20260929load1

js replicants.ai collected 2026-10-05 12:30:45 UTC 116,567 bytes, 2,204 lines download raw bytes

1/**
2 * src/public/js/course-config.js — the course page's three CONFIGURATION
3 * tabs: **Customize**, **Certificates** and **Settings**.
4 *
5 * They share a file because they share a shape: each is a small form or a
6 * doorway into an editor that already exists, and none of them is big enough
7 * to justify its own module. The three tabs that AUTHOR (Outline), SELL
8 * (Offers) and REPORT (Members) each get their own file, because those are
9 * the ones that grow.
10 *
11 * ── CUSTOMIZE — change, not first choice (D5) ────────────────────────
12 * The photo-led 31-template gallery lives INSIDE onboarding, deliberately:
13 * "people love templates and it gives such a lift" is a first-run moment,
14 * not a settings screen. So this tab is for CHANGING an applied look, and it
15 * is a compact list plus ONE live preview rather than a second gallery — the
16 * preview engine's whole design (send the facts once, swap the template per
17 * card) makes `setSlug` on a single frame the cheap, correct move, and a
18 * wall of 31 previews on a 390px phone is not a gallery, it is a scroll.
19 *
20 * D7/D49 hold absolutely: applying a template BAKES a `pages` row and the
21 * existing page editor edits it. No second editor, and this file does not
22 * touch the editor at all — it links to it.
23 *
24 * ── CERTIFICATES — ours already beats theirs, so say so ──────────────
25 * Kajabi's "verification" is a shareable URL. We issue a QR + a public
26 * verification page. That is surfaced here rather than buried — and since
27 * D19 landed (migration 390) it is surfaced with a link the coach can
28 * actually click, because a claim you can test is worth more than a
29 * paragraph.
30 *
31 * The tab owns what is true of THIS CREDENTIAL — on/off, plain vs branded,
32 * and how long it stays valid. What the certificate LOOKS like (which
33 * elements print, the fine print, logo/colour/signature) is one design
34 * shared by the whole community and lives one click away in
35 * course-certificates-editor.js. Splitting it that way means a coach never
36 * sets the same brand twice, and never has to wonder which course a design
37 * change applied to.
38 *
39 * Email-on-completion is the exception that sits here rather than in the
40 * design screen: it is delivery, not design, and it is the first question a
41 * coach asks on this tab. It is community-wide and the control says so.
42 *
43 * ── SETTINGS — including the switch that could never be flipped ──────
44 * `assistant_enabled` (migration 378) has been ENFORCED by
45 * course-assistant-service since the day it shipped and had no UI and no
46 * route that could set it. A kill switch nobody can pull is not a kill
47 * switch; it is now here. D30's tenant-visible conversation log is recorded
48 * (`community_course_assistant_turns`) but has no read surface at all, so it
49 * gets an honest empty state and not a fake screen.
50 *
51 * Loaded by app.html; window.App.CoursePage dispatches to all three.
52 */
53(function () {
54  'use strict';
55
56  var App = (window.App = window.App || {});
57  var P = App.CoursePage;
58
59  // ══ CUSTOMIZE ══════════════════════════════════════════════════════
60
61  var _preview = null; // the single mounted preview frame
62
63
64  /**
65   * Speak It on this tab's open text boxes (Alex, 2026-08-01: "every open text
66   * box"). Swept over the container after paint rather than wired field by
67   * field — attachVoicePill is idempotent per element, so a sweep cannot
68   * double-attach and cannot miss a field somebody adds later.
69   */
70  function _speakIt(root) {
71    // window.VoiceInput (js/voice-input.js) — the platform's one dictation
72    // control since the 2026-07-30 rebuild. Not a rename of attachVoicePill:
73    // the pill floated on the field and covered the text being typed, so the
74    // control now sits in its own row beneath it.
75    var V = window.VoiceInput;
76    if (!V || typeof V.attach !== 'function' || !root || !root.querySelectorAll) return;
77    Array.prototype.forEach.call(root.querySelectorAll('textarea:not([data-no-voice])'), function (f) {
78      try { V.attach(f); } catch (_) {}
79    });
80  }
81
82  /* ── Colours and fonts ────────────────────────────────────────────────
83   *
84   * Alex, 2026-08-01: "I think we definitely need a better way to customise
85   * the preview of the course page and we can use the tools from there…
86   * allowing them to pick the colours and the fonts."
87   *
88   * The tools from there, literally: window.LookPickers is published by
89   * launch-studio.js and carries the same six palettes, the same four
90   * pairings and the same two card renderers the website builder's Look step
91   * uses. The CSS is css/look-pickers.css, worn by putting `.lk-pickers` on
92   * this tab. Nothing about the controls is written here — only where they
93   * sit and which page they land on.
94   *
95   * Returns '' when the module has not loaded, so the template picker (which
96   * has always worked on its own) is never taken down by an absent optional.
97   */
98  var _lookSel = { pal: 0, font: 0 };
99
100  /* ══════════════════════════════════════════════════════════════════════
101     THEIR OWN COLOURS, HERE TOO
102
103     The wizard's Look step puts a "Your brand" card first and pre-selects it
104     (Alex, 2026-08-02: "if we've got brand colours already, I'd already
105     pre-selected those brand colours on the course and that would be the first
106     palette that is selected"). This is the SAME screen after the build — the
107     one a coach comes back to — and until now it showed the plain six.
108
109     Alex, confirming: "once you've built it and you go back to the look panel,
110     it will still show blank palettes. Yeah."
111
112     Same deriver (LookPickers.brandPalette), same card renderer. The colour
113     comes from the community's own theme, fetched once when this tab paints.
114     ══════════════════════════════════════════════════════════════════════ */
115  var _brandColour = null;
116
117  function _brandPal() {
118    var LP = window.LookPickers;
119    if (!LP || !LP.brandPalette) return null;
120    return LP.brandPalette({ brand: _brandColour, name: 'Your brand', tag: 'Your own colours' });
121  }
122
123  /**
124   * The palettes on offer, in the order they are painted.
125   *
126   * `_lookSel.pal` indexes THIS, not LookPickers.PALETTES — the grid's click
127   * handler works off DOM position, so a reader counting from the other list
128   * hands the coach a different colour from the one they tapped.
129   */
130  function _pals() {
131    var LP = window.LookPickers;
132    if (!LP) return [];
133    var b = _brandPal();
134    return b ? [b].concat(LP.PALETTES) : LP.PALETTES.slice();
135  }
136  function _palIdx() {
137    var n = _pals().length;
138    if (!n) return -1;
139    return (_lookSel.pal >= 0 && _lookSel.pal < n) ? _lookSel.pal : 0;
140  }
141  function _curPal() { var i = _palIdx(); return i < 0 ? null : _pals()[i]; }
142
143  /** Fetch the community's brand colour once, before the rail is painted. */
144  async function _loadBrandColour() {
145    if (_brandColour !== null) return _brandColour;
146    try {
147      var r = await P.api('/community-hosting/settings');
148      var c = ((r && (r.settings || r)) || {});
149      var hex = ((c.theme || {}).accent_colour) || null;
150      _brandColour = /^#[0-9a-fA-F]{6}$/.test(String(hex || '')) ? hex : '';
151    } catch (_) { _brandColour = ''; }
152    return _brandColour;
153  }
154
155  function _lookRailHtml() {
156    var LP = window.LookPickers;
157    if (!LP) return '';
158    return '<div class="lk-rail" style="margin-top:16px">' +
159      '<section class="lk-card"><div class="card-h">' +
160        '<h3><span class="n">A</span> Colour palette</h3>' +
161        '<div class="hint">Tap any palette: the preview re-colours as you look at it.</div>' +
162      '</div><div class="card-b">' +
163        '<div class="lk-palgrid" data-cz-pals>' + (function () {
164          var list = _pals(), cur = _palIdx(), brand = !!_brandPal();
165          return list.map(function (p, i) {
166            return LP.palCardHtml(p, i === cur, i === 0 && brand ? 'recommended' : '');
167          }).join('');
168        })() + '</div>' +
169      '</div></section>' +
170      '<section class="lk-card"><div class="card-h">' +
171        '<h3><span class="n">B</span> Typeface</h3>' +
172        '<div class="hint">A pair that works together: headings & body.</div>' +
173      '</div><div class="card-b">' +
174        '<div class="lk-fontgrid" data-cz-fonts>' + LP.fontCardsHtml(_lookSel.font) + '</div>' +
175        '<button class="btn btn-dark" type="button" data-cz-look-save style="width:100%;margin-top:12px">Apply to my course page</button>' +
176      '</div></section>' +
177    '</div>';
178  }
179
180  /** The 4-role model the website builder's apply-styles speaks — same keys,
181   *  so the same palette produces the same page. */
182  function _lookVars(p) {
183    return { bg: p.bg, surface: p.surface, text: p.text, text2: p.text2,
184      acc: p.acc, acc2: p.acc2, border: p.border };
185  }
186
187  function _wireLookRail(el, ctx) {
188    var LP = window.LookPickers;
189    if (!LP || !el.querySelector('[data-cz-pals]')) return;
190
191    function look() {
192      var f = LP.FONTS[_lookSel.font];
193      var p = _curPal() || LP.PALETTES[0];
194      return { cssVars: _lookVars(p), fonts: LP.buildFonts(f.d, f.b, f.hWeight) };
195    }
196    function paint() {
197      // In place, not a repaint: a repaint flashes white while the document
198      // rebuilds, which reads as a page reloading rather than a colour
199      // changing. setLook writes the same :root properties and the same
200      // #brand-fonts block the server writes, so this preview is the save.
201      if (_preview && _preview.setLook) _preview.setLook(look());
202    }
203    paint();
204
205    function pick(sel, key) {
206      Array.prototype.forEach.call(el.querySelectorAll(sel + ' > button'), function (b, i) {
207        b.addEventListener('click', function () {
208          Array.prototype.forEach.call(el.querySelectorAll(sel + ' > button'), function (x) { x.classList.remove('on'); });
209          b.classList.add('on');
210          _lookSel[key] = i;
211          paint();
212        });
213      });
214    }
215    pick('[data-cz-pals]', 'pal');
216    pick('[data-cz-fonts]', 'font');
217
218    var save = el.querySelector('[data-cz-look-save]');
219    if (save) save.addEventListener('click', async function () {
220      var label = save.textContent;
221      save.disabled = true; save.textContent = 'Applying…';
222      try {
223        await P.api('/templates/courses/look', {
224          method: 'POST',
225          body: Object.assign({ course_id: ctx.courseId }, look()),
226        });
227        P.toast('✓ Your course page is in ' + ((_curPal() || {}).name || 'your colours'));
228      } catch (e) {
229        // The one refusal worth explaining rather than reporting: colours need
230        // a page to land on, and the picker above is how you get one.
231        P.toast(e && e.code === 'no_course_page'
232          ? 'Pick a look above first. Then the colours have a page to land on.'
233          : 'Couldn’t apply that: ' + ((e && e.code) || 'unknown'));
234      } finally {
235        save.disabled = false; save.textContent = label;
236      }
237    });
238  }
239
240  async function renderCustomize(el, ctx) {
241    var course = ctx.course;
242    el.classList.add('lk-pickers');   // the website builder's own controls
243    el.innerHTML = '<div class="empty-block" style="opacity:0.6">Loading your course page…</div>';
244
245    if (!window.CourseCoverPreview) {
246      el.innerHTML = '<div class="panel cpg-panel"><div class="panel-body">' +
247        P.emptyBlock('⚠', 'The preview engine didn’t load', 'Refresh the page and try again.') +
248        '</div></div>';
249      return;
250    }
251
252    var roster = null, facts = null;
253    var out = await Promise.all([
254      window.CourseCoverPreview.roster().catch(function () { return null; }),
255      P.api('/community-hosting/courses/' + ctx.courseId + '/guide/cover-context', { method: 'POST', body: {} })
256        .catch(function () { return null; }),
257      // Their brand colour, so the palette grid can lead with it — fetched
258      // alongside the others rather than after, because the rail is painted
259      // from this same render and a late answer would paint the plain six.
260      _loadBrandColour(),
261    ]);
262    roster = out[0]; facts = out[1];
263
264    if (!roster || !Array.isArray(roster.templates)) {
265      el.innerHTML = '<div class="panel cpg-panel"><div class="panel-body">' +
266        P.emptyBlock('⚠', "Couldn't load the looks", 'Refresh to retry.') + '</div></div>';
267      return;
268    }
269
270    var applied = course.template_slug || null;
271    var sel = applied || (roster.templates[0] && roster.templates[0].slug) || null;
272    var catLabel = {};
273    (roster.categories || []).forEach(function (c) { catLabel[c.id] = c.label; });
274
275    var appliedRow = applied
276      ? (roster.templates.filter(function (t) { return t.slug === applied; })[0] || null)
277      : null;
278
279    el.innerHTML =
280      '<div class="panel cpg-panel">' +
281        '<div class="panel-head"><div class="cpg-row-end">' +
282          '<div><div class="cpg-section-title">' +
283            (appliedRow ? 'Your course page uses ' + P.esc(appliedRow.name) : 'No look applied yet') +
284          '</div>' +
285          '<div class="cpg-section-sub">' + (appliedRow
286            ? P.esc(appliedRow.feel || '')
287            : 'Your course has no course page of its own yet. Pick a look below and we’ll build one you can edit.') +
288          '</div></div>' +
289          (course.cover_page_id
290            ? '<button class="btn btn-secondary" type="button" data-cz-edit>✏️ Edit this page</button>'
291            : '') +
292        '</div></div>' +
293        '<div class="panel-body">' +
294          '<div class="cpg-tpl-layout">' +
295            '<div class="cpg-tpl-picker">' +
296              '<div class="cpg-row-end" style="margin-bottom:8px">' +
297                '<div class="cpg-section-title" style="font-size:14px">Change the look</div>' +
298              '</div>' +
299              '<div class="cpg-tpl-list" data-cz-list>' + _tplListHtml(roster, catLabel, sel, applied) + '</div>' +
300              '<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
301                '<button class="btn btn-dark" type="button" data-cz-apply>Use this look</button>' +
302                '<span class="cpg-section-sub" style="margin:0">
302Rebuilds your course page from your real course.</span>' +
303              '</div>' +
304              '<p class="cpg-msg" data-cz-msg style="display:none"></p>' +
305              _lookRailHtml() +
306            '</div>' +
307            '<div class="cpg-tpl-preview">' +
308              '<div class="cpg-row-end" style="margin-bottom:8px">' +
309                '<div class="cpg-section-title" style="font-size:14px">Preview</div>' +
310                '<div class="cpg-vp-chips" data-cz-vps>' +
311                  window.CourseCoverPreview.viewportChipsHtml('mobile', 'data-cz-vp') +
312                '</div>' +
313              '</div>' +
314              '<div data-cz-frame></div>' +
315              (facts ? '' : '<div class="cpg-section-sub">Couldn’t load your course’s details, so the preview is showing the look only.</div>') +
316            '</div>' +
317          '</div>' +
318        '</div>' +
319      '</div>' +
320      '<div class="panel cpg-panel"><div class="panel-body">' +
321        P.soonBlock('✏️', 'Editing happens in the page editor you already know',
322          'Applying a look builds a normal page and hands it to the same editor you use for every other page: ' +
323          'there is no second editor to learn, and your edits are yours. Re-applying a look asks first and keeps ' +
324          'your current version in the page’s revision history.') +
325      '</div></div>';
326
327    // ONE frame. `setSlug` repaints it — the preview engine was built for
328    // exactly this (see its header note about 31 cards vs 31 renders).
329    if (_preview) { try { _preview.destroy(); } catch (_) {} _preview = null; }
330    if (facts) {
331      // No `height`. In device mode the component reads it INSTEAD of the
332      // viewport's own height, so `height: 520` drew a 390x760 phone as
333      // 390x520 — and the same 520 for the 1280x860 desktop chip, which is a
334      // letterbox strip rather than a browser window. The wizard's gallery
335      // already carried this warning in a comment; this surface had not read
336      // it. Left out, the frame is the device and the wrap scales it to fit
337      // whatever width this column happens to have.
338      _preview = window.CourseCoverPreview.mount(el.querySelector('[data-cz-frame]'), {
339        slug: sel, facts: facts, viewport: 'mobile', mode: 'device',
340      });
341    }
342
343    var msg = el.querySelector('[data-cz-msg]');
344    var edit = el.querySelector('[data-cz-edit]');
345    if (edit) edit.addEventListener('click', function () {
346      var pid = course.cover_page_id;
347      if (typeof window.builderOpenQuickEdit === 'function') window.builderOpenQuickEdit(pid);
348      else window.open((window.__BASE_PATH || '') + '/builder/review/review.html?page_id=' + pid, '_blank');
349    });
350
351    _wireLookRail(el, ctx);
352
353    Array.prototype.forEach.call(el.querySelectorAll('[data-cz-tpl]'), function (b) {
354      b.addEventListener('click', function () {
355        sel = b.getAttribute('data-cz-tpl');
356        Array.prototype.forEach.call(el.querySelectorAll('[data-cz-tpl]'), function (x) {
357          x.classList.toggle('on', x.getAttribute('data-cz-tpl') === sel);
358        });
359        if (_preview) _preview.setSlug(sel);
360      });
361    });
362    Array.prototype.forEach.call(el.querySelectorAll('[data-cz-vp]'), function (b) {
363      b.addEventListener('click', function () {
364        Array.prototype.forEach.call(el.querySelectorAll('[data-cz-vp]'), function (x) {
365          x.classList.toggle('on', x === b);
366        });
367        if (_preview) _preview.setViewport(b.getAttribute('data-cz-vp'));
368      });
369    });
370
371    el.querySelector('[data-cz-apply]').addEventListener('click', async function () {
372      var btn = this;
373      if (!sel) return;
374      // Design audit F1: CBSheet, never the browser's dialog (ui-core.js:1845).
375      if (sel === applied && !(await window.CBSheet.confirm({
376        title: 'Rebuild this page?',
377        message: 'That look is already applied. Rebuild the page from your current course?',
378        confirmLabel: 'Rebuild',
379      }))) return;
380      btn.disabled = true;
381      P.setMsg(msg, 'Building your course page…');
382      try {
383        await _apply(ctx.courseId, sel, false);
384        P.setMsg(msg, '✓ Course page rebuilt.', 'good');
385        ctx.reload();
386      } catch (err) {
387        btn.disabled = false;
388        if (err && err.code === 'template_page_edited') {
389          if (await window.CBSheet.confirm({
390            title: 'Replace your edited page?',
391            message: 'You’ve edited this course page since the look was applied. ' +
392              'Replace it? Your current version is kept in the page’s revision history, so you can restore it.',
393            confirmLabel: 'Replace it',
394          })) {
395            btn.disabled = true;
396            try {
397              await _apply(ctx.courseId, sel, true);
398              P.setMsg(msg, '✓ Rebuilt: your previous version is in the page’s history.', 'good');
399              ctx.reload();
400              return;
401            } catch (e2) {
402              btn.disabled = false;
403              P.setMsg(msg, 'Couldn’t apply that look: ' + ((e2 && e2.code) || 'unknown'), 'bad');
404              return;
405            }
406          }
407          P.setMsg(msg, '');
408          return;
409        }
410        P.setMsg(msg, 'Couldn’t apply that look: ' + ((err && err.code) || 'unknown'), 'bad');
411      }
412    });
413  }
414
415  function _apply(courseId, slug, confirmFlag) {
416    return P.api('/templates/courses/apply', {
417      method: 'POST', body: { course_id: courseId, slug: slug, confirm: !!confirmFlag },
418    });
419  }
420
421  function _tplListHtml(roster, catLabel, sel, applied) {
422    var byCat = {};
423    var order = [];
424    roster.templates.forEach(function (t) {
425      if (!byCat[t.category]) { byCat[t.category] = []; order.push(t.category); }
426      byCat[t.category].push(t);
427    });
428    return order.map(function (cat) {
429      return '<div class="cpg-tpl-cat">' + P.esc(catLabel[cat] || cat) + '</div>' +
430        byCat[cat].map(function (t) {
431          return '<button type="button" class="cpg-tpl' + (t.slug === sel ? ' on' : '') + '" data-cz-tpl="' + P.esc(t.slug) + '">' +
432            '<span class="nm">' + P.esc(t.name) +
433              (t.slug === applied ? '<span class="badge badge-success">Applied</span>' : '') + '</span>' +
434            '<span class="fl">' + P.esc(t.niche || '') + (t.feel ? ' · ' + P.esc(t.feel) : '') + '</span>' +
435          '</button>';
436        }).join('');
437    }).join('');
438  }
439
440  // ══ CERTIFICATES ═══════════════════════════════════════════════════
441
442  /* ══════════════════════════════════════════════════════════════════════
443     U11 — THE CERTIFICATES SCREEN IS THE LOOK STEP'S LAYOUT
444
445     Alex, 2026-08-05: *"the certificates need to be set up very similar to how
446     the preview is done. Use the same sort of layout. The certificate should be
447     in the middle of the page so you preview it and then the controls should be
448     done in the same way."*
449
450     So this screen is `_renderLook`'s stage, mounted rather than redrawn:
451     `.pho-stage.pho-stage3` (css/photo-stage.css:132) — a pane SELECTOR on the
452     left, the artefact in the middle, and a rail on the right that holds
453     EXACTLY ONE card, because `_certCard()` returns '' for every pane that is
454     not the open one. That last part is the mechanic, not a detail
454: hiding six
455     cards with CSS is a different screen from having one.
456
457     ⚠️ WHAT IS *NOT* MOUNTED: `window.CourseCoverPreview`. That is the course
458     PAGE engine — it loads /templates/courses/_render.js and draws HTML in an
459     iframe, client-side. A certificate is a server-rendered PDF and nothing
460     else, so the middle frame here is its own small mount
461     (`_certPreviewMount`), sharing the layout and not the renderer.
462
463     ── Why the two screens became one ──────────────────────────────────
464     "Preview plain", "Preview branded" and "Design the certificate" were three
465     buttons that each replaced the screen you were on with something you then
466     had to come back from. They are now the preview itself (it is on the page),
467     pane B (Style) and panes D + E. The design editor
468     (course-certificates-editor.js) is still loaded and still works; nothing
469     here deletes it, because Alex's own sequencing rule is to cover everything
470     first and retire the old surface after.
471
472     ── The scope split is stated FOUR times, deliberately ───────────────
473     A coach must not change their logo for every course believing they changed
474     it for one. So: the left list is grouped under THIS COURSE / EVERY COURSE,
475     every card's hint opens with its scope, the retrospective truths sit in the
476     hints that own them, and — the one that catches the mistake at the moment
477     it is made — every save confirmation NAMES the scope it saved to.
478     ══════════════════════════════════════════════════════════════════════ */
479
480  // Whole months a coach is actually likely to want. "Never expires" is
481  // first because it is the right answer for almost every course, and
482  // there is deliberately no fixed-date option (D19): a fixed date is
483  // wrong for every student who finishes after it.
484  var CERT_VALIDITY_CHOICES = [
485    { m: 0,  label: 'Never expires' },
486    { m: 6,  label: '6 months from the day they finish' },
487    { m: 12, label: '1 year from the day they finish' },
488    { m: 24, label: '2 years from the day they finish' },
489    { m: 36, label: '3 years from the day they finish' },
490  ];
491
492  // The elements a coach can stop printing. Each one genuinely disappears
493  // from the rendered PDF (course-certificate-service `_certificateHtml`) —
494  // there are no cosmetic entries, because a control that does nothing is
495  // worse than no control at all. What is deliberately NOT here: the
496  // member's name, the course title, the certificate number and the frame.
497  var CERT_ELEMENTS = [
498    { key: 'community_name', label: 'Your community’s name',
499      hint: 'The line in small capitals above “Certificate of Completion”.' },
500    { key: 'logo',           label: 'Your logo',
501      hint: 'Branded certificates only: a plain certificate never shows a logo.' },
502    { key: 'date',           label: 'The completion date',
503      hint: 'The dated line at the bottom left.' },
504    { key: 'signature',      label: 'The signature line',
505      hint: 'Your signature image or name at the bottom right.' },
506    { key: 'seal',           label: 'The seal',
507      hint: 'The round laurel medallion between the date and the signature.' },
508    { key: 'qr',             label: 'The verification QR code',
509      hint: 'Turning this off removes the scannable square, not the verification. The certificate ' +
510        'number and the “verify at” line stay, so it can still be checked by hand. It just can’t ' +
511        'be checked with a phone camera.' },
512  ];
513
514  // Which card the rail is holding. Module scope, like `_lookPane` in the
515  // wizard, so the pane switch and the rail builder are the same two
516  // functions no matter which repaint asked.
517  var _certPane = 'on';
518  var CERT_PANES = ['on', 'style', 'validity', 'brand', 'prints', 'email'];
519
520  function _certToken() { try { return localStorage.getItem('cb_token'); } catch (_) { return null; } }
521
522  /* ══════════════════════════════════════════════════════════════════════
523     THE PDF IN THE MIDDLE — and why it is not "updates as you tap"
524
525     Measured on the dev box, 2026-08-05, through the real service:
526     plain 3,404 ms · branded 4,358 ms, because `_htmlToPdf` launches a fresh
527     headless Chrome per call. The Look step repaints per keystroke because it
528     renders in the browser; this cannot, and pretending otherwise would put a
529     Chrome process on a shared multi-tenant box behind every key press.
530
531     So the cadence is the blast-radius control, not polish:
532       · a refresh is only ever asked for AFTER a save that the PDF renders,
533       · debounced 500 ms so a burst of toggles is one render,
534       · single-flight with a GENERATION counter — a slow `plain` render must
535         not land on top of a fast `branded` one and show the wrong certificate,
536       · a 1,500 ms floor between the STARTS of two renders, with at most one
537         trailing refresh queued behind the one in flight,
538       · aborted at 20,000 ms (the server's own setContent timeout is 15,000).
539
540     ⚠️ AND THE CANVAS IS NEVER BLANKED. Four seconds of white is
541     indistinguishable from broken, and this screen will do it dozens of times
542     in a session — so the PREVIOUS certificate stays up, dimmed, while the next
543     one draws. Same for every failure: the last good render stays, and the
544     message separates *saved* from *drawn*, because they are two different
545     things and a coach must never be left thinking their change was lost.
546     ══════════════════════════════════════════════════════════════════════ */
547  var CERT_PV_DEBOUNCE = 500;
548  var CERT_PV_FLOOR = 1500;
549  var CERT_PV_TIMEOUT = 20000;
550
551  function _certPreviewMount(host, opts) {
552    opts = opts || {};
553    var gen = 0, inFlight = false, queued = false, lastStart = 0, busyRetries = 0;
554    var timer = null, ctrl = null, abortT = null, blobUrl = null, dead = false, painted = false;
555
556    var note = document.createElement('div');
557    note.className = 'pho-loading';
558    note.setAttribute('data-ct-pv-note', '');
559    note.textContent = 'Drawing your certificate… this takes a few seconds.';
560
561    /* §CERT-PREVIEW.1b — an <img>, NOT an <iframe>. The iframe handed the PDF
562       to the browser's own viewer, and Safari's drew a broken-image glyph
563       where Chrome drew the page — Alex hit it twice while the endpoint was
564       returning a valid PDF both times. The server now screenshots the same
565       render to a PNG, and an image renders identically everywhere. The box
566       is `.pho-page.pho-cert`, already the certificate's own 1122×793 shape,
567       so `contain` fills it edge to edge. */
568    var frame = document.createElement('img');
569    frame.setAttribute('alt', 'Certificate preview');
570    frame.setAttribute('data-ct-pv-frame', '');
571    frame.style.width = '100%';
572    frame.style.height = '100%';
573    frame.style.objectFit = 'contain';
574    frame.style.border = '0';
575    frame.style.display = 'none';
576    frame.style.background = '#fff';
577
578    host.innerHTML = '';
579    host.appendChild(note);
580    host.appendChild(frame);
581
582    function say(text) { if (typeof opts.onSubline === 'function') opts.onSubline(text); }
583
584    function dim(on) {
585      frame.style.opacity = on ? '0.55' : '';
586      frame.style.pointerEvents = on ? 'none' : '';
587    }
588
589    function show(blob) {
590      /* The Manage panel repaints by replacing its body, so a render that was
591         in flight when the coach left comes back to a frame nobody can see.
592         Painting it is harmless but pointless; checking is one line. */
593      if (!host.isConnected) return;
594      var next = null;
595      try { next = URL.createObjectURL(blob); } catch (_) { next = null; }
596      if (!next) return;
597      // The old object URL is revoked on replacement — a long editing session
598      // otherwise leaks one blob per render.
599      if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch (_) {} }
600      blobUrl = next;
601      frame.src = blobUrl;
602      frame.style.display = 'block';
603      painted = true;
604      busyRetries = 0;
605      note.style.display = 'none';
606      dim(false);
607      say(opts.styleLine ? opts.styleLine() : '');
608    }
609
610    function fail(err) {
611      dim(false);
612      /* `renderer_busy`: the server's headless pool is full (a 503 like
613         `pdf_unavailable`, but it passes in seconds). Say so, and try again by
614         itself — twice at most, so a busy box is never hammered by this tab. */
615      var busy = err && err.code === 'renderer_busy';
616      var willRetry = busy && busyRetries < 2 && !dead;
617      if (willRetry) {
618        busyRetries++;
619        if (timer) clearTimeout(timer);
620        timer = setTimeout(kick, 5000);
621      }
622      var unavailable = !busy && err && (err.code === 'pdf_unavailable' || err.status === 503);
623      var text = busy
624        ? 'Your change is saved. The preview is busy right now' + (willRetry ? ', so it will try again in a few seconds.' : '. Try again in a minute.')
625        : unavailable
626        ? 'Your change is saved: the preview engine isn’t available on this server right now.'
627        : 'Couldn’t redraw the preview just now. Your change is saved.';
628      if (painted) {
629        // The last good certificate stays exactly where it is. Only the line
630        // above it changes, because the change WAS saved.
631        say(text);
632      } else {
633        note.style.display = '';
634        note.textContent = text;
635      }
636    }
637
638    function start() {
639      if (dead) return;
640      inFlight = true;
641      lastStart = Date.now();
642      var mine = ++gen;
643      if (ctrl) { try { ctrl.abort(); } catch (_) {} }
644      try { ctrl = new AbortController(); } catch (_) { ctrl = null; }
645      if (painted) { dim(true); say('Redrawing…'); }
646      abortT = setTimeout(function () { if (ctrl) { try { ctrl.abort(); } catch (_) {} } }, CERT_PV_TIMEOUT);
647      var t = _certToken();
648      // The token stays OUT of the URL (this is a fetch, not a window.open), a
649      // non-OK response can be read as JSON so `pdf_unavailable` gets its own
650      // sentence, and `_=<generation>` stops the browser handing back the
651      // previous render of an identical URL after a settings change.
652      var url = '/api/community-hosting/courses/' + opts.courseId +
653        '/certificate-preview.png?style=' + encodeURIComponent(opts.style()) + '&_=' + mine;
654      var init = { credentials: 'same-origin', headers: t ? { Authorization: 'Bearer ' + t } : {} };
655      if (ctrl) init.signal = ctrl.signal;
656      fetch(url, init)
657        .then(function (res) {
658          if (res.ok) return res.blob();
659          return res.json().then(function (j) {
660            var e = new Error('preview_failed');
661            e.status = res.status; e.code = j && j.error;
662            throw e;
663          }, function () {
664            var e = new Error('preview_failed'); e.status = res.status; throw e;
665          });
666        })
667        .then(function (blob) {
668          // ⚠️ A superseded response is DISCARDED, not painted. Without this a
669          // slow plain render lands after a fast branded one and the screen
670          // shows a certificate the coach did not ask for.
671          if (dead || mine !== gen) return;
672          show(blob);
673        })
674        .catch(function (err) {
675          if (dead || mine !== gen) return;
676          fail(err);
677        })
678        .then(function () {
679          if (abortT) { clearTimeout(abortT); abortT = null; }
680          if (mine !== gen) return;
681          inFlight = false;
682          if (queued) { queued = false; kick(); }
683        });
684    }
685
686    function kick() {
687      if (dead) return;
688      if (inFlight) { queued = true; return; }
689      var wait = CERT_PV_FLOOR - (Date.now() - lastStart);
690      if (lastStart && wait > 0) { timer = setTimeout(kick, wait); return; }
691      start();
692    }
693
694    return {
695      /** Ask for a redraw. Debounced; the cadence rules above do the rest. */
696      refresh: function () {
697        if (dead) return;
698        if (timer) clearTimeout(timer);
699        timer = setTimeout(kick, CERT_PV_DEBOUNCE);
700      },
701      /** The first draw, with no debounce — the coach is waiting on it. */
702      first: function () { if (!dead) start(); },
703      destroy: function () {
704        dead = true;
705        if (timer) clearTimeout(timer);
706        if (abortT) clearTimeout(abortT);
707        if (ctrl) { try { ctrl.abort(); } catch (_) {} }
708        if (blobUrl) { try { URL.revokeObjectURL(blobUrl); } catch (_) {} }
709      },
710    };
711  }
712
713  async function renderCertificates(el, ctx) {
714    var course = ctx.course;
715    el.innerHTML = '<div class="empty-block" style="opacity:0.6">Loading certificates…</div>';
716    var out = await Promise.all([
717      P.api('/community-hosting/courses/' + ctx.courseId + '/certificates').catch(function () { return null; }),
718      P.api('/community-hosting/settings').catch(function () { return null; }),
719    ]);
720    var issued = (out[0] && out[0].certificates) || [];
721    var settings = (out[1] && out[1].settings) || {};
722    var cert = settings.certificates || {};
723    var theme = settings.theme || {};
724
725    // The screen's own copy of what it is showing. Every control writes here
726    // when its save succeeds, so a pane repaint draws the truth without
727    // re-fetching — and without ctx.reload(), which would tear the preview
728    // frame down and put four seconds of Chrome between a coach and a tick box.
729    var S = {
730      on: course.certificates_enabled !== false,
731      style: course.certificate_style === 'branded' ? 'branded' : 'plain',
732      months: Number(course.certificate_validity_months) || 0,
733      // Default-true, matching the server's merge — an older community whose
734      // settings blob predates D19 has no key here and must read as ON.
735      email: cert.email_on_completion !== false,
736      cert: cert,
737      theme: theme,
738    };
739    if (!CERT_PANES.some(function (p) { return p === _certPane; }
739)) _certPane = 'on';
740
741    // A coach who has set a term we don't offer as a preset (via the API, or a
742    // preset we later drop) must still see their real value, not be silently
743    // reset to "never". Show it rather than lose it.
744    function choices() {
745      var list = CERT_VALIDITY_CHOICES.slice();
746      if (S.months && !list.some(function (c) { return c.m === S.months; })) {
747        list.push({ m: S.months, label: S.months + ' months from the day they finish' });
748      }
749      return list;
750    }
751    function validityLabel() {
752      var hit = choices().filter(function (c) { return c.m === S.months; })[0];
753      return hit ? hit.label.replace(' from the day they finish', '') : 'Never expires';
754    }
755    function styleLine() {
756      return (S.style === 'branded' ? 'Branded' : 'Plain') + ' · a preview for this course';
757    }
758
759    // The most recent issued certificate is the live proof. A coach can click
760    // it and land exactly where an employer lands.
761    var sample = issued.filter(function (c) { return !c.pending && c.verify_url; })[0] || null;
762
763    el.innerHTML =
764      // ⚠️ `pho` opts this subtree into css/photo-stage.css and `lk-pickers`
765      // into css/look-pickers.css. Forgetting either leaves the markup correct
766      // and completely unstyled — the same two classes _renderLook adds.
767      '<div class="pho lk-pickers">' +
768        '<div class="pho-stage pho-stage3">' +
769          // `pho-pages-all`: six short rows, so every one shows when the columns
770          // stack on a phone (the shared cap clipped two of them, sweep R4).
771          '<aside class="pho-pages pho-pages-all" data-ct-panes></aside>' +
772          '<section class="pho-main">' +
773            '<div class="pho-ctx">' +
774              // `stack`: the name over its line, so the button beside them keeps
775              // its width (it wrapped onto four lines in a narrow column).
776              '<div class="where stack"><b>The certificate</b>' +
777                '<span class="s" data-ct-sub>' + P.esc(styleLine()) + '</span></div>' +
778              '<div class="ctx-right">' +
779                // The escape hatch for any browser that will not render a PDF
780                // inline — today's window.open preview, kept verbatim.
781                '<button type="button" class="btn btn-secondary" data-ct-open>👁 Open in a new tab</button>' +
782              '</div>' +
783            '</div>' +
784            /* §CERT-PREVIEW.2 (2026-08-06) — `pho-cert` shapes this box to the
785               certificate itself. Alex: *"the certificate is a landscape image
786               so you need to make the preview match that."* The PDF is drawn
787               at 1122×793 (course-certificate-service _htmlToPdf), so the CSS
788               carries that exact ratio rather than a guessed one. */
789            '<div class="pho-canvas"><div class="pho-page pho-cert" data-ct-preview-mount></div></div>' +
790          '</section>' +
791          '<aside class="pho-rail" data-ct-rail></aside>' +
792        '</div>' +
793
794        // ── Below the stage: the RECORD, full width ────────────────────
795        // Records, not controls — and a twenty-row list does not belong in a
796        // 330px rail.
797        '<div class="panel cpg-panel" style="margin-top:18px">' +
798          '<div class="panel-head">' +
799            '<div class="cpg-section-title">Anyone can check these are real</div>' +
800            '<div class="cpg-section-sub">Most course platforms hand out a PDF and a shareable link. Yours are verifiable.</div>' +
801          '</div>' +
802          '<div class="panel-body">' +
803            '<div class="cpg-verify-note">' +
804              '<p>Every certificate you issue carries a <b>QR code</b> and its own <b>certificate number</b>, ' +
805              'both pointing at a public verification page. A hiring manager can scan it from a printed CV, ' +
806              'or click it from a LinkedIn profile, and see the member’s name, your course and the date. ' +
807              'They need no account and never have to contact you, and nothing on that page can be edited ' +
808              'after the fact.</p>' +
809              '<p>The page keeps answering for good. It does not depend on the course still being published, ' +
810              'or on the member still being in your community.</p>' +
811            '</div>' +
812            (sample
813              ? '<div class="cpg-verify-live">' +
814                  '<div class="cpg-item-meta">See exactly what they see. This is a real certificate you have issued:</div>' +
815                  '<code class="cpg-code" data-ct-verify-url>' + P.esc(sample.verify_url) + '</code>' +
816                  '<div class="cpg-item-actions">' +
817                    '<a class="btn btn-secondary" href="' + P.esc(sample.verify_url) + '" target="_blank" rel="noopener">Open the verification page</a>' +
818                    '<button class="btn btn-secondary" type="button" data-ct-copy="' + P.esc(sample.verify_url) + '">Copy link</button>' +
819                  '</div>' +
820                '</div>'
821              /* Round 6: this said "The first certificate you issue will appear
822                 here" right above "2 earned for this course so far". Earned is
823                 not yet made: a finisher's certificate is made the first time
824                 they open it, so say THAT when people have earned one. */
825              : (issued.length
826                ? '<div class="cpg-item-meta" style="margin-top:12px" data-ct-verify-wait>' +
827                    (issued.length === 1 ? 'One student has' : issued.length + ' students have') +
828                    ' earned a certificate. Each one is made the first time that student opens it, ' +
829                    'and its live verification link then appears here.</div>'
830                : '<div class="cpg-item-meta" style="margin-top:12px" data-ct-verify-wait>The first certificate you issue will appear here with its live verification link.</div>')) +
831          '</div>' +
832        '</div>' +
833
834        '<div class="panel cpg-panel">' +
835          '<div class="panel-head">' +
836            '<div class="cpg-section-title">Issued</div>' +
837            '<div class="cpg-section-sub">' + issued.length + ' earned for this course so far.</div>' +
838          '</div>' +
839          '<div class="panel-body">' +
840            (issued.length
841              ? '<div class="cpg-list">' + issued.slice(0, 20).map(_issuedRowHtml).join('') + '</div>' +
842                (issued.length > 20 ? '<div class="cpg-section-sub" style="margin-top:10px">Showing the 20 most recent.</div>' : '')
843              : '<div class="cpg-section-sub">None yet: the first one appears the moment somebody finishes.</div>') +
844          '</div>' +
845        '</div>' +
846      '</div>';
847
848    var subEl = el.querySelector('[data-ct-sub]');
849    var railHost = el.querySelector('[data-ct-rail]');
850    var panesHost = el.querySelector('[data-ct-panes]');
851
852    // ── The middle. Mounted ONCE, and never rebuilt by a pane change ──
853    var preview = _certPreviewMount(el.querySelector('[data-ct-preview-mount]'), {
854      courseId: ctx.courseId,
855      style: function () { return S.style; },
856      styleLine: styleLine,
857      onSubline: function (t) { if (subEl) subEl.textContent = t; },
858    });
859    preview.first();
860
861    el.querySelector('[data-ct-open]').addEventListener('click', function () {
862      var t = _certToken();
863      window.open('/api/community-hosting/courses/' + ctx.courseId +
864        '/certificate-preview.pdf?style=' + S.style + (t ? '&token=' + encodeURIComponent(t) : ''), '_blank');
865    });
866
867    // ── The left list: two groups, each under its own header ──────────
868    // `.pho-pages` is a flex column, so header/list/header/list stacks with no
869    // new CSS. The split is visible before anything is clicked — the first of
870    // the four things that stop a coach rebranding every course by accident.
871    function panesHtml() {
872      function group(label, rows) {
873        return '<div class="pho-pagesh"><span class="l">' + P.esc(label) + '</span></div>' +
874          '<div class="pho-pageslist">' + rows.map(function (r) {
875            return '<button type="button" class="ccw-jump' + (_certPane === r[0] ? ' on' : '') +
876              '" data-ct-jump="' + r[0] + '"' + (_certPane === r[0] ? ' aria-current="true"' : '') + '>' +
877              '<span class="t">' + P.esc(r[1]) + '</span>' +
878              '<span class="v">' + P.esc(r[2]) + '</span></button>';
879          }).join('') + '</div>';
880      }
881      var shown = CERT_ELEMENTS.filter(function (e) { return (S.cert.elements || {})[e.key] !== false; }).length;
882      return group('This course', [
883        ['on', 'Certificate', S.on ? 'On' : 'Off'],
884        ['style', 'Style', S.style === 'branded' ? 'Branded' : 'Plain'],
885        ['validity', 'How long it’s valid', validityLabel()],
886      ]) + group('Every course · one brand, shared', [
887        ['brand', 'Your brand', S.cert.logo_url ? 'Logo set' : 'No logo yet'],
888        ['prints', 'What prints', shown + ' of ' + CERT_ELEMENTS.length + ' showing'],
889        ['email', 'Emailing', S.email ? 'On' : 'Off'],
890      ]);
891    }
892
893    /* One card, and `return ''` is the mechanic — copied from the Look step's
894       own `card()`. Hiding five cards with CSS would look the same and behave
895       differently: the coach would tab into controls they cannot see. */
896    function certCard(id, n, title, hint, bodyHtml) {
897      if (_certPane !== id) return '';
898      return '<section class="lk-card"><div class="card-h">' +
899        '<h3><span class="n">' + n + '</span> ' + title + '</h3>' +
900        '<div class="hint">' + hint + '</div>' +
901        '</div><div class="card-b">' + bodyHtml + '</div></section>';
902    }
903
904    function imageFieldHtml(kind, label, curUrl) {
905      var thumb = curUrl
906        ? '<img src="' + P.esc(curUrl) + '" alt="" style="width:72px;height:52px;object-fit:contain;background:#fff;border:1px solid var(--border);border-radius:8px;display:block">'
907        : '<div style="width:72px;height:52px;border-radius:8px;border:1px dashed var(--border-strong);display:flex;align-items:center;justify-content:center;color:var(--text-3);font-size:11px">None</div>';
908      return '<div class="field" style="margin-bottom:18px">' +
909        '<label>' + P.esc(label) + '</label>' +
910        '<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">' +
911          '<div data-ct-thumb="' + kind + '">' + thumb + '</div>' +
912          '<button class="btn btn-secondary" type="button" data-ct-pick="' + kind + '">Choose / upload</button>' +
913          '<button class="btn btn-secondary" type="button" data-ct-clear="' + kind + '"' + (curUrl ? '' : ' style="display:none"') + '>Remove</button>' +
914        '</div>' +
915      '</div>';
916    }
917
918    /* ⚠️ A refusal WITH THE DOOR ATTACHED — the same pattern as the Look step's
919       `_noDesignMsg`. Most brand fields only reach a BRANDED certificate
920       (`_certificateHtml` in course-certificate-service), so on a plain course
921       this whole card can be edited to no visible effect — and a preview that
922       does not move reads as a broken preview. Say so, and offer the switch. */
923    function plainNoteHtml() {
924      if (S.style === 'branded') return '';
925      return '<div class="ccw-note" style="margin-bottom:12px"><div class="d">' +
926        'This course is set to <b>Plain</b>, so nothing here shows on its certificate. Switch it to ' +
927        '<button type="button" class="btn btn-secondary cpg-btn-inline" data-ct-go="style">Branded</button> ' +
928        'to see these. (The fine print prints on both.)</div></div>';
929    }
930
931    function railHtml() {
932      var accentPlaceholder = S.theme.accent_colour || '#6366f1';
933      var els = S.cert.elements || {};
934      return '<div class="lk-rail">' +
935        certCard('on', 'A', 'Certificate',
936          'This course only. Issued automatically the moment a member finishes every lesson.',
937          '<label class="cpg-check"><input type="checkbox" data-ct-on' + (S.on ? ' checked' : '') + '> ' +
938          'Give members a certificate when they finish</label>') +
939
940        certCard('style', 'B', 'Style',
941          'This course only. Branding is shared. See <b>Your brand</b>.',
942          '<div class="ccw-opts">' +
943            /* §CERT-SIGNATURE.2 — DIVs, not spans. The wizard's own option
944               cards (`_glMountCert`) use block <div class="t">/<div class="d">
945               and read as label-over-description; these were <span>s, which
946               are inline, so "Plain" and "Clean and formal…" rendered as ONE
947               run-together line — his screenshot. Same markup as the wizard
948               now, and the shared stylesheet needs no change. */
949            '<button type="button" class="ccw-opt' + (S.style === 'plain' ? ' on' : '') + '" data-ct-style="plain">' +
950              '<div class="t">Plain</div><div class="d">Clean and formal. Nothing to set up.</div></button>' +
951            '<button type="button" class="ccw-opt' + (S.style === 'branded' ? ' on' : '') + '" data-ct-style="branded">' +
952              '<div class="t">Branded</div><div class="d">The same, with your logo, colour and signature.</div></button>' +
953          '</div>') +
954
955        certCard('validity', 'C', 'How long it’s valid',
956          'This course only. Counted from each member’s own completion date.',
957          '<select class="select" data-ct-validity>' +
958            choices().map(function (c) {
959              return '<option value="' + c.m + '"' + (c.m === S.months ? ' selected' : '') + '>' + P.esc(c.label) + '</option>';
960            }).join('') +
961          '</select>' +
962          '<div class="hint">Changing it affects certificates issued from now on. Anything already ' +
963            'issued keeps the term it was awarded under.</div>') +
964
965        certCard('brand', 'D', 'Your brand',
966          'Every course in your community: one brand, set once. Changing it changes certificates you have already issued.',
967          plainNoteHtml() +
968          imageFieldHtml('logo', 'Logo', S.cert.logo_url) +
969          '<div class="field" style="margin-bottom:18px"><label>Accent colour</label>' +
970            '<div class="color-row"><span class="color-preview" data-ct-accent-preview style="background:' +
971              P.esc(S.cert.accent_colour || accentPlaceholder) + '"></span>' +
972              '<input class="input color-hex-input" type="text" data-ct-accent placeholder="' +
973                P.esc(accentPlaceholder) + '" value="' + P.esc(S.cert.accent_colour || '') + '"></div>' +
974            '<div class="hint">Leave blank to use your community theme’s accent colour.</div></div>' +
975          imageFieldHtml('signature', 'Signature image', S.cert.signature_image_url) +
976          /* §CERT-SIGNATURE.1 — Alex: *"If you can't upload a file… You can
977             either draw a signature on the screen with your mouse or you can
978             just get some nice sort of handwriting text that they type in and
979             it turns it into handwriting text."* Both produce a PNG through
980             the SAME store the upload uses (posts/media data_url →
981             signature_image_url) — three doors, one field. */
982          '<div class="field" style="margin-top:-10px;margin-bottom:18px">' +
983            '<div style="display:flex;gap:8px;flex-wrap:wrap">' +
984              '<button class="btn btn-secondary cpg-btn-tight" type="button" data-ct-sig-draw>✍️ Draw it</button>' +
985              '<button class="btn btn-secondary cpg-btn-tight" type="button" data-ct-sig-type>⌨️ Type it</button>' +
986            '</div>' +
987            '<div data-ct-sig-box hidden style="margin-top:10px"></div>' +
988          '</div>' +
989          '<div class="field"><label>Signature name</label>' +
990            '<input class="input" data-ct-field="signature_name" value="' + P.esc(S.cert.signature_name || '') + '" placeholder="e.g. Alex Braganza"></div>' +
991          '<div class="field"><label>Signature title</label>' +
992            '<input class="input" data-ct-field="signature_title" value="' + P.esc(S.cert.signature_title || '') + '" placeholder="e.g. Founder, Acme Coaching"></div>' +
993          '<div class="field"><label>Custom congratulation line <span class="hint">(optional)</span></label>' +
994            '<textarea class="textarea" data-ct-field="custom_message" maxlength="300" placeholder="A personal note that appears on every branded certificate.">' +
995              P.esc(S.cert.custom_message || '') + '</textarea></div>' +
996          '<div class="field"><label>
996Fine print <span class="hint">(optional)</span></label>' +
997            '<textarea class="textarea" data-ct-field="fine_print" maxlength="200" placeholder="e.g. This certificate confirms course completion and is not a professional licence.">' +
998              P.esc(S.cert.fine_print || '') + '</textarea>' +
999            '<div class="hint">The smallest line on the certificate, under the issuer line. It prints on ' +
1000              'plain certificates too.</div></div>') +
1001
1002        certCard('prints', 'E', 'What prints',
1003          'Every course. These apply retrospectively: the certificate is drawn fresh every time it is opened.',
1004          '<div class="form-stack">' + CERT_ELEMENTS.map(function (e) {
1005            return '<div class="field cert-el-row">' +
1006              '<label class="cert-el-label"><input type="checkbox" data-ct-el="' + P.esc(e.key) + '"' +
1007                (els[e.key] !== false ? ' checked' : '') + '> <span>' + P.esc(e.label) + '</span></label>' +
1008              '<div class="hint">' + P.esc(e.hint) + '</div></div>';
1009          }).join('') + '</div>') +
1010
1011        certCard('email', 'F', 'Emailing',
1012          'Every course in your community. Delivery, not design. It does not change the certificate.',
1013          '<label class="cpg-check"><input type="checkbox" data-ct-email' + (S.email ? ' checked' : '') + '> ' +
1014          'Email members their certificate when they finish</label>' +
1015          '<div class="hint">The email links to the certificate rather than attaching it, so it always shows ' +
1016            'your current branding and carries the verification link. Members who have unsubscribed from ' +
1017            'your emails are never sent one.</div>') +
1018
1019        '<p class="cpg-msg" data-ct-msg style="display:none"></p>' +
1020      '</div>';
1021    }
1022
1023    function paintPanes() { panesHost.innerHTML = panesHtml(); wirePanes(); }
1024
1025    function paintRail() {
1026      railHost.innerHTML = railHtml();
1027      // Dictation attaches itself after every repaint — VoiceInput.attach is
1028      // idempotent per element, so a sweep cannot double-attach.
1029      _speakIt(railHost);
1030      wireRail();
1031    }
1032
1033    /* The pane switch — a copy of the Look step's `_goLookPane`, with its three
1034       behaviours: set the pane, rebuild the RAIL only, re-sync the chips. It
1035       does not touch `.pho-main`, which is the whole point: changing what you
1036       are controlling must never throw away the four seconds of Chrome that
1037       drew the certificate you are looking at. */
1038    function goCertPane(to) {
1039      if (!to || to === _certPane) return;
1040      if (!CERT_PANES.some(function (p) { return p === to; })) return;
1041      _certPane = to;
1042      paintRail();
1043      Array.prototype.forEach.call(el.querySelectorAll('[data-ct-jump]'), function (b) {
1044        var on = b.getAttribute('data-ct-jump') === _certPane;
1045        b.classList.toggle('on', on);
1046        if (on) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
1047      });
1048    }
1049
1050    function msgEl() { return el.querySelector('[data-ct-msg]'); }
1051    // ⚠️ THE CONFIRMATION NAMES THE SCOPE. This is the one of the four scope
1052    // signals that catches the coach at the exact moment of the mistake, and it
1053    // is the one that must not be dropped for brevity.
1054    function savedThisCourse() { P.setMsg(msgEl(), '✓ Saved for this course.', 'good'); }
1055    function savedEveryCourse() { P.setMsg(msgEl(), '✓ Saved for every course in your community.', 'good'); }
1056    /* ⚠️ THE ONE PLACE THIS SCREEN TELLS A COACH SOMETHING FAILED — so it is
1057       also the one place that must tell US. Nine call sites funnel through
1058       here, which is why the report goes in the choke point rather than in
1059       nine catch blocks that would drift apart within a month.
1060
1061       🚨 Nicola, 2026-08-18, setting up Deborah Wilder's course: *"Couldn't
1062       save photo: HTTP 404 … community_not_setup."* The coach saw it, the
1063       screenshot reached Alex, and NOTHING WAS RECORDED — a caught error shown
1064       as a message is invisible to a tracker that only watches uncaught ones.
1065       We learned of it because a human happened to be watching over her
1066       shoulder.
1067
1068       The message shown is unchanged. This only adds the telling. */
1069    function saveFailed(err) {
1070      P.setMsg(msgEl(), 'Couldn’t save: ' + ((err && err.code) || 'unknown'), 'bad');
1071      try {
1072        window.cbReportHandled && window.cbReportHandled(
1073          'course look save failed: ' + ((err && (err.code || err.message)) || 'unknown'),
1074          { action: 'course.look.save', status: err && err.status, error: err });
1075      } catch (_) { /* never let reporting break the screen it reports on */ }
1076    }
1077
1078    async function patchCourse(body) {
1079      await P.api('/community-hosting/courses/' + ctx.courseId, { method: 'PATCH', body: body });
1080    }
1081    async function patchCommunity(certPatch) {
1082      // The server merges key-by-key, so a one-field patch never blanks the
1083      // others — and a `{certificates:…}` body does NOT re-bake the published
1084      // community shell (community-hosting-routes only regenerates for
1085      // branding | theme | welcome | gamification | vault).
1086      await P.api('/community-hosting/settings', { method: 'PATCH', body: { certificates: certPatch } });
1087      S.cert = Object.assign({}, S.cert, certPatch);
1088    }
1089
1090    function wirePanes() {
1091      Array.prototype.forEach.call(panesHost.querySelectorAll('[data-ct-jump]'), function (b) {
1092        b.addEventListener('click', function () { goCertPane(b.getAttribute('data-ct-jump')); });
1093      });
1094    }
1095
1096    function wireRail() {
1097      Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-go]'), function (b) {
1098        b.addEventListener('click', function () { goCertPane(b.getAttribute('data-ct-go')); });
1099      });
1100
1101      // A — on/off. NOT a redraw: this decides whether a certificate is ever
1102      // issued, and it is not drawn on the certificate.
1103      var onChk = railHost.querySelector('[data-ct-on]');
1104      if (onChk) onChk.addEventListener('change', async function () {
1105        var want = !!onChk.checked;
1106        P.setMsg(msgEl(), 'Saving…');
1107        try {
1108          await patchCourse({ certificates_enabled: want });
1109          S.on = want; savedThisCourse(); paintPanes();
1110        } catch (e) { onChk.checked = !want; saveFailed(e); }
1111      });
1112
1113      // B — style. Two cards, not a select (U11.2): these ARE the old
1114      // "Preview plain" / "Preview branded" buttons, now that the preview is
1115      // on the page.
1116      Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-style]'), function (b) {
1117        b.addEventListener('click', async function () {
1118          var want = b.getAttribute('data-ct-style');
1119          if (want === S.style) return;
1120          var was = S.style;
1121          P.setMsg(msgEl(), 'Saving…');
1122          try {
1123            await patchCourse({ certificate_style: want });
1124            /* ⚠️ REPAINT FIRST, THEN SAY IT. `[data-ct-msg]` lives IN the rail,
1125               so setting the message before rebuilding the rail throws the
1126               confirmation away — and the confirmation is the one of the four
1127               scope signals that catches the coach at the moment of the
1128               mistake. Caught by tests/course-certificates-layout.test.js. */
1129            S.style = want; paintPanes(); paintRail(); savedThisCourse();
1130            preview.refresh();
1131          } catch (e) { S.style = was; saveFailed(e); }
1132        });
1133      });
1134
1135      // C — validity. It derives `expires_at`, so it changes the PDF.
1136      var val = railHost.querySelector('[data-ct-validity]');
1137      if (val) val.addEventListener('change', async function () {
1138        var want = parseInt(val.value, 10) || 0;
1139        var was = S.months;
1140        P.setMsg(msgEl(), 'Saving…');
1141        try {
1142          await patchCourse({ certificate_validity_months: want });
1143          S.months = want; savedThisCourse(); paintPanes();
1144          preview.refresh();
1145        } catch (e) { S.months = was; val.value = String(was); saveFailed(e); }
1146      });
1147
1148      // D — the brand. Text on blur, colour on change; never on keystroke,
1149      // because every save asks for a Chrome process.
1150      Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-field]'), function (f) {
1151        f.addEventListener('blur', async function () {
1152          var key = f.getAttribute('data-ct-field');
1153          var want = f.value.trim() || null;
1154          if ((S.cert[key] || null) === want) return;
1155          P.setMsg(msgEl(), 'Saving…');
1156          var patch = {}; patch[key] = want;
1157          try { await patchCommunity(patch); savedEveryCourse(); preview.refresh(); }
1158          catch (e) { saveFailed(e); }
1159        });
1160      });
1161      var accent = railHost.querySelector('[data-ct-accent]');
1162      if (accent) accent.addEventListener('change', async function () {
1163        var want = accent.value.trim() || null;
1164        if ((S.cert.accent_colour || null) === want) return;
1165        P.setMsg(msgEl(), 'Saving…');
1166        try {
1167          await patchCommunity({ accent_colour: want });
1168          var sw = railHost.querySelector('[data-ct-accent-preview]');
1169          if (sw) sw.style.background = want || (S.theme.accent_colour || '#6366f1');
1170          savedEveryCourse(); preview.refresh();
1171        } catch (e) {
1172          P.setMsg(msgEl(), (e && e.code === 'invalid_colour')
1173            ? 'That doesn’t look like a colour. Use a hex code like #6366f1.'
1174            : 'Couldn’t save. Please try again.', 'bad');
1175        }
1176      });
1177
1178      // Image picking keeps its existing path exactly: the community media
1179      // rail, then a one-field settings patch.
1180      async function saveImage(kind, pickedUrl) {
1181        P.setMsg(msgEl(), pickedUrl ? 'Uploading…' : 'Removing…');
1182        try {
1183          var finalUrl = '';
1184          if (pickedUrl) {
1185            var ing = await P.api('/community-hosting/posts/media', { method: 'POST', body: { source_url: pickedUrl } });
1186            finalUrl = (ing && ing.url) || '';
1187            if (!finalUrl) throw new Error('upload_failed');
1188          }
1189          var field = kind === 'logo' ? 'logo_url' : 'signature_image_url';
1190          var patch = {}; patch[field] = finalUrl || null;
1191          await patchCommunity(patch);
1192          // Repaint before the confirmation — `[data-ct-msg]` is in the rail.
1193          paintPanes(); paintRail(); savedEveryCourse();
1194          preview.refresh();
1195        } catch (e) { saveFailed(e); }
1196      }
1197      Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-pick]'), function (b) {
1198        b.addEventListener('click', function () {
1199          var kind = b.getAttribute('data-ct-pick');
1200          if (typeof window.openMediaLibrary !== 'function') {
1201            P.setMsg(msgEl(), 'Media library isn’t loaded. Refresh the page and retry.', 'bad');
1202            return;
1203          }
1204          window.openMediaLibrary({ type: 'asset-callback', callback: function (url) { if (url) saveImage(kind, url); } });
1205        });
1206      });
1207      Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-clear]'), function (b) {
1208        b.addEventListener('click', function () { saveImage(b.getAttribute('data-ct-clear'), ''); });
1209      });
1210
1211      /* ══ §CERT-SIGNATURE.1 — draw it, or type it ══════════════════════
1212         Both end in the SAME place the upload ends: a PNG in the media store
1213         (posts/media accepts a data_url — measured, not assumed) and its URL
1214         in signature_image_url. The canvas is transparent, so the signature
1215         sits on the certificate's paper rather than in a white box. */
1216      function _sigBox() { return railHost.querySelector('[data-ct-sig-box]'); }
1217
1218      async function saveSignatureDataUrl(dataUrl, btn) {
1219        if (btn) btn.disabled = true;
1220        P.setMsg(msgEl(), 'Saving your signature…');
1221        try {
1222          var ing = await P.api('/community-hosting/posts/media', {
1223            method: 'POST', body: { data_url: dataUrl },
1224          });
1225          var url = (ing && ing.url) || '';
1226          if (!url) throw new Error('upload_failed');
1227          await patchCommunity({ signature_image_url: url });
1228          paintPanes(); paintRail(); savedEveryCourse();
1229          preview.refresh();
1230        } catch (e) { if (btn) btn.disabled = false; saveFailed(e); }
1231      }
1232
1233      /* One handwriting face, loaded on demand. Rasterised into the PNG, so
1234         nothing downstream (the PDF's headless Chrome included) ever needs
1235         the font — the image is the signature. */
1236      function _sigFontReady() {
1237        if (!document.getElementById('cert-sig-font')) {
1238          var l = document.createElement('link');
1239          l.id = 'cert-sig-font'; l.rel = 'stylesheet';
1240          l.href = 'https://fonts.googleapis.com/css2?family=Caveat:wght@600&display=swap';
1241          document.head.appendChild(l);
1242        }
1243        try { return document.fonts.load('600 72px Caveat').then(function () {}); }
1244        catch (_) { return Promise.resolve(); }
1245      }
1246
1247      function _sigCanvas() {
1248        var cv = document.createElement('canvas');
1249        cv.width = 1200; cv.height = 360;   // 2× for a crisp print
1250        cv.style.width = '100%'; cv.style.maxWidth = '600px'; cv.style.height = 'auto';
1251        cv.style.border = '1px dashed var(--border-strong)'; cv.style.borderRadius = '10px';
1252        cv.style.background = 'repeating-linear-gradient(0deg, transparent, transparent 8px, transparent 8px)';
1253        cv.style.touchAction = 'none';
1254        return cv;
1255      }
1256
1257      function _openDraw() {
1258        var box = _sigBox(); if (!box) return;
1259        box.hidden = false;
1260        box.innerHTML = '<div class="hint" style="margin-bottom:6px">Sign in the box: mouse or finger.</div>';
1261        var cv = _sigCanvas();
1262        box.appendChild(cv);
1263        var g = cv.getContext('2d');
1264        g.lineWidth = 7; g.lineCap = 'round'; g.lineJoin = 'round'; g.strokeStyle = '#1a1a2e';
1265        var drawing = false, drew = false;
1266        function pt(ev) {
1267          var r = cv.getBoundingClientRect();
1268          return { x: (ev.clientX - r.left) * (cv.width / r.width), y: (ev.clientY - r.top) * (cv.height / r.height) };
1269        }
1270        cv.addEventListener('pointerdown', function (ev) {
1271          drawing = true; drew = true; cv.setPointerCapture(ev.pointerId);
1272          var p = pt(ev); g.beginPath(); g.moveTo(p.x, p.y); g.lineTo(p.x + 0.1, p.y + 0.1); g.stroke();
1273        });
1274        cv.addEventListener('pointermove', function (ev) {
1275          if (!drawing) return;
1276          var p = pt(ev); g.lineTo(p.x, p.y); g.stroke();
1277        });
1278        cv.addEventListener('pointerup', function () { drawing = false; g.beginPath(); });
1279        var row = document.createElement('div');
1280        row.className = 'cpg-item-actions'; row.style.marginTop = '8px';
1281        row.innerHTML =
1282          '<button class="btn btn-primary cpg-btn-tight" type="button" data-sig-use>Use this signature</button>' +
1283          '<button class="btn btn-secondary cpg-btn-tight" type="button" data-sig-clear>Clear</button>' +
1284          '<button class="btn btn-secondary cpg-btn-tight" type="button" data-sig-cancel>Cancel</button>';
1285        box.appendChild(row);
1286        row.querySelector('[data-sig-clear]').addEventListener('click', function () {
1287          g.clearRect(0, 0, cv.width, cv.height); drew = false;
1288        });
1289        row.querySelector('[data-sig-cancel]').addEventListener('click', function () { box.hidden = true; box.innerHTML = ''; });
1290        row.querySelector('[data-sig-use]').addEventListener('click', function () {
1291          if (!drew) { P.setMsg(msgEl(), 'Nothing drawn yet. Sign in the box first.', 'bad'); return; }
1292          saveSignatureDataUrl(cv.toDataURL('image/png'), this);
1293        });
1294      }
1295
1296      function _openType() {
1297        var box = _sigBox(); if (!box) return;
1298        box.hidden = false;
1299        box.innerHTML =
1300          '<div class="hint" style="margin-bottom:6px">Type your name and it becomes handwriting.</div>' +
1301          '<input class="input" data-sig-text maxlength="60" placeholder="' +
1302            P.esc(S.cert.signature_name || 'Your name') + '" style="max-width:340px">';
1303        var cv = _sigCanvas();
1304        cv.style.marginTop = '8px';
1305        box.appendChild(cv);
1306        var g = cv.getContext('2d');
1307        var input = box.querySelector('[data-sig-text]');
1308        function paintText() {
1309          g.clearRect(0, 0, cv.width, cv.height);
1310          var text = (input.value || '').trim();
1311          if (!text) return;
1312          g.fillStyle = '#1a1a2e'; g.textAlign = 'center'; g.textBaseline = 'middle';
1313          // Fit: shrink from 150px until it fits with margin.
1314          var size = 150;
1315          do { g.font = '600 ' + size + 'px Caveat, cursive'; size -= 8; }
1316          while (g.measureText(text).width > cv.width - 120 && size > 40);
1317          g.fillText(text, cv.width / 2, cv.height / 2);
1318        }
1319        _sigFontReady().then(paintText);
1320        input.addEventListener('input', paintText);
1321        var row = document.createElement('div');
1322        row.className = 'cpg-item-actions'; row.style.marginTop = '8px';
1323        row.innerHTML =
1324          '<button class="btn btn-primary cpg-btn-tight" type="button" data-sig-use>Use this signature</button>' +
1325          '<button class="btn btn-secondary cpg-btn-tight" type="button" data-sig-cancel>Cancel</button>';
1326        box.appendChild(row);
1327        row.querySelector('[data-sig-cancel]').addEventListener('click', function () { box.hidden = true; box.innerHTML = ''; });
1328        row.querySelector('[data-sig-use]').addEventListener('click', function () {
1329          if (!(input.value || '').trim()) { P.setMsg(msgEl(), 'Type your name first.', 'bad'); return; }
1330          saveSignatureDataUrl(cv.toDataURL('image/png'), this);
1331        });
1332        input.focus();
1333      }
1334
1335      var sigDraw = railHost.querySelector('[data-ct-sig-draw]');
1336      if (sigDraw) sigDraw.addEventListener('click', _openDraw);
1337      var sigType = railHost.querySelector('[data-ct-sig-type]');
1338      if (sigType) sigType.addEventListener('click', _openType);
1339
1340      // E — what prints. `show` in _certificateHtml, so every one redraws.
1341      Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-el]'), function (box) {
1342        box.addEventListener('change', async function () {
1343          var want = !!box.checked;
1344          /* ⚠️ BUILT FROM ALL SIX BOXES, NOT FROM THE ONE THAT MOVED. Five taps
1345             land as five change events before the first PATCH has resolved, so
1346             a patch merged against `S.cert.elements` carries the state from
1347             before the burst — and the last one to arrive wins, silently
1348             undoing the other four. Reading the boxes is reading the truth the
1349             coach is looking at. Same shape as the Look step's footer switches.
1350             Caught by tests/course-certificates-layout.test.js. */
1351          var els = {};
1352          Array.prototype.forEach.call(railHost.querySelectorAll('[data-ct-el]'), function (x) {
1353            els[x.getAttribute('data-ct-el')] = !!x.checked;
1354          });
1355          P.setMsg(msgEl(), 'Saving…');
1356          try {
1357            await patchCommunity({ elements: Object.assign({}, S.cert.elements, els) });
1358            savedEveryCourse(); paintPanes();
1359            preview.refresh();
1360          } catch (e) { box.checked = !want; saveFailed(e); }
1361        });
1362      });
1363
1364      // F — emailing. Delivery, not design: no redraw.
1365      var emailChk = railHost.querySelector('[data-ct-email]');
1366      if (emailChk) emailChk.addEventListener('change', async function () {
1367        var want = !!emailChk.checked;
1368        P.setMsg(msgEl(), 'Saving…');
1369        try {
1370          await patchCommunity({ email_on_completion: want });
1371          S.email = want; savedEveryCourse(); paintPanes();
1372        } catch (e) { emailChk.checked = !want; saveFailed(e); }
1373      });
1374    }
1375
1376    paintPanes();
1377    paintRail();
1378
1379    Array.prototype.forEach.call(el.querySelectorAll('[data-ct-copy]'), function (b) {
1380      b.addEventListener('click', function () {
1381        var url = b.getAttribute('data-ct-copy');
1382        if (navigator.clipboard && navigator.clipboard.writeText) {
1383          navigator.clipboard.writeText(url).then(function () { P.toast('✓ Verification link copied'); },
1384            function () { P.toast('Couldn’t copy. Select the link above instead'); });
1385        } else {
1386          P.toast('Couldn’t copy. Select the link above instead');
1387        }
1388      });
1389    });
1390
1391    // Handed back for a caller that wants to tear the mount down explicitly.
1392    // `_manageOpen` does not await this, so the mount ALSO defends itself: a
1393    // response that lands after the panel has been replaced is dropped by the
1394    // `isConnected` check in show(), rather than painting into a detached frame.
1395    return { destroy: function () { preview.destroy(); } };
1396  }
1397
1398  // One issued certificate. The verification link is on the ROW, not just
1399  // in the panel above, because the coach's real use for it is "send this
1400  // person's proof to someone" — one certificate at a time.
1401  function _issuedRowHtml(c) {
1402    /* Finished, certificate not opened yet (the one list in
1403       course-certificate-service): theirs, made the first time they open it. */
1404    if (c.pending) {
1405      return '<div class="cpg-item">' +
1406        '<div class="cpg-item-ico">🎓</div>' +
1407        '<div class="cpg-item-body">' +
1408          '<div class="cpg-item-title">' + P.esc(c.member_name || 'Member') + '</div>' +
1409          '<div class="cpg-item-meta">Finished' + (c.finished_at ? ' ' + P.esc(P.fmtDate(c.finished_at)) : '') +
1410            '. Their certificate is made the first time they open it.</div>' +
1411        '</div>' +
1412      '</div>';
1413    }
1414    var expiryBit = '';
1415    if (c.expires_at) {
1416      var past = new Date(c.expires_at).getTime() <= Date.now();
1417      expiryBit = ' · ' + (past ? 'expired ' : 'valid until ') + P.esc(P.fmtDate(c.expires_at));
1418    }
1419    // Whether the email actually went is worth showing: it is the one part
1420    // of this that leaves the platform and can fail.
1421    var emailBit = c.completion_email_sent_at
1422      ? ' · emailed ' + P.esc(P.fmtDate(c.completion_email_sent_at))
1423      : '';
1424    // The actions live INSIDE the body, not as a third flex child of
1425    // .cpg-item. `.cpg-item` is a non-wrapping flex row, so a sibling
1426    // action block never drops below on a phone — it just crushes the
1427    // meta text into a two-word column. Nested, it wraps under the title
1428    // exactly as the .cpg-item-actions comment in course-page.css intends.
1429    return '<div class="cpg-item">' +
1430      '<div class="cpg-item-ico">🎓</div>' +
1431      '<div class="cpg-item-body">' +
1432        '<div class="cpg-item-title">' + P.esc(c.member_name || 'Member') + '</div>' +
1433        '<div class="cpg-item-meta">' + P.esc(P.fmtDate(c.issued_at)) + ' · ' +
1434          (c.style === 'branded' ? 'Branded' : 'Plain') + expiryBit + emailBit + '</div>' +
1435        '<div class="cpg-item-meta">No. ' + P.esc(c.certificate_no || '') + '</div>' +
1436        '<div class="cpg-item-actions">' +
1437          '<a class="btn btn-secondary" href="' + P.esc(c.verify_url) + '" target="_blank" rel="noopener">Verify</a>' +
1438          '<button class="btn btn-secondary" type="button" data-ct-copy="' + P.esc(c.verify_url) + '">Copy link</button>' +
1439        '</div>' +
1440      '</div>' +
1441    '</div>';
1442  }
1443
1444  // ══ SETTINGS — DOMAIN + EMAIL, AND NOTHING ELSE ════════════════════
1445  //
1446  // U16.4/U16.5. Alex: *"Really what we should probably have only in the
1447  // settings is: Domain settings · Domain and email settings. The domain
1448  // settings would include the favicon settings and email settings will just
1449  // show what emails are set to go out."*
1450  //
1451  // WHAT LEFT THIS SCREEN, AND WHERE EACH THING WENT. Every row below was
1452  // traced to a second writer before it was cut, because a control deleted
1453  // from the only screen that could set it is a feature deleted
1454  // (SPEC-U16-settings-and-emails.md §2):
1455  //   · Cover image URL → GONE. The Look step's cover pane is a real picker
1456  //     with a focal point (`PATCH { cover_image_url }`, wizard:3145); this
1457  //     was a bare text box that could silently desync the crop.
1458  //   · Title + description → the Plan editor's Overview box, reachable from
1459  //     Manage → Structure.
1460  //   · Access, level, product → ⚠️ CORRECTED BY THE DOOR AUDIT (F2,
1461  //     2026-08-05). This line used to send people to "the Go-live 'Who gets
1462  //     in' card" for access changes — but the board card opens the go-live
1463  //     SETUP (product, web address, publish), which by design never re-asks
1464  //     the access answer; access is only ASKED in the guide's "Who gets in"
1465  //     step, and its writer PUBLISHES (publishFromGuide,
1466  //     course-creation-service:2652). The card is now labelled "Selling &
1467  //     publishing"; a standing access editor reachable from Manage is parked
1468  //     as a build.
1469  //   · Drip + discussion → kept reachable from the Structure editor.
1470  //   · Cohorts button → GONE. Manage already lists "Community & cohorts" and
1471  //     "1:1 sessions" as their own rows. A doorway to a doorway.
1472  //   · The assistant, its sources, its log, and nudges → the Manage board's
1473  //     AI block (`App.CourseAiTab` below), which is now the ONE control panel
1474  //     for every AI tool on the course. 🚨 They MOVED, they were not deleted.
1475  //     ⚠️ This comment used to say `classroom.tools.tutor` "is read by
1476  //     nothing". That is no longer true — course-assistant-service:865 throws
1477  //     `assistant_disabled` on it, alongside the `assistant_enabled` column at
1478  //     :854, so the Tutor has TWO enforced switches and the gate is their AND.
1479  //     The AI panel writes both from one tick; see its header.
1480  //
1481  // 🚨 THE FACT THAT HAS TO BE ON THE SCREEN. `ensureCourseSite` resolves the
1482  // site by `type='course_site' AND settings->>'course_design' = <design>`
1483  // (course-template-bridge:683-740). The site is per DESIGN, not per course,
1484  // so an address or a favicon set here applies to every course on this
1485  // design. The Look step already says exactly this about the footer, so the
1486  // domain panel says it in the same words rather than inventing new ones.
1487
1488  async function renderSettings(el, ctx) {
1489    el.innerHTML =
1490      /* §AUDIT-GAPS.1 — the page says what it is (the Live-sessions grammar,
1491         his "this page needs the same treatment"). */
1492      '<div class="cs-edu"><span class="ei" aria-hidden="true">🌐</span><div>' +
1493        '<b>This is the course site\u2019s plumbing.</b> Its web address, how its emails look, ' +
1494        'and the credit line: each one set once, for every course on this design.' +
1495      '</div></div>' +
1496      '<div class="panel cpg-panel">' +
1497        '<div class="panel-head">' +
1498          '<div class="cpg-section-title">Web address</div>' +
1499          '<div class="cpg-section-sub">Where this course lives on the internet, and the little icon in the browser tab. ' +
1500            'Like the footer, the answer sticks for the whole course <b>site</b> (every course on this design).</div>' +
1501        '</div>' +
1502        '<div class="panel-body"><div data-st-domain>' +
1503          '<div class="cpg-section-sub">Loading your address…</div>' +
1504        '</div></div>' +
1505      '</div>' +
1506
1507      /* ⚠️ A POINTER, NOT A SECOND MOUNT (door audit F1, 2026-08-05). This panel
1508         used to mount the full CourseEmailsTab inline, while the Manage rail's
1509         own "Emails" row mounted the exact same tool — two differently-labelled
1510         doors to two copies of one screen, and Alex hit it. Settings keeps
1511         NAMING emails (his U16 spec: settings "will just show what emails are
1512         set to go out") but the one full mount now lives behind the rail row,
1513         and this button goes there.
1514
1515         §CORE-SETTINGS (2026-08-06) — named "Email design", his words: *"the
1516         email settings should be called email design or email set. Email
1517         design is more the main thing here."* */
1518      /* §AUDIT-GAPS.1 — his words: *"I don't like the fact that you have to
1519         click on a button and the email opens up in another page. The email
1520         shouldn't be just an add-on."* So the DESIGN CHOOSER now embeds HERE,
1521         in place. ⚠️ This does NOT reopen door-audit F1: F1's fault was two
1522         full copies of the whole Emails screen behind different labels; this
1523         is the single-purpose design tool with two hosts (the sessions
1524         calendar's own sanctioned pattern), and the full catalogue still
1525         lives only on the Emails screen — the quiet door below goes there. */
1526      '<div class="panel cpg-panel">' +
1527        '<div class="panel-head">' +
1528          '<div class="cpg-section-title">Email design</div>' +
1529          '<div class="cpg-section-sub">The shell every email from this course renders in: logo, colours, layout. ' +
1530            'Which emails go out lives on the <b>Emails</b> screen: <a href="#" data-st-emails-go>see every email this course sends →</a></div>' +
1531        '</div>' +
1532        '<div class="panel-body"><div data-st-emaildesign>' +
1533          '<div class="cpg-section-sub">Loading the email design tool…</div>' +
1534        '</div></div>' +
1535      '</div>' +
1536
1537      /* ⚠️ "POWERED BY REPLICANTS" LIVES HERE NOW (2026-08-06). Alex, cutting
1538         the footer pane to a single panel: *"The 'powered by replicants' I
1539         think we should put in the settings area not in the footer. In the core
1540         settings area we remove the 'powered by replicants' but we take it off
1541         from here."*
1542
1543         It is the ONE footer switch that survived the cut — the footer itself,
1544         the business name, the address and the terms are all mandatory now
1545         (`footerShow`, services/site-chrome/course-footer.js). It writes
1546         through the SAME route the footer pane used, so there is one store and
1547         one meaning for the answer; only the door moved.
1548
1549         Like everything else on this screen it is per SITE, not per course —
1550         every course on this design shares one footer — and the copy says so
1551         rather than letting a coach discover it. */
1552      '<div class="panel cpg-panel">' +
1553        '<div class="panel-head">' +
1554          '<div class="cpg-section-title">The credit line</div>' +
1555          '<div class="cpg-section-sub">A quiet <b>Powered by Replicants</b> at the end of your footer. ' +
1556            'Like the web address, the answer sticks for the whole course <b>site</b>.</div>' +
1557        '</div>' +
1558        '<div class="panel-body"><div data-st-powered>' +
1559          '<div class="cpg-section-sub">Loading…</div>' +
1560        '</div></div>' +
1561      '</div>' +
1562
1563      /* U24.5's dictation-history panel is GONE FROM HERE — §CORE-SETTINGS
1564         (2026-08-06), Alex: *"Your dictation history, this tool that we built,
1565         is a dictation history for the entire platform. That should have its
1566         own settings in the settings area."* It now lives at Settings →
1567         Dictation history (js/dictation-settings.js) — the right door, since
1568         /voice-history never took a course id. The "looking for" panel below
1569         carries the pointer so a coach who remembers it here still finds it. */
1570
1571      // Everything that used to be here still has a home, and the screen names
1572      // it. A coach who opens Settings looking for the title should not have to
1573      // guess; "it moved" with no destination is how a control becomes lost.
1574      //
1575      // ⚠️ DOORS, NOT PROSE (door audit F7). This panel used to NAME four
1576      // destinations in plain text — inside a screen that has HIDDEN the board
1577      // those destinations live on, so the coach had to back out and hunt. The
1578      // Structure / Look / Selling & publishing cards are on the board behind
1579      // this panel, so those links are `ctx.back()`; the AI panel is a Manage
1580      // row, so that one is `ctx.goTab('ai')`. ("Who gets in" became "your
1581      // product and web address … Selling & publishing" with the F2 relabel —
1582      // that card opens the go-live setup, not an access editor.)
1583      /* b17b (C42, round 10 b job 6): on the course home there is no board,
1584         and "Structure", "Look" and "the AI section" are not tabs. There the
1585         doors name the home's own tabs and cards. */
1586      (ctx && ctx.inHome
1587      ? '<div class="panel cpg-panel"><div class="panel-body">' +
1588        '<div class="cpg-section-sub">Looking for something else? ' +
1589          'The <b>name and one line about it</b> are on the <a href="#" data-st-go="home:settings">Settings</a> tab under About the course, ' +
1590          'the <b>cover photo</b> and the rest of the look are under Look on the same tab, ' +
1591          '<b>your price and ways to pay</b> are on the <a href="#" data-st-go="home:selling">Selling</a> tab, ' +
1592          'the <b>AI tutor</b> is under Extras on the Settings tab, ' +
1593          'and <b>your dictation history</b> is in <a href="#" data-st-go="dictation">Maya’s Brain → Her sources</a>.</div>' +
1594      '</div></div>'
1595      : '<div class="panel cpg-panel"><div class="panel-body">' +
1596        '<div class="cpg-section-sub">Looking for something else? ' +
1597          'The <b>title and description</b> are in <a href="#" data-st-go="back">Structure</a>, ' +
1598          'the <b>cover photo</b> and the rest of the look are in <a href="#" data-st-go="back">Look</a>, ' +
1599          '<b>your product and web address</b> are in <a href="#" data-st-go="back">Selling &amp; publishing</a>, ' +
1600          'the <b>AI tutor and nudges</b> are in the <a href="#" data-st-go="ai">AI section</a>, ' +
1601          'and <b>your dictation history</b> is in <a href="#" data-st-go="dictation">Maya’s Brain → Her sources</a>.</div>' +
1602      '</div></div>');
1603
1604    _speakIt(el);
1605    _mountDomain(el.querySelector('[data-st-domain]'), ctx);
1606    _mountPowered(el.querySelector('[data-st-powered]'), ctx);
1607
1608    // The Emails door (F1's pointer, now a quiet link in the panel sub).
1609    // `ctx.goTab('emails')` opens the rail row that holds the full catalogue;
1610    // guarded because some hosts hand a ctx without goTab.
1611    var emailsGo = el.querySelector('[data-st-emails-go]');
1612    if (emailsGo) emailsGo.addEventListener('click', function (e) {
1613      e.preventDefault();
1614      if (ctx && typeof ctx.goTab === 'function') ctx.goTab('emails');
1615    });
1616
1617    // §AUDIT-GAPS.1 — the embedded design chooser: the same community tool
1618    // the Emails screen's tab mounts, hosted here in place. Course context
1619    // rides so its live preview wears THIS course's branding.
1620    var edHost = el.querySelector('[data-st-emaildesign]');
1621    if (edHost && typeof window.wireCommunityEmails === 'function') {
1622      try {
1623        window.wireCommunityEmails(edHost, {
1624          tabs: ['design'],
1625          course: { id: (ctx && ctx.courseId) || null, title: (ctx && ctx.course && ctx.course.title) || '' },
1626          heading: 'Email design',
1627          // The course page already says where this is (round 6: two breadcrumbs).
1628          crumbs: false,
1629          sub: 'One design for every email your community and this course send. The live preview shows it with this course\u2019s branding.',
1630        });
1631      } catch (err) {
1632        edHost.innerHTML = '<div class="cpg-section-sub">Could not open the email design tool just now. Reload and try again.</div>';
1633      }
1634    } else if (edHost) {
1635      edHost.innerHTML = '<div class="cpg-section-sub">The email tool has not loaded on this page. Reload and try again.</div>';
1636    }
1637
1638    // The "Looking for something else?" doors (F7): back() reveals the board
1639    // the cards live on; 'ai' opens the AI panel row. Guarded the same way as
1640    // the emails button above.
1641    Array.prototype.forEach.call(el.querySelectorAll('[data-st-go]'), function (a) {
1642      a.addEventListener('click', function (e) {
1643        e.preventDefault();
1644        var t = a.getAttribute('data-st-go');
1645        if (t.indexOf('home:') === 0) {
1646          if (ctx && typeof ctx.homeTab === 'function') ctx.homeTab(t.slice(5));
1647          return;
1648        }
1649        if (t === 'ai') {
1650          if (ctx && typeof ctx.goTab === 'function') ctx.goTab('ai');
1651          return;
1652        }
1653        // §CORE-SETTINGS — the moved dictation history. A real door to the
1654        // platform Settings page it now lives on, not prose about one.
1655        if (t === 'dictation') {
1656          if (typeof window.navigateToSettings === 'function') window.navigateToSettings('settings-dictation');
1657          return;
1658        }
1659        if (ctx && typeof ctx.back === 'function') ctx.back();
1660      });
1661    });
1662  }
1663
1664  /**
1665   * The credit line — the one footer switch that survived the 2026-08-06 cut.
1666   *
1667   * ⚠️ READS AND WRITES THE FOOTER'S OWN STORE, not a new one. `GET .../guide/footer`
1668   * returns the resolved footer including `show.powered`, and the PUT with a
1669   * `show` body is the same call the footer pane made. A second store for one
1670   * boolean is how a switch and a page end up disagreeing.
1671   *
1672   * Absent reads as ON, matching `footerShow`: the credit has always been on
1673   * unless a coach said otherwise, and a missing key must not read as a
1674   * decision they never made.
1675   */
1676  async function _mountPowered(mount, ctx) {
1677    if (!mount || !ctx || !ctx.courseId) return;
1678    var on = true;
1679    try {
1680      var r = await P.api('/community-hosting/courses/' + ctx.courseId + '/guide/footer');
1681      var show = (r && r.footer && r.footer.show) || {};
1682      on = show.powered !== false;
1683    } catch (e) {
1684      /* A course with no design yet has no site and so no footer to read
1685         (`no_design_yet`). Saying so beats a switch that looks live and cannot
1686         save — the same rule the footer pane follows. */
1687      mount.innerHTML = '<div class="cpg-section-sub">Pick a look for this course first: ' +
1688        'the credit line is part of its footer.</div>';
1689      return;
1690    }
1691    mount.innerHTML =
1692      '<label class="cpg-toggle" style="display:flex;gap:10px;align-items:center;min-height:34px">' +
1693        '<input type="checkbox" data-st-powered-cb' + (on ? ' checked' : '') + '>' +
1694        '<span>Show <b>Powered by Replicants</b> in the footer</span>' +
1695      '</label>' +
1696      '<div class="cpg-section-sub" data-st-powered-msg style="margin-top:6px"></div>';
1697    var cb = mount.querySelector('[data-st-powered-cb]');
1698    var msg = mount.querySelector('[data-st-powered-msg]');
1699    cb.addEventListener('change', async function () {
1700      var want = cb.checked;
1701      cb.disabled = true;
1702      if (msg) msg.textContent = 'Saving…';
1703      try {
1704        await P.api('/community-hosting/courses/' + ctx.courseId + '/guide/footer',
1705          { method: 'PUT', body: { show: { powered: want } } });
1706        if (msg) msg.textContent = want ? '\u2713 Shown in your footer.' : '\u2713 Hidden.';
1707      } catch (e) {
1708        // The switch goes back where it was: a control that stays where you put
1709        // it after a failed save is a control that lies about the answer.
1710        cb.checked = !want;
1711        if (msg) msg.textContent = 'Couldn\u2019t save that just now.';
1712      } finally { cb.disabled = false; }
1713    });
1714  }
1715
1716  // The Go-live sequence's address step, mounted here rather than rebuilt.
1717  // It already talks to `GET /courses/:id/guide/domain` and the platform's
1718  // `/api/domains` endpoints; there is no second domain implementation and
1719  // this file must not become one.
1720  function _mountDomain(mount, ctx) {
1721    if (!mount) return;
1722    var panel = App.CourseAddressPanel;
1723    if (panel && typeof panel.mount === 'function') {
1724      try { panel.mount(mount, ctx); return; }
1725      catch (err) { console.warn('[course-settings] address mount threw —', err && err.message); }
1726    }
1727    // ⚠️ Names the wizard step, so it moves with STEP_LABELS (renamed 2026-08-19).
1728    mount.innerHTML = '<div class="cpg-section-sub">Your web address and favicon are set in the ' +
1729      '<b>Publishing</b> sequence, on the Web address step.</div>';
1730  }
1731
1732  // (`_mountEmails` is gone with the duplicate mount — door audit F1. The one
1733  // full mount of CourseEmailsTab is the Manage rail's Emails row; Settings
1734  // points there with the data-st-emails-go button above.)
1735
1736  // ══ AI — ONE CONTROL PANEL FOR EVERY AI TOOL ON THIS COURSE ════════
1737  //
1738  // Alex, 2026-08-05: *"There should be one place where you're controlling all
1739  // the AI tools… one control panel… It should be the area where they're all
1740  // summarised and there's a simple on/off switch for each one… You can't be
1741  // hunting around in different places for them."*
1742  //
1743  // The switches were in TWO places: the Classroom board (six tools in
1744  // `classroom.tools`, PUT /courses/:id/classroom) and this tab (the enforced
1745  // columns `assistant_enabled` / `nudges_enabled`, PATCH /courses/:id). This
1746  // panel is the ONE place. It does not invent a store: every row still writes
1747  // the exact key the service that does the work already reads.
1748  //
1749  // 🚨 THE TUTOR HAD TWO SWITCHES AND THEY COULD DISAGREE.
1750  // `community_courses.assistant_enabled` (course-assistant-service:854) and
1751  // `classroom.tools.tutor` (course-assistant-service:865) are BOTH enforced,
1752  // and the runtime gate is the AND of them — so a coach could switch one off,
1753  // the other on, and get a Tutor that refuses with no visible reason. There is
1754  // ONE Tutor row here and it writes BOTH, so from this panel they can never
1755  // drift apart. The tick shows the EFFECTIVE state (the same AND the gate
1756  // computes), never one store's opinion of it.
1757  //
1758  // ⚠️ Write order is chosen so a half-failed save can never lie:
1759  //   turning ON  — column first, then the board. If the second write fails the
1760  //                 Tutor is still off, which is exactly what the reverted tick
1761  //                 then shows.
1762  //   turning OFF — board first, then the column. Either write alone already
1763  //                 closes the gate, so the coach's "off" is true immediately.
1764
1765  /** The rows the panel draws that are NOT on the classroom board. */
1766  var COLUMN_ROWS = [
1767    {
1768      key: 'nudges',
1769      name: 'Gentle nudges',
1770      blurb: 'One email to a member who has gone quiet, pointing at the lesson they stopped on.',
1771    },
1772  ];
1773
1774  /**
1775   * What using this tool costs, said ON the switch. A control that quietly
1776   * authorises spending is not a control. Only the three tools that reach
1777   * `aiCall` have a note; the rest are queries and emails and must not be made
1778   * to sound expensive.
1779   */
1780  function _costNote(key) {
1781    if (key === 'tutor') {
1782      return 'Every question a member asks is one AI request, charged to your account. '
1783        + 'Switched off, questions are refused straight away. Nothing reaches a model and nothing is charged.';
1784    }
1785    if (key === 'checks') return CHECKS_COST_NOTE;
1786    if (key === 'sticking_points') {
1787      return 'The report itself is free. Asking it for written suggestions is one AI request, '
1788        + 'charged to your account.';
1789    }
1790    return null;
1791  }
1792
1793  // ══ THE NUDGE WINDOW — one question about their course, not an integer ═
1794  //
1795  // SPEC-U21 §3. This row used to carry
1796  // `<input type="number" min="1" max="90">` under the label *"Nudge after
1797  // this many days of no activity"*. Alex's objection is U21.4 in one line:
1798  // nothing in a coach's expertise answers "how many days", so the box is a
1799  // pause pretending to be a control.
1800  //
1801  // 🚨 IT IS THE SAME COLUMN AND THE SAME SAVE. The choices below still
1802  // `PATCH { nudge_inactive_days }` on the existing course endpoint, which is
1803  // the value `course-nudges-service.computeNudgesDue` binds as `$3::int` in
1804  // the due-query. Changing the CONTROL and not the STORE is what keeps this a
1805  // UX change rather than a second way to set the same thing.
1806  //
1807  // ⚠️ THE SET AND THE RECOMMENDATION BOTH COME FROM THE SERVER
1808  // (`GET /courses/:id/classroom` → `nudge`), never from a default this file
1809  // invents. Two places computing a recommendation is two places to drift, and
1810  // the server is the one that can read the course's release cadence. If the
1811  // block is absent — an older server — the row draws its switch and no window
1812  // control at all, rather than making up a list of days.
1813  //
1814  // ⚠️ And the coach's CURRENT value is always one of the choices, server-side
1815  // (`nudgeChoices`), so a coach who typed 12 into the old box still sees 12
1816  // selected instead of watching this screen quietly move them to 10.
1817
1818  function _nudgeChoiceHtml(n, days) {
1819    var on = Number(n.current) === days;
1820    var rec = n.recommended != null && Number(n.recommended) === days;
1821    return '<button type="button" class="btn btn-sm' + (on ? '' : ' btn-secondary') + '"' +
1822      ' data-ai-nudge-days="' + days + '" aria-pressed="' + (on ? 'true' : 'false') + '"' +
1823      ' style="margin:0 8px 8px 0">' + days + ' days' +
1824      (rec ? '<span data-ai-nudge-rec> · recommended</span>' : '') +
1825    '</button>';
1826  }
1827
1828  function _nudgeWindowHtml(n, on) {
1829    if (!n || !Array.isArray(n.choices) || !n.choices.length) return '';
1830    // The basis, in the coach's own course's terms. A recommendation with no
1831    // visible reason is decoration — the same rule booking-setup-advisor
1832    // follows, and the same reason it returns nothing when it cannot read one.
1833    var basis = n.reason
1834      ? '<div class="cpg-section-sub" data-ai-nudge-why><b>Why ' + P.esc(String(n.recommended)) +
1835          ' days:</b> ' + P.esc(n.reason) + '</div>'
1836      : (n.missing
1837        ? '<div class="cpg-section-sub" data-ai-nudge-why>' + P.esc(n.missing) +
1838            ' Five days is a starting point rather than a suggestion.</div>'
1839        : '');
1840    return '<div class="field" data-ai-nudge-days-wrap style="margin:0 0 10px 30px;' +
1841        (on ? '' : 'display:none') + '">' +
1842        '<label>How long is too long for one of your students to go quiet?</label>' +
1843        '<div data-ai-nudge-choices style="display:flex;flex-wrap:wrap;margin:6px 0 0">' +
1844          n.choices.map(function (d) { return _nudgeChoiceHtml(n, Number(d)); }).join('') +
1845        '</div>' + basis +
1846      '</div>';
1847  }
1848
1849  function _aiRowHtml(r) {
1850    var cost = _costNote(r.key);
1851    return '<div class="cpg-src-row" data-ai-row="' + P.esc(r.key) + '">' +
1852      '<label class="cpg-src"' + (r.available === false ? ' style="opacity:.65"' : '') + '>' +
1853        '<input type="checkbox" data-ai-switch="' + P.esc(r.key) + '"' +
1854          (r.on ? ' checked' : '') + (r.available === false ? ' disabled' : '') +
1855          ' style="accent-color:var(--accent)">' +
1856        '<span class="cpg-src-body">' +
1857          '<span class="cpg-src-title">' + P.esc(r.name) + '</span>' +
1858          '<span class="cpg-src-blurb">' + P.esc(r.blurb) + '</span>' +
1859          (cost
1860            ? '<span class="cpg-src-blurb" data-ai-cost="' + P.esc(r.key) + '"><b>Costs money when used.</b> ' +
1861                P.esc(cost) + '</span>'
1862            : '') +
1863          (r.available === false
1864            ? '<span class="cpg-src-blurb">This one needs a community to talk in, so it cannot run on this course.</span>'
1865            : '') +
1866          '<span class="cpg-msg" data-ai-msg style="display:none;margin:6px 0 0"></span>' +
1867        '</span>' +
1868      '</label>' +
1869      // OUTSIDE the label on purpose: a control inside a <label> toggles the
1870      // checkbox every time the coach clicks into it.
1871      (r.key === 'nudges' ? _nudgeWindowHtml(r.nudge, r.on) : '') +
1872    '</div>';
1873  }
1874
1875  /**
1876   * Send one row's decision to the key the service actually reads.
1877   * Returns a promise; a rejection is what reverts the tick.
1878   */
1879  function _saveTool(ctx, key, want, extra) {
1880    var base = '/community-hosting/courses/' + ctx.courseId;
1881    function board(tools) {
1882      return P.api(base + '/classroom', {
1883        method: 'PUT',
1884        // PATCH semantics server-side: a tool not named here keeps what it had.
1885        body: { tools: tools, chosen_in: 'course_ai_panel' },
1886      });
1887    }
1888    function column(body) { return P.api(base, { method: 'PATCH', body: body }); }
1889
1890    if (key === 'tutor') {
1891      return want
1892        ? column({ assistant_enabled: true }).then(function () { return board({ tutor: true }); })
1893        : board({ tutor: false }).then(function () { return column({ assistant_enabled: false }); });
1894    }
1895    if (key === 'nudges') {
1896      var body = { nudges_enabled: want };
1897      if (extra && extra.days != null) body.nudge_inactive_days = extra.days;
1898      return column(body);
1899    }
1900    var t = {};
1901    t[key] = want;
1902    return board(t);
1903  }
1904
1905  function _wireAiRow(row, ctx) {
1906    var key = row.getAttribute('data-ai-row');
1907    var box = row.querySelector('[data-ai-switch]');
1908    var msg = row.querySelector('[data-ai-msg]');
1909    var daysWrap = row.querySelector('[data-ai-nudge-days-wrap]');
1910    var choices = row.querySelectorAll('[data-ai-nudge-days]');
1911    /** The window currently selected, read off the buttons themselves. */
1912    function chosenDays() {
1913      for (var i = 0; i < choices.length; i++) {
1914        if (choices[i].getAttribute('aria-pressed') === 'true') {
1915          return parseInt(choices[i].getAttribute('data-ai-nudge-days'), 10);
1916        }
1917      }
1918      return null;
1919    }
1920    if (!box) return;
1921
1922    box.addEventListener('change', async function () {
1923      var want = !!box.checked;
1924      var d = chosenDays();
1925      box.disabled = true;
1926      P.setMsg(msg, 'Saving…');
1927      try {
1928        // Switching nudges ON carries the chosen window with it, so a coach who
1929        // never touches the days still turns it on at the number they were
1930        // shown rather than at whatever the column happened to hold.
1931        await _saveTool(ctx, key, want, d != null ? { days: d } : null);
1932        box.disabled = false;
1933        if (daysWrap) daysWrap.style.display = want ? '' : 'none';
1934        P.setMsg(msg, want ? '✓ On' : '✓ Off', 'good');
1935      } catch (err) {
1936        // Revert the tick. A switch that looks saved and is not is the fault
1937        // this panel exists to remove.
1938        box.checked = !want;
1939        box.disabled = false;
1940        if (daysWrap) daysWrap.style.display = box.checked ? '' : 'none';
1941        P.setMsg(msg, 'Couldn’t save: ' + ((err && err.code) || (err && err.message) || 'unknown'), 'bad');
1942      }
1943    });
1944
1945    /** Paint one choice as the selected one. Nothing else decides this. */
1946    function paint(days) {
1947      Array.prototype.forEach.call(choices, function (b) {
1948        var on = parseInt(b.getAttribute('data-ai-nudge-days'), 10) === days;
1949        b.setAttribute('aria-pressed', on ? 'true' : 'false');
1950        b.className = 'btn btn-sm' + (on ? '' : ' btn-secondary');
1951      });
1952    }
1953
1954    Array.prototype.forEach.call(choices, function (btn) {
1955      btn.addEventListener('click', async function () {
1956        var n = parseInt(btn.getAttribute('data-ai-nudge-days'), 10);
1957        var was = chosenDays();
1958        if (!n || n === was) return;
1959        // Paint first so the tap feels immediate, and revert on failure — the
1960        // same bargain the switches above make. A control that looks saved and
1961        // is not is the fault this panel exists to remove.
1962        paint(n);
1963        Array.prototype.forEach.call(choices, function (b) { b.disabled = true; });
1964        P.setMsg(msg, 'Saving…');
1965        try {
1966          await P.api('/community-hosting/courses/' + ctx.courseId, {
1967            method: 'PATCH', body: { nudge_inactive_days: n },
1968          });
1969          Array.prototype.forEach.call(choices, function (b) { b.disabled = false; });
1970          P.setMsg(msg, '✓ We will wait ' + n + ' days', 'good');
1971        } catch (err) {
1972          paint(was);
1973          Array.prototype.forEach.call(choices, function (b) { b.disabled = false; });
1974          P.setMsg(msg, 'Couldn’t save: ' + ((err && err.code) || (err && err.message) || 'unknown'), 'bad');
1975        }
1976      });
1977    });
1978  }
1979
1980  async function renderAi(el, ctx) {
1981    var c = ctx.course;
1982
1983    el.innerHTML =
1984      '<div class="panel cpg-panel">' +
1985        '<div class="panel-head">' +
1986          '<div class="cpg-section-title">Your AI tools</div>' +
1987          '<div class="cpg-section-sub">Everything the AI does on this course, in one place. ' +
1988            'Turn any of these on or off now or later. Nothing here is permanent.</div>' +
1989        '</div>' +
1990        '<div class="panel-body"><div class="cpg-src-list" data-ai-list>' +
1991          '<div class="cpg-section-sub">Loading your tools…</div>' +
1992        '</div></div>' +
1993        '<div class="panel-foot" style="justify-content:flex-start">' +
1994          '<button class="btn btn-secondary" type="button" data-st-asklog>💬 Read what your students asked</button>' +
1995        '</div>' +
1996      '</div>' +
1997
1998      '<div class="panel cpg-panel">' +
1999        '<div class="panel-head">' +
2000          '<div class="cpg-section-title">What the AI tutor is allowed to read</div>' +
2001          '<div class="cpg-section-sub">The material behind its answers. Its own screen because switching a ' +
2002            'source off deletes what it had indexed.</div>' +
2003        '</div>' +
2004        '<div class="panel-body"><div data-st-sources>' +
2005          '<div class="cpg-section-sub">Loading what it reads…</div>' +
2006        '</div></div>' +
2007      '</div>';
2008
2009    _speakIt(el);
2010
2011    el.querySelector('[data-st-asklog]').addEventListener('click', function () {
2012      ctx.openFull(window.CourseAssistantLog, 'AI tutor questions screen');
2013    });
2014
2015    // The destructive source toggles keep their own button, their own confirm
2016    // and their own endpoint — turning a source off deletes its indexed
2017    // passages, and that never belongs on a one-tap switch.
2018    _loadSources(el.querySelector('[data-st-sources]'), ctx);
2019
2020    var list = el.querySelector('[data-ai-list]');
2021    var board;
2022    try {
2023      board = await P.api('/community-hosting/courses/' + ctx.courseId + '/classroom');
2024    } catch (err) {
2025      list.innerHTML = '<div class="cpg-section-sub">Couldn’t load your AI tools. Reopen this tab to retry. (' +
2026        P.esc((err && err.code) || (err && err.message) || 'unknown') + ')</div>';
2027      return;
2028    }
2029
2030    // Drawn from the board's own answer, so each tick matches what the service
2031    // will do — never from a default this file invents. The board's `name` and
2032    // `promise` are the copy; this screen writes no new copy for them.
2033    var rows = ((board && board.tools) || []).map(function (t) {
2034      return {
2035        key: t.key,
2036        name: t.name,
2037        blurb: t.promise,
2038        available: t.available !== false,
2039        // ⚠️ The Tutor's tick is the EFFECTIVE state: the same AND the runtime
2040        // gate computes from its two enforced switches.
2041        on: t.key === 'tutor' ? (!!t.enabled && c.assistant_enabled !== false) : !!t.enabled,
2042      };
2043    }).concat(COLUMN_ROWS.map(function (r) {
2044      return Object.assign({}, r, {
2045        available: true,
2046        on: c.nudges_enabled === true,
2047        // The window, its closed set of choices and the reason behind the
2048        // recommendation — all computed server-side from this course's own
2049        // release cadence. Absent means the row draws its switch alone.
2050        nudge: r.key === 'nudges' ? (board && board.nudge) : null,
2051      });
2052    }));
2053
2054    list.innerHTML = rows.map(_aiRowHtml).join('');
2055    Array.prototype.forEach.call(list.querySelectorAll('[data-ai-row]'), function (row) {
2056      _wireAiRow(row, ctx);
2057    });
2058  }
2059
2060  // ══ U13 — LESSON CHECKS: THE SWITCH, AND WHAT IT COSTS ═════════════
2061  //
2062  // 🚨 IT WRITES THE KEY THE SERVICE READS. `PUT /courses/:id/classroom`
2063  // → `classroom.tools.checks`, which is what `course-check-service`
2064  // `checksEnabled()` asks before `nextProposal` will spend anything. That is
2065  // the whole point: the last four switches in this area wrote one key and were
2066  // read from another, so a coach flipped them and nothing changed.
2067  //
2068  // ⚠️ AND IT SAYS WHAT IT COSTS, ON THE SWITCH. Turning this on means the
2069  // proposer may make ONE model call PER CHECK the coach asks for. A control
2070  // that quietly authorises spending is not a control, so the price is next to
2071  // the tick box and not in a help article — and the copy separates "on" from
2072  // "spending", because switching it on by itself charges nothing.
2073  var CHECKS_COST_NOTE = 'Each check is written by AI when you ask for it: about one AI request per lesson, '
2074    + 'charged to your account. Switching this on costs nothing on its own. Nothing is drafted until you '
2075    + 'ask for one on the Knowledge checks screen.';
2076
2077
2078  // ══ D26 — WHAT THE ASSISTANT IS ALLOWED TO READ ════════════════════
2079  //
2080  // The toggle is not a display filter. `course-index-service` deletes a
2081  // source's chunks the moment it is switched off — the blueprint's words
2082  // are "turning a source off must delete its chunks, or 'off' is a lie" —
2083  // and the server performs that deletion inside the save, then reports how
2084  // many passages actually went.
2085  //
2086  // So the UI has one job beyond drawing four checkboxes: make that legible
2087  // WITHOUT making it frightening. Three things do it, and none of them is
2088  // a red warning box:
2089  //   1. the count of passages sits ON each row, so the number a coach is
2090  //      about to lose is next to the switch they are about to flip;
2091  //   2. the confirm names the sources and the exact number of passages,
2092  //      and says what undo costs (re-reading, not re-uploading) — it only
2093  //      appears when something is actually being turned OFF;
2094  //   3. the result line says what happened in the past tense with the real
2095  //      number, so "off" is never something the coach has to take on trust.
2096  //
2097  // A source that is ON with zero passages is not a bug and the copy says
2098  // so: a recording with no transcript indexes nothing (the measured limit
2099  // in course-index-service's header, and §5.4).
2100
2101  function _srcRowHtml(s) {
2102    var count = s.chunks
2103      ? s.chunks + ' passage' + (s.chunks === 1 ? '' : 's') + ' indexed'
2104      : (s.enabled ? 'nothing to read yet' : 'off. Nothing indexed');
2105    return '<label class="cpg-src">' +
2106      '<input type="checkbox" data-st-src="' + P.esc(s.kind) + '"' + (s.enabled ? ' checked' : '') +
2107        ' data-st-src-chunks="' + (s.chunks || 0) + '" data-st-src-label="' + P.esc(s.label) + '"' +
2108        ' style="accent-color:var(--accent)">' +
2109      '<span class="cpg-src-body">' +
2110        '<span class="cpg-src-title">' + P.esc(s.label) +
2111          '<span class="cpg-src-count">' + P.esc(count) + '</span></span>' +
2112        '<span class="cpg-src-blurb">' + P.esc(s.blurb) + '</span>' +
2113      '</span>' +
2114    '</label>';
2115  }
2116
2117  async function _loadSources(mount, ctx) {
2118    if (!mount) return;
2119    var data;
2120    try {
2121      data = await P.api('/community-hosting/courses/' + ctx.courseId + '/assistant/sources');
2122    } catch (err) {
2123      mount.innerHTML = '<div class="cpg-section-sub">Couldn’t load what the AI tutor reads. Reopen this tab to try again.</div>';
2124      return;
2125    }
2126    var sources = (data && data.sources) || [];
2127    mount.innerHTML =
2128      /* No heading of its own: its one caller's panel already says "What the
2129         tutor is allowed to read", and the two sat one above the other. */
2130      '<div class="cpg-src-block">' +
2131        '<div class="cpg-section-sub">All on to start with. Switching one off <b>deletes the passages it has ' +
2132          'indexed</b>: the AI tutor can no longer quote it or point members at it. Switching it back on reads ' +
2133          'your material again from scratch; nothing you uploaded is ever touched.</div>' +
2134        '<div class="cpg-src-list">' + sources.map(_srcRowHtml).join('') + '</div>' +
2135        '<div class="cpg-row-end" style="margin-top:12px;gap:10px">' +
2136          '<button class="btn btn-secondary" type="button" data-st-src-save>Save what it reads</button>' +
2137          '<p class="cpg-msg" data-st-src-msg style="display:none;margin:0"></p>' +
2138        '</div>' +
2139      '</div>';
2140
2141    var msg = mount.querySelector('[data-st-src-msg]');
2142    var before = {};
2143    sources.forEach(function (s) { before[s.kind] = s.enabled; });
2144
2145    mount.querySelector('[data-st-src-save]').addEventListener('click', async function () {
2146      var btn = this;
2147      var boxes = Array.prototype.slice.call(mount.querySelectorAll('[data-st-src]'));
2148      var body = {};
2149      var losing = [];
2150      var losingChunks = 0;
2151      boxes.forEach(function (b) {
2152        var kind = b.getAttribute('data-st-src');
2153        body[kind] = !!b.checked;
2154        if (before[kind] && !b.checked) {
2155          losing.push(b.getAttribute('data-st-src-label'));
2156          losingChunks += parseInt(b.getAttribute('data-st-src-chunks'), 10) || 0;
2157        }
2158      });
2159      // CBSheet, not the browser dialog (design audit F1) — same words, with
2160      // the real counts kept.
2161      if (losing.length && !(await window.CBSheet.confirm({
2162        title: 'Turn off ' + losing.join(' and ') + '?', danger: true,
2163        message: 'Turning off ' + losing.join(' and ') + ' deletes ' + losingChunks + ' indexed passage' +
2164          (losingChunks === 1 ? '' : 's') + ' straight away. ' +
2165          'The AI tutor will stop being able to quote that material or point members at it. ' +
2166          'Your lessons, videos and recordings are not touched: switching it back on simply reads them again.',
2167        confirmLabel: 'Go ahead',
2168      }))) return;
2169
2170      btn.disabled = true;
2171      P.setMsg(msg, losing.length ? 'Saving and removing those passages…' : 'Saving…');
2172      try {
2173        var r = await P.api('/community-hosting/courses/' + ctx.courseId + '/assistant/sources', {
2174          method: 'PUT', body: { sources: body },
2175        });
2176        var parts = [];
2177        if (r.chunks_removed) parts.push('removed ' + r.chunks_removed + ' passage' + (r.chunks_removed === 1 ? '' : 's'));
2178        if (r.chunks_added) parts.push('read ' + r.chunks_added + ' new passage' + (r.chunks_added === 1 ? '' : 's'));
2179        if (!r.reindexed) {
2180          P.setMsg(msg, 'Saved, but the index could not be rebuilt just now. It will catch up on the next question.', 'bad');
2181        } else {
2182          P.setMsg(msg, '✓ Saved' + (parts.length ? ': ' + parts.join(' and ') + '.' : '.'), 'good');
2183        }
2184        // Repaint from what the server actually holds, so the counts on the
2185        // rows are the new truth rather than the numbers we sent.
2186        (r.sources || []).forEach(function (s) { before[s.kind] = s.enabled; });
2187        var list = mount.querySelector('.cpg-src-list');
2188        if (list && r.sources) list.innerHTML = r.sources.map(_srcRowHtml).join('');
2189        btn.disabled = false;
2190      } catch (err) {
2191        btn.disabled = false;
2192        P.setMsg(msg, 'Couldn’t save: ' + ((err && err.code) || 'unknown'), 'bad');
2193      }
2194    });
2195  }
2196
2197  App.CourseCustomizeTab = { render: renderCustomize };
2198  App.CourseCertificatesTab = { render: renderCertificates };
2199  App.CourseSettingsTab = { render: renderSettings };
2200  // U16.2 — the assistant + nudges cluster, for the Manage board's AI block.
2201  // Same file because it is the same markup that used to be inside Settings;
2202  // it moved screens, it was not rewritten.
2203  App.CourseAiTab = { render: renderAi };
2204})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.