PageSourceSearch

https://app.replicants.ai/js/game-creation-wizard.js?v=20261002bf

js replicants.ai collected 2026-10-05 12:30:58 UTC 228,450 bytes, 4,345 lines download raw bytes

1// game-creation-wizard.js — setting a game up, one question at a time.
2//
3// Alex, 2026-08-02: "use the design styling that we're using across the site
4// and do it in steps so that it's a much clearer setup UX. There won't be any
5// AI assistance so that bit you remove... basically just look at these pages
6// and get their identical layout done. And you shouldn't be doing this from
7// scratch. You should just be using the styling that's already in the
8// platform."
9//
10// ── SO NOTHING HERE IS NEW DESIGN ─────────────────────────────────────
11//
12// The layout is the GUIDED WORKSPACE (css/guided-workspace.css), which is
13// already shared by the course guide and the website Launch Studio:
14//
15//   .phase-grid   the working area on the left, the rail on the right
16//   .ls-fcard     the card the question lives in
17//   .road         the vertical stepper, its done/now/locked states and blurbs
18//   .composer     the answer box, its Back button and its Send button
19//   .focus-foot   the "every answer saves itself" footnote and Finish later
20//   .design-card  the "what you're working toward" card in the rail
21//
22// and the option cards are `.ccw-opt` from css/course-wizard.css. This screen
23// therefore carries the SAME class stack as the course guide — `ccw wz-shell
24// gw ccw-work` — because that is what makes the layout identical without
25// writing a line of CSS. `.ccw` there is only a max-width that `ccw-work`
26// then removes.
27//
28// (Worth saying plainly: `ccw` reads as "course creation wizard", and a
29// second wizard wearing it is a naming wart. The fix is to promote the option
30// cards into guided-workspace.css under `.gw` so both wizards share a neutral
31// name — a small rename that touches the course wizard's stylesheet, so it
32// belongs in its own change rather than smuggled into this one.)
33//
34// ── AND NO AI ─────────────────────────────────────────────────────────
35//
36// The course guide opens with Maya, an AI coach who turns answers into a
37// plan. There is no equivalent here and none is wanted: every answer on this
38// screen is a value that goes straight into a column. So the persona header
39// and its intro paragraph are simply not rendered — the progress bar that
40// shares their row still is.
41//
42// ── THE SPEAK BUTTON IS AUTOMATIC ─────────────────────────────────────
43//
44// Alex: "it always uses a speaker button." It is not rendered here. Every
45// text field is swept by window.VoiceInput (js/voice-input.js — THE dictation
46// control for the platform), which inserts its own row beneath the field. A
47// MutationObserver re-sweeps after each repaint, so a field that appears on
48// question five gets the same button as one on question one.
49
50(function () {
51  'use strict';
52
53  var W = {
54    el: null, gameId: null, qIdx: 0, answers: {}, channels: [],
55    busy: false, saving: false, building: false, error: '',
56    // The channel picker's own state — what is typed in its search box,
57    // whether its create row is open, and whether that create is in flight.
58    chanQuery: '', chanNew: false, chanBusy: false, chanError: '',
59    // The drafted plan shown on the last step, and whether a draft is running.
60    plan: null, cost: null, planBusy: false, planErr: '',
61    // Which primer card they are reading, and whether they have left the
62    // deck. `null` means the deck has never started — a resumed game whose
63    // plan is already drafted must not be walked through an explanation of a
64    // wait that is not happening.
65    primer: null, primerDone: false,
66    // Which plan cards are open. Kept here rather than in <details>, because
67    // _render rewrites innerHTML and a native disclosure would snap shut
68    // under the coach on every repaint — including the one their own click
69    // caused. `{}` means "nobody has touched anything", so CARDS_DEFAULT wins.
70    cards: {},
71    // Whether the plan on screen is still the one the answers describe.
72    // Set when an answer is edited or Maya is given feedback; cleared by a
73    // redraft. It is what makes "Draft it again" mean something.
74    editedSince: false, feedbackSince: false,
75    // The DURABLE version of the same question, from the server: an ISO stamp
76    // when the plan on screen was drafted from answers that have since changed,
77    // plus which answers moved. Unlike the two flags above it survives a
78    // refresh and it does not care which route the edit arrived by — see
79    // _redraftReason for why all three exist rather than one.
80    planStale: null, planStaleWhat: [],
81    // Which answer's modal is open on the review screen, by key.
82    editing: null,
83    // The Maya panel's DOM node, kept across repaints so the conversation
84    // survives them. Null until the plan step is first reached.
85    mayaEl: null,
86    // The advisory half: the library, what we recommended and why, the
87    // generated questions, the levers with their real costs, which card's
88    // detail panel is open, and which coming-soon formats this coach has
89    // already asked for (so the button reads "noted" rather than 
89inviting a
90    // second click that does nothing).
91    // ⚠️ `Tried` IS NOT THE SAME AS "GOT SOMETHING", AND CONFLATING THEM SPINS.
92    //
93    // Found 2026-08-04 by walking the wizard for real: parked on the levers
94    // screen with a levers call that came back empty, it made 299 requests in
95    // 300ms and would not have stopped. Arriving at a step is what loads it
96    // (see _wire), and a loader ends by calling _render — so if the loader's
97    // own guard asks "do we have any?" rather than "have we asked?", an empty
98    // or failed answer is indistinguishable from never having asked, and the
99    // render→load→render cycle never terminates.
100    //
101    // It needs no exotic conditions: any 500, timeout, dropped connection or
102    // spend cap puts `[]` in there, and one coach's browser then hammers the
103    // endpoint until they close the tab. Nothing detects it and nothing stops
104    // it. `_loadDynamic` and `_loadNames` were already right — they guard on a
105    // null sentinel, and `[]` is truthy — so this is these two catching up.
106    formats: [], recommendation: null, fmtBusy: false, fmtTried: false, panel: null, wanted: {},
107    names: null, namesBusy: false,
108    dynamic: null, dynBusy: false,
109    levers: [], levBusy: false, levTried: false,
110    // Which of the three recommended settings has its editor open on the row.
111    // One at a time — the whole point of that screen is three decisions read
112    // side by side, and three open editors is the wall of controls it replaced.
113    runEdit: null,
114    // ⚠️ WHICH RECOMMENDED FIELDS THE COACH HAS CHANGED BY HAND.
115    //
116    // Alex, 2026-08-03: "we have to honour whatever has been overridden by the
117    // coach so we can't override the coach." So this is not cosmetic — it is
118    // the record that a re-recommendation must skip. Picking a different format
119    // re-derives length, win mode and deadlines from the new one, EXCEPT for
120    // anything in here. Without it, a coach who sets 21 days and then changes
121    // their mind about the format silently loses the 21.
122    //
123    // It was read by _runRow before it existed, which would have meant the
124    // marker never rendered and — worse — the skip never happened. Caught by
125    // checking every W.* read against the state object rather than by noticing
126    // it on screen.
127    touched: {},
128    onExit: function () {}, onDone: function () {},
129  };
130
131  function esc(s) {
132    return String(s == null ? '' : s)
133      .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
134      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
135  }
136
137  /**
138   * Shorten the COACH'S OWN WORDS for display, and say that you have.
139   *
140   * ⚠️ Alex, 2026-08-05: *"my quoted text is being cut off. Make sure this can't
141   * happen anywhere across your UX."* Three places in this file did
142   * `.slice(0, N)` with nothing after it, so his answer ended mid-word — the
143   * screen read *"You said: “So, for the ones that don't have a course, it's a
144   * huge time job to do it. They d”."* A quote that stops mid-word does not look
145   * shortened, it looks BROKEN, and worse it puts words in his mouth: the
146   * sentence he actually wrote is not the sentence shown.
147   *
148   * Two things fix it, and both matter:
149   *   · an ellipsis, so the reader knows there is more;
150   *   · a cut on a WORD BOUNDARY, so the last thing shown is a whole word.
151   *
152   * `community-games.js` already did this correctly (`said.length > 400 ?
153   * said.slice(0, 400) + '…' : said`) — the wizard simply never used the
154   * pattern. This is that pattern, in one place, so the next surface inherits
155   * it instead of re-deciding.
156   */
157  function shorten(s, max) {
158    var t = String(s == null ? '' : s).trim();
159    var n = Math.max(8, parseInt(max, 10) || 80);
160    if (t.length <= n) return t;
161    var cut = t.slice(0, n);
162    // Back up to the last space so a word is never sliced in half. If there is
163    // no space in the window (one very long token) the hard cut stands — better
164    // a cut token than an empty quote.
165    var sp = cut.lastIndexOf(' ');
166    if (sp > n * 0.6) cut = cut.slice(0, sp);
167    return cut.replace(/[\s,;:.\-–—]+$/, '') + '…';
168  }
169
170  /* ⚠️ THE SHARED api() DOES NOT THROW ON A 4xx — it returns the parsed body.
171   *
172   * That is the real reason Alex's error message said nothing useful. The
173   * server replied `{"error":"slug_taken"}`, which names the fault exactly,
174   * and every caller here treated a resolved promise as success. _ensureGame
175   * threw away the code and substituted its own `create_failed`; the PATCH
176   * calls did not notice at all, so a FAILED save advanced the coach to the
177   * next question as though it had worked — the quieter and worse half of the
178   * same bug.
179   *
180   * So the check lives in the one place every call goes through. A body
181   * carrying an `error` becomes a thrown Error whose `.code` is the server's
182   * own word for what happened, which is what _saveError needs to say
183   * something true. */
184  /* ══════════════════════════════════════════════════════════════════════
185     THE NAME A GAME HAS BEFORE IT HAS A NAME
186
187     The row cannot exist without a name and the coach is not asked for one
188     until the very end, so something has to go in the box. It used to be the
189     literal string "New game" — for every game, every time.
190
191     Alex, 2026-08-04, after correcting his own first diagnosis: "if you're
192     saving your name and you haven't got a name for the game yet, it needs to
193     have a unique name that you understand what it's for… It all stems from
194     not knowing what game you're going into to continue to set up."
195
196     He is right and it caused three separate faults:
197       · the slug derived from it, so the second game in a community could
198         never be created at all (the `slug_taken` he hit);
199       · two half-finished games sat in his list both reading "New game", with
200         nothing to tell them apart — he has two right now, from 07:19 and
201         09:36;
202       · and the resume check treated it as a real answer, so a game with one
203         question answered looked finished.
204
205     So it carries the moment it was started. Unique by construction, readable
206     in a list, and obviously temporary — which is the honest signal that this
207     game has not been named yet.
208     ══════════════════════════════════════════════════════════════════════ */
209  var PLACEHOLDER_PREFIX = 'Untitled game';
210
211  function _placeholderName() {
212    var d = new Date();
213    var p = function (n) { return (n < 10 ? '0' : '') + n; };
214    // Dates: month in letters, the tenant's order ("27 Sep 14:30"), never "27/09".
215    return PLACEHOLDER_PREFIX + ' — ' + App.Dates.short(d, { year: false }) +
216      ' ' + p(d.getHours()) + ':' + p(d.getMinutes());
217  }
218
219  /** Is this a name the coach chose, or the one we put there to save the row?
220   *  Matched on the prefix, because the rest is a timestamp. Legacy rows that
221   *  say exactly "New game" are still recognised — there are two on dev. */
222  function _isPlaceholderName(n) {
223    var t = String(n == null ? '' : n).trim();
224    return t === '' || t === 'New game' || t.indexOf(PLACEHOLDER_PREFIX) === 0;
225  }
226
227  /** The length a resumed game was interviewed with, in days. Mirrors
228   *  games-service `_lengthDays`: the current key, then the pre-460 one in
229   *  weeks, then nothing — and never above the platform's 30-day ceiling, so a
230   *  13-week game from the old world comes back as the 30 the server will use
231   *  rather than a number the coach would be shown and then quietly lose. */
232  function _seedLength(iv) {
233    var a = iv || {};
234    var d = null;
235    if (a.length_days != null && a.length_days !== '') d = parseInt(a.length_days, 10);
236    else if (a.length_weeks != null && a.length_weeks !== '') d = parseInt(a.length_weeks, 10) * 7;
237    if (d == null || !isFinite(d)) return '';
238    return String(Math.max(1, Math.min(30, d)));
239  }
240
241  function _api(path, opts) {
242    if (typeof window.api !== 'function') return Promise.reject(new Error('api_unavailable'));
243    return Promise.resolve(window.api(path, opts)).then(function (r) {
244      if (r && r.error) {
245        var e = new Error(String(r.error));
246        e.code = String(r.error);
247        e.body = r;
248        throw e;
249      }
250      return r;
251    });
252  }
253
254  // The same sheets the course guide loads, in the same order — the last one
255  // wins, so the order is part of the styling, not an accident of the list.
256  function _ensureSheets() {
257    // assistant-panel is Maya's own sheet. app.html already loads it, and the
258    // guard below makes this a no-op when it does — it is here so the panel
259    // cannot arrive unstyled if that ever stops being true.
260    ['guided-workspace', 'wizard-shell', 'course-wizard', 'assistant-panel',
261     'game-wizard'].forEach(function (name) {
262      try {
263        if (document.querySelector('link[href*="' + name + '.css"]')) return;
264        var l = document.createElement('link');
265        l.rel = 'stylesheet';
266        // The game sheet carries a version, so a layout change reaches a browser that cached the old one.
267        l.href = (window.__BASE_PATH || '') + '/css/' + name + '.css' + (name === 'game-wizard' ? '?v=20261001r4o1' : '');
268        document.head.appendChild(l);
269      } catch (_) {}
270    });
271  }
272
273  // ── The steps ─────────────────────────────────────────────────────────
274  //
275  // Six, like the course guide's six, and every one of them maps onto columns
276  // that already exist on community_games. Nothing here asks for a value the
277  // game cannot store.
278
279  // ⚠️ THE NAME IS LAST, AND THAT IS THE WHOLE RESHAPE. It used to be first.
280  //
281  // Alex, 2026-08-03: "they need to define all of that before we come up with a
282  // name. We should be following the same format as what we do with creating a
283  // community or creating a course." The course guide already does exactly
284  // this — about, audience, transformation, and only THEN the title — for the
285  // reason that nobody can name a thing they have not defined yet. Asking for
286  // the name on screen one is asking the hardest question when the coach has
287  // the least to go on.
288  //
289  // The other half of the reshape: only the first two steps are QUESTIONS.
290  // Everything from 'The format' onward is a RECOMMENDATION the coach confirms
291  // or overrides, written from what they said in the first two.
292  // ⚠️ AND THE NAME MOVED FORWARD, WHICH IS NOT A REVERSAL OF THE ABOVE.
293  //
294  // Alex, 2026-08-04: "I think we should get the name as soon as we're in a
295  // position to ask for the names. That means that we know who it's for and we
296  // know the kind of goal and the kind of things that the game needs… We
297  // shouldn't be asking questions that throw our users off what the goal is of
298  // this point, which is actually to have a game set up and then they're
299  // excited about the game."
300  //
301  // Both of his statements are satisfied at step 4 and that is the whole
302  // argument. His 2026-08-03 rule — nobody can name a thing they have not
303  // defined yet — still holds, because by the time the format is chosen the
304  // game IS defined: who it is for, what they will have done, why the coach is
305  // running it, and its shape. What it is not yet is SCHEDULED, and scheduling
306  // was never what made a name possible.
307  //
308  // It pays for itself immediately: every screen after it — the levers, the
309  // plan, the drafted posts — can say the game's name instead of "your game".
310  /* ⚠️ GO LIVE IS NOT A STEP HERE — IT IS A WALK-THROUGH, AND THAT IS COPIED
311   * RATHER THAN DESIGNED.
312   *
313   * Alex, 2026-08-04, on the split: "model it on how we've done the course
314   * setup. The course UX is really good at the moment so just have a look at
315   * the course UX and get your answer from there."
316   *
317   * So it is the course's shape, read out of course-creation-wizard.js: the
318   * build ends at its own screen, and Go live is entered by a BUTTON
319   * (`data-ccw-golive`, line 8946) into a sequence that resets on entry —
320   * "this is a walk-through, not a saved position" (line 9980). Its questions
321   * are derived from what that course actually calls for (`_glSteps`, 9954),
322   * never a fixed list, and advice never becomes a step.
323   *
324   * The build therefore ends at THE PLAN, where the game exists and the coach
325   * is pleased with it — which is Alex's whole complaint about the old order:
326   * "We shouldn't be asking questions that throw our users off what the goal is
327   * of this point, which is actually to have a game set up and then they're
328   * excited about the game." */
329  var STEP_LABELS = ['About them', 'A bit more', 'The format', 'Name it',
330                     'What’s in it', 'How it runs', 'The plan'];
331  var GOLIVE_STEP = 'Go live';
332  /* F11 (spaces review r3): ONE PLAIN WORD FOR LETTING MEMBERS IN. "Go live"
333     meant "publish the game" here and "start a video call" on the space page
334     beside it, so a coach could not tell which they were pressing. Opening a
335     game to members is "Open it"; "Go live" is left to live video. The key
336     above stays as it is: it is a step id the questions are filed under, and
337     nobody reads it. */
338  var OPEN_LABEL = 'Open it';
339  var STEP_BLURB = {
340    'About them':  'Who they are and what they get to',
341    'A bit more':  'Only if something is missing',
342    'The format':  'What kind of game this is',
343    'Name it':     'Now you know what it is',
344    'What’s in it': 'What you are committing to',
345    'How it runs': 'Length, winning, and deadlines',
346    'The plan':    'The journey we have drafted',
347    'Go live':     'Where it runs and who can join',
348    'Open it':     'Who can join, then let them in',
349  };
350
351  /**
352   * The questions, in order. `step` is the label above; `when` hides a
353   * question that the answers so far have made irrelevant, so a co
353ach never
354   * reads "Level needed" after saying anyone can join.
355   */
356  // ── The examples are a COACH'S examples ───────────────────────────────
357  //
358  // Alex, 2026-08-03, looking at "Founding beta" in the name box: "you've got
359  // to remember that this is for coaches for their community. Can you replace
360  // all the example text with actual relevant examples for our target
361  // audience? Not taking from me."
362  //
363  // So every placeholder below is something a coach would actually run for
364  // their members — a challenge, an accountability sprint, a book club — and
365  // none of them is Replicants' own founding-beta bug hunt. A placeholder is
366  // the fastest teaching an empty field can do, and one drawn from the wrong
367  // business teaches the wrong thing.
368
369  function QUESTIONS() {
370    return [
371      // ── The five things only the coach knows ─────────────────────────
372      //
373      // Alex, 2026-08-03: "creating a game is something that people never
374      // really do. They're going to be really lost for any idea of how to do
375      // this, but they'll know what they're trying to achieve."
376      //
377      // So none of these asks a coach to design anything. They ask about their
378      // own people, their own goal and their own capacity — and everything
379      // downstream is derived from the answers.
380
381      { key: 'who_for', step: 'About them', type: 'text', required: true,
382        q: 'Who is this for?',
383        ph: 'Members who bought the course but never finished it',
384        hint: 'Not everyone — the specific group you have in mind. It shapes every word ' +
385              'of the copy we write for you.' },
386
387      // ⚠️ THE BASELINE, AND WHY IT EARNS A WHOLE SCREEN.
388      //
389      // The single most repeated finding in the research is that a challenge
390      // lives or dies on whether day one produces something real in about half
391      // an hour. That is not designable without knowing where they START.
392      // "By the end they will have posted a client win" tells you the finish;
393      // only "most of them have never posted at all" tells you what a win in
394      // the first sitting looks like.
395      { key: 'start_point', step: 'About them', type: 'text',
396        q: 'Where are they starting from?',
397        ph: 'Most of them have never posted in the community at all',
398        hint: 'Be honest rather than flattering. The first thing we ask them to do has to ' +
399              'be something a person in exactly that state can finish today, without help.' },
400
401      // ⚠️ THE ANCHOR. Everything mechanical is derived from this answer.
402      { key: 'outcome', step: 'About them', type: 'text', required: true,
403        q: 'By the end, what will they have DONE that they haven’t yet?',
404        ph: 'Posted their first client win',
405        hint: 'Something you could actually see them do — posted, shared, finished, ' +
406              'booked. A game can only reward what it can see happen, so this is what ' +
407              'turns into the things people earn points for.' },
408
409      { key: 'blocker', step: 'About them', type: 'text',
410        q: 'What stops them today?',
411        ph: 'They start strong and drop off in week two',
412        hint: 'Optional, and it is the strongest single clue we get about which kind of ' +
413              'game to build you. "They never start" and "they fade in week three" need ' +
414              'very different machines.' },
415
416      // ⚠️ THE COACH'S GOAL IS PLURAL; THE MEMBER'S IS NOT.
417      //
418      // Alex, 2026-08-03: "the goal can be multiple goals." He is right, and
419      // the resolution is that two different things get called the goal and
420      // they pull opposite ways. The MEMBER'S outcome above stays singular,
421      // because every source says the sharp single promise is what works and a
422      // coach who names three has described milestones. The COACH'S business
423      // goal is genuinely plural — and it was missing entirely. It moves the
424      // recommendation more than almost anything else: "sell something" points
425      // at a five-day launch with an offer at the close, "get the community
426      // active again" points at a bingo card with no leaderboard at all.
427      //
428      // Capped at two, because a game aimed at four business goals achieves
429      // none of them.
430      { key: 'coach_goals', step: 'About them', type: 'goals',
431        q: 'Why are you running it?',
432        hint: 'Pick one or two. This is about what YOU get out of it, not what they do.' },
433
434      // ── Up to three more, only if something is genuinely missing ──────
435      //
436      // Generated from the five above. Zero is the expected answer and the
437      // screen says so rather than looking like a step that failed to load.
438      //
439      // ⚠️ AND IT DISAPPEARS WHEN THE ANSWER IS ZERO. Alex, 2026-08-04, on a
440      // screen reading "Nothing else to ask": "if there's nothing else to add,
441      // we don't even need to show this so the couple more things shouldn't be
442      // visible if it's not needed… If you need to add it in, you can just add
443      // it in at the stage that we need to add in that question."
444      //
445      // He is right, and the previous design had it backwards. The empty state
446      // was written carefully — a blank step reads as one that failed to load —
447      // but the careful empty state was solving a problem that only existed
448      // because the step was shown before we knew whether it had anything in
449      // it. The step is now offered only when it has questions.
450      //
451      // `null` means NOT ASKED YET and keeps the step, because the honest thing
452      // while we are waiting is a screen that says we are waiting. `[]` means
453      // asked and answered "nothing", and that removes it from the list — which
454      // also shifts every later index down by one, so a coach sitting on this
455      // step when the answer lands is carried straight to the next one with no
456      // extra bookkeeping. Prefetched from the previous screen (see _wire), so
457      // in the ordinary case they never see the wait at all.
458      { key: 'dynamic', step: 'A bit more', type: 'dynamic',
459        q: 'A couple more things',
460        hint: '',
461        when: function () { return W.dynamic === null || W.dynamic.length > 0; } },
462
463      // ── Everything below is a RECOMMENDATION, not a question ─────────
464      { key: 'format', step: 'The format', type: 'gallery',
465        q: 'Here’s what I’d run',
466        hint: '' },
467
468      // ⚠️ IT SITS HERE RATHER THAN NEAR THE END — see the note on STEP_LABELS.
469      // Everything downstream can use the name, and nothing upstream could have
470      // produced one: on the previous screen the coach has just been told what
471      // kind of game this is, which is the last piece a name needs.
472      { key: 'name', step: 'Name it', type: 'text', single: true, required: true,
473        q: 'What shall we call it?',
474        ph: '30-Day Momentum Challenge',
475        hint: 'Members see this at the top of the game. Short and specific beats clever — ' +
476              'you know who it is for, what they get to and what shape it takes, so this ' +
477              'is the easy question now.' },
478
479      { key: 'levers', step: 'What’s in it', type: 'levers',
480        q: 'What’s in it',
481        hint: 'Ticked by the format. Untick anything you do not want to commit to — ' +
482              'each one shows what it will actually cost you.' },
483
484      { key: 'runs', step: 'How it runs', type: 'settings',
485        q: 'Here’s how I’d run it',
486        hint: 'All three are set by the format you picked. Change any of them and the ' +
487              'rest stay as they are.' },
488
489      /* ⚠️ THESE TWO SIT WITH 'How it runs', WHERE THEY BELONG, AND THAT IS A
490       * FIX RATHER THAN A TIDY-UP. They were declared after the plan, and the
491       * list is walked in ARRAY order — so the moment Go live stopped being
492       * part of the build (G20), `finisher_reward` became the last question in
493       * the wizard and `_firstUnanswered` parked every finished coach on it
494       * instead of on their plan. Found by driving the real screen, not by
495       * reading: array order and step order have to agree. */
496      // ── The two rewards, and they are not the same thing ─────────────
497      //
498      // The research is unanimous and we had only half of it: every 30-day
499      // challenge in the wild rewards EVERYONE WHO FINISHED, not only the
500      // winner. Bikram's finisher shirt is the whole emotional payload, and it
501      // goes to all 200 people who did the 30 classes, not to the fastest.
502      //
503      // So there are two fields, each shown only when the chosen win mode
504      // actually has that kind of winner — asking a coach what the top prize is
505      // for a game where completion IS the win would be asking them to design
506      // something the game does not have.
507      { key: 'finisher_reward', step: 'How it runs', type: 'text', single: true, max: 200,
508        q: 'What does everyone who finishes get?',
509        ph: 'A shout-out in the newsletter and the finisher badge',
510        hint: 'It does not have to cost anything. Being named in front of the community ' +
511              'is a real reward, and it is the one most people actually remember.',
512        when: function (a) { return a.win_mode === 'completion' || a.win_mode === 'both'; } },
513
514      { key: 'prize', step: 'How it runs', type: 'text', single: true, max: 200,
515        q: 'What does finishing top get them?',
516        ph: 'A free month, or a one-to-one session with you',
517        hint: 'Optional, and it does not have to cost anything either.',
518        when: function (a) { return a.win_mode === 'top_n' || a.win_mode === 'both' ||
519                                    a.win_mode === 'raffle'; } },
520
521      /* ── What YOU can give it (G30) ────────────────────────────────────
522       *
523       * Alex, 2026-08-04: *"I expect 20 participants and I've got about two
524       * hours… If I said I've got about 90 minutes a day or an hour a day or an
525       * hour Monday to Friday, I could say which days I have time and over
526       * what. Then it could also plan when it's best to do each of the steps."*
527       *
528       * ⚠️ I RECOMMENDED ONE QUESTION AND WAS OVERRULED, CORRECTLY. "An hour a
529       * day" and "an hour on Mondays" are the same number and completely
530       * different calendars, so the days are their own answer. They decide
531       * which day a post that needs checking goes out.
532       *
533       * ⚠️ AND THE TURNOUT IS PRE-FILLED, NEVER ASKED COLD. The community's own
534       * size is a number we already hold; `_seedTurnout` puts it in the box so
535       * the coach corrects rather than calculates. Alex has asked three times
536       * for a shorter interview, and a question whose answer is already in the
537       * database is exactly the kind that lengthens it for nothing. */
538      { key: 'expected_players', step: 'How it runs', type: 'number', min: 0, single: true,
539        q: 'How many people do you expect to take part?',
540        hint: 'A rough number is fine — it is what tells you how much checking this ' +
541              'will actually be, instead of assuming your whole community joins in.' },
542
543      { key: 'coach_days', step: 'How it runs', type: 'days', single: true,
544        q: 'Which days will you be around for it?',
545        hint: 'Anything that needs you to look at it gets scheduled for a day you said ' +
546              'yes to. Leave them all off and we will not move anything.' },
547
548      { key: 'coach_minutes', step: 'How it runs', type: 'options', single: true,
549        q: 'And roughly how long on one of those days?',
550        hint: 'This is what a plan gets weighed against, so you find out before you ' +
551              'build it rather than in week two.',
552        opts: [
553          { v: '15',  t: '15 minutes', d: 'A quick look at what came in.' },
554          { v: '30',  t: 'Half an hour', d: 'Enough for a handful of submissions.' },
555          { v: '60',  t: 'An hour', d: 'Room to reply properly as well as approve.' },
556          { v: '120', t: 'Two hours or more', d: 'You are running this one properly.' },
557        ] },
558
559      { key: 'plan', step: 'The plan', type: 'review',
560        q: 'The journey',
561        hint: '' },
562
563      /* F11: a game in a space happens in that space's own feed, so there is
564         no channel to choose or make. Asking anyway sent coaches off to make
565         one, and it then showed community-wide as "#Calm mornings". */
566      { key: 'channel_id', step: 'Go live', type: 'channel', required: true,
567        q: 'Where does it happen?',
568        hint: 'Posts and comments in this channel are what the game counts. ' +
569              'Most coaches give a challenge its own channel so the feed stays clean.',
570        when: function () { return _needsChannel(); } },
571
572      { key: 'access_rule', step: 'Go live', type: 'options',
573        q: 'Who can take part?',
574        hint: W.spaceName
575          ? 'Only members of ' + W.spaceName + ' see it at all. This is about which of them can post, comment and vote in it.'
576          : 'Everyone can READ a game either way. This is about who can post, comment and vote in it.',
577        opts: [
578          // A space game is asked in the space's terms, not the community's.
579          { v: 'free',  t: W.spaceName ? '👥 Everyone in ' + esc(W.spaceName) : '👥 Anyone in the community',
580            d: W.spaceName
581              ? 'Every member of the space can join in. The usual choice.'
582              : 'Every member can join in. The usual choice for a challenge you want people to notice.' },
583          { v: 'level', t: '🪜 Members who have reached a level',
584            d: 'Keeps it for people who already show up. You pick the level next.' },
585          { v: 'invite', t: '✉️ Invite only',
586            d: 'Nobody joins on their own. You email invites, or they arrive from a sign-up page — ' +
587               'right for a paid cohort or a small pilot group.' },
588        ] },
589
590      { key: 'min_level', step: 'Go live', type: 'number', min: 1, required: true,
591        q: 'Which level do they need?',
592        ph: '3',
593        hint: 'Members below this can read the game but cannot take part in it.',
594        when: function (a) { return a.access_rule === 'level'; } },
595
596      // ⚠️ THIS WAS "WHERE DO PEOPLE SIGN UP?" AND IT TOOK A URL.
597      //
598      // Alex, 2026-08-03: "as we haven't got a landing page option… they will
599      // create a community post specifically to sign up… it's not yet
600      // published. Maybe they can choose when to publish it. There are only two
601      // ways in: they're tagged into it from there, or they can be added in
602      // manually through the backend."
603      //
604      // The URL question assumed a landing page the product does not have. A
605      // coach without one had nowhere to point it; a coach with one sent their
606      // members off the platform to a form we know nothing about. And it sat on
607      // the invite branch, which is exactly where it hurt most — the coach most
608      // likely to be running a small private cohort is the least likely to have
609      // built a page for it.
610      //
611      // So it is not a question any more. The plan already drafts the post; this
612      // screen tells them it exists and that publishing it is theirs to choose.
613      { key: 'signup_post', step: 'Go live', type: 'signup',
614        q: 'How people will ask to join',
615        hint: '',
616        when: function (a) { return a.access_rule === 'invite'; } },
617
618      /* ⚠️ TWO DATE QUESTIONS BECAME AT MOST ONE, AND FOR MOST FORMATS NONE.
619       *
620       * Alex, 2026-08-04: "We should never be needing to ask for a finish date…
621       * Does the game format that they've opted for require there to be a start
622       * date for everyone to start at the same time? Or is it something that
623       * people can join whenever they're ready… Basically we haven't thought
624       * about this step enough."
625       *
626       * THE FINISH DATE IS GONE OUTRIGHT. We already know the length — it is
627       * `length_days`, set two screens earlier and clamped by the format — so
628       * the finish is the start plus that. Asking a coach to type an answer we
629       * can compute is how the previous screen got a game whose stated length
630       * and stated finish date disagreed, with nothing to say which was true.
631       * And a game with no start has no finish either, which is the honest
632       * description of one that runs until it is closed.
633       *
634       * THE START DATE IS THE FORMAT'S BUSINESS. A bootcamp has a live call in
635       * it and a showcase cannot judge submissions that are still arriving:
636       * those need everyone on one clock. A daily streak and a bingo card do
637       * not, and asking their coach to pick a day is an interruption dressed as
638       * a decision. `format.start` (game-formats.js) carries which, and its
639       * header states the four mechanics that make a format cohort. */
640      { key: 'opens_at', step: 'Go live', type: 'datetime',
641        q: 'When does everyone start?',
642        hint: 'This format works because everybody is on the same day — a live call or a ' +
643              'closing vote cannot be run one member at a time. Leave it empty to start ' +
644              'the moment you open it.',
645        when: function (a) {
646          var f = _fmt(a.format);
647          return !!(f && f.start === 'cohort');
648        } },
649
650      { key: 'points', step: 'Go live', type: 'weights',
651        q: 'Does ordinary activity score?',
652        hint: 'Points for the specific things you ask for are set on each post. These are for taking ' +
653              'part at all — leave them at zero if only the things you ask for should count.' },
654
655      // F12: a yes or no, answered with two buttons (see the 'publish' screen).
656      { key: 'review', step: 'Go live', type: 'publish',
657        q: 'Ready to open it?' },
658    ].filter(function (Q) {
659      // The publishing questions are not part of the build. They appear only
660      // when the coach presses "Go live" on the built game — so the wizard's
661      // own list is the build's list, and the counter, the road and
662      // `_firstUnanswered` all follow it without knowing anything new.
663      if (Q.step === GOLIVE_STEP && !W.golive) return false;
664      return !Q.when || Q.when(W.answers);
665    });
666  }
667
668  /** Where a question with this key sits right now — the review rows jump by it. */
669  function _indexOfKey(key) {
670    var qs = QUESTIONS();
671    for (var i = 0; i < qs.length; i++) if (qs[i].key === key) return i;
672    return -1;
673  }
674
675  function _stepIndex() {
676    var qs = QUESTIONS();
677    var Q = qs[Math.min(W.qIdx, qs.length - 1)];
678    /* F11: opening it comes AFTER all seven build steps. Clamping it to step 1
679       showed "1 About them, You're here" on the last screen of the flow. */
680    if (Q && Q.step === GOLIVE_STEP) return STEP_LABELS.length + 1;
681    var i = STEP_LABELS.indexOf(Q ? Q.step : 'About');
682    return (i < 0 ? 0 : i) + 1;
683  }
684
685  // ── WHAT COUNTS AS A QUESTION, AND WHAT DOES NOT ──────────────────────
686  //
687  // Alex, 2026-08-04, on "Question 1 of 17": "the one of 17 questions is a
688  // little bit overwhelming… I think when you get into double figures we've
689  // got a problem." Then his own resolution, which is better than bundling:
690  //
691  //   "The questions should only relate to the steps prior to when we start
692  //    giving suggestions… The count should only relate to the questions where
693  //    the customer is giving their input at the beginning and then that's when
694  //    the questions are over because now we're actually delivering stuff to
695  //    them. That's how you reduce the steps down."
696  //
697  // Two faults, one cause. QUESTIONS() re-filters a 21-entry list on every
698  // render, so the DENOMINATOR was (a) everything, including the eleven screens
699  // that are recommendations rather than questions, and (b) LIVE — `min_level`,
700  // `prize` and `finisher_reward` switch on as answers arrive, which is how a
701  // coach saw "1 of 17" and then "8 of 18". A total that grows while you answer
702  // is worse than a big total: it reads as the finish line moving away.
703  //
704  // Scoping it to the opening interview fixes both. It is single figures by
705  // construction rather than by trimming.
706  //
707  // ⚠️ AND 'A bit more' IS DELIBERATELY NOT IN THIS LIST, which looks wrong
708  // until you follow it through. Those questions are generated, so whether the
709  // step exists at all is not known when the coach reads "1 of …". Counting it
710  // would put the denominator back to 5-or-6-decided-later — the very fault
711  // this is fixing, just smaller and later, landing at the worst moment: on the
712  // last screen before it resolves. Counting it always would promise a sixth
713  // question that usually does not exist.
714  //
715  // So the counted set is the FIVE THINGS ONLY THE COACH KNOWS. Fixed at five,
716  // for every coach, forever. The generated step, when it exists, is genuinely
717  // extra — it did not exist when they started — and it shows the step
718  // indicator like every other screen past the interview. Its own title,
719  // "A couple more things", is what tells them what it is; a number would add
720  // nothing except something that can be wrong.
721  var ASK_STEPS = ['About them'];
722
723  /** The opening interview — the only screens that are numbered as questions. */
724  function _asks() {
725    return QUESTIONS().filter(function (Q) { return ASK_STEPS.indexOf(Q.step) >= 0; });
726  }
727
728  /**
729   * Where the coach is, said the way Alex asked for it: a question number while
730   * they are still the one supplying answers, and a STEP number once the app
731   * has started delivering. "One of eight is great" — so both are single
732   * figures, and neither can grow mid-flow.
733   */
734  function _progress() {
735    var qs = QUESTIONS();
736    var Q = qs[Math.min(Math.max(W.qIdx, 0), Math.max(0, qs.length - 1))];
737    if (Q && ASK_STEPS.indexOf(Q.step) >= 0) {
738      var asks = _asks();
739      for (var i = 0; i < asks.length; i++) {
740        if (asks[i].key === Q.key) {
741          return { kind: 'q', label: 'Questions', n: i + 1, total: asks.length };
742        }
743      }
744    }
745    /* ⚠️ THE WALK-THROUGH COUNTS ITSELF, NOT THE BUILD.
746     *
747     * `Go live` is deliberately not in STEP_LABELS (see the note there), so
748     * `_stepIndex` cannot find it, clamps to 0 and returns 1 — a coach
749     * publishing their game was shown "Step 1 of 7", which reads as being sent
750     * back to the beginning at the moment they press the most consequential
751     * button in the flow. Found by the step-exists guard in the wizard tests,
752     * which is exactly what it is for. */
753    if (Q && Q.step === GOLIVE_STEP) {
754      var gl = qs.filter(function (x) { return x.step === GOLIVE_STEP; });
755      for (var g = 0; g < gl.length; g++) {
756        if (gl[g].key === Q.key) {
757          return { kind: 's', label: 'Opening it', n: g + 1, total: gl.length };
758        }
759      }
760    }
761    return { kind: 's', label: 'Step', n: _stepIndex(), total: STEP_LABELS.length };
762  }
763
764  // ── The shell ─────────────────────────────────────────────────────────
765
766  var ARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">' +
767    '<path d="M5 12h14M13 5l7 7-7 7"/></svg>';
768  var BACK_ARROW = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">' +
769    '<path d="M19 12H5M11 19l-7-7 7-7"/></svg>';
770  var TICK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">' +
771    '<path d="M5 12l5 5L20 7"/></svg>';
772  var LOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
773    '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0110 0v4"/></svg>';
774
775  /** The stepper — .road, exactly as the course guide and Launch Studio draw it. */
776  function _roadHtml() {
777    var here = _stepIndex();
778    // While opening it, the road shows that step too, after the seven done ones.
779    var labels = W.golive ? STEP_LABELS.concat([OPEN_LABEL]) : STEP_LABELS;
780    return '<div class="road">' +
781      '<div class="rt">Your game, in ' + labels.length + ' steps</div>' +
782      '<ol>' + labels.map(function (label, idx) {
783        var n = idx + 1;
784        var cls = n < here ? 'done' : (n === here ? 'now' : 'locked');
785        var mark = n === here ? String(n) : (n < here ? TICK : LOCK);
786        var status = n === here ? 'You’re here'
787          : (n < here ? 'Done' : (n === here + 1 ? 'Next' : 'Soon'));
788        // A finished step is a way back. People click these — a stepper that
789        // ignores the click reads as broken, and Back-Back-Back is the tax
790        // for not honouring it.
791        return '<li class="' + cls + '"' +
792          (n < here && label !== OPEN_LABEL ? ' data-gw-step="' + esc(label) + '" style="cursor:pointer"' : '') +
793          '><span class="m">' + mark + '</span>' +
794          '<span class="tx"><span class="n">' + esc(label) + '</span>' +
795          '<span class="s">' + esc(STEP_BLURB[label] || '') + '</span></span>' +
796          '<span class="st">' + status + '</span></li>';
797      }).join('') + '</ol></div>';
798  }
799
800  /** The rail's preview card — what they are building, as it stands. */
801  function _previewHtml(pct) {
802    var a = W.answers;
803    var chan = (W.channels || []).filter(function (c) {
804      return String(c.id) === String(a.channel_id);
805    })[0];
806    var lines = [];
807    // shorten(), not slice() — see its comment. These are the coach's own
808    // words on the rail card, and a hard slice cut them mid-word.
809    if (a.outcome) lines.push('They will have <b>' + esc(shorten(a.outcome, 120)) + '</b>');
810    else if (a.who_for) lines.push('For ' + esc(shorten(a.who_for, 120)));
811    if (chan) lines.push('Runs in <b>#' + esc(chan.name || chan.slug) + '</b>');
812    if (a.access_rule === 'level') lines.push('Level <b>' + esc(String(a.min_level || 0)) + '</b> and up');
813    else if (a.access_rule === 'invite') lines.push('<b>Invite only</b>');
814    else if (a.access_rule === 'free') lines.push(W.spaceName
815      ? 'Open to <b>everyone</b> in ' + esc(W.spaceName)   // a challenge in a space (855)
816      : 'Open to <b>everyone</b> in the community');
817    if (a.closes_at) lines.push('Finishes <b>' + esc(_niceDate(a.closes_at)) + '</b>');
818    return '<div class="design-card">' +
819      '<div class="design-card-head"><div class="ey">What you’re working toward</div>' +
820        // The placeholder name is bookkeeping, not a title — a card headed
821        // "Untitled game — 04/08 09:38" reads as a bug, not as a draft.
822        '<div class="ti">' + esc(_isPlaceholderName(a.name) ? 'Your game' : a.name) + '</div></div>' +
823      (lines.length
824        ? '<div style="padding:0 16px 4px;font-size:13px;line-height:1.6;color:var(--text-3)">' +
825          lines.join('<br>') + '</div>'
826        : '') +
827      '<div class="design-foot"><span class="pulse"></span>' +
828        '<span class="ft"><b>Setting up.</b>
828 Nothing is open to members until you open it.</span>' +
829        '<span class="fp">' + pct + '%</span></div>' +
830    '</div>';
831  }
832
833  function _niceDate(v) {
834    if (!v) return '';
835    try {
836      return App.Dates.short(v);
837    } catch (_) { return ''; }
838  }
839
840  // datetime-local wants local wall-clock, not the ISO the API returns.
841  function _forDateInput(v) {
842    if (!v) return '';
843    try {
844      var d = new Date(v);
845      if (isNaN(d.getTime())) return '';
846      var p = function (n) { return String(n).padStart(2, '0'); };
847      return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) +
848        'T' + p(d.getHours()) + ':' + p(d.getMinutes());
849    } catch (_) { return ''; }
850  }
851  function _fromDateInput(v) {
852    if (!v) return null;
853    var d = new Date(v);
854    return isNaN(d.getTime()) ? null : d.toISOString();
855  }
856
857  var WEIGHTS = [
858    ['post', 'A post'],
859    ['comment', 'A comment'],
860    ['upvote_given', 'A vote they give'],
861    ['upvote_received', 'A vote they receive'],
862  ];
863
864  // ── The question body ─────────────────────────────────────────────────
865
866  // The coach's own reasons, in their words. Matches GOAL_LABEL in
867  // games-service — one list, so the question a coach answers is the fact the
868  // model is later told.
869  /* Which days the coach is around (G30). Sunday-first, matching JavaScript's
870   * own getDay() and the service's COACH_DAYS — the mapping from a date to "is
871   * the coach there" is then an index rather than a lookup table that can drift
872   * from the one on the server. */
873  var DAYS = [
874    { v: 'mon', t: 'Mon' }, { v: 'tue', t: 'Tue' }, { v: 'wed', t: 'Wed' },
875    { v: 'thu', t: 'Thu' }, { v: 'fri', t: 'Fri' }, { v: 'sat', t: 'Sat' },
876    { v: 'sun', t: 'Sun' },
877  ];
878
879  var GOALS = [
880    { v: 'reactivate', t: 'Get the community active again', d: 'It has gone quiet and you want people posting.' },
881    { v: 'onboard',    t: 'Activate new members',           d: 'They join and then nothing happens.' },
882    { v: 'sell',       t: 'Sell something',                 d: 'This ends in an offer.' },
883    { v: 'proof',      t: 'Build proof and testimonials',   d: 'You want their results, in their words.' },
884    { v: 'launch',     t: 'Launch a product',               d: 'Something new is coming and this warms it up.' },
885    { v: 'consume',    t: 'Get people through content',     d: 'They own it and have not finished it.' },
886  ];
887
888  var WIN_LABEL = {
889    completion: 'Everyone who finishes',
890    top_n:      'The top of the leaderboard',
891    both:       'Finishers, and a prize at the top',
892    raffle:     'A draw — a ticket for every thing they do',
893  };
894
895  /** One format from the loaded library. */
896  function _fmt(key) {
897    if (!key) return null;
898    var list = W.formats || [];
899    for (var i = 0; i < list.length; i++) if (list[i].key === key) return list[i];
900    return null;
901  }
902
903  /** Its drawing. Degrades to nothing rather than throwing if the art module
904   *  has not loaded — a missing thumbnail is a worse card, not a broken one. */
905  function _art(key) {
906    try { return window.GameFormatArt ? window.GameFormatArt.art(key) : ''; }
907    catch (_) { return ''; }
908  }
909
910  /**
911   * The detail panel — Alex: "when you click on a template, you should explain
912   * why, what the strengths and weaknesses of this template are, what sort of
913   * audiences it's good for."
914   *
915   * ⚠️ IDENTICAL FOR A COMING-SOON FORMAT. If a coach cannot tell whether they
916   * want it, the demand signal the button collects is worthless.
917   */
918  function _panelHtml(f) {
919    if (!f) return '';
920    var soon = f.status !== 'ready';
921    return '<div class="gw-panel">' +
922      '<h4>' + esc(f.name) + '<span class="gp-feel">' + esc(f.feel) + '</span></h4>' +
923      '<p class="gp-why"><b>Why it works.</b> ' + esc(f.why) + '</p>' +
924      '<p><b>Best for.</b> ' + esc(f.bestFor) + '</p>' +
925      '<div class="gp-cols">' +
926        '<div><b>Strengths</b><ul>' +
927          f.strengths.map(function (x) { return '<li>' + esc(x) + '</li>'; }).join('') +
928        '</ul></div>' +
929        // The honest half. A library where every card is good at everything is
930        // a library nobody can choose from.
931        '<div><b>Watch out for</b><ul>' +
932          f.watchOut.map(function (x) { return '<li>' + esc(x) + '</li>'; }).join('') +
933        '</ul></div>' +
934      '</div>' +
935      (f.needs ? '<p class="gp-needs"><b>What changes if you pick this:</b> ' + esc(f.needs) + '</p>' : '') +
936      (soon
937        ? '<button type="button" class="send-btn" data-gw-want="' + esc(f.key) + '">' +
938          (W.wanted[f.key] ? '✓ Noted — thank you' : 'Tell us you want this') + '</button>'
939        : '<button type="button" class="send-btn" data-gw-format="' + esc(f.key) + '">Use this one</button>') +
940    '</div>';
941  }
942
943  /** One recommended setting: what we chose, why, and a way to change it. */
944  function _runRow(key, label, value, why, editor) {
945    var touched = W.touched && W.touched[key];
946    var open = W.runEdit === key;
947    return '<div class="gw-run' + (open ? ' editing' : '') + '">' +
948      '<div class="gr-l">' + esc(label) + '</div>' +
949      '<div class="gr-v">' + esc(value) +
950        // ⚠️ "You set this" is not decoration. Alex: "we have to honour
951        // whatever has been overridden by the coach so we can't override the
952        // coach." A touched field is never re-recommended over, and the marker
953        // is how a coach sees WHY it did not move when everything around it did.
954        (touched ? '<span class="gr-you">you set this</span>' : '') + '</div>' +
955      '<div class="gr-w">' + esc(why) + '</div>' +
956      '<button type="button" class="nav-btn" data-gw-run="' + key + '">' +
957        (open ? 'Done' : 'Change') + '</button>' +
958      (open ? '<div class="gr-edit">' + editor + '</div>' : '') +
959    '</div>';
960  }
961
962  // ── THE THREE EDITORS, AND WHY THEY ARE NOT A DIALOG ──────────────────
963  //
964  // Alex, 2026-08-04, having been shown a browser prompt() reading
965  // "dev.replicants.ai says — How many days should it run? (7 to 30)":
966  // "We should never ever be using ugly dialogue boxes or ugly buttons…
967  //  You should always use lovely modals, beautiful modals with nice text."
968  //
969  // He is right about the fault and I have taken a narrower fix than the
970  // word "modal", deliberately: a modal to type one number is nearly as
971  // heavy as the native box it replaces, and the rest of this wizard answers
972  // every question with chips and cards in place. So Change opens the control
973  // ON THE ROW. Same principle — our interface, not the browser's — without
974  // putting a dialog in front of a one-number decision.
975  //
976  // The old prompt() also lost two things a control does not: it could not
977  // show the range as anything but text in a sentence, and Cancel and "typed
978  // nonsense" came back indistinguishable (`Number('abc') || cur`), so a
979  // mistyped value silently became the old one.
980
981  function _daysEditor(f) {
982    var lo = f ? f.days.min : 1, hi = f ? f.days.max : 30;
983    var cur = Number(W.answers.length_days || (f ? f.days.def : 14));
984    return '<div class="gr-steps">' +
985      '<button type="button" class="gr-step" data-gw-days="' + Math.max(lo, cur - 1) + '"' +
986        (cur <= lo ? ' disabled' : '') + ' aria-label="One day shorter">−</button>' +
987      '<div class="gr-num"><b>' + cur + '</b><span>days</span></div>' +
988      '<button type="button" class="gr-step" data-gw-days="' + Math.min(hi, cur + 1) + '"' +
989        (cur >= hi ? ' disabled' : '') + ' aria-label="One day longer">+</button>' +
990      '<input class="gr-range" type="range" min="' + lo + '" max="' + hi + '" value="' + cur +
991        '" data-gw-days-range aria-label="How many days it runs">' +
992      '</div>' +
993      '<p class="gr-hint">This format runs from ' + lo + ' to ' + hi + ' days.</p>';
994  }
995
996  // The four are named ONCE, in WIN_LABEL, and described once, in _whyWin. The
997  // old cycling button had the order hardcoded a second time; a fifth mode
998  // would have had to be remembered in two places, and the one that got
999  // forgotten would simply have been unreachable with no error to say so.
1000  var WIN_ORDER = ['completion', 'top_n', 'both', 'raffle'];
1001
1002  function _winEditor(f) {
1003    var cur = W.answers.win_mode || 'completion';
1004    return '<div class="gr-picks">' + WIN_ORDER.map(function (v) {
1005      return '<button type="button" class="gr-pick' + (v === cur ? ' on' : '') +
1006        '" data-gw-win="' + v + '">' +
1007        '<span class="gp-t">' + esc(WIN_LABEL[v]) + '</span>' +
1008        '<span class="gp-d">' + esc(_whyWin(v, f)) + '</span></button>';
1009    }).join('') + '</div>';
1010  }
1011
1012  function _datesEditor() {
1013    var cur = W.answers.date_mode === 'hard' ? 'hard' : 'soft';
1014    var opts = [
1015      { v: 'soft', t: 'Soft deadlines',
1016        d: 'A missed day still scores, at half. One slip does not end their game.' },
1017      { v: 'hard', t: 'Hard deadlines',
1018        d: 'Miss the day and it does not count. Needed when rounds have to close.' },
1019    ];
1020    return '<div class="gr-picks">' + opts.map(function (o) {
1021      return '<button type="button" class="gr-pick' + (o.v === cur ? ' on' : '') +
1022        '" data-gw-dates="' + o.v + '">' +
1023        '<span class="gp-t">' + esc(o.t) + '</span>' +
1024        '<span class="gp-d">' + esc(o.d) + '</span></button>';
1025    }).join('') + '</div>';
1026  }
1027
1028  /**
1029   * Why this length — and ⚠️ IT MUST NOT CLAIM THE COACH'S NUMBER IS THE
1030   * FORMAT'S.
1031   *
1032   * This read `d + ' days is what this format is built for'` where `d` was the
1033   * CURRENT value. So the moment a coach moved the slider to 14 on a format
1034   * whose default is 7, the card said "14 days is what this format is built
1035   * for" directly beside the chip reading "YOU SET THIS" — two claims about the
1036   * same number, contradicting each other, in one card. Alex hit exactly that.
1037   *
1038   * Now the sentence knows which it is looking at.
1039   */
1040  function _whyDays(f, a) {
1041    if (!f) return 'Long enough to matter, short enough that the end is in sight.';
1042    var d = Number(a.length_days || f.days.def);
1043    var mine = d !== f.days.def;
1044    var lead = mine
1045      ? 'You set ' + d + ' days. This format is built for ' + f.days.def +
1046        ' and runs from ' + f.days.min + ' to ' + f.days.max + '.'
1047      : d + ' days is what this format is built for — it runs from ' + f.days.min +
1048        ' to ' + f.days.max + '.';
1049    return lead + ' ' +
1050      (String(a.blocker || '').trim()
1051        ? 'You said: “' + shorten(a.blocker, 80) + '”.'
1052        : 'Past 30 days it stops being a challenge and becomes a programme.');
1053  }
1054
1055  function _whyWin(mode, f) {
1056    if (mode === 'completion') return 'Your outcome is something they either did or did not do. Ranking people would make the person in twelfth stop trying.';
1057    if (mode === 'raffle') return 'A ticket for every thing they do, drawn at the end — so the person in fourteenth still has a reason to turn up on day twenty.';
1058    if (mode === 'both') return 'A reward for everyone who finishes, and a prize at the top for the one who did most.';
1059    return 'The leaderboard decides it. Works when the thing you are counting is genuinely comparable.';
1060  }
1061
1062  function _whyDates(mode, f) {
1063    if (mode === 'hard') return 'This format needs its rounds to actually end — submissions have to close before anyone can judge them.';
1064    return 'A missed day still scores, at half. One slip does not end their game, which is the single biggest reason people finish.';
1065  }
1066
1067  function _bodyHtml(Q, i, total) {
1068    var a = W.answers;
1069    // The kicker says what the screen IS. On the interview it is a numbered
1070    // question; past it, it is a step of eight — see _progress().
1071    var p = _progress();
1072    var body = '<div class="q-kicker">' +
1073        (p.kind === 'q' ? 'Question ' + p.n + ' of ' + p.total
1074                        : 'Step ' + p.n + ' of ' + p.total) + '</div>' +
1075      '<h2 class="q-title">' + esc(_framed(Q)) + '</h2>' +
1076      (Q.hint ? '<p class="q-body">' + esc(Q.hint) + '</p>' : '') +
1077      /* ⚠️ WHERE A PREFILLED ANSWER CAME FROM, SAID OUT LOUD. A box that is
1078         already full with no explanation reads as the screen having decided
1079         something. Told where it came from, and that changing it is expected,
1080         it reads as us having listened. See `_prefillFromBrain`. */
1081      ((W.prefilled && W.prefilled[Q.key])
1082        ? '<p class="q-body gw-prefill">This is what you told us your community '
1083          + 'is for. Change it if this game is for a different group.</p>'
1084        : '');
1085
1086    if (Q.type === 'options') {
1087      body += _optsHtml(Q.opts || [], a[Q.key]) + _sendRowHtml(i, 'Continue');
1088      return body;
1089    }
1090
1091    if (Q.type === 'channel') {
1092      body += _channelHtml(a[Q.key]) + _sendRowHtml(i, 'Continue');
1093      return body;
1094    }
1095
1096    if (Q.type === 'weights') {
1097      var w = (a.points && typeof a.points === 'object') ? a.points : {};
1098      body += '<div class="field-grid">' + WEIGHTS.map(function (p) {
1099        return '<div class="field"><label for="gw-w-' + p[0] + '">' + esc(p[1]) + '</label>' +
1100          '<input class="input" id="gw-w-' + p[0] + '" data-gw-weight="' + p[0] + '" ' +
1101          'type="number" min="0" step="1" value="' + esc(String(w[p[0]] == null ? 0 : w[p[0]])) + '"></div>';
1102      }).join('') + '</div>' + _sendRowHtml(i, 'Save and carry on');
1103      return body;
1104    }
1105
1106    // ── Why are you running it? ──────────────────────────────────────
1107    //
1108    // Chips rather than a dropdown, because the answer is genuinely plural and
1109    // a multi-select dropdown is the worst control on the web. Capped at two in
1110    // the handler AND said out loud here, so the cap is a stated rule rather
1111    // than a click that silently does nothing.
1112    if (Q.type === 'goals') {
1113      var picked = Array.isArray(a.coach_goals) ? a.coach_goals : [];
1114      body += '<div class="gw-chips">' + GOALS.map(function (g) {
1115        var on = picked.indexOf(g.v) >= 0;
1116        var full = picked.length >= 2 && !on;
1117        return '<button type="button" class="gw-chip' + (on ? ' on' : '') +
1118          (full ? ' dim' : '') + '" data-gw-goal="' + g.v + '">' +
1119          '<span class="gc-t">' + esc(g.t) + '</span>' +
1120          '<span class="gc-d">' + esc(g.d) + '</span></button>';
1121      }).join('') + '</div>' +
1122      '<p class="q-body gw-note">' + (picked.length >= 2
1123        ? 'Two is the limit — a game aimed at four things achieves none of them. Tap one to swap it.'
1124        : 'Pick one or two.') + '</p>' +
1125      _sendRowHtml(i, picked.length ? 'Continue' : 'Skip this');
1126      return body;
1127    }
1128
1129    /* ── Which days the coach is there (G30) ─────────────────────────
1130     *
1131     * The goals chips again rather than a new control, because it is the same
1132     * question shape — pick several from a short fixed set — and a second
1133     * multi-select idiom on the same wizard would be two things to learn for no
1134     * reason. No cap here: a coach who is there all week should be able to say
1135     * so, and saying so is what turns the constraint OFF.
1136     *
1137     * ⚠️ NONE PICKED IS A REAL ANSWER, and the sentence has to say what it
1138     * means. Left unsaid, a coach reads an empty row as a question they have
1139     * not finished rather than as "do not move anything". */
1140    if (Q.type === 'days') {
1141      var onDays = Array.isArray(a.coach_days) ? a.coach_days : [];
1142      body += '<div class="gw-chips gw-days">' + DAYS.map(function (d) {
1143        var on = onDays.indexOf(d.v) >= 0;
1144        return '<button type="button" class="gw-chip' + (on ? ' on' : '') +
1145          '" data-gw-day="' + d.v + '" aria-pressed="' + (on ? 'true' : 'false') + '">' +
1146          '<span class="gc-t">' + esc(d.t) + '</span></button>';
1147      }).join('') + '</div>' +
1148      '<p class="q-body gw-note">' + (onDays.length
1149        ? 'Anything that needs you to look at it lands on one of these.'
1150        : 'Nothing picked, so nothing gets moved — every post goes out on the day the ' +
1151          'journey says.') + '</p>' +
1152      _sendRowHtml(i, onDays.length ? 'Continue' : 'Skip this');
1153      return body;
1154    }
1155
1156    // ── The up-to-three ──────────────────────────────────────────────
1157    //
1158    // ⚠️ ZERO IS THE EXPECTED ANSWER, SO ZERO MUST LOOK DELIBERATE. A step that
1159    // renders empty reads as a step that failed to load, and a coach who thinks
1160    // the wizard broke does not trust the plan it produces afterwards. So the
1161    // empty case gets its own sentence saying we have what we need.
1162    if (Q.type === 'dynamic') {
1163      if (W.dynBusy) {
1164        body += '<p class="q-body">Reading back what you told me…</p>' + _sendRowHtml(i, 'Skip ahead');
1165        return body;
1166      }
1167      var qs = W.dynamic || [];
1168      if (!qs.length) {
1169        body += '<div class="gw-empty-ok">' +
1170          '<p><b>Nothing else to ask.</b> What you have told me is enough to build ' +
1171          'you a proper game.</p></div>' + _sendRowHtml(i, 'Good — carry on');
1172        return body;
1173      }
1174      body += qs.map(function (d, n) {
1175        return '<div class="gw-dyn">' +
1176          '<label class="gw-dyn-q" for="gw-dyn-' + n + '">' + esc(d.question) + '</label>' +
1177          (d.why ? '<div class="gw-dyn-why">' + esc(d.why) + '</div>' : '') +
1178          '<input class="input ccw-answer" id="gw-dyn-' + n + '" data-gw-dyn="' + n + '" ' +
1179          'type="text" value="' + esc(a['dyn_' + n] || '') + '" ' +
1180          'placeholder="A line is plenty">' +
1181        '</div>';
1182      }).join('') + _sendRowHtml(i, 'Continue');
1183      return body;
1184    }
1185
1186    // ── The gallery ──────────────────────────────────────────────────
1187    if (Q.type === 'gallery') {
1188      if (W.fmtBusy) {
1189        body += '<p class="q-body">Working out what suits you…</p>' + _sendRowHtml(i, 'Skip ahead');
1190        return body;
1191      }
1192      var rec = W.recommendation;
1193      var chosen = a.format || (rec && rec.buildable ? rec.format : null);
1194
1195      if (rec) {
1196        var recFmt = _fmt(rec.format);
1197        // ⚠️ WHEN THE BEST FIT IS NOT BUILT YET, SAY SO. Alex, on the
1198        // coming-soon cards: "we can then gauge, on that, how many people
1199        // contact us asking for it." A coach being told "the one I would
1200        // actually pick for you is not ready" is the strongest demand signal
1201        // available and it costs nothing — but it must never be the silent
1202        // default, only a stated second-best.
1203        body += '<div class="gw-rec' + (rec.buildable ? '' : ' not-ready') + '">' +
1204          '<div class="gw-rec-art">' + _art(rec.format) + '</div>' +
1205          '<div class="gw-rec-t">' +
1206            '<div class="gw-rec-tag">' + (rec.buildable ? 'Recommended' : 'The one I’d pick — not ready yet') + '</div>' +
1207            '<h3>' + esc(recFmt ? recFmt.name : rec.format) + '</h3>' +
1208            '<p>' + esc(rec.why) + '</p>' +
1209            (!rec.buildable
1210              ? '<p class="gw-rec-alt"><b>It isn’t built yet.</b> Tell us you want it and we’ll ' +
1211                'know to build it — then pick the closest thing below.</p>' +
1212                '<button type="button" class="nav-btn" data-gw-want="' + esc(rec.format) + '">' +
1213                (W.wanted[rec.format] ? '✓ Noted — thank you' : 'Tell us you want this') + '</button>'
1214              : '') +
1215            (rec.runner_up ? '<p class="gw-rec-alt"><b>Second choice: ' +
1216              esc((_fmt(rec.runner_up) || {}).name || rec.runner_up) + '</b> — ' +
1217              esc(rec.runner_up_why) + '</p>' : '') +
1218          '</div></div>';
1219      }
1220
1221      body += '<div class="gw-gal">
1221' + (W.formats || []).map(function (f) {
1222        var soon = f.status !== 'ready';
1223        return '<button type="button" class="gw-card' + (soon ? ' soon' : '') +
1224          (chosen === f.key ? ' on' : '') + '" data-gw-format="' + esc(f.key) + '">' +
1225          '<span class="gw-card-art">' + _art(f.key) + '</span>' +
1226          '<span class="gw-card-n">' + esc(f.name) +
1227            (soon ? '<span class="gw-soon">Coming soon</span>' : '') + '</span>' +
1228          '<span class="gw-card-f">' + esc(f.feel) + '</span>' +
1229        '</button>';
1230      }).join('') + '</div>';
1231
1232      if (W.panel) body += _panelHtml(_fmt(W.panel));
1233      body += _sendRowHtml(i, chosen ? 'Use this one' : 'Pick one to carry on');
1234      return body;
1235    }
1236
1237    // ── What's in it ─────────────────────────────────────────────────
1238    if (Q.type === 'levers') {
1239      if (W.levBusy) {
1240        body += '<p class="q-body">Checking what you already have set up…</p>' + _sendRowHtml(i, 'Skip ahead');
1241        return body;
1242      }
1243      body += '<div class="gw-levers">' + (W.levers || []).map(function (l) {
1244        return '<label class="gw-lev' + (l.on ? ' on' : '') + '">' +
1245          '<input type="checkbox" data-gw-lever="' + esc(l.key) + '"' + (l.on ? ' checked' : '') + '>' +
1246          '<span class="gl-i" aria-hidden="true">' + (LEVER_ART[l.key] || LEVER_ART._) + '</span>' +
1247          '<span class="gl-t">' + esc(l.label) +
1248            // ⚠️ THE BADGE IS "WHAT THE FORMAT PICKED", NOT "WHAT IS TICKED".
1249            // It stays on a lever the coach has since unticked, because
1250            // "recommended, and you turned it off" is a thing worth being able
1251            // to see — and a badge that just mirrors the checkbox says nothing
1252            // the checkbox has not already said.
1253            (l.recommended
1254              ? '<span class="gl-rec" title="' + esc(_recWhy(l)) + '">★ Recommended</span>'
1255              : '') + '</span>' +
1256          '<span class="gl-b">' + esc(l.becomes) + '</span>' +
1257          '<span class="gl-c">' + esc(l.cost) + '</span>' +
1258          (l.recommended ? '<span class="gl-why">' + esc(_recWhy(l)) + '</span>' : '') +
1259          // Setup is a FLAG, never a blocker — the course levers' rule, kept.
1260          // A coach can publish with this outstanding; they just need to know.
1261          (l.on && !l.ready && l.setup
1262            ? '<span class="gl-need">Needs setting up first: ' + esc(l.setup.need) +
1263              ' — ' + esc(l.setup.why) + ' You can do it later.</span>'
1264            : '') +
1265        '</label>';
1266      }).join('') + '</div>' + _sendRowHtml(i, 'That’s right');
1267      return body;
1268    }
1269
1270    // ── Three recommendations, not three questions ───────────────────
1271    if (Q.type === 'settings') {
1272      var f = _fmt(a.format);
1273      body += '<div class="gw-runs">' +
1274        _runRow('days', 'How long', String(a.length_days || (f ? f.days.def : 14)) + ' days',
1275          _whyDays(f, a), _daysEditor(f)) +
1276        _runRow('win', 'How it’s won', WIN_LABEL[a.win_mode] || WIN_LABEL.completion,
1277          _whyWin(a.win_mode, f), _winEditor(f)) +
1278        _runRow('dates', 'Deadlines', a.date_mode === 'hard' ? 'Hard' : 'Soft',
1279          _whyDates(a.date_mode, f), _datesEditor()) +
1280      '</div>' + _sendRowHtml(i, 'Run it like that');
1281      return body;
1282    }
1283
1284    // Not a question — a statement, with the one control that IS a choice.
1285    if (Q.type === 'signup') {
1286      body += '<div class="gw-empty-ok">' +
1287        '<p><b>You do not need a sign-up page.</b> We have written you a post that ' +
1288        'invites people to comment if they want in — it is in the plan, as a draft.</p>' +
1289        '<p>Publish it whenever you are ready, from the game\'s plan board. Anyone who ' +
1290        'comments, you add. Nobody gets into an invite-only game on their own.</p>' +
1291        '</div>' + _sendRowHtml(i, 'Got it');
1292      return body;
1293    }
1294
1295    /* F12: "Ready to open it?" is a yes or no. It had no screen of its own and
1296       fell through to the free-text box, so a coach was handed an empty box
1297       and "Save answer" for the most consequential press in the flow (and
1298       typing anything at all opened the game). Two buttons that say what
1299       each one does. */
1300    if (Q.type === 'publish') {
1301      var whoSees = W.spaceName ? 'Members of ' + esc(W.spaceName) : 'Your members';
1302      body += '<p class="q-body">' + whoSees + ' will see it and can join in straight away. ' +
1303        'You can close it again from its settings.</p>' +
1304        '<div class="gw-built-acts" style="margin-top:18px">' +
1305          '<button class="send-btn" type="button" data-gw-next' + (W.saving ? ' disabled' : '') + '>' +
1306            (W.saving ? 'Opening it…' : 'Yes, open it to members') + '</button>' +
1307          '<button class="btn btn-secondary" type="button" data-gw-later>Not yet</button>' +
1308        '</div>';
1309      return body;
1310    }
1311
1312    if (Q.type === 'review') {
1313      // While the primer is up it owns the screen. Showing the plan summary and
1314      // a Publish button underneath an explanation of what a plan IS would be
1315      // the wall this step is being rebuilt to remove — and would offer the
1316      // single most consequential button on the flow to someone who has been
1317      // told they are still waiting.
1318      if (_primerUp()) {
1319        body += _planHtml() +
1320          _sendRowHtml(i, W.plan ? 'See your plan' :
1321            (W.primer >= PRIMER.length - 1 ? 'Waiting for it…' : 'Next'));
1322        return body;
1323      }
1324      /* ⚠️ THE BUILD ENDS HERE, AND IT ENDS WITH A GAME.
1325       *
1326       * This button used to read "Build it and publish", which did both in one
1327       * press and then dropped the coach into eight more questions. Building
1328       * and publishing are different jobs — one is creative and ends with
1329       * something they are pleased with, the other is logistics they can do
1330       * when they are ready. The course draws the line in exactly this place. */
1331      if (W.built) {
1332        body += _builtHtml() + _editHtml();
1333        return body;
1334      }
1335      if (W.mayaFailed) { body += _planHtml(); return body; }
1336      body += _simpleNoteHtml() + _planHtml() + _reviewHtml() +
1337        _sendRowHtml(i, W.plan ? 'Build it' : 'Build the game') +
1338        _editHtml();
1339      return body;
1340    }
1341
1342    if (Q.type === 'datetime') {
1343      // Alex, 2026-08-03: "the calendar icon button to pull up the calendar
1344      // should be nearer, or it should automatically pull it up when you click
1345      // on the calendar as well. Just make it a bit easier."
1346      //
1347      // Both halves. The field was full-width, which parks the browser's little
1348      // calendar glyph at the far right of the card — a long way from the
1349      // dd/mm/yyyy it belongs to — so it is capped at the width the date
1350      // actually needs. And clicking anywhere on it opens the picker (wired in
1351      // _wire), so the glyph stops being the only way in.
1352      body += '<div class="composer one-line">' +
1353        '<input class="ccw-answer" data-gw-answer data-no-voice type="datetime-local" ' +
1354        'style="max-width:280px" value="' +
1355        esc(_forDateInput(a[Q.key])) + '">' +
1356        _composerRowHtml(i, a[Q.key] ? 'Save answer' : 'Leave it empty') +
1357      '</div>';
1358      return body;
1359    }
1360
1361    if (Q.type === 'number') {
1362      body += '<div class="composer one-line">' +
1363        '<input class="ccw-answer" data-gw-answer data-no-voice type="number" min="' +
1364        (Q.min == null ? 0 : Q.min) + '" step="1" placeholder="' + esc(Q.ph || '') +
1365        '" value="' + esc(a[Q.key] == null ? '' : String(a[Q.key])) + '">' +
1366        _composerRowHtml(i, 'Save answer') +
1367      '</div>';
1368      return body;
1369    }
1370
1371    // The name, with three to pick from. They sit ABOVE the field rather than
1372    // replacing it — a coach who has known the name all along should not have
1373    // to read three suggestions to get to the box.
1374    if (Q.key === 'name') {
1375      if (W.namesBusy) body += '<p class="q-body">Thinking of a few…</p>';
1376      else if (W.names && W.names.length) {
1377        body += '<div class="gw-names">' + W.names.map(function (n) {
1378          return '<button type="button" class="gw-name' +
1379            (String(a.name || '') === n ? ' on' : '') + '" data-gw-name="' + esc(n) + '">' +
1380            esc(n) + '</button>';
1381        }).join('') + '</div>';
1382      }
1383    }
1384
1385    // Plain text. `.ccw-answer` is the class VoiceInput sweeps, so the Speak
1386    // button appears under it without this file rendering one.
1387    body += '<div class="composer' + (Q.single ? ' one-line' : '') + '">' +
1388      (Q.single
1389        ? '<input class="ccw-answer" data-gw-answer type="text" maxlength="' + (Q.max || 120) +
1390          '" placeholder="' +
1391          esc(Q.ph || '') + '" value="' + esc(a[Q.key] || '') + '">'
1392        : '<textarea class="ccw-answer" data-gw-answer placeholder="' +
1393          esc(Q.ph || 'Type your answer here — or tap “Speak” and just talk.') + '">' +
1394          esc(a[Q.key] || '') + '</textarea>') +
1395      _composerRowHtml(i, 'Save answer') +
1396    '</div>';
1397    return body;
1398  }
1399
1400  /**
1401   * The footer, and EVERY question has one.
1402   *
1403   * ⚠️ This is the fix for the fault Alex hit on 2026-08-03: "there's no
1404   * button to move to the next step so I'm stuck at step four of eight."
1405   *
1406   * Step four is "Who can take part?", and it is a choice-card question. The
1407   * cards answered on click and the footer was a lone Back button, on the
1408   * reasoning that a card IS the answer so a Send button would be furniture.
1409   * That reasoning has a hole in it exactly one screen wide: `access_rule`
1410   * DEFAULTS to 'free', so the coach arrives to find a card already lit and
1411   * nothing that says "go on then". Clicking the lit card does move on — but
1412   * nothing on screen says so, and a highlighted answer with no visible way
1413   * forward reads as a dead end, because that is what it looks like.
1414   *
1415   * So the click-to-advance shortcut stays (it is genuinely quicker) and a
1416   * Continue button sits underneath it always. Two ways forward, no state in
1417   * which there are none.
1418   *
1419   * ⚠️ AND THAT INCLUDES THE LOADING STATES. The advisory screens each fetch
1420   * something on arrival, and the first cut of all three returned early with a
1421   * "working on it…" line and NO FOOTER — so a coach on a slow connection, or
1422   * one whose recommendation call is queued behind a spend cap, got the same
1423   * dead end Alex hit on step four: a screen with nothing to press. The guard
1424   * test caught it, which is exactly what it is for. Every early return now
1425   * carries the footer, and the button says "Skip ahead" rather than
1426   * "Continue", because moving on before the answer arrives is a real choice
1427   * and should read like one.
1428   */
1429  function _composerRowHtml(i, sendLabel) {
1430    var busy = W.saving;
1431    return '<div class="composer-row">' +
1432      '<div class="cr-left">' + (i > 0
1433        ? '<button class="nav-btn" type="button" data-gw-back' + (busy ? ' disabled' : '') + '>' +
1434          BACK_ARROW + ' Back</button>'
1435        : '') + '</div>' +
1436      '<button class="send-btn" type="button" data-gw-next' + (busy ? ' disabled' : '') + '>' +
1437        esc(busy ? (W.building ? 'Writing your posts…' : 'Saving…') : sendLabel) + (busy ? '' : ' ' + ARROW) + '</button>' +
1438    '</div>' +
1439    /* ⚠️ BUILDING IS NOT SAVING. "Build it" writes every post of the plan in the
1440     * coach's voice (games-service writePlanPosts), which took 91 to 113 seconds
1441     * as one call and still takes the better part of a minute in batches. A
1442     * button that says "Saving…" for that long reads as stuck (LOG.md,
1443     * 2026-10-01), so it says what is happening and roughly how long. */
1444    (busy && W.building
1445      ? '<p class="gw-building" role="status" aria-live="polite">Writing the ' +
1446        (W.plan && W.plan.posts && W.plan.posts.length ? W.plan.posts.length + ' ' : '') +
1447        'posts for your game in your voice. This takes up to a minute. You can stay on this page.</p>'
1448      : '');
1449  }
1450
1451  /**
1452   * The end of the build, and the door to publishing.
1453   *
1454   * The course's Fill-in screen does exactly this: a primary "Go live →"
1455   * (`data-ccw-golive`, course-creation-wizard.js:8946) and no obligation to
1456   * press it. Alex, on what this moment is for: "the goal of this point, which
1457   * is actually to have a game set up and then they're excited about the game.
1458   * The dating stuff becomes a formality that they can do at publishing time."
1459   */
1460  function _builtHtml() {
1461    var a = W.answers;
1462    var name = _isPlaceholderName(a.name) ? 'Your game' : a.name;
1463    var stops = ((W.plan && W.plan.milestones) || []).length;
1464    var asks = ((W.plan && W.plan.asks) || []).length;
1465    var posts = ((W.plan && W.plan.posts) || []).length;
1466    var bits = [];
1467    if (stops) bits.push(_plural(stops, 'stop'));
1468    if (asks) bits.push(asks + (asks === 1 ? ' thing to do' : ' things to do'));
1469    if (posts) bits.push(posts + (posts === 1 ? ' post written' : ' posts written'));
1470    return '<div class="gw-built">' +
1471      '<div class="gw-built-tick">✓</div>' +
1472      '<h3>' + esc(name) + ' is built.</h3>' +
1473      '<p>' + (bits.length ? esc(bits.join(' · ')) + '. ' : '') +
1474        'Nobody can see it yet. It stays a draft until you open it to members.</p>' +
1475      '<p class="gw-built-next">Opening it to members takes a couple of answers: ' +
1476        (_needsChannel() ? 'where it happens, ' : '') + 'who can join' +
1477        (_fmt(a.format) && _fmt(a.format).start === 'cohort' ? ', and when everyone starts' : '') +
1478        '. You can do that now or come back to it.</p>' +
1479      '<div class="gw-built-acts">' +
1480        '<button class="send-btn" type="button" data-gw-golive>Open it to members ' + ARROW + '</button>' +
1481        '<button class="btn btn-secondary" type="button" data-gw-later>Not just yet</button>' +
1482      '</div>' +
1483    '</div>' + _pendingCardHtml();
1484  }
1485
1486  /* ── G15: the questions listen to the answers already given ───────────
1487   *
1488   * Alex, 2026-08-04: "some of the questions are quite difficult to understand,
1489   * just because they're kind of new ideas… we've now got information in the
1490   * previous answers to help frame the question better. For example it says,
1491   * 'What stops them today?' and we're doing a game all about improving your
1492   * football and we've already given the goal. It could be framed a bit more
1493   * clearly like, 'What stops them today from achieving the goal of getting the
1494   * ball in the top bins'… with the goal of making the questions much easier to
1495   * understand so that their response really trips off."
1496   *
1497   * ⚠️ AND HIS GUARD RAIL IS THE WHOLE DESIGN: "We have to be very careful that
1498   * it doesn't go too extreme where the question starts getting silly."
1499   *
1500   * A model rewriting each question is the obvious build and the wrong one. It
1501   * costs a metered call in the middle of someone typing, it adds latency to
1502   * the most fragile screen in the flow, and "silly" is exactly what a model
1503   * asked to be helpful with a coach's half-sentence produces. So the framing
1504   * is DETERMINISTIC and uses the coach's own words verbatim — which is also
1505   * the thing that makes it read as listening rather than as cleverness.
1506   *
1507   * Three rules keep it out of trouble, and the third is the one that matters:
1508   *   1. Only a SHORT, clean fragment is used — one clause, no full stop, no
1509   *      line break. A coach who pasted three sentences gets the plain question.
1510   *   2. Their words are quoted, never conjugated. "Posted their first client
1511   *      win" goes in as it was typed; no attempt to make it agree with the
1512   *      sentence around it, because that is where silly comes from.
1513   *   3. Anything that fails either test falls back to today's question exactly.
1514   *      The framed version is a bonus, never a dependency.
1515   */
1516  var FRAME_MAX = 64;
1517
1518  /** A previous answer, if it is short and clean enough to quote inside a
1519   *  question. Returns '' when it is not — which is the common case for a
1520   *  coach who wrote a paragraph, and is fine. */
1521  function _fragment(key) {
1522    var v = String((W.answers && W.answers[key]) || '').trim();
1523    if (!v || v.length > FRAME_MAX) return '';
1524    if (/[\r\n]/.test(v)) return '';
1525    // A full stop mid-answer means sentences, not a phrase. A trailing one is
1526    // just punctuation and comes off.
1527    v = v.replace(/[.!?]+$/, '').trim();
1528    if (/[.!?]/.test(v)) return '';
1529    if (v.split(/\s+/).length < 2) return '';
1530    return v;
1531  }
1532
1533  /** The question, framed from what they have already said where that helps. */
1534  function _framed(Q) {
1535    if (!Q || !Q.q) return '';
1536    if (Q.key === 'outcome') {
1537      var who = _fragment('who_for');
1538      // "By the end, what will THEY have done" — naming who "they" are is the
1539      // whole gain, and it is the question coaches read twice.
1540      if (who) return 'By the end, what will ' + who + ' have DONE that they haven’t yet?';
1541    }
1542    if (Q.key === 'blocker') {
1543      /* ⚠️ HIS CONSTRUCTION, NOT MINE — "from ACHIEVING THE GOAL OF …" — and it
1544       * is load-bearing rather than decorative. The outcome is a VERB phrase
1545       * ("Get the ball in the top bins", "Posted their first client win"), and
1546       * dropping it straight after "from" produces "What stops them today from
1547       * get the ball in the top bins?" — which is the silly he warned about,
1548       * and which my own rule about never conjugating had already forbidden.
1549       * His wording gives the fragment a noun slot, and the quotes make any
1550       * tense of their own reading as a citation rather than a mistake. */
1551      var out = _fragment('outcome');
1552      if (out) return 'What stops them today from achieving the goal of “' + out + '”?';
1553      /* ⚠️ AND THERE IS NO who_for FALLBACK HERE, DELIBERATELY. It read
1554       * "What stops players who can already keep the ball up today?" — where
1555       * the trailing "today" is swallowed by the fragment and the sentence
1556       * becomes ambiguous. The gain was small and the ambiguity was real, so
1557       * the plain question is better. Only the outcome frames this one, which
1558       * is the example Alex actually wrote out. */
1559    }
1560    if (Q.key === 'start_point') {
1561      var who3 = _fragment('who_for');
1562      if (who3) return 'Where are ' + who3 + ' starting from?';
1563    }
1564    return Q.q;
1565  }
1566
1567  function _sendRowHtml(i, label) {
1568    return '<div style="margin-top:18px">' + _composerRowHtml(i, label) + '</div>';
1569  }
1570
1571  function _optsHtml(opts, chosen) {
1572    return '<div class="ccw-opts cols-3">
1572' + opts.map(function (o) {
1573      return '<button type="button" class="ccw-opt' +
1574        (String(chosen == null ? '' : chosen) === String(o.v) ? ' on' : '') +
1575        '" data-gw-opt="' + esc(o.v) + '">' +
1576        '<div class="t">' + o.t + '</div><div class="d">' + esc(o.d) + '</div></button>';
1577    }).join('') + '</div>';
1578  }
1579
1580  // ── Picking a channel, when there may be hundreds ─────────────────────
1581  //
1582  // Alex, 2026-08-03: "you also have to give them an option to create a new
1583  // channel. Imagine also here there might be hundreds of channels so it needs
1584  // to be designed so it doesn't get overblown."
1585  //
1586  // Both halves of that are the same problem — a coach whose community has one
1587  // channel and a coach whose community has three hundred are looking at the
1588  // same question — so the control has three modes and picks its own:
1589  //
1590  //   none          "Make one now" is the ONLY thing on screen. A game with no
1591  //                 channel counts nothing, so this is not a step to walk past.
1592  //   a handful     every channel as a card, the way it has always been.
1593  //   more than 8   a search box first, and at most 12 cards under it, with a
1594  //                 line saying how many are not shown. Three hundred cards is
1595  //                 not a choice, it is a wall.
1596  //
1597  // The create row is present in all three. It posts to the same endpoint the
1598  // channels screen uses (POST /community-hosting/channels), so a channel made
1599  // here is an ordinary channel — no game-owned flavour of one exists, and
1600  // inventing one would strand it when the game ends.
1601
1602  /* ── G14: the channel names itself ────────────────────────────────────
1603   *
1604   * Alex, 2026-08-04: "when it comes to suggesting a name for the channel,
1605   * we'll make a new channel. It can suggest the name for the channel as well
1606   * if you haven't come up with the name because it's got enough information
1607   * at that stage to give you a name."
1608   *
1609   * ⚠️ AND IT COSTS NOTHING, BECAUSE THE INFORMATION IS THE GAME'S OWN NAME.
1610   * The obvious build is a metered call — there is one for the game's name
1611   * already (`suggestNames`). It would be the wrong tool twice over: a channel
1612   * name is a short label rather than copy, and by this point the coach has
1613   * ALREADY chosen the game's name two steps earlier (G18g moved it to step 4),
1614   * so the honest suggestion is that name, tidied. Asking a model to invent a
1615   * different one would be a second name for the same thing.
1616   *
1617   * The tidying is what a coach would do by hand: drop a leading article, drop
1618   * the words every challenge ends with, and cap it at something that fits a
1619   * channel list. "30-Day Momentum Challenge" → "30-day momentum". If nothing
1620   * survives that, the game's own name goes in whole rather than a blank box —
1621   * a suggestion nobody has to accept is better than an empty field.
1622   */
1623  /* ⚠️ TWO WORDS, NOT EIGHT. The first cut stripped challenge, game, programme,
1624   * sprint, bootcamp, club and group — and turned "Book Club" into "Book",
1625   * which is not a tidier name, it is a different one. Only these two are
1626   * genuinely redundant in a list of game channels; every other word a coach
1627   * chose is part of what they called it. Caught by running the tidy over the
1628   * names a coach would actually type rather than the ones that suited it. */
1629  var CHAN_NOISE = /\b(challenge|game)\b/gi;
1630
1631  function _suggestChannelName() {
1632    var raw = W.answers && W.answers.name;
1633    if (_isPlaceholderName(raw)) return '';
1634    var n = String(raw || '').trim();
1635    if (!n) return '';
1636    var t = n.replace(/^(the|a|an)\s+/i, '').replace(CHAN_NOISE, ' ')
1637             .replace(/\s{2,}/g, ' ').replace(/[\s\-–—:]+$/, '').trim();
1638    if (t.length < 3) t = n;
1639    // Channels read as lower-case labels in the list, so the suggestion looks
1640    // like the thing it will become rather than like a title.
1641    t = t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
1642    return t.slice(0, 60);
1643  }
1644
1645  var CHAN_SEARCH_AT = 8;   // above this many, ask before showing
1646  var CHAN_SHOW_MAX  = 12;  // most cards on screen at once
1647
1648  /** Every channel a game may live in. General is never one of them: it is
1649   *  where the whole community posts, and a game gates its channel to players
1650   *  (games-service refuses it too; this keeps the refusal off the screen). */
1651  function _gameChannels() {
1652    return (W.channels || []).filter(function (c) { return String(c.slug || '') !== 'general'; });
1653  }
1654
1655  function _chanMatches() {
1656    var q = String(W.chanQuery || '').trim().toLowerCase();
1657    var all = _gameChannels();
1658    if (!q) return all;
1659    return all.filter(function (c) {
1660      return String(c.name || '').toLowerCase().indexOf(q) >= 0 ||
1661             String(c.slug || '').toLowerCase().indexOf(q) >= 0;
1662    });
1663  }
1664
1665  /** The cards and the "N more" line — repainted on every keystroke. */
1666  function _chanListHtml(chosen) {
1667    var all = _gameChannels();
1668    var hits = _chanMatches();
1669    var shown = hits.slice(0, CHAN_SHOW_MAX);
1670
1671    if (!all.length) {
1672      return '<div class="ccw-note"><div class="t">This community has no channels yet.</div>' +
1673        '<div class="d">A game needs somewhere to live — its posts, its comments and everything ' +
1674        'it counts happen in one channel. Make the first one below.</div></div>';
1675    }
1676    if (!hits.length) {
1677      return '<div class="ccw-note"><div class="t">Nothing matches “' + esc(W.chanQuery) + '”.</div>' +
1678        '<div class="d">Try fewer letters, or make a new channel below.</div></div>';
1679    }
1680
1681    return _optsHtml(shown.map(function (c) {
1682      return {
1683        v: String(c.id),
1684        t: '#' + esc(c.name || c.slug),
1685        d: Number(c.min_level || 0) > 0
1686          ? 'Members reach this channel at level ' + Number(c.min_level) + '.'
1687          : 'Open to every member of the community.',
1688      };
1689    }), chosen) +
1690    (hits.length > shown.length
1691      ? '<div class="hint" style="margin-top:8px">Showing ' + shown.length + ' of ' + hits.length +
1692        '. Type above to narrow it down.</div>'
1693      : '');
1694  }
1695
1696  function _channelHtml(chosen) {
1697    var all = _gameChannels();
1698    var search = all.length > CHAN_SEARCH_AT
1699      ? '<div class="composer one-line" style="margin-bottom:12px">' +
1700          '<input class="ccw-answer" data-gw-chansearch data-no-voice type="search" ' +
1701          'placeholder="Search your ' + all.length + ' channels" value="' +
1702          esc(W.chanQuery || '') + '">' +
1703        '</div>'
1704      : '';
1705
1706    var create = '<div style="margin-top:14px">' +
1707      (W.chanNew
1708        ? '<div class="field"><label for="gw-chan-name">Name the new channel</label>' +
1709            '<div style="display:flex;gap:8px;align-items:flex-start">' +
1710              '<input class="input" id="gw-chan-name" data-gw-channame type="text" maxlength="60" ' +
1711                'value="' + esc(_suggestChannelName()) + '" ' +
1712                'placeholder="e.g. 30-day challenge" style="flex:1">' +
1713              '<button class="send-btn" type="button" data-gw-chanmake' +
1714                (W.chanBusy ? ' disabled' : '') + '>' +
1715                (W.chanBusy ? 'Making it…' : 'Create it') + '</button>' +
1716            '</div>' +
1717            '<div class="hint">' +
1718              (_suggestChannelName()
1719                ? 'Named after your game — change it if you would rather. '
1720                : '') +
1721              'Members see it in the community straight away, and you can rename ' +
1722              'or reorder it later on the Channels screen.</div>' +
1723          '</div>'
1724        : '<button class="nav-btn" type="button" data-gw-channew>+ Make a new channel for it</button>') +
1725      (W.chanError
1726        ? '<div class="hint" style="color:var(--danger,#c0423a);margin-top:8px">' +
1727          esc(W.chanError) + '</div>'
1728        : '') +
1729    '</div>';
1730
1731    return search +
1732      '<div data-gw-chanlist>' + _chanListHtml(chosen) + '</div>' +
1733      create;
1734  }
1735
1736  // ── The last step: what will actually be built ────────────────────────
1737  //
1738  // The old review listed the answers back. This one shows the THING — the
1739  // pitch, the asks, the posts — because a coach cannot judge "is this a good
1740  // game" from a table of the values they just typed.
1741  //
1742  // ⚠️ THE COST LINE IS THE POINT OF THIS SCREEN.
1743  //
1744  // An ask whose evidence the platform cannot observe needs a human decision on
1745  // every single claim, forever. A generated plan full of those reads
1746  // perfectly and buries a coach in week two, and there is nowhere else in the
1747  // product they would find that out before it happened. So it is stated in
1748  // submissions — the unit the work actually arrives in — and it is counted
1749  // from the plan and the community's real size, never estimated.
1750
1751  /** One line of the plan: what it is worth, what it is, and who checks it. */
1752  /* One row of the plan: points, the ask, who scores it. Laid out by
1753     css/game-wizard.css (.gw-prow), which stacks it at phone width: the inline
1754     three-column grid it used to carry squeezed the ask to a word a line at
1755     390px and pushed "scores itself" off the screen (round 3). */
1756  function _planRow(lead, title, sub, tail) {
1757    return '<div class="gw-prow">' +
1758      '<div class="gw-prow-l">' + esc(lead) + '</div>' +
1759      '<div class="gw-prow-t"><b>' + esc(title) + '</b>' +
1760        (sub ? '<div class="hint">' + esc(sub) + '</div>' : '') + '</div>' +
1761      '<div class="hint gw-prow-r">' + esc(tail) + '</div>' +
1762    '</div>';
1763  }
1764  /* This file's one way to count a noun ("1 stop", "2 stops"). */
1765  function _plural(n, one, many) { n = Number(n) || 0; return n + ' ' + (n === 1 ? one : (many || one + 's')); }
1766
1767  /* ══════════════════════════════════════════════════════════════════════
1768     THE PRIMER — WHAT THE WAIT IS FOR
1769
1770     Alex, 2026-08-04: "look at how we've done this in the course. We've done a
1771     nice little page where it says we're now building your game or now building
1772     your course. It gives a little bit of information that they can read to
1773     prepare them for what's next… They need to understand what happens next so
1774     they're mentally prepared for the next steps because it's all going to be
1775     completely new and unexpected for the person doing this."
1776
1777     The wait already existed and was dead: one line reading "Putting a game
1778     together…". It is the single moment in this flow where the product does its
1779     most impressive work, and it looked like the emptiest screen in it.
1780
1781     Three rules inherited from the course's own working screen
1782     (course-creation-wizard.js, _renderWorking) rather than re-decided:
1783     say WHO is working and WHAT FROM, say they may leave, and — stated in that
1784     file and true here for the same reason — ⚠️ NO PERCENTAGE BAR. This is one
1785     model call. A bar would be invented, and inventing progress is the thing we
1786     have been told never to do.
1787
1788     ⚠️ AND THE DECK IS NOT A GATE. Alex's wording allows for it and I said I
1789     would build it this way: the moment the draft lands, the forward button
1790     becomes "See your plan" on whatever card they are on. A deck that paces a
1791     wait is a kindness; the same deck in front of a finished result is an
1792     obstacle, and the difference is one condition.
1793     ══════════════════════════════════════════════════════════════════════ */
1794  var PRIMER = [
1795    { t: 'Your game is a journey, not a list',
1796      d: 'It runs in stops. Each stop is a few days apart, and each one has a small ' +
1797         'amount of work hung on it — something a member can actually finish in a sitting.',
1798      w: 'What you are about to read is those stops, in order.' },
1799    { t: 'Your members see the same drawing',
1800      d: 'The timeline you are about to approve is the one they get, with their own ' +
1801         'position marked on it. There is no second version written for them.',
1802      w: 'So if a stop reads oddly to you, it will read oddly to them.' },
1803    { t: 'Some of it you will have to check by hand',
1804      d: 'Anything a computer can see — a post, a comment, a streak — scores itself. ' +
1805         'Anything else needs your eyes on it.',
1806      w: 'The plan tells you how many, before you commit to it, not after.' },
1807    { t: 'Nothing goes out until you say so',
1808      d: 'The game needs a few posts along the way, and we will write them with you ' +
1809         'once the journey is settled. Publishing makes the game visible; it does not ' +
1810         'send anything to anybody.',
1811      w: 'You can change every word first.' },
1812  ];
1813
1814  /* ══════════════════════════════════════════════════════════════════════
1815     ONE SCROLL BECOMES FOUR CARDS
1816
1817     Alex, 2026-08-04, on this screen: "this page is too wordy and it's a bit
1818     overwhelming… you've just chunked. We should always be avoiding these big
1819     walls of text that are overwhelming and confusing."
1820
1821     What was wrong is not the amount of information — a coach about to commit
1822     to a month of work should see all of it — it is that four different KINDS
1823     of thing were rendered at one weight, in one column, in one scroll: a
1824     journey, a scoring cost, five drafted posts and a rules block. Nothing told
1825     the eye which of them it had to read now.
1826
1827     So: the two that decide whether the plan is any good are open, and the two
1828     that are reference material are shut with their contents counted on the
1829     lid. Nothing is removed and nothing is behind more than one click.
1830
1831     ⚠️ Open state lives in W, not in <details>. _render rewrites innerHTML, so a
1832     native <details open> would snap shut under the coach every time anything
1833     on the screen changed — including their own click.
1834     ══════════════════════════════════════════════════════════════════════ */
1835  var CARDS_DEFAULT = { journey: true, cost: true, posts: false, rules: false, answers: false };
1836
1837  /* ── An icon per lever ────────────────────────────────────────────────
1838   *
1839   * Alex, 2026-08-04: "Use some icon imagery here for each one to make it a
1840   * little bit more exciting."
1841   *
1842   * Drawn in code and in currentColor, for the same reason the twelve format
1843   * thumbnails are: no asset pipeline, and they follow the shell into dark mode
1844   * without a second set of files. Each one is the SHAPE of the commitment —
1845   * a calendar for scheduled calls, a clock for a booking, a tick for something
1846   * you approve — rather than decoration.
1847   */
1848  var _svg = function (d, extra) {
1849    return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
1850      'stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' + d +
1851      (extra || '') + '</svg>';
1852  };
1853  //
1854  // ⚠️ THE KEYS ARE THE REGISTRY'S, CHECKED AGAINST IT. The first cut of this
1855  // map invented `confirm`, `product` and `leaderboard`; the real ones are
1856  // `review`, `offer` and `channel`. Three levers would have silently fallen
1857  // back to the placeholder circle, which is the quietest possible failure —
1858  // it looks like a design choice.
1859  var LEVER_ART = {
1860    _:          _svg('<circle cx="12" cy="12" r="8"/>'),
1861    // A calendar: something that goes in the diary.
1862    calls:      _svg('<rect x="3" y="5" width="18" height="16" rx="2"/><path d="M3 10h18M8 3v4M16 3v4"/>'),
1863    // A clock: a slot someone books out of your day.
1864    one_to_one: _svg('<circle cx="12" cy="12" r="8"/><path d="M12 8v4l3 2"/>'),
1865    // A tick: you look at it and decide.
1866    review:     _svg('<path d="M4 13l5 5L20 6"/>'),
1867    // A sheet with lines: a thing they fill in.
1868    workbook:   _svg('<path d="M5 4h11l4 4v12H5z"/><path d="M16 4v4h4M9 13h7M9 17h5"/>'),
1869    // An envelope: the only lever that sends anything.
1870    nudges:     _svg('<path d="M3 6h18v12H3z"/><path d="M3 7l9 6 9-6"/>'),
1871    // A rosette: everyone who finishes, not just the winner.
1872    finisher:   _svg('<circle cx="12" cy="9" r="5"/><path d="M9 13.5L8 21l4-2 4 2-1-7.5"/>'),
1873    // A box: something you sell them at the end.
1874    offer:      _svg('<path d="M4 8l8-4 8 4v8l-8 4-8-4z"/><path d="M4 8l8 4 8-4M12 12v8"/>'),
1875    // A speech bubble: a room of its own for it to happen in.
1876    channel:    _svg('<path d="M21 12a8 8 0 0 1-8 8H7l-4 3v-6a8 8 0 0 1 8-8h2a8 8 0 0 1 8 3z"/>'),
1877  };
1878
1879  /* Why the format picked this one. Short, because it is a tooltip and a
1880   * one-line note, not an essay — the cost line underneath is the detail.
1881   *
1882   * ⚠️ NO ARTICLE GLUED TO A VARIABLE. The first cut read 'A ' + name, which
1883   * produced "A this format works better…" the moment the library had not
1884   * loaded — the same fault as the name generator's "The Bought the course and
1885   * never Challenge". A sentence that needs a noun phrase cannot be built by
1886   * concatenation unless BOTH branches are written as whole sentences. */
1887  function _recWhy(l) {
1888    var f = _fmt(W.answers.format);
1889    if (!f) return 'Recommended for the format you picked.';
1890    return 'Part of what makes a ' + String(f.name || '').toLowerCase() + ' work.';
1891  }
1892
1893  function _cardOpen(key) {
1894    if (!W.cards) W.cards = {};
1895    return W.cards[key] == null ? !!CARDS_DEFAULT[key] : !!W.cards[key];
1896  }
1897
1898  /** One card. `meta` is what the lid says when it is shut — a count, so a
1899   *  closed card still tells the coach how much is inside it. */
1900  function _cardHtml(key, title, meta, body) {
1901    var open = _cardOpen(key);
1902    return '<section class="gw-pc' + (open ? ' open' : '') + '">' +
1903      '<button type="button" class="gw-pc-h" data-gw-card="' + esc(key) + '" ' +
1904        'aria-expanded="' + (open ? 'true' : 'false') + '">' +
1905        '<span class="pc-t">' + esc(title) + '</span>' +
1906        (meta ? '<span class="pc-m">
1906' + esc(meta) + '</span>' : '') +
1907        '<span class="pc-c" aria-hidden="true"></span>' +
1908      '</button>' +
1909      (open ? '<div class="gw-pc-b">' + body + '</div>' : '') +
1910    '</section>';
1911  }
1912
1913  /* ══════════════════════════════════════════════════════════════════════
1914     MAYA, ON THE PLAN STEP
1915
1916     Alex, 2026-08-04: "Once you've come to the course plan, you've got Maya
1917     alongside you that's already trained up to work with you… Yes Maya
1918     definitely should be at this point forward." And, when I proposed putting
1919     her on the last two steps as well: "Only put Maya where it makes sense.
1920     Don't put her in for the sake of it, definitely."
1921
1922     So: this step only. Name it already drafts three names for you and Go live
1923     is a channel and two dates — a chat panel beside either would be furniture
1924     saying "talk to me" on a screen with nothing to discuss.
1925
1926     ⚠️ IT IS THE COURSE BUILDER'S PANEL, NOT A COPY OF IT. Same file, same
1927     stylesheet, same thread, same accepted/passed measurement — pointed at the
1928     games endpoints through the `base` seam. CLAUDE.md names six duplicate Maya
1929     implementations as debt; this is deliberately not the seventh.
1930
1931     ⚠️ AND THE NODE IS KEPT, NOT REMOUNTED. _render rewrites the whole screen
1932     on every state change — opening a plan card, toggling an editor — and
1933     mounting the panel again each time would wipe the conversation the coach is
1934     in the middle of. So the panel lives on a detached element that is moved
1935     back into place after each repaint. Mounted once per game.
1936     ══════════════════════════════════════════════════════════════════════ */
1937  function _mayaCtx() {
1938    return {
1939      api: _api,
1940      base: '/community-hosting/games/' + W.gameId + '/builder',
1941      // A game plan is one object. There is nothing to select, so no job is
1942      // ever refused for want of a target.
1943      needsTarget: function () { return false; },
1944      role: 'Working on this game with you',
1945      wholeLabel: 'This game',
1946      // NA8: an older turn with no `did` reads "Helped with this game", never "this course".
1947      thingLabel: 'this game',
1948      // No paperclip: a file can only go on a lesson, and a game has none.
1949      attach: false,
1950      target: function () { return null; },
1951      /* ⚠️ EVERYTHING THE COACH TYPES IS AN INSTRUCTION SHE MAY ACT ON.
1952       *
1953       * This was `plan_feedback`, whose only possible output is a DESCRIPTION
1954       * of a change — so "yes, go ahead" came back as another description, and
1955       * Alex hit it: "I told her to do it herself and she just came back with
1956       * the same answer again." `plan_apply` is the one job that can both
1957       * answer a question and rewrite the plan, and it decides which from the
1958       * thread it is already holding. */
1959      freeTextJob: function () { return 'plan_apply'; },
1960      chipsCourse: [
1961        { job: 'plan_apply', label: 'Something is missing',
1962          brief: 'Something is missing from this plan. Look at it again against what I ' +
1963            'told you about my people, and add what should be there.' },
1964        { job: 'format_advice', label: 'Is this the right shape?' },
1965        { job: 'explain', label: 'Why these stops?',
1966          brief: 'Why did you put the stops where you did?' },
1967      ],
1968      /* She has just rewritten the plan, so the screen has to stop showing the
1969       * old one. It re-reads rather than taking the copy that came back with
1970       * the turn: the plan endpoint also recomputes the QUEUE COST, and that
1971       * number — how many submissions this will put in front of the coach — is
1972       * the one thing on this screen that must never disagree with the plan
1973       * beside it. Two sources for that would drift on the first apply. */
1974      onApplied: function (r) {
1975        // A waiting change on a built game changed nothing yet (NA8): the
1976        // plan on screen is still the game's, and onPending shows the change.
1977        if (r && r.pending) return;
1978        W.editedSince = false; W.feedbackSince = false;
1979        _loadPlan(false);
1980      },
1981      /* ⚠️ SHE SUGGESTS IT; THE COACH TAKES IT. Post-engine spec §5. Alex,
1982       * 2026-08-04: "what you should do is maybe say it's a suggestion and then
1983       * you have to confirm it for it to go in. You also need an undo button."
1984       *
1985       * The Confirm is appended to the message she just sent, so nothing sends
1986       * the coach to another screen — his earlier rule holds. What changed is
1987       * that the change now has a moment where it can be refused. */
1988      onSuggested: function (r, el) {
1989        if (!el) return;
1990        var wrap = document.createElement('div');
1991        wrap.className = 'gw-sugg';
1992        var what = (r.changed || []).map(function (c) {
1993          return '<li>' + esc(String(c)) + '</li>';
1994        }).join('');
1995        wrap.innerHTML = (what ? '<ul class="gw-sugg-list">' + what + '</ul>' : '') +
1996          '<div class="gw-sugg-act">' +
1997            '<button type="button" class="btn btn-primary" data-gw-confirm>Put it in</button>' +
1998            '<button type="button" class="btn btn-secondary" data-gw-dismiss>Leave it</button>' +
1999          '</div>';
2000        el.appendChild(wrap);
2001        wrap.querySelector('[data-gw-confirm]').addEventListener('click', function () {
2002          _settleSuggestion(wrap, true);
2003        });
2004        wrap.querySelector('[data-gw-dismiss]').addEventListener('click', function () {
2005          _settleSuggestion(wrap, false);
2006        });
2007      },
2008      /* NA8: on a built game her change waits for the coach. It is shown on
2009       * her message, said in full, with Approve it / Throw it away. */
2010      onPending: function (r, el) {
2011        W.pending = { pending: true, changes: r.pending, summary: '' };
2012        W.pendingInThread = true;
2013        if (!el) { _render(); return; }
2014        var wrap = document.createElement('div');
2015        wrap.className = 'gw-sugg gw-pend-msg';
2016        wrap.innerHTML = _pendingListHtml(r.pending) + _pendingActsHtml();
2017        el.appendChild(wrap);
2018        _wirePending(wrap);
2019      },
2020      opener: 'I have your answers, the format and the plan in front of me. Tell me what ' +
2021        'to change — a stop that does not fit, something you would never ask your people ' +
2022        'to do, anything missing — and I will write it for you. You say yes to it here, ' +
2023        'and you can put it back if you change your mind.',
2024    };
2025  }
2026
2027  /**
2028   * Take the suggestion, or leave it.
2029   *
2030   * ⚠️ THE BUTTONS GO FIRST, WHATEVER HAPPENS NEXT. A coach who presses Put it
2031   * in and sees the pair still sitting there presses again, and the second
2032   * press is a confirm with nothing to confirm — a 400 and a message saying
2033   * something went wrong, on a change that worked. Replacing them with the
2034   * outcome is also what makes the thread readable afterwards: it says what was
2035   * decided, not what could have been.
2036   */
2037  async function _settleSuggestion(wrap, take) {
2038    if (!W.gameId || !wrap || wrap.getAttribute('data-gw-done')) return;
2039    wrap.setAttribute('data-gw-done', '1');
2040    var act = wrap.querySelector('.gw-sugg-act');
2041    if (act) act.innerHTML = '<span class="gw-sugg-busy">' +
2042      (take ? 'Putting it in…' : 'Leaving it…') + '</span>';
2043    try {
2044      await _api('/community-hosting/games/' + W.gameId + '/builder/suggestion/' +
2045        (take ? 'confirm' : 'dismiss'), { method: 'POST', body: {} });
2046      if (act) {
2047        act.innerHTML = take
2048          ? '<span class="gw-sugg-done">In the plan. ' +
2049            '<button type="button" class="gw-link" data-gw-undo>Undo that</button></span>'
2050          : '<span class="gw-sugg-done">Left as it was.</span>';
2051        var u = act.querySelector('[data-gw-undo]');
2052        if (u) u.addEventListener('click', function () { _undoPlan(act); });
2053      }
2054      if (take) { W.editedSince = false; W.feedbackSince = false; _loadPlan(false); }
2055    } catch (err) {
2056      // The suggestion is still on the server, so the honest recovery is to let
2057      // them try again rather than to leave a dead message in the thread.
2058      wrap.removeAttribute('data-gw-done');
2059      if (act) act.innerHTML =
2060        '<button type="button" class="btn btn-primary" data-gw-confirm>Put it in</button>' +
2061        '<button type="button" class="btn btn-secondary" data-gw-dismiss>Leave it</button>' +
2062        '<div class="gw-sugg-err">That did not save. Try again.</div>';
2063      var c = act && act.querySelector('[data-gw-confirm]');
2064      var d = act && act.querySelector('[data-gw-dismiss]');
2065      if (c) c.addEventListener('click', function () { _settleSuggestion(wrap, true); });
2066      if (d) d.addEventListener('click', function () { _settleSuggestion(wrap, false); });
2067    }
2068  }
2069
2070  /**
2071   * Put the plan back to what it was before the last confirmed change.
2072   *
2073   * No version picker here, and that is deliberate: a coach pressing Undo is not
2074   * choosing from a list, they are saying "not that". The history exists behind
2075   * `GET /games/:id/plan/versions` for a screen that wants to offer the list.
2076   */
2077  async function _undoPlan(host) {
2078    if (!W.gameId) return;
2079    if (host) host.innerHTML = '<span class="gw-sugg-busy">Putting it back…</span>';
2080    try {
2081      await _api('/community-hosting/games/' + W.gameId + '/plan/undo',
2082        { method: 'POST', body: {} });
2083      if (host) host.innerHTML = '<span class="gw-sugg-done">Put back.</span>';
2084      W.editedSince = false; W.feedbackSince = false;
2085      _loadPlan(false);
2086    } catch (err) {
2087      if (host) host.innerHTML = '<span class="gw-sugg-err">' +
2088        'There was nothing to go back to.</span>';
2089    }
2090  }
2091
2092  /* ══════════════════════════════════════════════════════════════════════
2093     NA8 (Anne, round 4, sixth check): A WAITING CHANGE ON A BUILT GAME IS
2094     SHOWN, AND DECIDED, WHERE MAYA MADE IT.
2095
2096     On a built game she cannot change the game itself: she writes a waiting
2097     change (`pending_plan`) for the coach to approve. That change used to be
2098     visible only under Manage once the game was open, so on this screen she
2099     said she had "redrafted the whole plan" and there was nothing to look at,
2100     approve or throw away. Now the change is read from the game (every post it
2101     touches, what cannot be done and why, any post still carrying an old
2102     prize) and shown under her reply, and on the built card after a reload,
2103     with the same two decisions the Manage screen offers, through the same
2104     endpoints.
2105     ══════════════════════════════════════════════════════════════════════ */
2106  async function _loadPending() {
2107    if (!W.gameId || !W.built) { W.pending = null; return; }
2108    try {
2109      var r = await _api('/community-hosting/games/' + W.gameId + '/pending');
2110      W.pending = (r && r.pending) ? r : null;
2111    } catch (_) { W.pending = null; }
2112  }
2113
2114  /** The change, said in full. Same headings as the Manage screen. */
2115  function _pendingListHtml(c) {
2116    c = c || {};
2117    var block = function (rows, head, warn) {
2118      if (!rows || !rows.length) return '';
2119      return '<div class="gw-pend-sec' + (warn ? ' warn' : '') + '"><p class="gw-pend-h">' + esc(head) + '</p>' +
2120        '<ul class="gw-sugg-list">' + rows.map(function (x) { return '<li>' + esc(String(x)) + '</li>'; }).join('') + '</ul></div>';
2121    };
2122    var prize = (c.old_prize || []).map(function (p) {
2123      return '"' + p.title + '" still mentions the old prize (' + p.prize + ')';
2124    });
2125    return block(c.added, 'New things to do') +
2126      block(c.copy, 'Wording') +
2127      block(c.activities, 'Changes to the things to do') +
2128      block(prize, 'Read before you approve', true) +
2129      block(c.posts_already_out, 'Already out, so left as they are') +
2130      block(c.posts_left, 'Not in this change, so left as they are') +
2131      block(c.blocked, 'Cannot be done, so left out', true);
2132  }
2133
2134  function _pendingActsHtml() {
2135    return '<div class="gw-sugg-act">' +
2136      '<button type="button" class="btn btn-primary" data-gw-pend-apply>Approve it</button>' +
2137      '<button type="button" class="btn btn-secondary" data-gw-pend-discard>Throw it away</button>' +
2138    '</div>';
2139  }
2140
2141  /** On the built card, after a reload: the change her reply pointed at. */
2142  function _pendingCardHtml() {
2143    if (!W.pending || W.pendingInThread) return '';
2144    return '<div class="gw-pend" data-gw-pend>' +
2145      '<h4>Maya has a change waiting for you</h4>' +
2146      '<p class="gw-pend-sub">' + esc(W.pending.summary || '') +
2147        ' Nothing has changed yet. The game stays exactly as it is until you approve it.</p>' +
2148      _pendingListHtml(W.pending.changes) + _pendingActsHtml() +
2149    '</div>';
2150  }
2151
2152  function _wirePending(root) {
2153    if (!root) return;
2154    var a = root.querySelector('[data-gw-pend-apply]');
2155    var d = root.querySelector('[data-gw-pend-discard]');
2156    if (a) a.addEventListener('click', function () { _settlePending(root, true); });
2157    if (d) d.addEventListener('click', function () { _settlePending(root, false); });
2158  }
2159
2160  /** Approve or throw away, through the Manage screen's own endpoints. */
2161  async function _settlePending(root, take) {
2162    if (!W.gameId || !root || root.getAttribute('data-gw-done')) return;
2163    if (take && window.App && App.dialog && typeof App.dialog.confirm === 'function') {
2164      var ok = await App.dialog.confirm({
2165        title: 'Approve this change?',
2166        body: W.published
2167          ? 'It goes into your game now and your members will see it. Anything listed as left out stays exactly as it is.'
2168          : 'It goes into your game now. Members see it once you open the game. Anything listed as left out stays exactly as it is.',
2169        confirmLabel: 'Approve it', cancelLabel: 'Not yet',
2170      });
2171      if (!ok) return;
2172    }
2173    root.setAttribute('data-gw-done', '1');
2174    var act = root.querySelector('.gw-sugg-act');
2175    if (act) act.innerHTML = '<span class="gw-sugg-busy">' + (take ? 'Putting it in…' : 'Throwing it away…') + '</span>';
2176    try {
2177      var r = await _api('/community-hosting/games/' + W.gameId + '/pending/' + (take ? 'apply' : 'discard'),
2178        { method: 'POST', body: {} });
2179      if (!r || r.error) throw new Error((r && r.error) || 'failed');
2180      var said = 'Thrown away. Nothing on the game changed.';
2181      if (take) {
2182        var ap = r.applied || {};
2183        var bits = [];
2184        var n = function (k, one, many) { if (k) bits.push(k + ' ' + (k === 1 ? one : many)); };
2185        n((ap.posts || []).length, 'post reworded', 'posts reworded');
2186        n((ap.new_posts || []).length, 'new post', 'new posts');
2187        n((ap.added || []).length, 'new thing to do', 'new things to do');
2188        n((ap.activities || []).length, 'thing to do changed', 'things to do changed');
2189        said = 'Approved. ' + (bits.length ? 'Done: ' + bits.join(', ') + '.' : 'Your game has the change.') +
2190          ((r.refused || []).length ? ' What could not be done was left as it was.' : '');
2191      }
2192      if (act) act.innerHTML = '<span class="gw-sugg-done">' + esc(said) + '</span>';
2193      W.pending = null; W.pendingInThread = false;
2194      W.editedSince = false; W.feedbackSince = false;
2195      if (take) _loadPlan(false); else _render();
2196    } catch (err) {
2197      root.removeAttribute('data-gw-done');
2198      if (act) act.innerHTML = _pendingActsHtml().replace(/^<div class="gw-sugg-act">|<\/div>$/g, '') +
2199        '<div class="gw-sugg-err">That did not go through. Try again.</div>';
2200      _wirePending(root);
2201    }
2202  }
2203
2204  /** Where she belongs: the plan step, once the primer is out of the way. */
2205  function _mayaHere(Q) {
2206    return !!(Q && Q.type === 'review' && !_primerUp() && W.gameId);
2207  }
2208
2209  function _maya(host) {
2210    if (!host || !W.gameId) return;
2211    var P = window.App && window.App.CourseBuilderPanel;
2212    // No panel loaded is a quieter screen, not a broken one. It happens if the
2213    // script order changes; the plan is still readable and still publishable.
2214    if (!P || typeof P.mount !== 'function') return;
2215    /* ⚠️ IN THE DOCUMENT FIRST, THEN MOUNTED. THE ORDER IS THE WHOLE BUG.
2216     *
2217     * Alex, 2026-08-04: "The Speak It UI is all wrong. It should be below the
2218     * box… Just look at the other UIs for the Maya and you'll see how this is
2219     * wrong." He was right, and it was this line.
2220     *
2221     * The panel attaches the platform's dictation control to its own composer
2222     * (`VoiceInput.attach`), and that control decides WHERE to put itself by
2223     * climbing out of any row-flex ancestor — it reads `getComputedStyle`. On a
2224     * node that is not in the document yet, `getComputedStyle(...).display` is
2225     * the empty string, so the climb stops immediately and the control is
2226     * inserted next to the textarea INSIDE the composer instead of in its own
2227     * row beneath the box.
2228     *
2229     * Measured in headless Chromium rather than reasoned about: detached →
2230     * `display: ""` → the row lands inside `.rc-composer`; attached →
2231     * `display: "flex"` → it lands in `.rc-foot`, below the box, which is what
2232     * the course page has always shown. The course host mounts into a node that
2233     * is already on the page, which is exactly why his "look at the other UIs"
2234     * was the right instinct.
2235     *
2236     * It costs nothing to get right and it is invisible to read, so: append,
2237     * then mount. */
2238    var fresh = !W.mayaEl;
2239    if (fresh) {
2240      W.mayaEl = document.createElement('div');
2241      W.mayaEl.className = 'gw-maya';
2242    }
2243    host.appendChild(W.mayaEl);
2244    if (fresh) {
2245      try { P.mount(W.mayaEl, _mayaCtx(), W.mayaEl); }
2246      catch (_) { if (W.mayaEl.parentNode) W.mayaEl.parentNode.removeChild(W.mayaEl); W.mayaEl = null; }
2247    }
2248  }
2249
2250  /** Is the deck currently the thing on screen? One place, so the renderer,
2251   *  the footer label and the forward handler cannot disagree about it. */
2252  function _primerUp() {
2253    return W.primer != null && !W.primerDone && !W.planErr && !W.mayaFailed;
2254  }
2255
2256  function _primerHtml() {
2257    var n = Math.max(0, Math.min(PRIMER.length - 1, W.primer || 0));
2258    var card = PRIMER[n];
2259    var ready = !!W.plan;
2260    var last = n === PRIMER.length - 1;
2261    return '<div class="gw-primer">' +
2262      '<div class="gp-head">' +
2263        '<span class="gp-dots"><i></i><i></i><i></i></span>' +
2264        '<span class="gp-who">' + (ready
2265          ? (W.planBy === 'simple' ? 'The simple version is ready' : 'Your game plan is ready')
2266          : 'Maya is writing your game plan') + '</span>' +
2267      '</div>' +
2268      '<p class="gp-sub">' + (ready
2269        ? 'Read the rest whenever you like — it is all in the plan too.'
2270        : 'Building it from your answers. <b>You can stay and read, or come back later</b> — ' +
2271          'everything is saved.') + '</p>' +
2272      '<div class="gp-card">' +
2273        '<div class="gp-n">' + (n + 1) + ' of ' + PRIMER.length + '</div>' +
2274        '<h3 class="gp-t">' + esc(card.t) + '</h3>' +
2275        '<p class="gp-d">' + esc(card.d) + '</p>' +
2276        '<p class="gp-w">' + esc(card.w) + '</p>' +
2277      '</div>' +
2278      // ⚠️ THE PIPS ARE BUTTONS, AND THAT IS THE FIX FOR A REAL FAULT.
2279      //
2280      // Alex, 2026-08-04: "for the one of four little tips, there's no way for
2281      // me to scroll through the four of them. If I click on it, I'm just stuck
2282      // on the first one."
2283      //
2284      // He was, and it was mine. Making the deck not-a-gate, I gave the footer
2285      // a single button — and when the plan comes back quickly that button says
2286      // "See your plan", so the only way forward LEAVES the deck. Cards 2, 3
2287      // and 4 became unreachable. I removed the gate by removing the door.
2288      //
2289      // So the deck now has its own way through, independent of the footer: the
2290      // pips are real buttons and they already looked like a pager.
2291      '<div class="gp-pips">' + PRIMER.map(function (_, k) {
2292        return '<button type="button" class="' + (k === n ? 'on' : (k < n ? 'seen' : '')) +
2293          '" data-gw-primer="' + k + '" aria-label="Tip ' + (k + 1) + ' of ' + PRIMER.length +
2294          '"' + (k === n ? ' aria-current="true"' : '') + '></button>';
2295      }).join('') + '</div>' +
2296      // And a plain forward, so nobody has to discover that the dots are live.
2297      (n < PRIMER.length - 1
2298        ? '<div class="gp-more"><button type="button" class="nav-btn" data-gw-primer="' +
2299          (n + 1) + '">Next tip ' + ARROW + '</button></div>'
2300        : '') +
2301      // The still-building state Alex asked for by name: "it either shows them
2302      // that their game is still being built… or it will take them into the
2303      // plan." Only reachable by reading faster than the model writes.
2304      (last && !ready
2305        ? '<p class="gp-wait">That is everything worth knowing. Still writing yours — ' +
2306          'this button will change the moment it is done.</p>'
2307        : '') +
2308    '</div>';
2309  }
2310
2311  /** Has anything changed since this plan was drafted? The answer is what makes
2312   *  redrafting meaningful, and its absence is why the button used to be noise. */
2313  /* ══════════════════════════════════════════════════════════════════════
2314     EDITING ONE ANSWER WITHOUT LOSING YOUR PLACE
2315
2316     Alex, 2026-08-04: "He could edit the questions that you've got by clicking
2317     it and a modal comes up and it doesn't throw you back in the whole flow.
2318     You just edit one question at a time and then you come back to your journey
2319     summary."
2320
2321     The jump-to-the-question behaviour was written for a different problem —
2322     §B3, where fixing one wrong answer cost six presses of Back — and it is
2323     still right on the publish step. Here it is wrong: this screen is a REVIEW,
2324     and leaving a review to change one line means finding your way back to it.
2325
2326     ⚠️ It is our own modal, not a browser dialog. This is the first place in
2327     this wizard that genuinely needs one; a whole screen for one field would be
2328     the heavier answer.
2329     ══════════════════════════════════════════════════════════════════════ */
2330  function _editHtml() {
2331    var k = W.editing;
2332    if (!k) return '';
2333    var Q = null, qs = QUESTIONS();
2334    for (var i = 0; i < qs.length; i++) if (qs[i].key === k) { Q = qs[i]; break; }
2335    if (!Q) return '';
2336    var v = W.answers[k];
2337    var body;
2338    if (Q.type === 'goals') {
2339      var picked = Array.isArray(v) ? v : [];
2340      body = '<div class="gw-chips">' + GOALS.map(function (g) {
2341        var on = picked.indexOf(g.v) >= 0;
2342        return '<button type="button" class="gw-chip' + (on ? ' on' : '') +
2343          '" data-gw-goal="' + g.v + '"><span class="gc-t">' + esc(g.t) + '</span>' +
2344          '<span class="gc-d">' + esc(g.d) + '</span></button>';
2345      }).join('') + '</div>';
2346    } else {
2347      body = '<textarea class="input ccw-answer" data-gw-edit-field rows="3">' +
2348        esc(String(v == null ? '' : v)) + '</textarea>';
2349    }
2350    return '<div class="gw-modal" data-gw-edit-scrim>' +
2351      '<div class="gw-modal-box" role="dialog" aria-modal="true">' +
2352        '<h3 class="gw-modal-t">' + esc(_framed(Q)) + '</h3>' +
2353        (Q.hint ? '<p class="gw-modal-h">' + esc(Q.hint) + '</p>' : '') +
2354        body +
2355        '<div class="gw-modal-acts">' +
2356          '<button type="button" class="nav-btn" data-gw-edit-cancel>Cancel</button>' +
2357          '<button type="button" class="send-btn" data-gw-edit-save>Save this answer</button>' +
2358        '</div>' +
2359      '</div>' +
2360    '</div>';
2361  }
2362
2363  /**
2364   * Why "Draft it again" is being offered, in the coach's own terms — or ''.
2365   *
2366   * ⚠️ THREE REASONS NOW, AND THE THIRD IS THE ONE THAT SURVIVES A REFRESH.
2367   * Alex, 2026-08-05: *"I went back a few steps to add it but when I went
2368   * forward it hadn't included my additional information."*
2369   *
2370   * The first two flags are set HERE, by this screen, when the coach edits an
2371   * answer in its own modal or talks to Maya — so they cover exactly the paths
2372   * that pass through this screen and nothing else. Going BACK through the
2373   * wizard and changing an answer there never touched them, which is the route
2374   * Alex actually took; and being client state, both are gone the moment the
2375   * tab reloads.
2376   *
2377   * `planStale` is the server's answer to both problems: `saveInterview`
2378   * stamps it whichever route the edit arrived by, and it is stored on the
2379   * game, so it is still true tomorrow. It is checked LAST because when the
2380   * coach has just done the thing, the immediate reason reads better than the
2381   * durable one — "you told Maya what to change" beats "your answers moved".
2382   */
2383  function _redraftReason() {
2384    if (W.editedSince) return 'You changed an answer since this was drafted.';
2385    if (W.feedbackSince) return 'You told Maya what to change.';
2386    if (W.planStale) {
2387      var what = (W.planStaleWhat || []).map(function (k) { return STALE_LABEL[k]; })
2388        .filter(Boolean);
2389      // Name what moved. "This may be out of date" is a shrug; "you changed
2390      // what they walk away with" is a reason, and it is the difference
2391      // between a warning that gets read and one that gets clicked past.
2392      return what.length
2393        ? 'You changed ' + _andList(what) + ' since this was drafted.'
2394        : 'Your answers changed since this was drafted.';
2395    }
2396    return '';
2397  }
2398
2399  // The seven answers a plan is reasoned from, as the coach would say them.
2400  // Mirrors INTERVIEW_LABEL on the server (games-service.js) — the server sends
2401  // the KEYS and the screen owns the words, so the prompt's phrasing and the
2402  // coach's phrasing can differ without either drifting by accident.
2403  var STALE_LABEL = {
2404    who_for: 'who it is for',
2405    start_point: 'where they are starting from',
2406    outcome: 'what they walk away with',
2407    blocker: 'what stops them',
2408    coach_goals: 'why you are running it',
2409    length_days: 'how long it runs',
2410    prize: 'what the prize is',
2411  };
2412
2413  /** "a", "a and b", "a, b and c" — never a bare comma list ending flat. */
2414  function _andList(xs) {
2415    if (xs.length <= 1) return xs[0] || '';
2416    return xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];
2417  }
2418
2419  /** N3: a plan Maya did not write says so, with the way to ask her. */
2420  function _simpleNoteHtml() {
2421    if (!(W.plan && W.planBy === 'simple')) return '';
2422    return '<div class="ccw-note gw-simple-note"><div class="d">This is the simple version, made from your answers without Maya. ' +
2423      '<button class="nav-btn" type="button" data-gw-plantry>Ask Maya to write it</button></div></div>';
2424  }
2425
2426  function _planHtml() {
2427    if ((W.planErr || W.mayaFailed) && W.primer != null && !W.primerDone) W.primerDone = true;
2428    if (W.primer != null && !W.primerDone && !W.planErr && !W.mayaFailed) return _primerHtml();
2429    if (W.planBusy) {
2430      return '<div class="ccw-note"><div class="t">Putting a game together…</div>' +
2431        '<div class="d">Reading what you said about your people and drafting the whole thing — ' +
2432        'the pitch, what earns points, the rules and the posts. A few seconds.</div></div>';
2433    }
2434    if (W.planErr) {
2435      return '<div class="ccw-note"><div class="t">' + esc(W.planErr) + '</div>' +
2436        '<div class="d">You can still publish and set the rest up by hand.</div></div>';
2437    }
2438    /* N3 (Anne, round 4): MAYA COULD NOT WRITE IT, AND THE SCREEN SAYS SO. It
2439     * used to say "Your game plan is ready" over a plan she had not written.
2440     * The coach chooses: try her again, or start from the simple version (three
2441     * stops and three plain activities from their own answers, every word
2442     * editable). Nothing is built until they press Build it either way. */
2443    if (W.mayaFailed) {
2444      return '<div class="ccw-note gw-maya-failed"><div class="t">Maya couldn\u2019t write your plan just now</div>' +
2445        '<div class="d">Your answers are all saved. Try her again in a moment, or start from the simple version: ' +
2446        'three stops and three plain activities made from your answers, which you can change before anything is built.</div>' +
2447        '<div class="gw-built-acts" style="margin-top:14px">' +
2448          '<button class="send-btn" type="button" data-gw-plantry>Try Maya again</button>' +
2449          '<button class="btn btn-secondary" type="button" data-gw-plansimple>Use the simple version</button>' +
2450          (W.mayaFailed.had_plan ? '<button class="btn btn-secondary" type="button" data-gw-plankeep>Keep the plan I had</button>' : '') +
2451        '</div></div>';
2452    }
2453    var p = W.plan;
2454    if (!p) return '';
2455    var c = W.cost || {};
2456
2457    // ── THE JOURNEY, NOT A LIST ──────────────────────────────────────
2458    //
2459    // Alex, 2026-08-03: "a game plan is essentially the journey, the linear
2460    // journey that we want our gamers to go through."
2461    //
2462    // The flat list of asks was true and useless: it told a coach WHAT earns
2463    // points and nothing at all about WHEN, so a plan whose whole point is a
2464    // shape in time was reviewed as a spreadsheet. Grouped under its stops, the
2465    // same data answers the question the screen exists for — what happens
2466    // first, what happens next, and what the end looks like.
2467    //
2468    // This is also the shape the member's own journey view renders, with their
2469    // progress on it. One drawing of the plan, two audiences.
2470    var stops = (p.milestones || []);
2471    /* ⚠️ SOME FORMATS HAVE STOPS THAT ARE NOT MOMENTS IN TIME.
2472     *
2473     * Alex's screenshot, 2026-08-04: two milestones both stamped DAY 21 on a
2474     * 21-day game. He asked whether it was a bug. The DATA is right — it is a
2475     * bingo card, whose `free` rhythm deliberately puts every stop on the
2476     * closing day because a bingo card has no order; its stops are AMOUNTS
2477     * ("any three squares", "the full card"), not dates.
2478     *
2479     * The DRAWING was wrong. Rendering that as "Day 21 / Day 21" tells a coach
2480     * their timeline is broken, which is exactly what he read it as. So when
2481     * every stop shares a day, it stops pretending to be a sequence of dates
2482     * and says what it actually is.
2483     *
2484     * ⚠️ And note what this is NOT: it is not a length-change bug, which is
2485     * what I had written in the backlog before reproducing it. The evidence was
2486     * in my own overnight walk — `bingo_card 14d stops 14/14/14` — recorded and
2487     * not questioned. Reading the format's own rhythm settled it in a minute. */
2488    var sameDay = stops.length > 1 && stops.every(function (m) {
2489      return Number(m.day) === Number(stops[0].day);
2490    });
2491    var asks;
2492    if (stops.length) {
2493      asks = (sameDay
2494        ? '<p class="gw-nolinear">These are not dates — this format has no fixed order. ' +
2495          'Members pick their own route, and <b>everything is due by day ' +
2496          esc(String(stops[0].day)) + '</b>.</p>'
2497        : '') + stops.map(function (m, n) {
2498        var mine = (p.asks || []).filter(function (a) { return Number(a.milestone) === n; });
2499        return '<div class="gw-stop">' +
2500          '<div class="gw-stop-h">' +
2501            '<span class="gw-stop-d">' +
2502              (sameDay ? 'Stage ' + (n + 1) : 'Day ' + esc(String(m.day))) + '</span>' +
2503            '<span class="gw-stop-t">' + esc(m.title) + '</span>' +
2504          '</div>' +
2505          (m.description ? '<div class="gw-stop-b">' + esc(m.description) + '</div>' : '') +
2506          (mine.length
2507            ? mine.map(function (a) {
2508                return _planRow(String(a.points) + ' pts', a.title, a.description,
2509                  a.auto ? 'scores itself' : 'you check it');
2510              }).join('')
2511            // Should be impossible — the normaliser drops bare stops — but a
2512            // timeline that renders a silent gap is worse than one that says so,
2513            // and a plan a coach has edited by hand can reach here.
2514            : '<div class="hint">Nothing to do at this one yet.</div>') +
2515        '</div>';
2516      }).join('');
2517    } else {
2518      // A game with no journey — everything built before migration 460. Still
2519      // renders, still readable, just without the shape.
2520      asks = (p.asks || []).map(function (a) {
2521        return _planRow(String(a.points) + ' pts', a.title, a.description,
2522          a.auto ? 'scores itself' : 'you check it');
2523      }).join('');
2524    }
2525
2526    // Said in submissions, not in asks: "3 of 5 are manual" is a statistic,
2527    // "roughly 120 submissions to read" is a decision.
2528    // No .ccw-note wrapper any more — it now sits INSIDE its own card, and a
2529    // note inside a card is two boxes saying the same thing.
2530    var cost = '';
2531    if (c.by_hand) {
2532      cost = '<p class="gw-costline"><b>' + c.by_hand + ' of these ' + c.total_asks +
2533        ' you will check by hand.</b>' +
2534        // ⚠️ SAY WHAT THE NUMBER ASSUMED. Alex, 2026-08-04: "has it worked out
2535        // without knowing how many people will be participating?" It had not —
2536        // it multiplies the hand-checked asks by the whole community, because
2537        // nobody has joined a game that does not exist yet. That is a
2538        // defensible CEILING and a bad estimate, and "at your current size" was
2539        // too quiet about which. A number a coach plans around has to say what
2540        // it assumed, or it is a guess wearing a decimal point.
2541        (c.submissions
2542          ? ' If every one of your <b>' + c.submissions / Math.max(1, c.by_hand) +
2543            '</b> members took part that is <b>' + c.submissions + ' submissions</b> to read ' +
2544            'over the life of the game — the most it can be, not what to expect.'
2545          : '') +
2546        '</p><p class="gw-costline">The other ' + c.automatic + ' score themselves the moment ' +
2547        'they happen. If that is more reading than you want, say so on the ones you would ' +
2548        'rather trust people on.</p>';
2549    } else if (c.total_asks) {
2550      cost = '<p class="gw-costline"><b>Nothing here needs checking.</b> All ' + c.total_asks +
2551        ' score themselves the moment they happen, so the game runs without you in the loop.</p>';
2552    }
2553
2554    /* ⚠️ THE COUNT IS A RECOMMENDATION, NOT A CEILING (G28). Alex, 2026-08-04:
2555     * "There'll be a recommendation but the coach should be able to decide what
2556     * they want to do."
2557     *
2558     * It used to be a SILENT SLICE at six — work dropped without telling
2559     * anyone, and the reason Maya once reported adding an activity that existed
2560     * nowhere. So the number is advice now, it appears only when the plan is
2561     * actually past it, and it says WHY rather than just stating a limit. The
2562     * number and the reason both travel with the plan so this line and the gate
2563     * cannot hold different ones. */
2564    var rec = p.recommendation || null;
2565    var nAsks = (p.asks || []).length;
2566    if (rec && rec.asks && nAsks > rec.asks) {
2567      cost += '<p class="gw-costline gw-costrec">You have <b>' + nAsks + '</b>, and we usually ' +
2568        'suggest around <b>' + rec.asks + '</b>. ' + esc(rec.why || '') +
2569        ' Nothing here is capped — this is the plan you asked for.</p>';
2570    }
2571
2572    /* And anything the platform ceiling actually removed. Almost never — twenty
2573     * activities is a malformed answer rather than a coach's game — but it must
2574     * not disappear in silence, which is the whole of what was wrong with six. */
2575    var over = (p.over_limit || []);
2576    if (over.length) {
2577      cost += '<p class="gw-costline gw-costrec"><b>' + over.length +
2578        (over.length === 1 ? ' activity was' : ' activities were') + ' left out.</b> ' +
2579        'A game can carry twenty and this plan came back with more: ' +
2580        over.map(function (o) { return esc(o.title); }).join(', ') + '.</p>';
2581    }
2582
2583    /* ⚠️ THE DRAFTS WERE WRITTEN AND THEN NOT SHOWN.
2584     *
2585     * Alex, 2026-08-04: "it's got a little edit pencil next to it. You kind of
2586     * think that you're meant to be editing here… There's no way, if I click on
2587     * anything or the pencil, it doesn't allow me to actually read the draft.
2588     * I'm assuming all the drafts should be actually shown there."
2589     *
2590     * He is right on both counts. The model returns `{title, body}` for every
2591     * post and this rendered the title only — and put a PENCIL beside it, which
2592     * promises an editor that does not exist. A dead affordance is worse than
2593     * no affordance: he clicked it, twice.
2594     *
2595     * So the body is shown, because it was always there, and the pencil is
2596     * gone. Publishing them is a later decision on the plan board, and the line
2597     * underneath says so — this screen is for reading what was written. */
2598    /* ⚠️ THESE ARE NOT DRAFTS, AND CALLING THEM THAT WAS THE PROBLEM.
2599     *
2600     * Alex, 2026-08-04: "These aren't drafts because drafts imply these are the
2601     * finished version just waiting for approval whereas they're not… This is
2602     * just to give us a description and say that we're going to make the post
2603     * later."
2604     *
2605     * ⚠️ AND I MADE IT WORSE AN HOUR AGO. He asked to be able to READ them, so
2606     * I rendered the bodies — which is exactly what turned a rough sketch into
2607     * something that looks like finished copy awaiting sign-off. The fix he
2608     * needed was the LABEL, not the content. Showing more of a mislabelled
2609     * thing only makes the mislabelling louder.
2610     *
2611     * So: the title and when it lands. This step approves a SHAPE; the words
2612     * get written properly later, "when it comes to actually implementing the
2613     * game". */
2614    var posts = (p.posts || []).map(function (x) {
2615      return '<div class="gw-draft">' +
2616        '<div class="gw-draft-t">' + esc(x.title || 'A post') +
2617          (x.day != null
2618            ? '<span class="gw-draft-d">' + (Number(x.day) < 0
2619                ? Math.abs(Number(x.day)) + ' days before it opens'
2620                : (Number(x.day) === 0 ? 'the day it opens' : 'day ' + esc(String(x.day)))) +
2621              '</span>'
2622            : '') + '</div>' +
2623      '</div>';
2624    }).join('');
2625
2626    var nPosts = (p.posts || []).length;
2627    return '<div class="gw-pitch">' +
2628        '<div class="gp-lab">What members will read</div>' +
2629        '<p>' + esc(p.description || '—') + '</p>' +
2630      '</div>' +
2631      _cardHtml('journey', stops.length ? 'The journey' : 'What earns points',
2632        stops.length ? _plural(stops.length, 'stop') : _plural((p.asks || []).length, 'thing'),
2633        (asks || '<div class="hint">Nothing yet.</div>')) +
2634      (cost ? _cardHtml('cost', 'What it will cost you',
2635        c.by_hand ? c.by_hand + ' to check' : 'nothing to check', cost) : '') +
2636      (nPosts ? _cardHtml('posts', 'The posts we will write for you',
2637        nPosts + ' to come',
2638        posts + '<div class="hint" style="margin-top:8px">Nothing is written yet — these ' +
2639        'are the moments the game needs a post. You will write them, with help, once the ' +
2640        'journey is settled, and nothing goes to your members until you say so.</div>') : '') +
2641      (p.rules ? _cardHtml('rules', 'The rules it wrote', 'for your members',
2642        '<pre class="gw-rules">' + esc(p.rules) + '</pre>') : '') +
2643      /* ⚠️ REDRAFTING IS NOT A BUTTON YOU PRESS FOR ITS OWN SAKE.
2644       *
2645       * Alex, 2026-08-04: "there's a draft it again button. I'm not quite sure
2646       * how that works. I haven't been able to give any sort of feedback for it
2647       * to be drafted again… Drafting again just for the sake of drafting again
2648       * makes no sense whatsoever." And on the collision: "why are we saying
2649       * 'draft it again' here when we've got Maya working at the top? Aren't we
2650       * giving two conflicting routes?"
2651       *
2652       * Both true. A bare reroll asks the model to try again with EXACTLY the
2653       * same input, so the only thing it can change is chance — and a plan that
2654       * shuffles when you press a button is not a plan, it is a slot machine.
2655       * Maya was already the considered route and this sat beside her offering
2656       * the thoughtless one.
2657       *
2658       * So it appears only when something has changed since the draft: an
2659       * answer edited here, or feedback given to Maya. Otherwise there is
2660       * nothing new to draft FROM, and the screen says that instead. */
2661      (_redraftReason()
2662        ? '<div class="gw-redraft"><button class="nav-btn" type="button" data-gw-redraft>' +
2663            'Draft it again with that</button>' +
2664          '<span class="gw-redraft-w">' + esc(_redraftReason()) + '</span></div>'
2665        : '');
2666  }
2667
2668  function _reviewHtml() {
2669    var a = W.answers;
2670    var chan = (W.channels || []).filter(function (c) {
2671      return String(c.id) === String(a.channel_id);
2672    })[0];
2673    var w = (a.points && typeof a.points === 'object') ? a.points : {};
2674    var scoring = WEIGHTS.filter(function (p) { return Number(w[p[0]] || 0) > 0; })
2675      .map(function (p) { return p[1] + ' · ' + w[p[0]]; });
2676    // key, label, value — the key is what makes a row a way BACK. Reviewing a
2677    // summary and then having to press Back six times to fix the one wrong
2678    // line is the standard way a review step wastes someone's afternoon.
2679    /* ⚠️ THIS LIST OMITTED THREE OF THE FIVE THINGS THE COACH ANSWERED.
2680     *
2681     * Alex, 2026-08-04: "I clicked open the questions that I'd already asked
2682     * and clicked on one of them and it took me back to things that hadn't
2683     * actually been asked on the setup. I think you've omitted things by
2684     * accident."
2685     *
2686     * Exactly that. It listed the MECHANICAL settings — name, channel, access,
2687     * dates, points — and none of `start_point`, `blocker` or `coach_goals`,
2688     * which are three of the five questions the interview actually asks and the
2689     * ones he put real thought into. So "your answers" showed him things he had
2690     * not been asked yet and hid the things he had.
2691     *
2692     * The interview comes first now, in the order it was asked, then the
2693     * settings. `_labelFor` keeps the wording identical to the question, so a
2694     * row and the screen it jumps to say the same thing. */
2695    // ⚠️ The labels come from GOALS, the wizard's own list. My first cut
2696    // reached for GOAL_LABEL, which lives in games-service on the SERVER — it
2697    // threw ReferenceError and took the whole review screen down with it.
2698    var GOAL_TEXT = function (v) {
2699      return (Array.isArray(v) ? v : []).map(function (k) {
2700        for (var i = 0; i < GOALS.length; i++) if (GOALS[i].v === k) return GOALS[i].t;
2701        return k;
2702      }).join(' and ');
2703    };
2704    var rows = [
2705      ['who_for', 'Who it is for', a.who_for || '—'],
2706      ['start_point', 'Where they are starting from', a.start_point || '—'],
2707      ['outcome', 'What they will have done', a.outcome || '—'],
2708      ['blocker', 'What stops them today', a.blocker || '—'],
2709      ['coach_goals', 'Why you are running it', GOAL_TEXT(a.coach_goals) || '—'],
2710      ['name', 'Name', _isPlaceholderName(a.name) ? 'Not named yet' : a.name],
2711      // A space's game has no channel to show or jump to (F11).
2712      ['channel_id', 'Where', chan ? '#' + (chan.name || chan.slug) : 'No channel yet'],
2713      ['access_rule', 'Who', a.access_rule === 'level'
2714        ? 'Level ' + (a.min_level || 0) + ' and up'
2715        : (a.access_rule === 'invite' ? 'Invite only' : (W.spaceName ? 'Anyone in ' + W.spaceName : 'Anyone in the community'))],
2716    ];
2717    if (!_needsChannel()) rows = rows.filter(function (r) { return r[0] !== 'channel_id'; });
2718    if (a.access_rule === 'invite') {
2719      rows.push(['join_url', 'Sign-up link',
2720        a.join_url || 'None — you add people yourself']);
2721    }
2722    if (a.prize) rows.push(['prize', 'Top of the board gets', a.prize]);
2723    /* ⚠️ ONE DATE ROW, NOT TWO, AND THE FINISH IS COMPUTED RATHER THAN LISTED.
2724     * The finish-date QUESTION is gone (see the note beside `opens_at`), so a
2725     * row jumping to it would jump at nothing. The length is what the coach
2726     * actually chose, so the row says the length — and for a rolling format it
2727     * does not pretend a start date was ever a decision. */
2728    var fmtObj = _fmt(a.format);
2729    var runLen = Number(a.length_days) || (fmtObj ? fmtObj.days.def : 0);
2730    var runFor = runLen ? ' · runs ' + runLen + ' days' : '';
2731    rows.push(
2732      ['opens_at', 'Starts',
2733        (a.opens_at ? _niceDate(a.opens_at) : 'As soon as you publish') + runFor],
2734      ['points', 'Ordinary activity', scoring.length ? scoring.join(' · ') : 'Scores nothing']
2735    );
2736
2737    /* ⚠️ THE "WHERE IT RUNS" WARNING WAS REMOVED FROM THIS SCREEN, NOT REWORDED.
2738     *
2739     * Alex, 2026-08-04: "I don't like having this one thing left where it runs
2740     * at all. I think it completely makes no sense whatsoever. We know it's
2741     * running in the community. All they got to decide later on is if it is in
2742     * a channel… This stuff all comes later on about how it's published. I
2743     * don't know why it's being included here."
2744     *
2745     * ⚠️ And the mistake worth recording: he had ALREADY told me this message
2746     * was confusing, and I rewrote the wording instead of asking whether it
2747     * belonged. It did not. The publish step still refuses to go live without a
2748     * channel (§B1), which is where that check belongs — on the screen that
2749     * publishes, not on the one that reviews the journey.
2750     *
2751     * The rule this screen now follows, in his words: "This section here should
2752     * just be about the journey not the delivery." */
2753    /* ⚠️ THE ANSWERS ARE AVAILABLE, NOT DISPLAYED.
2754     *
2755     * Alex, 2026-08-04: "If you see there's a big wall of text of all your
2756     * answers at the bottom, I think it's nice to be able to access your
2757     * answers but I don't think this has been the most sensible way of doing
2758     * that."
2759     *
2760     * Both halves of that. They stay reachable in one click, and each row is
2761     * still the button that jumps back to the question that wrote it (§B3) —
2762     * so reading an answer and changing it remain one gesture rather than two.
2763     * What goes is showing them unasked, underneath everything else, at full
2764     * length. One of his own answers rendered as a nine-line paragraph, which
2765     * is the wall he is describing: `.gw-ans-v` clamps it, and the way to read
2766     * all of it is the same click that lets you edit it. */
2767    // ⚠️ ONLY WHAT THEY HAVE ACTUALLY ANSWERED. Alex: "You should only see the
2768    // answers that you've already completed… I don't think we should be showing
2769    // all these blanks here because we're gonna be dealing with them in the
2770    // next steps." A row reading "—" is not information, it is a chore he has
2771    // not reached yet, listed as though he had skipped it.
2772    /* ⚠️ A DEFAULT IS NOT AN ANSWER, and filtering on the DISPLAYED string could
2773     * not tell the difference. "Anyone in the community", "As soon as you
2774     * publish", "No end date", "Scores nothing" are all what the game does when
2775     * nobody has said otherwise — so six rows showed for two answers, which is
2776     * the padding Alex asked to be rid of twice: "You should only see the
2777     * answers that you've already completed", and "the channel definitely
2778     * should be done later. Start date confirmation can be done later."
2779     *
2780     * So it asks the ANSWER, not the sentence rendered from it. */
2781    /* ⚠️ AND IT IS THE INTERVIEW ONLY. Alex, on this screen: "This section here
2782     * should just be about the journey not the delivery… The channel definitely
2783     * should be done later. Start date confirmation can be done later."
2784     *
2785     * `access_rule` was the last one slipping through — it is 'free' from the
2786     * moment the wizard opens, so it is indistinguishable from a choice and
2787     * always rendered. Rather than guess at which defaults were chosen, the
2788     * card lists what the coach was ASKED in the interview, plus the name once
2789     * it has one. The publish step still shows everything. */
2790    var JOURNEY_ROWS = { who_for: 1, start_point: 1, outcome: 1, blocker: 1,
2791                         coach_goals: 1, name: 1 };
2792    var given = rows.filter(function (r) { return JOURNEY_ROWS[r[0]]; }).filter(function (r) {
2793      var raw = a[r[0]];
2794      if (r[0] === 'name') return !_isPlaceholderName(raw);
2795      if (r[0] === 'coach_goals') return Array.isArray(raw) && raw.length > 0;
2796      if (r[0] === 'min_level') return Number(raw) >= 1;
2797      if (r[0] === 'points') {
2798        return raw && typeof raw === 'object' &&
2799          Object.keys(raw).some(function (k) { return Number(raw[k]) > 0; });
2800      }
2801      return String(raw == null ? '' : raw).trim() !== '';
2802    });
2803    var answers = '<div class="gw-answers">' + given.map(function (r) {
2804      return '<button type="button" class="gw-ans" data-gw-jump="' + esc(r[0]) + '">' +
2805        '<span class="gw-ans-l">' + esc(r[1]) + '</span>' +
2806        '<span class="gw-ans-v">' + esc(String(r[2])) + '</span>' +
2807      '</button>';
2808    }).join('') + '</div>' +
2809    '<div class="hint" style="margin-top:8px">Any line takes you back to the question that ' +
2810    'set it.</div>';
2811
2812    return '<p class="gw-pubnote"><b>This is the shape of the game.</b> Nothing here is live, and ' +
2813      'nothing goes out — where it runs and when it opens come after this.</p>' +
2814      _cardHtml('answers', 'Built from your answers',
2815        given.length + ' answer' + (given.length === 1 ? '' : 's'), answers);
2816  }
2817
2818  // ── The whole screen ──────────────────────────────────────────────────
2819
2820  function _render() {
2821    if (!W.el) return;
2822    var qs = QUESTIONS();
2823    if (W.qIdx >= qs.length) W.qIdx = qs.length - 1;
2824    if (W.qIdx < 0) W.qIdx = 0;
2825    var i = W.qIdx;
2826    var Q = qs[i];
2827    // The BAR stays whole-flow — it is the one honest picture of how far
2828    // through they are, and unlike a number it can move without alarming
2829    // anybody. Only the counter beside it is scoped; see _progress().
2830    var pct = qs.length ? Math.round(((i + 1) / qs.length) * 100) : 0;
2831    var prog = _progress();
2832
2833    W.el.innerHTML =
2834      '<div class="ccw wz-shell gw ccw-work">' +
2835        '<div class="topbar">' +
2836          // Inside a space's Game tab (space-game-setup.js) the space's own
2837          // header is the way back, so the guide names only the game.
2838          (W.embedded ? '<div class="page-head-crumbs"><span class="crumb-current">' +
2839            esc(_isPlaceholderName(W.answers.name) ? (W.gameId ? 'Unnamed game' : 'New game') : W.answers.name) +
2840            '</span></div>' : '') +
2841          (W.embedded ? '' : '<div class="page-head-crumbs">' +
2842            '<span class="crumb-current">Community</span><span class="crumb-sep">/</span>' +
2843            /* F11: a space's game is the space's. Its way back is the space,
2844               which is also where "Finish later" returns (the host's onExit). */
2845            (W.spaceName
2846              ? '<span class="crumb-current">Spaces</span><span class="crumb-sep">/</span>' +
2847                '<a href="#" class="crumb" data-gw-exit>' + esc(W.spaceName) + '</a>'
2848              : '<a href="#" class="crumb" data-gw-exit>Games</a>') +
2849            // ⚠️ THIS SAID "New game" WHETHER YOU WERE CREATING ONE OR RESUMING
2850            // ONE. Alex could not tell which game he had opened, which is the
2851            // root of the confusion he reported: "It all stems from not knowing
2852            // what game you're going into to continue to set up." A crumb that
2853            // is the same on every game is not navigation, it is decoration.
2854            '<span class="crumb-sep">/</span><span class="crumb-current">' +
2855              esc(_isPlaceholderName(W.answers.name)
2856                ? (W.gameId ? 'Unnamed game' : 'New game')
2857                : W.answers.name) + '</span>' +
2858          '</div>') +
2859          // A challenge in a space (855): say where it will live.
2860          '<span class="save-hint">✓ Everything saves as you go</span>' +
2861        '</div>' +
2862        '<div class="phase-grid">' +
2863          '<section class="ls-fcard">' +
2864            // .ls-fhead without the persona: the course guide puts Maya and
2865            // the progress bar in this row, and only the progress bar belongs
2866            // on a screen with no AI in it.
2867            '<div class="ls-fhead">' +
2868              '<div class="focus-progress">' +
2869                '<div class="pl">' + prog.label + '</div>' +
2870                '<div class="pv"><b>' + prog.n + '</b> of ' + prog.total + '</div>' +
2871                '<div class="pbar"><i style="width:' + pct + '%"></i></div>' +
2872              '</div>' +
2873            '</div>' +
2874            '<div class="ls-fbody">' + _bodyHtml(Q, i, qs.length) + '</div>' +
2875            (W.error ? '<div class="hint" style="color:var(--danger,#c0423a);margin-top:10px">' +
2876              esc(W.error) + '</div>' : '') +
2877            '<div class="focus-foot">' +
2878              '<span class="tip">' + TICK + ' Answer as many as you like now, or stop whenever — ' +
2879                'every answer saves itself.</span>' +
2880              '<div class="foot-actions">' +
2881                '<button class="later" type="button" data-gw-exit>Finish later</button>' +
2882              '</div>' +
2883            '</div>' +
2884          '</section>' +
2885          '<aside class="rail">' +
2886            // On the plan step the preview card is a smaller copy of what the
2887            // coach is already reading, so Maya takes its place rather than
2888            // being added underneath it. Everywhere else the rail is unchanged.
2889            (_mayaHere(Q) ? '<div data-gw-maya></div>' : _previewHtml(pct)) +
2890            _roadHtml() + '</aside>' +
2891        '</div>' +
2892      '</div>';
2893
2894    var mayaHost = W.el.querySelector('[data-gw-maya]');
2895    if (mayaHost) _maya(mayaHost);
2896
2897    _wire(Q);
2898  }
2899
2900  function _wire(Q) {
2901    var el = W.el;
2902    if (!el) return;
2903
2904    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-exit]'), function (b) {
2905      b.addEventListener('click', function (e) { e.preventDefault(); W.onExit(W.gameId); });
2906    });
2907    var back = el.querySelector('[data-gw-back]');
2908    if (back) back.addEventListener('click', function () {
2909      W.error = ''; W.qIdx = Math.max(0, W.qIdx - 1); _render();
2910    });
2911
2912    // A choice card answers and moves on in one click — the quick path. The
2913    // Continue button below it is the one that is always there; see
2914    // _composerRowHtml for why both exist.
2915    _wireOpts(Q);
2916
2917    // A finished step in the rail jumps to the first question in it.
2918    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-step]'), function (li) {
2919      li.addEventListener('click', function () {
2920        var label = li.getAttribute('data-gw-step');
2921        var qs = QUESTIONS();
2922        for (var n = 0; n < qs.length; n++) {
2923          if (qs[n].step === label) { W.error = ''; W.qIdx = n; _render(); return; }
2924        }
2925      });
2926    });
2927
2928    // The review's summary lines are the way back to any one answer.
2929    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-jump]'), function (b) {
2930      b.addEventListener('click', function () {
2931        var key = b.getAttribute('data-gw-jump');
2932        // On the review screen editing happens HERE, in place. Everywhere else
2933        // the jump is still right — see §B3.
2934        if (Q && Q.type === 'review') { W.editing = key; _render(); return; }
2935        var at = _indexOfKey(key);
2936        // A key whose question is not in the list right now — `prize` when the
2937        // win mode has no top prize, say. Silently doing nothing reads as a
2938        // broken button, which is what a dead click always reads as.
2939        if (at < 0) {
2940          W.error = 'That one is not part of this game, so there is nothing to change.';
2941          _render();
2942          return;
2943        }
2944        W.error = ''; W.qIdx = at; _render();
2945      });
2946    });
2947
2948    _wireChannel(Q);
2949
2950    var next = el.querySelector('[data-gw-next]');
2951    if (next) next.addEventListener('click', function () {
2952      if (Q.type === 'weights') {
2953        var w = {};
2954        Array.prototype.forEach.call(el.querySelectorAll('[data-gw-weight]'), function (inp) {
2955          w[inp.getAttribute('data-gw-weight')] = Math.max(0, Math.round(Number(inp.value) || 0));
2956        });
2957        _answer(Q, w);
2958        return;
2959      }
2960      if (Q.type === 'review') {
2961        // The deck, while it is up. Reaching the plan is what ends it — and
2962        // a ready plan ends it from ANY card, which is the not-a-gate rule.
2963        if (_primerUp()) {
2964          if (W.plan) { W.primerDone = true; _render(); return; }
2965          if (W.primer < PRIMER.length - 1) { W.primer += 1; _render(); return; }
2966          return;   // last card, still drafting: the label already says so
2967        }
2968        _buildIt(); return;
2969      }
2970      // The last screen of the Go-live walk-through is the one that publishes.
2971      if (Q.type === 'publish') { _publish(); return; }
2972      // A choice question's answer is already in W.answers — Continue is
2973      // confirming what is lit, not reading a field.
2974      if (Q.type === 'options' || Q.type === 'channel') { _answer(Q, W.answers[Q.key]); return; }
2975
2976      // ── The advisory screens ─────────────────────────────────────
2977      //
2978      // Each of these already holds its answer in W.answers or on the server,
2979      // so Continue is a confirmation. What it must NOT be is a no-op that
2980      // leaves the coach on the same screen — that is the "stuck at step four"
2981      // fault, and it is why every branch below ends in an advance.
2982      if (Q.type === 'goals') {
2983        _answer(Q, Array.isArray(W.answers.coach_goals) ? W.answers.coach_goals : []);
2984        return;
2985      }
2986      // Same as goals: the chips already hold the answer, and NONE picked is a
2987      // real answer meaning "do not move anything" rather than an unfinished
2988      // question. Sending [] is what records that.
2989      if (Q.type === 'days') {
2990        _answer(Q, Array.isArray(W.answers.coach_days) ? W.answers.coach_days : []);
2991        return;
2992      }
2993      if (Q.type === 'dynamic') {
2994        _saveDynamicAnswers();
2995        return;
2996      }
2997      if (Q.type === 'gallery') {
2998        // Nothing chosen is not an error — it is a coach who has not decided.
2999        // Saying so beats a dead button they cannot explain.
3000        if (!W.answers.format) {
3001          W.error = 'Pick one to carry on — tap a card to read what it is first.';
3002          _render();
3003          return;
3004        }
3005        _answer(Q, W.answers.format);
3006        return;
3007      }
3008      if (Q.type === 'levers') {
3009        // Already saved on every tick. Continue just moves on.
3010        W.qIdx = Math.min(W.qIdx + 1, QUESTIONS().length - 1);
3011        W.error = '';
3012        _render();
3013        return;
3014      }
3015      if (Q.type === 'settings') { _saveRuns(); return; }
3016      // Nothing to save — it is a statement, and the post it describes is
3017      // already in the plan.
3018      if (Q.type === 'signup') {
3019        W.qIdx = Math.min(W.qIdx + 1, QUESTIONS().length - 1);
3020        W.error = '';
3021        _render();
3022        return;
3023      }
3024      var f = el.querySelector('[data-gw-answer]');
3025      _answer(Q, f ? f.value : '');
3026    });
3027
3028    // Clicking the date field opens the picker, rather than making them hit a
3029    // 16px glyph. showPicker throws when the browser has not seen a user
3030    // gesture and is absent on older Safari — both are fine, the field still
3031    // takes typing, so the whole thing is a try/catch enhancement.
3032    var dt = el.querySelector('input[type="datetime-local"][data-gw-answer]');
3033    if (dt) dt.addEventListener('click', function () {
3034      try { if (typeof dt.showPicker === 'function') dt.showPicker(); } catch (_) {}
3035    });
3036
3037    // Enter sends a single-line answer, the way every other one-line field on
3038    // the platform behaves.
3039    var single = el.querySelector('input[data-gw-answer]');
3040    if (single) single.addEventListener('keydown', function (e) {
3041      if (e.key === 'Enter') { e.preventDefault(); _answer(Q, single.value); }
3042    });
3043
3044    // The plan is fetched when they ARRIVE at the last step, not when the
3045    // wizard opens: a coach who leaves at question three should never have
3046    // paid for a draft they did not see.
3047    if (Q && Q.type === 'review' && !W.plan && !W.planBusy && !W.planErr && !W.mayaFailed) _loadPlan(false);
3048
3049    // ── Arriving at a step is what loads it ──────────────────────────
3050    //
3051    // Same rule as the plan draft, for the same reason: everything below costs
3052    // either a round trip or a metered model call, and a coach who leaves at
3053    // question two should never have paid for a screen they did not reach.
3054    if (Q && Q.type === 'gallery') _loadFormats(true);
3055    if (Q && Q.type === 'dynamic') _loadDynamic();
3056
3057    /* ⚠️ AND THE LIBRARY LOADS FOR A RESUMED GAME TOO, NOT ONLY AT THE GALLERY.
3058     *
3059     * The rule above — arriving at a step is what loads it — is right for the
3060     * metered recommendation and wrong for the twelve formats, because the
3061     * screens AFTER the gallery read them. A coach resuming at "Go live" had
3062     * `W.formats` empty, so `_fmt()` returned null and every format-derived
3063     * thing on those screens fell back to a generic default.
3064     *
3065     * That was cosmetic until the start-date question started depending on
3066     * `format.start`: with no library, a cohort game would have quietly stopped
3067     * asking for the one date it genuinely needs. A question that disappears is
3068     * invisible — nobody reports it — so the library is fetched whenever a game
3069     * already HAS a format and we have not tried yet. It is one cached GET of
3070     * static data; the metered call stays behind the gallery. */
3071    if (W.answers.format && !(W.formats || []).length) _loadFormats(false);
3072
3073    // ── Ask for the extra questions ONE SCREEN EARLY ──────────────────
3074    //
3075    // The exception to the rule directly above, and it earns it. The generated
3076    // questions decide whether their own step exists at all (see the `when` on
3077    // `dynamic`), so asking on arrival means arriving at a screen that may be
3078    // about to delete itself. Asking from the last interview question instead
3079    // means the answer is normally back before the coach presses Continue, and
3080    // an empty answer removes the step before it is ever drawn.
3081    //
3082    // It does not break the "never pay for a screen you did not reach" rule:
3083    // this fires on the LAST question of the interview, which is a screen they
3084    // are already looking at, and the call it makes is the one that step would
3085    // have made a moment later anyway.
3086    if (Q && Q.key === 'coach_goals') _loadDynamic();
3087    // The turnout box is pre-filled from the levers call (G30). A resumed game
3088    // lands on this question without passing the levers screen, so it asks too.
3089    if (Q && (Q.type === 'levers' || Q.key === 'expected_players')) _loadLevers(false);
3090    if (Q && Q.key === 'name')     _loadNames();
3091
3092    // A suggestion fills the box; it does not submit. The coach still reads it
3093    // in place and presses Continue, so choosing one and then editing it is the
3094    // same two clicks as typing your own.
3095    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-name]'), function (b) {
3096      b.addEventListener('click', function () {
3097        W.answers.name = b.getAttribute('data-gw-name');
3098        _render();
3099      });
3100    });
3101
3102    // Pick up to two reasons. The cap is enforced HERE rather than by
3103    // disabling the chips, because a dead control tells a coach nothing —
3104    // tapping a third swaps the oldest out, which is what they meant anyway.
3105    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-goal]'), function (b) {
3106      b.addEventListener('click', function () {
3107        var v = b.getAttribute('data-gw-goal');
3108        var cur = Array.isArray(W.answers.coach_goals) ? W.answers.coach_goals.slice() : [];
3109        var at = cur.indexOf(v);
3110        if (at >= 0) cur.splice(at, 1);
3111        else { cur.push(v); if (cur.length > 2) cur.shift(); }
3112        W.answers.coach_goals = cur;
3113        _render();
3114      });
3115    });
3116
3117    /* The days the coach is around (G30). Same toggle as the goals above, with
3118     * no cap: being there every day is a legitimate answer and it is what turns
3119     * the scheduling constraint OFF. Kept in week order rather than click
3120     * order, so two coaches who chose the same days store the same value — the
3121     * same normalising the service does, on the side that renders it. */
3122    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-day]'), function (b) {
3123      b.addEventListener('click', function () {
3124        var v = b.getAttribute('data-gw-day');
3125        var cur = Array.isArray(W.answers.coach_days) ? W.answers.coach_days.slice() : [];
3126        var at = cur.indexOf(v);
3127        if (at >= 0) cur.splice(at, 1);
3127 else cur.push(v);
3128        W.answers.coach_days = DAYS.map(function (d) { return d.v; })
3129          .filter(function (d) { return cur.indexOf(d) >= 0; });
3130        _render();
3131      });
3132    });
3133
3134    // The generated answers are held locally until Continue, so a coach who
3135    // types into one and then goes Back has not half-written an interview.
3136    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-dyn]'), function (inp) {
3137      inp.addEventListener('input', function () {
3138        W.answers['dyn_' + inp.getAttribute('data-gw-dyn')] = inp.value;
3139      });
3140    });
3141
3142    // A card opens its panel; the panel's button is what actually chooses. One
3143    // exception — clicking the card that is ALREADY chosen closes the panel
3144    // rather than re-choosing, so the gallery does not feel sticky.
3145    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-format]'), function (b) {
3146      b.addEventListener('click', function (e) {
3147        e.preventDefault();
3148        var key = b.getAttribute('data-gw-format');
3149        var f = _fmt(key);
3150        if (b.classList.contains('gw-card')) {
3151          W.panel = (W.panel === key) ? null : key;
3152          _render();
3153          return;
3154        }
3155        // From the panel's own button. A coming-soon format cannot be chosen —
3156        // the server refuses it too (format_not_ready), and this is the half
3157        // that stops the coach ever getting the error.
3158        if (!f || f.status !== 'ready') return;
3159        _applyFormatDefaults(key);
3160        W.panel = null;
3161        _render();
3162      });
3163    });
3164
3165    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-want]'), function (b) {
3166      b.addEventListener('click', function (e) {
3167        e.preventDefault();
3168        _wantFormat(b.getAttribute('data-gw-want'));
3169      });
3170    });
3171
3172    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-lever]'), function (c) {
3173      c.addEventListener('change', function () {
3174        var key = c.getAttribute('data-gw-lever');
3175        (W.levers || []).forEach(function (l) { if (l.key === key) l.on = c.checked; });
3176        _saveLevers();
3177      });
3178    });
3179
3180    // Change opens the control on the row; pressing it again closes it. The
3181    // row is the editor, so there is no dialog to dismiss and nothing that can
3182    // return "the coach cancelled" and "the coach typed rubbish" as the same
3183    // value — which is what the prompt() this replaced did.
3184    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-run]'), function (b) {
3185      b.addEventListener('click', function () {
3186        var which = b.getAttribute('data-gw-run');
3187        W.runEdit = (W.runEdit === which) ? null : which;
3188        _render();
3189      });
3190    });
3191
3192    // ⚠️ THE MARK IS SET BY THE VALUE CHANGING, NOT BY OPENING THE EDITOR.
3193    // Alex: "we have to honour whatever has been overridden by the coach so we
3194    // can't override the coach." Opening a row to read the options and closing
3195    // it again is not an override, and marking it there would freeze a setting
3196    // the coach never actually moved — silently stopping the format from ever
3197    // improving it again.
3198    function _setRun(which, value) {
3199      W.answers[which === 'days' ? 'length_days'
3200        : which === 'win' ? 'win_mode' : 'date_mode'] = value;
3201      W.touched[which] = true;
3202      _render();
3203    }
3204
3205    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-days]'), function (b) {
3206      b.addEventListener('click', function () {
3207        if (b.disabled) return;
3208        _setRun('days', parseInt(b.getAttribute('data-gw-days'), 10));
3209      });
3210    });
3211    var rng = el.querySelector('[data-gw-days-range]');
3212    if (rng) {
3213      // `input` keeps the number under the slider honest while it is dragged;
3214      // `change` is what commits. Re-rendering on every `input` would tear the
3215      // slider out from under the pointer mid-drag.
3216      rng.addEventListener('input', function () {
3217        var n = el.querySelector('.gr-num b');
3218        if (n) n.textContent = String(rng.value);
3219      });
3220      rng.addEventListener('change', function () {
3221        _setRun('days', parseInt(rng.value, 10));
3222      });
3223    }
3224    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-win]'), function (b) {
3225      b.addEventListener('click', function () { _setRun('win', b.getAttribute('data-gw-win')); });
3226    });
3227    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-dates]'), function (b) {
3228      b.addEventListener('click', function () { _setRun('dates', b.getAttribute('data-gw-dates')); });
3229    });
3230
3231    // ── The edit modal ────────────────────────────────────────────────
3232    var scrim = el.querySelector('[data-gw-edit-scrim]');
3233    if (scrim) {
3234      // Clicking the backdrop closes it; clicking the card must not.
3235      scrim.addEventListener('click', function (e) {
3236        if (e.target === scrim) { W.editing = null; _render(); }
3237      });
3238      var cancel = el.querySelector('[data-gw-edit-cancel]');
3239      if (cancel) cancel.addEventListener('click', function () { W.editing = null; _render(); });
3240      var save = el.querySelector('[data-gw-edit-save]');
3241      if (save) save.addEventListener('click', function () {
3242        var k = W.editing;
3243        var f = el.querySelector('[data-gw-edit-field]');
3244        var val = f ? f.value : W.answers[k];
3245        W.editing = null;
3246        // ⚠️ The plan on screen was drafted from the OLD answer. Saying so is
3247        // what makes "Draft it again" mean something — see _redraftReason.
3248        W.editedSince = true;
3249        _saveOneAnswer(k, val);
3250      });
3251      var fld = el.querySelector('[data-gw-edit-field]');
3252      if (fld) { try { fld.focus(); } catch (_) {} }
3253    }
3254
3255    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-primer]'), function (b) {
3256      b.addEventListener('click', function () {
3257        var k = parseInt(b.getAttribute('data-gw-primer'), 10);
3258        if (!Number.isFinite(k)) return;
3259        W.primer = Math.max(0, Math.min(PRIMER.length - 1, k));
3260        _render();
3261      });
3262    });
3263
3264    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-card]'), function (b) {
3265      b.addEventListener('click', function () {
3266        var k = b.getAttribute('data-gw-card');
3267        if (!W.cards) W.cards = {};
3268        W.cards[k] = !_cardOpen(k);
3269        _render();
3270      });
3271    });
3272
3273    var golive = el.querySelector('[data-gw-golive]');
3274    if (golive) golive.addEventListener('click', function () { _enterGoLive(); });
3275    _wirePending(el.querySelector('[data-gw-pend]'));
3276
3277    // "Not just yet" is a real answer, and the course's Go live is reachable
3278    // afterwards from the game's own board — so leaving here loses nothing.
3279    var later = el.querySelector('[data-gw-later]');
3280    // It was NOT opened, so the host must not say it was (it toasted "Published").
3281    if (later) later.addEventListener('click', function () { W.onDone(W.gameId, { opened: false }); });
3282
3283    Array.prototype.forEach.call(el.querySelectorAll('[data-gw-plantry]'), function (b) {
3284      b.addEventListener('click', function () { _loadPlan(true); });
3285    });
3286    var simpleBtn = el.querySelector('[data-gw-plansimple]');
3287    if (simpleBtn) simpleBtn.addEventListener('click', function () { _loadPlan(true, 'simple'); });
3288    var keepBtn = el.querySelector('[data-gw-plankeep]');
3289    if (keepBtn) keepBtn.addEventListener('click', function () { _loadPlan(false); });
3290
3291    var redraft = el.querySelector('[data-gw-redraft]');
3292    if (redraft) redraft.addEventListener('click', function () {
3293      W.editedSince = false; W.feedbackSince = false;
3294      _loadPlan(true);
3295    });
3296
3297    _speakIt();
3298  }
3299
3300  /**
3301   * The library. Loaded once and kept — it is the same twelve for everybody, so
3302   * re-fetching it when a coach steps back and forward is a round trip that can
3303   * only return what we already have.
3304   */
3305  async function _loadFormats(withAdvice) {
3306    // Set before the first await, so a render triggered inside this function
3307    // cannot slip a second call past the guard.
3308    //
3309    // ⚠️ TWO GUARDS, BECAUSE THERE ARE TWO CALLS AND ONLY ONE OF THEM COSTS.
3310    // The library is static and can be fetched from any screen; the
3311    // recommendation is metered and belongs to the gallery alone. One flag for
3312    // both would mean whichever screen asked first decided whether the coach
3313    // ever got advice.
3314    if (W.fmtBusy) return;
3315    var needList = !W.fmtTried;
3316    // No game row yet means no recommendation to ask for — the endpoint is
3317    // keyed on the id. The library is still fetchable, and worth fetching.
3318    var needRec = !!(withAdvice && !W.recTried && W.gameId);
3319    if (!needList && !needRec) return;
3320    if (needList) W.fmtTried = true;
3321    if (needRec) W.recTried = true;
3322    W.fmtBusy = true; _render();
3323    try {
3324      if (needList) {
3325        var r = await _api('/community-hosting/games/formats');
3326        W.formats = (r && r.formats) || [];
3327      }
3328      if (!needRec) { W.fmtBusy = false; _render(); return; }
3329      // The recommendation is a SECOND call and a metered one, so it happens
3330      // only once the gallery is actually on screen — a coach who leaves at
3331      // question two never pays for advice they did not see. Same rule the plan
3332      // draft already follows.
3333      var rec = await _api('/community-hosting/games/' + W.gameId + '/assistant/recommendation');
3334      W.recommendation = (rec && rec.recommendation) || null;
3335      // Pre-select the recommendation, but ONLY if it can actually be built and
3336      // the coach has not already chosen. Pre-selecting a coming-soon format
3337      // would put them on a card they cannot use and make Continue do nothing.
3338      if (W.recommendation && W.recommendation.buildable && !W.answers.format) {
3339        _applyFormatDefaults(W.recommendation.format);
3340      }
3341    } catch (_) {
3342      // A gallery with no recommendation is still a usable gallery. Losing the
3343      // advice is a worse screen, not a broken one.
3344    }
3345    W.fmtBusy = false; _render();
3346  }
3347
3348  /**
3349   * What a format WRITES when it is chosen — length, win mode, deadlines.
3350   *
3351   * ⚠️ IT NEVER OVERWRITES SOMETHING THE COACH SET BY HAND. Alex: "we have to
3352   * honour whatever has been overridden by the coach so we can't override the
3353   * coach." So a coach who sets 21 days and then changes their mind about the
3354   * format keeps the 21 — every other default moves around it.
3355   */
3356  function _applyFormatDefaults(key) {
3357    var f = _fmt(key);
3358    W.answers.format = key;
3359    if (!f) return;
3360    if (!W.touched.days)  W.answers.length_days = f.days.def;
3361    if (!W.touched.win)   W.answers.win_mode = f.winMode;
3362    if (!W.touched.dates) W.answers.date_mode = f.dateMode;
3363  }
3364
3365  /* ── WHAT WENT WRONG, WHEN WE KNOW ───────────────────────────────────
3366   *
3367   * Alex, 2026-08-04, on "That did not save just now — try again in a moment."
3368   * appearing permanently: "I don't know how these ugly error messages come up
3369   * anyway. We should probably have something a bit better."
3370   *
3371   * He is right twice over. The wording was a shrug, and worse, it was a LIE
3372   * about the kind of failure: it said "try again in a moment", which is advice
3373   * for something transient, while the real fault (`slug_taken`) was permanent
3374   * for that community. He followed the advice and it failed identically three
3375   * times, because the message sent him back into a wall.
3376   *
3377   * So: name the causes we actually know, and only fall back to the vague line
3378   * for genuinely unknown ones — where "try again" IS the right advice.
3379   */
3380  var SAVE_ERRORS = {
3381    slug_taken:      'A game with that name already exists here. Try a different name.',
3382    name_required:   'That needs a name before it can be saved.',
3383    game_not_found:  'This game is not there any more — it may have been deleted in another tab.',
3384    channel_missing: 'That channel is not there any more. Pick another one on the Where step.',
3385    game_locked:     'This game is locked and cannot be changed.',
3386    min_level_invalid: 'The level has to be 1 or more.',
3387  };
3388
3389  /* ⚠️ A FAILURE WE CAUGHT IS INVISIBLE UNLESS WE SAY SO.
3390   *
3391   * Alex, 2026-08-04: "is there any way… where all these errors can't come up
3392   * orphaned, they all report back into our admin tool."
3393   *
3394   * The platform reporter (js/error-reporter.js) is installed globally and its
3395   * fetch wrapper does report 400/409/422/5xx — which is why his `slug_taken`
3396   * IS already in the admin tool, from both the client and the server. But its
3397   * own header says the gap out loud: "window.onerror only ever sees what
3398   * crashes, so a feature that quietly degrades to a lesser mode is invisible
3399   * to every handler below." That is exactly this screen. A 404
3400   * (`game_not_found`) or a 403 (`game_locked`) is outside the wrapper's list,
3401   * and a 200 that came back without a game throws inside our own catch and
3402   * reaches nothing at all.
3403   *
3404   * So every error a coach is SHOWN is now also reported. `__reportClientError`
3405   * is the reporter's own hook for precisely this, it is rate-limited and
3406   * deduplicated on its side, and it never throws — so this cannot make a bad
3407   * moment worse. */
3408  function _saveError(err) {
3409    var code = (err && (err.code || err.error || err.message)) || '';
3410    try {
3411      if (typeof window.__reportClientError === 'function') {
3412        window.__reportClientError({
3413          kind: 'games_wizard_save',
3414          message: String(code || 'unknown'),
3415          action: 'game-wizard:' + (W.gameId ? 'q' + W.qIdx : 'create'),
3416          status: (err && err.body && err.body.status) || undefined,
3417          stack: err && err.stack,
3418        });
3419      }
3420    } catch (_) { /* reporting must never be the thing that breaks */ }
3421    if (SAVE_ERRORS[code]) return SAVE_ERRORS[code];
3422    // A spend cap is not a save failure and must not read like one.
3423    if (String(code).indexOf('CAP') >= 0) return 'That is this month’s AI allowance used up.';
3424    return 'That did not save just now — try again in a moment.';
3425  }
3426
3427  /** Up to three, or none — and none is a real answer, not a failure. */
3428  async function _loadDynamic() {
3429    if (W.dynamic || W.dynBusy || !W.gameId) return;
3430    W.dynBusy = true; _render();
3431    try {
3432      var r = await _api('/community-hosting/games/' + W.gameId + '/assistant/questions');
3433      W.dynamic = (r && r.questions) || [];
3434    } catch (_) {
3435      // Asking nothing is the safe failure here: the five fixed questions are
3436      // already enough to build a good game, so a failed call costs polish
3437      // rather than the plan.
3438      W.dynamic = [];
3439    }
3440    W.dynBusy = false; _render();
3441  }
3442
3443  /** The eight, with this coach's real numbers and whatever setup is missing. */
3444  async function _loadLevers(force) {
3445    if ((W.levTried && !force) || W.levBusy || !W.gameId) return;
3446    W.levTried = true; W.levBusy = true; _render();
3447    try {
3448      var r = await _api('/community-hosting/games/' + W.gameId + '/levers');
3449      W.levers = (r && r.levers) || [];
3450      /* ⚠️ THE TURNOUT BOX IS FILLED FROM HERE (G30), and this call is the one
3451       * that carries it because of WHEN it happens: the levers screen is the
3452       * step immediately before "How it runs", where the number is asked for.
3453       *
3454       * ⚠️ ONLY WHEN IT IS STILL EMPTY. A coach who already typed a number, or
3455       * a resumed game that seeded one from the interview, must not have it
3456       * overwritten by their community's size — that would silently discard the
3457       * one answer this question exists to capture, at the moment they moved on
3458       * from it. */
3459      if (r && r.community_size && !W.answers.expected_players) {
3460        W.answers.expected_players = String(r.community_size);
3461      }
3462    } catch (_) { W.levers = []; }
3463    W.levBusy = false; _render();
3464  }
3465
3466  async function _saveLevers() {
3467    var map = {};
3468    (W.levers || []).forEach(function (l) { map[l.key] = !!l.on; });
3469    try {
3470      var r = await _api('/community-hosting/games/' + W.gameId + '/levers',
3471        { method: 'PATCH', body: { levers: map } });
3472      // Re-read rather than trust the local copy: readiness is a live query, so
3473      // ticking one-to-ones is the moment we can tell them the bookings are not
3474      // set up yet.
3475      if (r && r.levers) W.levers = r.levers;
3476    } catch (_) { /* the tick stands locally; the save retries on Continue */ }
3477    _render();
3478  }
3479
3480  /** Tell us you want a format we have not built. One click, one row. */
3481  async function _wantFormat(key) {
3482    if (W.wanted[key]) return;
3483    W.wanted[key] = true; _render();
3484    try {
3485      await _api('/community-hosting/games/format-interest', { method: 'POST', body: { format: ke
3485y } });
3486    } catch (_) {
3487      // Losing the vote is not worth an error message to the coach — the button
3488      // has already thanked them, and re-showing it would invite a second click
3489      // that changes nothing.
3490    }
3491  }
3492
3493  /**
3494   * The generated answers, folded into the interview.
3495   *
3496   * ⚠️ THEY GO IN UNDER THEIR OWN QUESTION TEXT, NOT UNDER `dyn_0`. The drafter
3497   * reads the interview as ground truth, and "dyn_0: about twenty minutes"
3498   * tells it nothing — the question is half the fact. So each pair is appended
3499   * to `blocker`, which is the field the truth box already labels as the one
3500   * that shapes the WORDS rather than the mechanics. That keeps the extra
3501   * context where it can help the copy without inventing a new mechanic from an
3502   * answer nothing was designed to act on.
3503   */
3504  async function _saveDynamicAnswers() {
3505    var qs = W.dynamic || [];
3506    var extra = [];
3507    // ⚠️ U4b — AN ANSWER THAT SETTLES A FIXED FIELD GOES ON THAT FIELD TOO.
3508    // Alex, 2026-08-05: "if the system is asked for more information by itself,
3509    // it should then know what the next questions are coming up and already
3510    // have filled them out with the answer that I gave earlier."
3511    //
3512    // The prose merge below is kept exactly as it was — see its comment: the
3513    // question is half the fact, and `blocker` is where extra context belongs.
3514    // This is IN ADDITION, not instead: the drafter still gets the pair, and the
3515    // later screen also arrives with the coach's own words in the box.
3516    //
3517    // `fills` is validated server-side against a closed, free-text-only list
3518    // (DYNAMIC_FILLS), so anything arriving here is already a key it is safe to
3519    // write a sentence into.
3520    var fills = {};
3521    qs.forEach(function (d, n) {
3522      var v = String(W.answers['dyn_' + n] || '').trim();
3523      if (!v) return;
3524      extra.push(d.question + ' — ' + v);
3525      // ⚠️ NEVER OVER THE TOP OF AN ANSWER THE COACH ALREADY GAVE. Going back a
3526      // step and re-answering the dynamic questions must not silently rewrite a
3527      // field they have since filled in themselves — that is U5's complaint
3528      // arriving from the other direction. Empty only.
3529      if (d.fills && d.fills !== 'blocker' && !String(W.answers[d.fills] || '').trim()) {
3530        fills[d.fills] = v;
3531      }
3532    });
3533    if (!extra.length) {
3534      W.qIdx = Math.min(W.qIdx + 1, QUESTIONS().length - 1);
3535      W.error = '';
3536      _render();
3537      return;
3538    }
3539    W.saving = true; W.error = ''; _render();
3540    try {
3541      var merged = [String(W.answers.blocker || '').trim()].concat(extra)
3542        .filter(Boolean).join('\n');
3543      W.answers.blocker = merged;
3544      // One POST, not two: `saveInterview` whitelists and merges every key it
3545      // is given, so the prose and the filled fields land in the same write and
3546      // cannot half-succeed.
3547      var body = { blocker: merged };
3548      Object.keys(fills).forEach(function (k) { body[k] = fills[k]; W.answers[k] = fills[k]; });
3549      await _api('/community-hosting/games/' + W.gameId + '/assistant/interview',
3550        { method: 'POST', body: body });
3551      W.qIdx = Math.min(W.qIdx + 1, QUESTIONS().length - 1);
3552    } catch (_) {
3553      W.error = 'Could not save that just now.';
3554    }
3555    W.saving = false;
3556    _render();
3557  }
3558
3559  /** The three recommendations, committed in one PATCH. */
3560  async function _saveRuns() {
3561    var f = _fmt(W.answers.format);
3562    W.saving = true; W.error = ''; _render();
3563    try {
3564      var days = W.answers.length_days || (f ? f.days.def : 14);
3565      // The length is an INTERVIEW answer (it is what the coach wants), and the
3566      // two modes are game columns. Two calls because they genuinely live in
3567      // two places — the drafter reads one, the running game reads the other.
3568      await _api('/community-hosting/games/' + W.gameId + '/assistant/interview',
3569        { method: 'POST', body: { length_days: days } });
3570      await _api('/community-hosting/games/' + W.gameId, {
3571        method: 'PATCH',
3572        body: {
3573          win_mode: W.answers.win_mode || (f ? f.winMode : 'completion'),
3574          date_mode: W.answers.date_mode || (f ? f.dateMode : 'soft'),
3575        },
3576      });
3577      W.qIdx = Math.min(W.qIdx + 1, QUESTIONS().length - 1);
3578    } catch (_) {
3579      W.error = 'Could not save that just now.';
3580    }
3581    W.saving = false;
3582    _render();
3583  }
3584
3585  /** Three names, drafted from everything the coach has already told us. */
3586  async function _loadNames() {
3587    if (W.names || W.namesBusy || !W.gameId) return;
3588    W.namesBusy = true; _render();
3589    try {
3590      var r = await _api('/community-hosting/games/' + W.gameId + '/assistant/names');
3591      W.names = (r && r.names) || [];
3592    } catch (_) {
3593      // No suggestions is a plainer screen, not a broken one — the field is
3594      // still there and still the only thing that actually matters.
3595      W.names = [];
3596    }
3597    W.namesBusy = false; _render();
3598  }
3599
3600  /* `how`: 'simple' when the coach chose the simple version after Maya could
3601   * not write it (N3). Every load asks the server to TELL us when she cannot
3602   * (if_maya_cannot=ask) instead of quietly handing over the floor plan. */
3603  async function _loadPlan(again, how) {
3604    if (W.planBusy || !W.gameId) return;
3605    W.planBusy = true; W.planErr = ''; W.mayaFailed = null;
3606    if (again) { W.plan = null; W.cost = null; }
3607    /* The deck opens with the first draft only. A redraft is a coach who has
3608     * already read all four cards asking for another go.
3609     *
3610     * ⚠️ AND NEVER FOR A GAME THAT IS ALREADY BUILT. The deck explains what a
3611     * journey is and what happens when you publish — which is the right thing
3612     * to read while the first draft is being written, and an odd thing to be
3613     * shown about a game you finished building yesterday. Worse, it sits in
3614     * front of the built card, so a coach coming back to press "Go live" had
3615     * to click through four explanations first. Found by driving the resumed
3616     * flow, not by reading it. */
3617    if (!again && W.primer == null && !W.primerDone && !W.built) W.primer = 0;
3618    _render();
3619    try {
3620      var r = await _api('/community-hosting/games/' + W.gameId + '/assistant/plan' +
3621        '?if_maya_cannot=ask' + (again ? '&regenerate=1' : '') + (how === 'simple' ? '&simple=1' : ''));
3622      W.plan = (r && r.plan) || null;
3623      W.planBy = (r && r.written_by) || null;
3624      if (r && r.maya_failed && !W.plan) W.mayaFailed = { had_plan: !!r.had_plan };
3625      W.cost = (r && r.cost) || null;
3626      // U5b — is this plan still true? The server stamps it when an answer the
3627      // plan was reasoned from changes, and names which answers moved.
3628      W.planStale = (r && r.stale_at) || null;
3629      W.planStaleWhat = (r && r.stale_changed) || [];
3630      if (!W.plan && !W.mayaFailed) W.planErr = 'Nothing came back to show you just now.';
3631    } catch (err) {
3632      W.planErr = 'Could not draft it just now.';
3633    }
3634    W.planBusy = false;
3635    _render();
3636  }
3637
3638  function _wireOpts(Q) {
3639    Array.prototype.forEach.call(W.el.querySelectorAll('[data-gw-opt]'), function (b) {
3640      b.addEventListener('click', function () {
3641        _answer(Q, b.getAttribute('data-gw-opt'));
3642      });
3643    });
3644  }
3645
3646  /**
3647   * Search repaints the LIST, not the screen. Re-rendering the whole question
3648   * on every keystroke would take the focus and the caret with it, which is
3649   * the classic way a search box becomes unusable after the first letter.
3650   */
3651  function _wireChannel(Q) {
3652    var el = W.el;
3653    if (!el || Q.type !== 'channel') return;
3654
3655    var search = el.querySelector('[data-gw-chansearch]');
3656    if (search) search.addEventListener('input', function () {
3657      W.chanQuery = search.value;
3658      var list = el.querySelector('[data-gw-chanlist]');
3659      if (!list) return;
3660      list.innerHTML = _chanListHtml(W.answers[Q.key]);
3661      _wireOpts(Q);
3662    });
3663
3664    var open = el.querySelector('[data-gw-channew]');
3665    if (open) open.addEventListener('click', function () {
3666      W.chanNew = true; W.chanError = ''; _render();
3667      var f = W.el.querySelector('[data-gw-channame]');
3668      // Selected, not just focused: the box already has a suggestion in it, and
3669      // a coach who wants their own should be able to type straight over it
3670      // rather than clear it first.
3671      if (f) { f.focus(); try { f.select(); } catch (_) {} }
3672    });
3673
3674    var name = el.querySelector('[data-gw-channame]');
3675    if (name) name.addEventListener('keydown', function (e) {
3676      if (e.key === 'Enter') { e.preventDefault(); _makeChannel(Q, name.value); }
3677    });
3678
3679    var make = el.querySelector('[data-gw-chanmake]');
3680    if (make) make.addEventListener('click', function () {
3681      var f = el.querySelector('[data-gw-channame]');
3682      _makeChannel(Q, f ? f.value : '');
3683    });
3684  }
3685
3686  /**
3687   * Make a channel from inside the wizard.
3688   *
3689   * It is the ordinary channel endpoint. A game-owned channel would be a new
3690   * kind of thing with a question nobody has answered — what happens to it
3691   * when the game ends — so the wizard makes a normal channel and simply
3692   * points the game at it.
3693   */
3694  async function _makeChannel(Q, rawName) {
3695    if (W.chanBusy) return;
3696    var name = String(rawName || '').trim();
3697    if (!name) { W.chanError = 'Give the channel a name first.'; _render(); return; }
3698    W.chanBusy = true; W.chanError = ''; _render();
3699    try {
3700      var r = await _api('/community-hosting/channels', { method: 'POST', body: { name: name } });
3701      var ch = r && r.channel;
3702      if (!ch || !ch.id) throw new Error('create_failed');
3703      W.channels = (W.channels || []).concat([ch]);
3704      W.chanNew = false;
3704 W.chanQuery = ''; W.chanBusy = false;
3705      // Made it, chose it, moved on — three intents, one click.
3706      _answer(Q, String(ch.id));
3707      return;
3708    } catch (err) {
3709      var code = (err && (err.error || err.message)) || '';
3710      W.chanError = /slug_taken|409/.test(String(code))
3711        ? 'There is already a channel with that name — pick it from the list above.'
3712        : 'That channel did not get made just now — try again in a moment.';
3713    }
3714    W.chanBusy = false;
3715    _render();
3716  }
3717
3718  // ── Saving ────────────────────────────────────────────────────────────
3719  //
3720  // The game row is created on the FIRST answer, not when the screen opens —
3721  // a coach who opens the guide and walks away should not leave a nameless
3722  // draft behind. After that every answer is a PATCH of one field, which is
3723  // what makes "every answer saves itself" true rather than a promise.
3724
3725  async function _ensureGame(name) {
3726    if (W.gameId) return W.gameId;
3727    var body = { name: String(name || '').trim() || _placeholderName() };
3728    if (W.spaceId) body.space_id = W.spaceId;
3729    var r = await _api('/community-hosting/games', { method: 'POST', body: body });
3730    // _api already threw if the server named a fault; this is the shape check
3731    // for a 200 that did not carry a game, which would otherwise leave W.gameId
3732    // null and fail again on the next question with nothing to explain it.
3733    if (!r || !r.game || !r.game.id) throw new Error('create_failed');
3734    W.gameId = r.game.id;
3735    return W.gameId;
3736  }
3737
3738  // The interview is not columns on the game — it is the assistant's
3739  // conversation, and it has its own endpoint (migration 459). Routing by key
3740  // rather than by step, because a question moving between steps must not
3741  // silently change where its answer is written.
3742  // ⚠️ ONE LIST, SHARED WITH THE SERVER (js/game-interview-keys.js). It decides
3743  // which endpoint an answer is POSTed to — the interview blob or the game row.
3744  // This used to be a second copy of six keys "that must match" the server's
3745  // ten, and it did not: "How many people", "Which days" and "How long" went to
3746  // the game row, which has no column for them, and were asked again on every
3747  // "Continue setup" (Anne, N1). Loaded before this file in app.html.
3748  function _isInterview(key) {
3749    var K = window.CBGameInterviewKeys;
3750    // Fail loudly rather than guess: a guess is an answer PATCHed nowhere (7b).
3751    if (!K) { console.error('[games] game-interview-keys.js did not load: answers cannot be routed'); throw new Error('keys_missing'); }
3752    return K.guideSendsToInterview(key);
3753  }
3754
3755  function _payloadFor(Q, value) {
3756    var b = {};
3757    if (Q.key === 'channel_id')  { b.channel_id = value === '' ? null : parseInt(value, 10); return b; }
3758    if (Q.key === 'join_url')    { b.join_url = String(value || '').trim() || null; return b; }
3759    if (Q.key === 'min_level')   { b.min_level = Math.max(0, Math.round(Number(value) || 0)); return b; }
3760    if (Q.key === 'opens_at')    { b.opens_at = _fromDateInput(value); return b; }
3761    if (Q.key === 'closes_at')   { b.closes_at = _fromDateInput(value); return b; }
3762    if (Q.key === 'points')      { b.points = value; return b; }
3763    // migration 460 — the journey's own settings, sent under their column names.
3764    if (Q.key === 'format')      { b.format = value || null; return b; }
3765    if (Q.key === 'win_mode')    { b.win_mode = value; return b; }
3766    if (Q.key === 'date_mode')   { b.date_mode = value; return b; }
3767    // Named explicitly rather than left to the generic fall-through, because
3768    // this is the field that was silently dropped (see _answer) and a trimmed
3769    // value is what every other text answer sends.
3770    if (Q.key === 'name')        { b.name = String(value || '').trim(); return b; }
3771    if (Q.key === 'prize')       { b.prize = String(value || '').trim() || null; return b; }
3772    if (Q.key === 'finisher_reward') { b.finisher_reward = String(value || '').trim() || null; return b; }
3773    b[Q.key] = value;
3774    return b;
3775  }
3776
3777  /**
3778   * What stops an answer, and why each one stops it.
3779   *
3780   * A required field is required because the game does not WORK without it,
3781   * not because a form likes full boxes. Both of these are that:
3782   *
3783   *   name        it is the heading members read; a nameless game is a bug
3784   *               on their screen, not just an untidy record.
3785   *   channel_id  a game counts what happens in its channel. Without one it
3786   *               scores nothing, forever, silently — the worst kind of
3787   *               nothing, because everything else about it looks fine.
3788   *
3789   * min_level starts at 1 rather than 0 for the same reason: "level 0 and up"
3790   * is every member, so a level-gated game with min_level 0 is a gate that
3791   * says nothing while looking like it says something.
3792   */
3793  function _invalid(Q, value) {
3794    var s = String(value == null ? '' : value).trim();
3795    if (Q.key === 'name' && !s) {
3796      return 'It needs a name — that is what members will see at the top.';
3797    }
3798    if (Q.key === 'channel_id' && !s && _needsChannel()) {
3799      return 'Pick a channel, or make one below. A game counts what happens in its ' +
3800             'channel, so without one it will score nothing.';
3801    }
3802    if (Q.key === 'min_level' && !(Number(s) >= 1)) {
3803      return 'Pick a level of 1 or more — level 0 is everybody, which is the same as ' +
3804             'saying anyone can take part.';
3805    }
3806    if (Q.key === 'join_url' && s && !/^https?:\/\/.+/i.test(s)) {
3807      return 'That needs to be a full web address, starting with http:// or https://.';
3808    }
3809    return '';
3810  }
3811
3812  /**
3813   * Does this game need a channel to go live? A COMMUNITY game does: it counts
3814   * what happens in its channel, and without one it scores nothing. A game in a
3815   * SPACE (migration 855) does not: it happens in its space's own feed. One
3816   * rule, read by the channel question and by Publish alike.
3817   */
3818  function _needsChannel() {
3819    return !W.spaceId && !W.spaceName;
3820  }
3821
3822  async function _answer(Q, value) {
3823    if (W.saving) return;
3824    var bad = _invalid(Q, value);
3825    if (bad) { W.error = bad; _render(); return; }
3826    W.answers[Q.key] = value;
3827    // Choosing the level gate lands on the level question next; arriving there
3828    // with an empty box, when 0 is not a legal answer, is a question that
3829    // starts wrong. 2 is a starting point, not a decision.
3830    if (Q.key === 'access_rule' && value === 'level' && !(Number(W.answers.min_level) >= 1)) {
3831      W.answers.min_level = 2;
3832    }
3833    W.saving = true;
3834    W.error = '';
3835    _render();   // the button says "Saving…" while it is
3836    try {
3837      await _ensureGame(W.answers.name);
3838      // ⚠️ THE NAME USED TO BE SKIPPED HERE, AND IT WAS NEVER SAVED AT ALL.
3839      //
3840      // Alex, 2026-08-05: *"Why does my game come through as untitled? I was
3841      // pretty sure there was a step where I gave it a name."* He did. It was
3842      // thrown away.
3843      //
3844      // The line said `var skip = (Q.key === 'name')`, on the reasoning that
3845      // "the name rode in on create". That was TRUE when Name was the first
3846      // question. It is now the EIGHTH — who_for, start_point, outcome,
3847      // blocker, coach_goals, the dynamic ones and format all come first, and
3848      // every one of them calls `_ensureGame` too. So the row is created by
3849      // question ONE, with `W.answers.name` still empty, which means it is
3850      // created with the PLACEHOLDER — and then the one question that could
3851      // fix it was the one question skipped.
3852      //
3853      // ⚠️ AND IT LOOPED. Resume sends the coach to the first unanswered
3854      // question, and `_isPlaceholderName` counts a placeholder as unanswered —
3855      // so "Continue setup" dropped them back on "Name it", where answering
3856      // discarded it again. Alex: *"It's actually taking me through to give it
3857      // a name again… even when I say the name, it doesn't keep it."* One
3858      // stale assumption, three symptoms.
3859      //
3860      // The name is now saved exactly like every other non-interview answer.
3861      if (_isInterview(Q.key)) {
3862        var body = {};
3863        body[Q.key] = value;
3864        await _api('/community-hosting/games/' + W.gameId + '/assistant/interview',
3865          { method: 'POST', body: body });
3866        (W.ivHas = W.ivHas || {})[Q.key] = true;
3867      } else {
3868        await _api('/community-hosting/games/' + W.gameId, {
3869          method: 'PATCH', body: _payloadFor(Q, value),
3870        });
3871      }
3872      W.qIdx = Math.min(W.qIdx + 1, QUESTIONS().length - 1);
3873    } catch (err) {
3874      // The answer stays on screen. Losing what someone just typed because a
3875      // request failed is the one outcome worth guarding against here.
3876      W.error = _saveError(err);
3877    }
3878    W.saving = false;
3879    _render();
3880  }
3881
3882  /**
3883   * Save one edited answer and stay exactly where we are.
3884   *
3885   * Deliberately NOT _answer(): that one advances to the next question, which
3886   * is the whole thing Alex asked us to stop doing here — "it doesn't throw you
3887   * back in the whole flow… you come back to your journey summary".
3888   */
3889  async function _saveOneAnswer(key, value) {
3890    if (W.saving) return;
3891    W.answers[key] = value;
3892    W.saving = true; W.error = ''; _render();
3893    try {
3894      if (_isInterview(key)) {
3895        var body = {}; body[key] = value;
3896        await _api('/community-hosting/games/' + W.gameId + '/assistant/interview',
3897          { method: 'POST', body: body });
3898        (W.ivHas = W.ivHas || {})[key] = true;
3899      } else {
3900        var one = {}; one[key] = value;
3901        await _api('/community-hosting/games/' + W.gameId, { method: 'PATCH', body: one });
3902      }
3903    } catch (err) {
3904      W.error = _saveError(err);
3905      W.editedSince = false;   // it did not land, so there is nothing new to draft from
3906    }
3907    W.saving = false; _render();
3908  }
3909
3910  /**
3911   * Build the game. This is the end of the wizard's job.
3912   *
3913   * ⚠️ IT DOES NOT PUBLISH, AND IT DOES NOT ASK FOR A CHANNEL. Both used to
3914   * happen here, and both are what Alex objected to: "The channel definitely
3915   * should be done later. Start date confirmation can be done later." A game
3916   * with no channel is a perfectly good draft; it is PUBLISHING that wants one,
3917   * which is where that check now lives.
3918   *
3919   * 409 already_built is success. It means a previous press got this far, and
3920   * refusing on the second one would strand a coach in front of a game that
3921   * exists.
3922   */
3923  async function _buildIt() {
3924    if (W.saving || !W.gameId) return;
3925    if (!W.plan) return;
3926    W.saving = true; W.building = true; W.error = '';
3927    _render();
3928    try {
3929      await _api('/community-hosting/games/' + W.gameId + '/assistant/build',
3930        { method: 'POST', body: {} });
3931    } catch (err) {
3932      var code = String((err && (err.code || err.error || err.message)) || '');
3933      if (!/already_built|409/.test(code)) {
3934        W.saving = false; W.building = false;
3935        W.error = _saveError(err, 'Could not build it just now — try again in a moment.');
3936        _render();
3937        return;
3938      }
3939    }
3940    W.saving = false; W.building = false;
3941    W.built = true;
3942    W.error = '';
3943    _render();
3944  }
3945
3946  /** Enter the publishing walk-through — the course's `_renderGoLive` shape:
3947   *  it starts at its first question every time, because it is a walk-through
3948   *  and not a position the coach is resuming. */
3949  function _enterGoLive() {
3950    W.golive = true;
3951    W.error = '';
3952    /* The walk's FIRST question, whatever it is. It was the channel question by
3953       key, and a space's game has none (F11), so the press did nothing. */
3954    var qs = QUESTIONS(), at = -1;
3955    for (var k = 0; k < qs.length; k++) { if (qs[k].step === GOLIVE_STEP) { at = k; break; } }
3956    W.qIdx = at >= 0 ? at : W.qIdx;
3957    _render();
3958  }
3959
3960  async function _publish() {
3961    if (W.saving) return;
3962    if (!W.gameId) { W.error = 'Answer the first question and it will appear here.'; _render(); return; }
3963    // Publishing a game with no channel publishes a game that can never
3964    // score. Better to be sent back one screen than to find that out from a
3965    // leaderboard of zeroes a fortnight later.
3966    // A challenge in a space (855) happens in its space and needs no channel.
3967    if (!W.answers.channel_id && _needsChannel()) {
3968      var at = _indexOfKey('channel_id');
3969      W.error = 'It needs a channel before it can open. A game counts what happens in one.';
3970      if (at >= 0) W.qIdx = at;
3971      _render();
3972      return;
3973    }
3974    W.saving = true; W.error = '';
3975    _render();
3976    try {
3977      // ⚠️ NO BUILD HERE ANY MORE — `_buildIt` did it, at the end of the
3978      // wizard. Kept as a belt-and-braces call ONLY for a game that reached
3979      // this screen without one (a coach who published from the board before
3980      // this split existed); 409 already_built is the ordinary answer and is
3981      // success.
3982      if (W.plan && !W.built) {
3983        try {
3984          await _api('/community-hosting/games/' + W.gameId + '/assistant/build',
3985            { method: 'POST', body: {} });
3986        } catch (err) {
3987          var code = (err && (err.error || err.message)) || '';
3988          if (!/already_built|409/.test(String(code))) throw err;
3989        }
3990      }
3991      await _api('/community-hosting/games/' + W.gameId, {
3992        method: 'PATCH', body: { published: true },
3993      });
3994      W.saving = false;
3995      W.onDone(W.gameId, { opened: true });
3996      return;
3997    } catch (err) {
3998      W.error = 'Could not open it just now. Try again in a moment.';
3999    }
4000    W.saving = false;
4001    _render();
4002  }
4003
4004  // ── The Speak button ──────────────────────────────────────────────────
4005
4006  function _speakIt() {
4007    var V = window.VoiceInput;
4008    if (!V || typeof V.attach !== 'function' || !W.el) return;
4009    var fields = W.el.querySelectorAll(
4010      'textarea:not([data-no-voice]), input.ccw-answer:not([data-no-voice])');
4011    Array.prototype.forEach.call(fields, function (f) {
4012      if (f.tagName === 'INPUT' && (f.getAttribute('type') || '').toLowerCase() === 'number') return;
4013      try { V.attach(f); } catch (_) {}
4014    });
4015  }
4016
4017  // ⚠️ EVERYTHING HERE IS ABOUT ONE PARTICULAR GAME, so it all has to go when
4018  // the wizard is pointed at a different one.
4019  //
4020  // This runs from both start() and open(), which is the only reason the name
4021  // is now wrong for what it does — it already reset the plan as well as the
4022  // channel picker. The advisory state was missed: the app is a single page, so
4023  // a coach who builds one game and then presses "New game" keeps the SAME
4024  // module. Without this, game two shows game one's levers, game one's
4025  // recommendation and game one's suggested names, all looking freshly written
4026  // for it. The levers are the sharp one — they are what the coach is
4027  // committing to, and they would have been the wrong game's commitments.
4028  function _resetForNewGame() {
4029    W.chanQuery = ''; W.chanNew = false; W.chanBusy = false; W.chanError = '';
4030    W.plan = null; W.cost = null; W.planBusy = false; W.planErr = '';
4031    W.ivHas = {};   // open() re-seeds it from the game's own interview
4032    W.mayaFailed = null; W.planBy = null;
4033    // Cleared with the plan it describes. A second game in the same session
4034    // inheriting "your answers changed" from the first one would be a warning
4035    // about a plan that no longer exists.
4036    W.planStale = null; W.planStaleWhat = [];
4037    W.primer = null; W.primerDone = false; W.cards = {}; W.mayaEl = null;
4038    W.editedSince = false; W.feedbackSince = false; W.editing = null;
4039    W.formats = []; W.recommendation = null; W.fmtBusy = false; W.fmtTried = false;
4040    // Cleared with the rest of it, or the second game in a session would keep
4041    // the first one's advice-already-asked flag and never get a recommendation.
4042    W.recTried = false;
4043    W.panel = null; W.wanted = {};
4044    W.levers = []; W.levBusy = false; W.levTried = false;
4045    W.dynamic = null; W.dynBusy = false;
4046    W.names = null; W.namesBusy = false;
4047    W.touched = {}; W.runEdit = null;
4048    // The build/publish split: a second game starts unbuilt and not
4049    // publishing, or game two would open on game one's built card.
4050    W.built = false; W.golive = false;
4051    W.pending = null; W.pendingInThread = false; W.published = false;
4052  }
4053
4054  /**
4055   * The first question this game has no answer for — where a resumed setup
4056   * should open.
4057   *
4058   * ⚠️ THIS USED TO CHECK FIVE KEYS AND SKIP EVERYTHING ELSE, AND IT SENT
4059   * COACHES TO THE WRONG PLACE. Alex, 2026-08-04, having answered ONE question
4060   * and come back: "it drops you in at the 'Choose the channel name', which is
4061   * obviously not where you've actually stopped… It's assuming that you've
4062   * completed all the sets once. That's very bad."
4063   *
4064   * He is right, and there were two faults stacked on each other:
4065   *
4066   *   1. It only looked at name / who_for / outcome / channel_id / min_level.
4067   *      Answer who_for and the next of THOSE that is empty is the channel —
4068   *      so it leapt from question 1 to step 8, past six questions and the
4069   *      entire advisory middle.
4070   *   2. `name` was already filled with the placeholder "New game" that
4071   *      creating the row requires, so the one field that could have caught it
4072   *      looked answered from the very first save. Alex saw this too: "we
4073   *      shouldn't be storing the name if they're still answering the
4074   *      questions."
4075   *
4076   * So it now walks the REAL list in order and stops at the first question
4077   * genuinely without an answer — which is what "where you left off" means.
4078   *
4079   * The exception list is the honest part. For those keys an empty value IS
4080   * the answer ("no end date", "scores nothing"), so treating empty as missing
4081   * would park every coach on a date picker forever. They are skipped as
4082   * blockers, not skipped as questions — a coach still walks through them.
4083   */
4084  var EMPTY_IS_AN_ANSWER = {
4085    opens_at: 1, closes_at: 1, points: 1, prize: 1, finisher_reward: 1,
4086    blocker: 1, start_point: 1, coach_goals: 1, join_url: 1, signup_post: 1,
4087    dynamic: 1, levers: 1, runs: 1, plan: 1, format: 1, review: 1,
4088  };
4089
4090  /* N1: for these three an empty-looking value is a real, stored answer —
4091   * no days ticked means "no constraint", 0 people means "I do not know" —
4092   * so a resumed game counts them as answered when the interview HAS them,
4093   * rather than by what the box shows. Unlike EMPTY_IS_AN_ANSWER, a game that
4094   * was never asked them still stops on them. */
4095  var PRESENT_IS_ANSWERED = { expected_players: 1, coach_days: 1, coach_minutes: 1 };
4096
4097  /** Has this question been answered? Per type, because a checkbox list, a
4098   *  number and a text box each mean something different by "empty". */
4099  function _isAnswered(Q, a) {
4100    var v = a[Q.key];
4101    if (PRESENT_IS_ANSWERED[Q.key] && W.ivHas && W.ivHas[Q.key]) return true;
4102    if (Q.key === 'name') {
4103      // ⚠️ The placeholder is not an answer. It exists only because the row
4104      // cannot be created without one, and counting it is what broke this.
4105      return !_isPlaceholderName(v);
4106    }
4107    if (Q.key === 'min_level') return Number(v) >= 1;
4108    if (Array.isArray(v)) return v.length > 0;
4109    if (v && typeof v === 'object') return Object.keys(v).length > 0;
4110    return String(v == null ? '' : v).trim() !== '';
4111  }
4112
4113  function _firstUnanswered() {
4114    var qs = QUESTIONS();
4115    var a = W.answers;
4116    for (var i = 0; i < qs.length; i++) {
4117      if (EMPTY_IS_AN_ANSWER[qs[i].key]) continue;
4118      if (!_isAnswered(qs[i], a)) return i;
4119    }
4120    return Math.max(0, qs.length - 1);   // everything answered — the review
4121  }
4122
4123  /* ── ASKING A COACH SOMETHING THEY TOLD US THIRTY SECONDS AGO ────────────
4124   *
4125   * 🔻 ALEX, 2026-09-10, on a coach arriving here from the finish line of the
4126   * Green Lane: *"there is probably some basic stuff we can do there… just tidy
4127   * up that UX so they're not repeating themselves."*
4128   *
4129   * ⚠️ THE REPETITION IS EXACT, NOT APPROXIMATE. This wizard's first REQUIRED
4130   * question is "Who is this for?". The ladder they have just finished collected
4131   * precisely that, in their own words, and wrote it into their community
4132   * description; `coaching-success/absorb.js` now carries it into Maya's Brain
4133   * as `target_audience`. So the answer is sitting in the database while the
4134   * screen asks for it again.
4135   *
4136   * ⚠️ IT PREFILLS, IT DOES NOT ANSWER. The box is filled and left focused and
4137   * editable, with a line saying where it came from — a coach whose game is for
4138   * a different group than their community must be able to say so without
4139   * hunting for how to clear it. Anything that SUBMITTED this on their behalf
4140   * would be the screen deciding what their game is about.
4141   *
4142   * ⚠️ AND ONLY WHEN THE BOX IS EMPTY. Resume re-enters through `open`, not
4143   * here, but a coach who typed something and pressed back must not find it
4144   * replaced.
4145   *
4146   * Never throws and never blocks the render: `_render()` has already run when
4147   * this is called, so a brain that will not load costs a prefill and nothing
4148   * else.
4149   */
4150  function _prefillFromBrain() {
4151    /* `_api` is this file's own wrapper; `api` is the global it wraps. Checked
4152       rather than assumed — reading the helper's name off memory is how a
4153       prefill silently never runs. */
4154    _api('/maya-brain/overview')
4155      .then(function (d) {
4156        var know = (d && d.know) || [];
4157        var found = null;
4158        for (var i = 0; i < know.length; i++) {
4159          if (know[i] && know[i].key === 'target_audience') { found = know[i].value; break; }
4160        }
4161        if (!found || W.answers.who_for || W.gameId) return;
4162        W.answers.who_for = String(found).slice(0, 300);
4163        W.prefilled = { who_for: true };
4164        _render();
4165      })
4166      .catch(function () { /* a prefill is a nicety; the question still works */ });
4167  }
4168
4169  // ── Public API ────────────────────────────────────────────────────────
4170
4171  var API = {
4172    /** Start a new game. No row is written until the first answer. */
4173    start: function (container, opts) {
4174      opts = opts || {};
4175      W.el = container;
4176      W.gameId = null;
4177      W.qIdx = 0;
4178      W.answers = { access_rule: 'free', points: {} };
4179      W.channels = opts.channels || [];
4180      W.error = '';
4181      _resetForNewGame();
4182      // A challenge in a space (855): the game is created in that space.
4183      W.spaceId = opts.spaceId || null;
4184      W.spaceName = opts.spaceName || '';
4185      W.embedded = !!opts.embedded;
4186      W.onExit = opts.onExit || function () {};
4187      W.onDone = opts.onDone || function () {};
4188      _ensureSheets();
4189      _render();
4190      _prefillFromBrain();
4191    },
4192
4193    /** Resume setting up a game that already exists. */
4194    open: function (container, opts) {
4195      opts = opts || {};
4196      var g = opts.game || {};
4197      W.el = container;
4198      W.gameId = g.id || null;
4199      W.spaceId = null;   // an existing game already carries its space
4200      W.spaceName = g.space_name || '';
4201      W.embedded = !!opts.embedded;
4202      // ⚠️ THE INTERVIEW IS NOT ON THE GAME ROW. It lives in assistant_state,
4203      // and seeding only the columns is how a coach who answered four questions
4204      // yesterday comes back to four empty boxes — and gets sent to question
4205      // one by _firstUnanswered, which is precisely the cost that function
4206      // exists to avoid. The answers were saved; the form just did not know
4207      // where to look for them.
4208      var st = (g.assistant_state && typeof g.assistant_state === 'object')
4209        ? g.assistant_state : {};
4210      var iv = st.interview || {};
4211      W.answers = {
4212        name: g.name || '', description: g.description || '',
4213        who_for: iv.who_for || '', outcome: iv.outcome || '',
4214        blocker: iv.blocker || '',
4215        // ⚠️ THESE TWO WERE MISSING, AND IT IS THE SAME FAULT ALEX NAMED —
4216        // "I think you've omitted things by accident."
4217        //
4218        // The interview asks five things and this seeded three. A coach who
4219        // answered where their people are starting from, and why they are
4220        // running it, came back to two blank boxes and would have been asked
4221        // both again — with the answers still sitting in assistant_state,
4222        // shaping the plan, invisible to the screen that claims to list them.
4223        start_point: iv.start_point || '',
4224        coach_goals: Array.isArray(iv.coach_goals) ? iv.coach_goals : [],
4225        /* ⚠️ AND THE SAME FAULT AGAIN, ONE LAYER DOWN: THE MECHANICS WERE NOT
4226         * SEEDED EITHER, AND THE LENGTH WAS SEEDED INTO THE WRONG KEY.
4227         *
4228         * The interview has stored `length_days` since migration 460
4229         * (games-service INTERVIEW_KEYS); `length_weeks` is the pre-460 key,
4230         * which the SERVER still converts (`_lengthDays`) and this form never
4231         * did. So the seed wrote `W.answers.length_weeks`, which no screen
4232         * reads, and every resumed game showed the format's default length
4233         * instead of the one the coach chose. The conversion is mirrored here
4234         * for the legacy rows, the same way and with the same 30-day ceiling.
4235         *
4236         * Worse, `format` was missing entirely, and it is a COLUMN sitting
4237         * right there on the row. A coach resuming a half-built game came back
4238         * with no format in the form, so the gallery showed nothing chosen and
4239         * the recommendation would quietly re-select whatever it fancied — over
4240         * a format they had already picked by hand. That is Alex's G12 in its
4241         * root form: "It all stems from not knowing what game you're going into
4242         * to continue to set up."
4243         *
4244         * It also decides, now, whether the coach is asked for a start date at
4245         * all (`format.start`), so an unseeded format would silently drop a
4246         * question a cohort game genuinely needs. */
4247        length_days: _seedLength(iv),
4248        /* ⚠️ SEEDED FROM THE COMMUNITY'S OWN SIZE, NOT ASKED COLD (G30). The
4249         * number is already in the database, so the coach corrects it rather
4250         * than calculating it — Alex has asked three times for a shorter
4251         * interview, and a question whose answer we already hold is exactly the
4252         * kind that lengthens it for nothing.
4253         *
4254         * ⚠️ THEIR OWN ANSWER WINS. `iv.expected_players` is read FIRST, so a
4255         * coach who said twenty does not come back to a resumed game showing
4256         * their whole community again — that would silently discard the one
4257         * number this question exists to capture. */
4258        /* N1: `opts.communitySize` was read here and no caller ever passed it,
4259         * so a resumed game's box came back empty. The community's size now
4260         * arrives the same way it does on the forward path, from the levers
4261         * call (`_loadLevers`), which the turnout question itself asks for. */
4262        expected_players: iv.expected_players ? String(iv.expected_players) : '',
4263        coach_days: Array.isArray(iv.coach_days) ? iv.coach_days : [],
4264        coach_minutes: iv.coach_minutes ? String(iv.coach_minutes) : '',
4265        format: g.format || '',
4266        win_mode: g.win_mode || '',
4267        date_mode: g.date_mode || '',
4268        finisher_reward: g.finisher_reward || '',
4269        // Not `levers` — the ticks live in `W.levers`, the list the levers
4270        // endpoint returns with `on` already resolved against the row. Seeding
4271        // a second copy into `W.answers` would be a field nothing reads and a
4272        // second source of truth for the same ticks.
4273        // prize is a column, not an interview answer — the wizard writes it
4274        // straight to the game, so it comes back from there.
4275        prize: g.prize || '',
4276        channel_id: g.channel_id == null ? '' : String(g.channel_id),
4277        access_rule: g.access_rule || 'free', min_level: g.min_level || 0,
4278        join_url: g.join_url || '',
4279        opens_at: g.opens_at || '', closes_at: g.closes_at || '',
4280        points: (g.points && typeof g.points === 'object') ? g.points : {},
4281      };
4282      W.channels = opts.channels || [];
4283      W.error = '';
4284      _resetForNewGame();
4285      /* ⚠️ A GAME THAT HAS ALREADY BEEN BUILT COMES BACK BUILT.
4286       *
4287       * `_resetForNewGame` clears `W.built`, which is right for a new game and
4288       * wrong for a resumed one: without this, a coach who built a game and
4289       * came back would be shown "Build it" on a game that already exists, and
4290       * pressing it would 409. Exactly the fault class the resume seed had
4291       * before — state on the row that the form did not read. */
4292      W.built = Boolean(st.materialized_at);
4293      // Which interview answers this game already HAS, whatever their value
4294      // (see PRESENT_IS_ANSWERED): "no days ticked" and "I do not know how
4295      // many" are answers, and a resumed game must not ask them again.
4296      W.ivHas = {};
4297      Object.keys(iv).forEach(function (k) { W.ivHas[k] = true; });
4298      // Resume where they stopped, not at question one. "Every answer saves
4299      // itself" is only worth saying if coming back costs nothing — walking
4300      // someone through four answers they already gave is the cost.
4301      W.qIdx = _firstUnanswered();
4302      /* NM1 (spaces round 4): A GAME WITH A PLAN OPENS ON ITS PLAN.
4303       * Maya drafts the plan from what the coach told her and says "read it on
4304       * the Game tab and press Build". Her interview leaves the coach's own
4305       * time (which days, how long) unasked, so `_firstUnanswered` parked Anne
4306       * on "Step 6 of 7: Which days…", with no plan and no Build on screen.
4307       * The plan IS where they left off: it is on the server, written from
4308       * their answers, and those questions only tune it (Back still reaches
4309       * them). So a drafted, unbuilt plan opens on the plan step, and the
4310       * explainer deck, which is for the minute while a FIRST draft is being
4311       * written, does not stand in front of one already written. */
4312      var hasPlan = !!(st.plan && Array.isArray(st.plan.asks) && st.plan.asks.length);
4313      /* NA7 (Anne, round 4, sixth check): A BUILT GAME OPENS ON ITS BUILT CARD.
4314       * A game Maya built has no `expected_players`, `coach_days` or
4315       * `coach_minutes` (her interview never asks them), so "Open it to
4316       * members" on the Game tab landed on "Step 6 of 7: How many people do
4317       * you expect…" and two more questions before the card with the real
4318       * button. Those three only tune how a plan is weighed and when its
4319       * posts are scheduled, which Build has already done, and opening a game
4320       * reads none of them. So a built game opens where it is: on the built
4321       * card, with "Open it to members" on it. Back still reaches them. */
4322      if (hasPlan || W.built) {
4323        var qsNow = QUESTIONS();
4324        for (var pi = 0; pi < qsNow.length; pi++) {
4325          if (qsNow[pi].key === 'plan') { if (pi > W.qIdx) W.qIdx = pi; break; }
4326        }
4327        W.primerDone = true;
4328      }
4329      W.published = !!g.published;
4330      W.onExit = opts.onExit || function () {};
4331      W.onDone = opts.onDone || function () {};
4332      _ensureSheets();
4333      _render();
4334      // NA8: a change Maya left waiting on a built game shows on its card.
4335      if (W.built) {
4336        var openedId = W.gameId;
4337        _loadPending().then(function () { if (W.gameId === openedId && W.pending) _render(); });
4338      }
4339    },
4340
4341    close: function () { W.el = null; W.gameId = null; W.answers = {}; },
4342  };
4343
4344  window.GameCreationWizard = API;
4345})();

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.