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, '&').replace(/</g, '<').replace(/>/g, '>') 134 .replace(/"/g, '"').replace(/'/g, '''); 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 ? '®enerate=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.