kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

game.js (44969B)


      1 // Story Stamp — the game. Plain DOM + CSS animations, keyboard only.
      2 //
      3 // The child runs the checkout desk of the island library. Kids queue up, each
      4 // wanting a book about something (bugs, space, dinosaurs ...). The child
      5 // picks who to help; that patron hands over a request slip with the book's
      6 // title in sound boxes. The child stamps each letter (hearing its sound),
      7 // then presses SPACE to sound the title out: the sounds play with shrinking
      8 // gaps until they nearly touch — the word itself is never said, the child
      9 // has to say it — and then picks the right book from the cart, stamps it, and
     10 // the patron leaves happy. "Returns" (unlocked later) go the other way: the
     11 // patron says the word and the child types its sounds to shelve the book.
     12 // Pure logic lives in lib.js, audio in audio.js; this file is UI + flow.
     13 //
     14 // Wall-clock rule: every delay is a setTimeout so the flow keeps moving in
     15 // throttled / headless browsers. window.__TEST_MODE__ shortens them all.
     16 (function (root) {
     17   'use strict';
     18 
     19   const SS     = root.SS;
     20   const ASSETS = root.SS_ASSETS || { phonemes: {}, words: {}, images: {} };
     21   const TEST   = !!root.__TEST_MODE__;
     22 
     23   const T     = (ms) => (TEST ? Math.min(ms, 15) : ms);
     24   const sleep = (ms) => new Promise((r) => setTimeout(r, T(ms)));
     25   const esc   = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
     26   const pick  = (arr) => arr[Math.floor(Math.random() * arr.length)];
     27   const cap   = (s) => s.charAt(0).toUpperCase() + s.slice(1);
     28   // Picture by Fluent Emoji asset name (see manifest.js); unknown names draw nothing.
     29   const img   = (name, cls) => {
     30     const f = ASSETS.images && ASSETS.images[name];
     31     return f ? '<img class="ss-img ' + (cls || '') + '" src="assets/' + f + '" alt="" draggable="false">' : '';
     32   };
     33   const BOX_COLORS = ['#E63946', '#1D7CF2', '#2EC4B6', '#9B5DE5', '#F4A261', '#06A77D', '#FF5DA2', '#8AC926'];
     34   const WALKERS = ['Child/Default', 'Girl/Default', 'Boy/Default', 'Penguin', 'Koala', 'Panda', 'Lion', 'Tiger face', 'Bear', 'Alien'];
     35   const DECOR = {
     36     rug:       '<div class="d-rug"></div>',
     37     plant:     '<span class="d-plant">' + img('Potted plant') + '</span>',
     38     lamp:      '<span class="d-lamp">' + img('Light bulb') + '</span>',
     39     fishtank:  '<div class="d-tank">' + img('Fish') + img('Tropical fish') + '</div>',
     40     bunting:   '<div class="d-bunting"></div>',
     41     cat:       '<span class="d-cat">' + img('Cat') + '</span>',
     42     globe:     '<span class="d-globe">' + img('Globe showing americas') + '</span>',
     43     telescope: '<span class="d-telescope">' + img('Telescope') + '</span>',
     44     dino:      '<span class="d-dino">' + img('Sauropod') + '</span>',
     45     rocket:    '<span class="d-rocket">' + img('Rocket') + '</span>',
     46   };
     47 
     48   const LINES = {
     49     hello:      ['Hello!', 'Hi there!', 'Good morning!', 'Hi!', 'Yoo-hoo!', 'Hello, librarian!'],
     50     again:      ['It is me again!', 'I am back!', 'Remember me?', 'Back for another book!'],
     51     want:       ['I want a book about {t}!', 'Do you have a book about {t}?', 'A book about {t}, please!', 'I would love a book about {t}.'],
     52     wantAny:    ['I want a good book!', 'Can you pick me a book?', 'Any good book, please!'],
     53     slip:       ['Here is my request slip. Can you read it?', 'It is called this. Can you read it?', 'This is the one I want!'],
     54     returning:  ['I am bringing this book back.', 'I finished my book! Here it is.', 'Time to return this one.'],
     55     happy:      ['Thank you!', 'Just what I wanted!', 'I love it!', 'You are the best librarian!', 'Hooray!', 'My favourite!'],
     56     wrongBook:  ['Hmm, that is not it.', 'Oops! Not that one.', 'Not quite! Try again.'],
     57     instrRead:  'Sound it out! Press each letter.',
     58     instrHear:  'Which sounds do you hear? Press the letters.',
     59     readIt:     'Now press space to read it out loud!',
     60     shelveIt:   'Now press space to put it back!',
     61     choose:     'What does it say? Say the word, then pick the right book.',
     62     queue:      'Who is next? Use the arrows and press enter.',
     63   };
     64 
     65   class StoryStamp {
     66     constructor(container) {
     67       this.container = container;
     68       this.storage = null;
     69       try { this.storage = root.localStorage; } catch (_) {}
     70       this.storage = this.storage || { getItem() { return null; }, setItem() {}, removeItem() {} };
     71       this.store   = SS.loadStore(this.storage);
     72       this.profile = null;
     73       this.audio   = new root.StoryStampAudio(ASSETS, 'assets/audio/');
     74       this.state   = 'boot';
     75       this.busy    = false;
     76       this.order   = null;
     77       this.queue   = [];
     78       this.sel     = 0;
     79       this.booksToday = 0;
     80       this.todayWords  = [];
     81       this.todaySounds = [];
     82       this.applied = {};
     83       this._build();
     84       this._bindKeys();
     85       this._watchSize();
     86       this.showProfiles();
     87       if (!TEST) this._ambient();
     88     }
     89 
     90     now() { return root.__SS_NOW__ || Date.now(); }
     91     save() { SS.saveStore(this.storage, this.store); }
     92 
     93     // ── DOM ────────────────────────────────────────────────────────────────
     94 
     95     _build() {
     96       const r = document.createElement('div');
     97       r.className = 'ss-root' + (TEST ? ' ss-test' : '');
     98       const spines = (n, seed) => {
     99         let out = '';
    100         for (let i = 0; i < n; i++) {
    101           const h = 55 + (SS.hashStr(seed + i) % 40);
    102           out += '<i style="height:' + h + '%;background:' + BOX_COLORS[(SS.hashStr(seed + 'c' + i)) % BOX_COLORS.length] + '"></i>';
    103         }
    104         return out;
    105       };
    106       const shelf = (side) => '<div class="ss-shelf ' + side + '">' +
    107         [0, 1, 2, 3].map((row) => '<div class="ss-row"><div class="ss-spines">' + spines(7, side + row) + '</div></div>').join('') + '</div>';
    108       r.innerHTML =
    109         '<div class="ss-wall">' +
    110           '<div class="ss-window"><div class="ss-sky"><div class="ss-sun"></div><div class="ss-cloud"></div><div class="ss-cloud c2"></div>' +
    111             '<span class="ss-bird">' + img('Bird') + '</span></div><div class="ss-sea"><span class="ss-boat">' + img('Sailboat') + '</span></div>' +
    112             '<span class="ss-palm">' + img('Palm tree') + '</span><div class="ss-walkers"></div></div>' +
    113           shelf('left') + shelf('right') +
    114           '<div class="ss-sign">Island Library</div>' +
    115           '<div class="ss-decor"></div>' +
    116         '</div>' +
    117         '<div class="ss-floor"></div>' +
    118         '<div class="ss-queue"></div>' +
    119         '<div class="ss-customer"><div class="ss-bubble"></div><div class="ss-cust-body"></div><div class="ss-cust-name"></div></div>' +
    120         '<div class="ss-desk"><div class="ss-desk-top"></div><div class="ss-desk-front"></div>' +
    121           '<div class="ss-slip"><div class="ss-slip-title"></div><div class="ss-boxes"></div><div class="ss-finger">' + img('Backhand index pointing up/Default') + '</div></div>' +
    122           '<div class="ss-stamp"><div class="ss-stamp-handle"></div><div class="ss-stamp-base"></div></div>' +
    123           '<div class="ss-cart"></div>' +
    124           '<div class="ss-book-out"></div>' +
    125         '</div>' +
    126         '<div class="ss-prompt"></div>' +
    127         '<div class="ss-hud"><div class="ss-chip ss-who"></div><div class="ss-chip right ss-shelf-chip"></div></div>' +
    128         '<div class="ss-day"></div>' +
    129         '<div class="ss-overlay" hidden><div class="ss-panel"></div></div>';
    130       this.container.appendChild(r);
    131       this.$ = (sel) => r.querySelector(sel);
    132       this.root = r;
    133       this.$queue = this.$('.ss-queue'); this.$boxes = this.$('.ss-boxes'); this.$prompt = this.$('.ss-prompt');
    134       this.$cart = this.$('.ss-cart'); this.$slip = this.$('.ss-slip'); this.$slipTitle = this.$('.ss-slip-title');
    135       this.$finger = this.$('.ss-finger'); this.$stamp = this.$('.ss-stamp'); this.$bookOut = this.$('.ss-book-out');
    136       this.$customer = this.$('.ss-customer'); this.$bubble = this.$('.ss-bubble'); this.$custBody = this.$('.ss-cust-body');
    137       this.$custName = this.$('.ss-cust-name'); this.$overlay = this.$('.ss-overlay'); this.$panel = this.$('.ss-panel');
    138       this.$who = this.$('.ss-who'); this.$shelfChip = this.$('.ss-shelf-chip'); this.$day = this.$('.ss-day');
    139       this.$decor = this.$('.ss-decor'); this.$walkers = this.$('.ss-walkers');
    140 
    141       const btnV = document.getElementById('btn-voice'), btnS = document.getElementById('btn-sfx');
    142       if (btnV) btnV.addEventListener('click', () => { this.audio.voiceMuted = !this.audio.voiceMuted; btnV.classList.toggle('muted', this.audio.voiceMuted); btnV.textContent = 'Speech: ' + (this.audio.voiceMuted ? 'OFF' : 'ON'); if (this.audio.voiceMuted) this.audio.cancel(); });
    143       if (btnS) btnS.addEventListener('click', () => { this.audio.sfxMuted = !this.audio.sfxMuted; btnS.classList.toggle('muted', this.audio.sfxMuted); btnS.textContent = 'SFX: ' + (this.audio.sfxMuted ? 'OFF' : 'ON'); });
    144     }
    145 
    146     _watchSize() {
    147       const apply = () => {
    148         const w = this.container.clientWidth || 800, h = this.container.clientHeight || 600;
    149         this.root.style.setProperty('--u', (Math.min(w, h * 1.25) / 100) + 'px');
    150         this.root.classList.toggle('ss-narrow', w < h * 0.9);
    151       };
    152       apply();
    153       if (root.ResizeObserver) { try { new ResizeObserver(apply).observe(this.container); } catch (_) {} }
    154       root.addEventListener('resize', apply);
    155     }
    156 
    157     // Passers-by outside the window.
    158     _ambient() {
    159       const spawn = () => {
    160         if (document.visibilityState === 'hidden') return;
    161         const el = document.createElement('span');
    162         el.className = 'ss-walker';
    163         const rev = Math.random() < 0.5;
    164         el.style.left = rev ? '100%' : '-15%';
    165         el.style.transform = 'translateX(0)';
    166         el.innerHTML = img(pick(WALKERS));
    167         this.$walkers.appendChild(el);
    168         requestAnimationFrame(() => requestAnimationFrame(() => { el.style.transform = 'translateX(' + (rev ? '-' : '') + '130%)'; }));
    169         setTimeout(() => el.remove(), 16000);
    170       };
    171       setTimeout(spawn, 3000);
    172       setInterval(spawn, 12000 + Math.random() * 6000);
    173     }
    174 
    175     setPrompt(html) { this.$prompt.innerHTML = html || ''; }
    176 
    177     toast(text) {
    178       const t = document.createElement('div');
    179       t.className = 'ss-toast'; t.innerHTML = text;
    180       this.root.appendChild(t);
    181       setTimeout(() => t.remove(), T(2600));
    182     }
    183 
    184     fx(emoji, x, y) {
    185       const e = document.createElement('span');
    186       e.className = 'ss-fx'; e.textContent = emoji;
    187       e.style.left = x + '%'; e.style.top = y + '%';
    188       this.root.appendChild(e);
    189       setTimeout(() => e.remove(), T(1400));
    190     }
    191 
    192     // ── Keyboard ───────────────────────────────────────────────────────────
    193 
    194     _bindKeys() {
    195       root.addEventListener('keydown', (e) => {
    196         if (e.ctrlKey || e.metaKey || e.altKey) return;
    197         const k = e.key;
    198         if ([' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Backspace'].indexOf(k) >= 0) e.preventDefault();
    199         this.onKey(k);
    200       });
    201     }
    202 
    203     onKey(k) {
    204       const letter = k.length === 1 && /[a-z]/i.test(k) ? k.toLowerCase() : null;
    205       const s = this.state;
    206       if (s === 'profiles')      return this._keyProfiles(k, letter);
    207       if (s === 'avatar')        return this._keyAvatar(k);
    208       if (s === 'name')          return this._keyName(k, letter);
    209       if (s === 'confirmDelete') return this._keyConfirm(letter);
    210       if (s === 'menu')          return this._keyMenu(k);
    211       if (s === 'shelf')         return this._keyShelf(k);
    212       if (s === 'dayend')        { if (k === 'Enter' || k === ' ') this.newDay(); else if (k === 'Backspace') this.showProfiles(); return; }
    213       if (s === 'newsound')      { if ((k === 'Enter' || k === ' ') && this._continue) { const c = this._continue; this._continue = null; c(); } return; }
    214       if (k === 'Backspace' || k === 'Escape') { if (!this.busy) this.showMenu(); return; }
    215       if (s === 'queue')  return this._keyQueue(k);
    216       if (s === 'read')   { if (letter) this.typeLetter(letter); else if (k === ' ' || k === 'Enter') this.hint(); return; }
    217       if (s === 'blend')  { if (k === ' ' || k === 'Enter') this.soundOut(); return; }
    218       if (s === 'choose') return this._keyChoose(k);
    219     }
    220 
    221     // ── Profiles ───────────────────────────────────────────────────────────
    222 
    223     showProfiles() {
    224       this.audio.cancel();
    225       this.state = 'profiles'; this.busy = false; this.sel = 0;
    226       const list = this.store.profiles;
    227       const idx = list.findIndex((p) => p.id === this.store.last);
    228       if (idx >= 0) this.sel = idx;
    229       this._renderProfiles();
    230       this.audio.speak(list.length ? 'Who is the librarian today? Use the arrows and press enter.' : 'Welcome to the island library! Press enter to make a new librarian.');
    231     }
    232 
    233     _renderProfiles() {
    234       const list = this.store.profiles;
    235       const items = list.map((p, i) => '<div class="ss-item' + (i === this.sel ? ' sel' : '') + '">' + img(p.avatar) + esc(p.name) +
    236         '<small>' + SS.masteredWords(p).length + ' books</small></div>');
    237       if (list.length < SS.MAX_PROFILES) items.push('<div class="ss-item new' + (this.sel === list.length ? ' sel' : '') + '">✨ New librarian</div>');
    238       this._panel('<h2>Who is the librarian today?</h2><div class="ss-list">' + items.join('') + '</div>' +
    239         '<p><kbd>↑</kbd> <kbd>↓</kbd> choose · <kbd>Enter</kbd> start' + (list.length ? ' · <kbd>X</kbd> remove' : '') + '</p>');
    240     }
    241 
    242     _keyProfiles(k, letter) {
    243       const list = this.store.profiles;
    244       const n = list.length + (list.length < SS.MAX_PROFILES ? 1 : 0);
    245       if (k === 'ArrowUp')   { this.sel = (this.sel + n - 1) % n; this._renderProfiles(); this.audio.init(); this.audio.tick(); return; }
    246       if (k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderProfiles(); this.audio.init(); this.audio.tick(); return; }
    247       if (/^[1-9]$/.test(k) && +k <= n) { this.sel = +k - 1; this._renderProfiles(); return; }
    248       if (k === 'Enter' || k === ' ') {
    249         this.audio.init();
    250         if (this.sel < list.length) this.start(list[this.sel]);
    251         else this.showAvatar();
    252         return;
    253       }
    254       if ((letter === 'x' || k === 'Delete') && this.sel < list.length) {
    255         this.state = 'confirmDelete';
    256         const p = list[this.sel];
    257         this._panel('<h2>Remove ' + esc(p.name) + '?</h2><p>' + img(p.avatar) + '</p><p>All of ' + esc(p.name) + '\'s books will be forgotten.</p><p><kbd>Y</kbd> yes, remove · any other key: keep</p>');
    258         this.audio.speak('Remove ' + p.name + '? Press Y to remove, or any other key to keep.');
    259       }
    260     }
    261 
    262     _keyConfirm(letter) {
    263       if (letter === 'y') {
    264         const p = this.store.profiles[this.sel];
    265         this.store.profiles = this.store.profiles.filter((x) => x !== p);
    266         if (this.store.last === p.id) this.store.last = null;
    267         this.save();
    268       }
    269       this.showProfiles();
    270     }
    271 
    272     showAvatar() {
    273       this.state = 'avatar'; this.avatarSel = Math.floor(Math.random() * SS.AVATARS.length);
    274       this._renderAvatar();
    275       this.audio.speak('Pick a picture for yourself. Use the arrows, then press enter.');
    276     }
    277     _renderAvatar() {
    278       this._panel('<h2>Pick your picture</h2><div class="ss-avatars">' +
    279         SS.AVATARS.map((a, i) => img(a, i === this.avatarSel ? 'sel' : '')).join('') +
    280         '</div><p><kbd>←</kbd> <kbd>→</kbd> choose · <kbd>Enter</kbd> next</p>');
    281     }
    282     _keyAvatar(k) {
    283       const n = SS.AVATARS.length;
    284       if (k === 'ArrowLeft' || k === 'ArrowUp')    { this.avatarSel = (this.avatarSel + n - 1) % n; this._renderAvatar(); this.audio.tick(); }
    285       if (k === 'ArrowRight' || k === 'ArrowDown') { this.avatarSel = (this.avatarSel + 1) % n; this._renderAvatar(); this.audio.tick(); }
    286       if (k === 'Enter' || k === ' ') { this.state = 'name'; this.nameBuf = ''; this._renderName(); this.audio.speak('Now type your name and press enter.'); }
    287       if (k === 'Backspace') this.showProfiles();
    288     }
    289     _renderName() {
    290       this._panel('<h2>What is your name?</h2><p>' + img(SS.AVATARS[this.avatarSel]) + '</p><div class="ss-namefield">' + esc(this.nameBuf) + '</div>' +
    291         '<p>Type your name · <kbd>Enter</kbd> when done</p>');
    292     }
    293     _keyName(k, letter) {
    294       if (letter && this.nameBuf.length < 12) { this.nameBuf += letter; this._renderName(); this.audio.tick(); return; }
    295       if (k === 'Backspace') { this.nameBuf = this.nameBuf.slice(0, -1); this._renderName(); return; }
    296       if (k === 'Enter') {
    297         const p = SS.newProfile(cap(this.nameBuf || 'librarian'), SS.AVATARS[this.avatarSel], this.now());
    298         this.store.profiles.push(p);
    299         this.save();
    300         this.start(p);
    301       }
    302     }
    303 
    304     // Test / debug helper: create (or reuse) a profile and open the desk.
    305     quickStart(name) {
    306       let p = this.store.profiles.find((x) => x.name === name);
    307       if (!p) { p = SS.newProfile(name, SS.AVATARS[0], this.now()); this.store.profiles.push(p); this.save(); }
    308       this.audio.init();
    309       return this.start(p);
    310     }
    311 
    312     // ── Menu / my shelf ────────────────────────────────────────────────────
    313 
    314     showMenu() {
    315       this.audio.cancel();
    316       this._resumeState = this.state; this.state = 'menu'; this.sel = 0;
    317       this._renderMenu();
    318       this.audio.speak('Menu. My shelf, change librarian, or back to the desk.');
    319     }
    320     _menuItems() { return ['📚 My shelf', '👋 Change librarian', '🏷️ Back to the desk']; }
    321     _renderMenu() {
    322       this._panel('<h2>Menu</h2><div class="ss-list">' + this._menuItems().map((t, i) => '<div class="ss-item' + (i === this.sel ? ' sel' : '') + '">' + t + '</div>').join('') +
    323         '</div><p><kbd>↑</kbd> <kbd>↓</kbd> · <kbd>Enter</kbd> · <kbd>⌫</kbd> back</p>');
    324     }
    325     _keyMenu(k) {
    326       const n = 3;
    327       if (k === 'ArrowUp')   { this.sel = (this.sel + n - 1) % n; this._renderMenu(); this.audio.tick(); }
    328       if (k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderMenu(); this.audio.tick(); }
    329       if (k === 'Backspace' || k === 'Escape') this._closeOverlay();
    330       if (k === 'Enter' || k === ' ') {
    331         if (this.sel === 0) this.showShelf();
    332         else if (this.sel === 1) { this.$overlay.hidden = true; this.showProfiles(); }
    333         else this._closeOverlay();
    334       }
    335     }
    336     _closeOverlay() {
    337       this.$overlay.hidden = true;
    338       this.state = this._resumeState || 'queue';
    339       this.audio.speak(this.state === 'queue' ? LINES.queue : 'Back to the desk!');
    340     }
    341 
    342     showShelf() {
    343       this.state = 'shelf'; this.sel = 0;
    344       this._renderShelf();
    345       const n = SS.masteredWords(this.profile).length;
    346       this.audio.speak(n ? 'Your shelf has ' + n + ' books. Press enter to hear one.' : 'Your shelf is empty. Check out some books!');
    347     }
    348     _shelfEntries() {
    349       const p = this.profile;
    350       const solid = SS.masteredWords(p), shaky = SS.shakyWords(p);   // both skip words no longer in WORDS
    351       const all = solid.map((w) => ({ w, solid: true })).concat(shaky.map((w) => ({ w, solid: false })));
    352       // Group by first topic so the shelf reads like the library's sections.
    353       const order = SS.TOPICS.map((t) => t.id);
    354       all.forEach((e) => { e.topic = SS.WORD_BY_NAME[e.w].t[0]; });
    355       all.sort((a, b) => order.indexOf(a.topic) - order.indexOf(b.topic) || a.w.localeCompare(b.w));
    356       return all;
    357     }
    358     _renderShelf() {
    359       const entries = this._shelfEntries();
    360       let html = '', lastTopic = null;
    361       entries.forEach((e, i) => {
    362         if (e.topic !== lastTopic) { html += '<div class="ss-shelf-label">' + img(SS.TOPIC_BY_ID[e.topic].art) + ' ' + esc(SS.TOPIC_BY_ID[e.topic].name) + '</div>'; lastTopic = e.topic; }
    363         html += '<div class="ss-book-mini' + (e.solid ? '' : ' shaky') + (i === this.sel ? ' sel' : '') + '">' + img(SS.WORD_BY_NAME[e.w].art) + '<span>' + e.w + '</span></div>';
    364       });
    365       this._panel('<h2>My shelf</h2>' + (entries.length ? '<div class="ss-books">' + html + '</div>' : '<p>No books yet — help someone at the desk!</p>') +
    366         '<p><kbd>←</kbd> <kbd>→</kbd> choose · <kbd>Enter</kbd> hear it · <kbd>⌫</kbd> back</p>');
    367       const sel = this.$panel.querySelector('.ss-book-mini.sel');
    368       if (sel && sel.scrollIntoView) { try { sel.scrollIntoView({ block: 'nearest' }); } catch (_) {} }
    369     }
    370     _keyShelf(k) {
    371       const entries = this._shelfEntries(); const n = entries.length || 1;
    372       if (k === 'ArrowLeft' || k === 'ArrowUp')    { this.sel = (this.sel + n - 1) % n; this._renderShelf(); this.audio.tick(); }
    373       if (k === 'ArrowRight' || k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderShelf(); this.audio.tick(); }
    374       if (k === 'Backspace' || k === 'Escape') this._closeOverlay();
    375       if ((k === 'Enter' || k === ' ') && entries.length) {
    376         const word = SS.WORD_BY_NAME[entries[this.sel].w];
    377         this.audio.cancel();
    378         this.audio.playSegmented(SS.phonemeUnits(word), { gap: 0.3 }).then(() => this.audio.playWord(word.w));
    379       }
    380     }
    381 
    382     _panel(html) { this.$panel.innerHTML = html; this.$overlay.hidden = false; }
    383 
    384     // ── Start / HUD / library ──────────────────────────────────────────────
    385 
    386     async start(profile) {
    387       this.profile = profile;
    388       this.store.last = profile.id;
    389       this.save();
    390       this.$overlay.hidden = true;
    391       this.booksToday = 0; this.todayWords = []; this.todaySounds = [];
    392       this.root.classList.remove('ss-dusk');
    393       this.applied = {};
    394       this._applyLibrary(true);
    395       this._hud();
    396       this.audio.init();
    397       this.busy = true; this.state = 'opening';
    398       root.__SS_READY__ = true;
    399       await this.audio.speak('Hello ' + profile.name + '! Welcome to the island library. You are at the checkout desk.');
    400       this.showQueue();
    401     }
    402 
    403     _hud() {
    404       const p = this.profile; if (!p) return;
    405       this.$who.innerHTML = img(p.avatar) + esc(p.name);
    406       this.$shelfChip.innerHTML = '📚 ' + SS.masteredWords(p).length + ' <small>⌫ menu</small>';
    407       let dots = '';
    408       for (let i = 0; i < SS.BOOKS_PER_DAY; i++) dots += '<span class="' + (i < this.booksToday ? 'on' : '') + '"></span>';
    409       this.$day.innerHTML = dots;
    410     }
    411 
    412     // Decorate the library for every upgrade earned; announce new ones if asked.
    413     _applyLibrary(silent) {
    414       const ups = SS.libraryUpgrades(SS.masteredWords(this.profile).length);
    415       const fresh = [];
    416       ups.forEach((u) => {
    417         if (this.applied[u.id]) return;
    418         this.applied[u.id] = true;
    419         this.root.classList.add('has-' + u.id);
    420         if (DECOR[u.id]) this.$decor.insertAdjacentHTML('beforeend', DECOR[u.id]);
    421         if (!silent) fresh.push(u);
    422       });
    423       return fresh;
    424     }
    425 
    426     // ── The queue ──────────────────────────────────────────────────────────
    427 
    428     _resetDesk() {
    429       this.order = null;
    430       this.$boxes.innerHTML = ''; this.$boxes.className = 'ss-boxes';
    431       this.$slip.className = 'ss-slip'; this.$slipTitle.innerHTML = '';
    432       this.$cart.innerHTML = ''; this.$cart.className = 'ss-cart'; this.$cart.parentNode.classList.remove('choosing');
    433       this.$bookOut.innerHTML = ''; this.$bookOut.className = 'ss-book-out';
    434       this.$stamp.className = 'ss-stamp';
    435       this.$finger.className = 'ss-finger';
    436       this.setPrompt('');
    437     }
    438 
    439     async showQueue() {
    440       this.busy = true; this.state = 'arrive';
    441       this._resetDesk();
    442       if (SS.shouldIntroduce(this.profile)) {
    443         const g = SS.introduceNext(this.profile);
    444         this.save();
    445         this.todaySounds.push(g);
    446         await this.showNewSound(g);
    447       }
    448       const queue = SS.buildQueue(this.profile, this.now(), Math.random, 3);
    449       if (root.__SS_FORCE_MODE__) queue.forEach((q) => { if (this.profile.words[q.word.w] && this.audio.hasWord(q.word.w)) q.mode = root.__SS_FORCE_MODE__; });
    450       // Returns need a recording of the word (the patron has to say it).
    451       queue.forEach((q) => { if (q.mode === 'return' && !this.audio.hasWord(q.word.w)) q.mode = 'read'; });
    452       this.queue = queue;
    453       this.sel = 0;
    454       queue.forEach((q) => this.audio.preloadWord(q.word.w));
    455       this._renderQueue(true);
    456       await sleep(900);
    457       this.state = 'queue'; this.busy = false;
    458       this.setPrompt('Who is next? <kbd>←</kbd> <kbd>→</kbd> then <kbd>Enter</kbd>');
    459       await this.audio.speak(LINES.queue);
    460       if (this.state === 'queue') this._sayWish();
    461     }
    462 
    463     _renderQueue(fresh) {
    464       this.$queue.innerHTML = this.queue.map((q, i) => {
    465         const t = q.topic;
    466         return '<div class="ss-patron' + (i === this.sel ? ' sel' : '') + (fresh ? ' in' : '') + '" style="--c:' + q.patron.color + ';animation-delay:' + (i * 0.15) + 's">' +
    467           '<div class="ss-sign-card" style="border-color:' + t.color + '">' + img(t.art) + '<span>' + esc(t.name) + '</span></div>' +
    468           (q.mode === 'return' ? '<div class="ss-holding">' + img(q.word.art) + '</div>' : '') +
    469           '<div class="ss-patron-body">' + img(q.patron.art) + '</div>' +
    470           '<div class="ss-patron-name">' + esc(q.patron.name) + '</div><span class="ss-num">' + (i + 1) + '</span></div>';
    471       }).join('');
    472     }
    473 
    474     _sayWish() {
    475       const q = this.queue[this.sel]; if (!q) return;
    476       const line = q.mode === 'return' ? pick(LINES.returning)
    477         : q.topic.id === 'any' ? pick(LINES.wantAny) : pick(LINES.want).replace('{t}', q.topic.name);
    478       this.audio.cancel();
    479       this.audio.speak(q.patron.name + ' says: ' + line, q.patron.voice);
    480     }
    481 
    482     _keyQueue(k) {
    483       if (this.busy) return;
    484       const n = this.queue.length;
    485       if (!n) { this.showQueue(); return; }
    486       if (k === 'ArrowLeft' || k === 'ArrowUp')    { this.sel = (this.sel + n - 1) % n; this._renderQueue(); this.audio.tick(); this._sayWish(); }
    487       if (k === 'ArrowRight' || k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderQueue(); this.audio.tick(); this._sayWish(); }
    488       if (/^[1-9]$/.test(k) && +k <= n) { this.sel = +k - 1; this._renderQueue(); this.callPatron(); }
    489       if (k === 'Enter' || k === ' ') this.callPatron();
    490     }
    491 
    492     async callPatron() {
    493       const q = this.queue[this.sel];
    494       if (!q) return;
    495       this.busy = true; this.state = 'arrive';
    496       this.audio.cancel();
    497       SS.recordTopic(this.profile, q.topic.id);
    498       this.save();
    499       this.order = {
    500         word: q.word, mode: q.mode, review: q.review, topic: q.topic, patron: q.patron,
    501         idx: 0, buffer: '', missed: false, boxMissed: false, wrongPicks: 0,
    502         units: SS.phonemeUnits(q.word), graphemes: SS.graphemes(q.word),
    503       };
    504       const els = Array.from(this.$queue.children);
    505       els.forEach((el, i) => el.classList.add(i === this.sel ? 'called' : 'wait'));
    506       this.setPrompt('');
    507       await sleep(500);
    508       this.$queue.innerHTML = '';
    509       await this.arrive();
    510     }
    511 
    512     async showNewSound(g) {
    513       this.state = 'newsound';
    514       const ps = SS.graphemePhonemes(g);
    515       const example = SS.availableWords(this.profile.introduced).find((w) => SS.graphemes(w).map(SS.baseGrapheme).indexOf(g) >= 0 && this.audio.hasWord(w.w))
    516         || SS.availableWords(this.profile.introduced).find((w) => SS.graphemes(w).map(SS.baseGrapheme).indexOf(g) >= 0);
    517       this._panel('<h2>A new sound!</h2><div class="ss-big-letter">' + g + '</div>' +
    518         (example ? '<p>like in <b>' + example.w + '</b> ' + img(example.art) + '</p>' : '') + '<p><kbd>Enter</kbd> to keep going</p>');
    519       this.audio.sparkle();
    520       await this.audio.speak('A new sound! This one says');
    521       for (let rep = 0; rep < 2; rep++) {
    522         let t = 0;
    523         ps.forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.7 }) + 0.05; });
    524         await sleep(t * 1000 + 500);
    525       }
    526       if (example) {
    527         await this.audio.speak('Like in');
    528         await this.audio.playSegmented(SS.phonemeUnits(example), { gap: 0.25, len: 0.35 });
    529         await this.audio.playWord(example.w);
    530       }
    531       await new Promise((resolve) => {
    532         this._continue = resolve;
    533         if (TEST) resolve();
    534         else setTimeout(() => { if (this._continue === resolve) { this._continue = null; resolve(); } }, 9000);
    535       });
    536       this.$overlay.hidden = true;
    537     }
    538 
    539     // ── Serving one patron ─────────────────────────────────────────────────
    540 
    541     _renderCustomer() {
    542       const c = this.order.patron;
    543       this.$custBody.innerHTML = img(c.art);
    544       this.$custName.textContent = c.name;
    545       this.$custName.style.background = c.color;
    546       this.$bubble.className = 'ss-bubble'; this.$bubble.innerHTML = '';
    547     }
    548 
    549     // Build the sound boxes once per book; _updateBoxes() keeps their classes in
    550     // step so a stamped tile stays stamped (re-rendering would replay its drop).
    551     _renderBoxes() {
    552       const o = this.order;
    553       this.$boxes.innerHTML = o.graphemes.map((g, i) =>
    554         '<div class="ss-box' + (g.length > 1 ? ' wide' : '') + '" data-i="' + i + '" style="--c:' + BOX_COLORS[i % BOX_COLORS.length] + '">' + esc(g) + '</div>').join('');
    555       this._updateBoxes();
    556     }
    557     _updateBoxes() {
    558       const o = this.order;
    559       Array.from(this.$boxes.children).forEach((b, i) => {
    560         const g = o.graphemes[i];
    561         b.classList.toggle('done', i < o.idx);
    562         b.classList.toggle('next', i === o.idx);
    563         b.classList.toggle('hidden-letter', o.mode === 'return' && i >= o.idx);
    564         if (i === o.idx && o.buffer) b.innerHTML = '<span class="ss-partial">' + esc(o.buffer) + '</span>' + esc(g.slice(o.buffer.length));
    565         else b.textContent = g;
    566       });
    567     }
    568 
    569     async arrive() {
    570       const o = this.order, c = o.patron;
    571       this._renderCustomer();
    572       this.$customer.classList.remove('in', 'out', 'happy', 'puzzled');
    573       void this.$customer.offsetWidth;   // restart the walk-in transition
    574       this.$customer.classList.add('in');
    575       this.$boxes.innerHTML = '';
    576       await sleep(900);
    577       const hello = pick(LINES.hello) + (o.review ? ' ' + pick(LINES.again) : '');
    578       await this.audio.speak(hello, c.voice);
    579       if (o.mode === 'read') {
    580         this.$bubble.innerHTML = img(o.topic.art);
    581         this.$bubble.classList.add('show');
    582         await this.audio.speak(o.topic.id === 'any' ? pick(LINES.wantAny) : pick(LINES.want).replace('{t}', o.topic.name), c.voice);
    583         this.$slipTitle.innerHTML = 'Request';
    584         this._renderBoxes();
    585         this.$slip.classList.add('show');
    586         this.audio.pageFlip();
    587         await this.audio.speak(pick(LINES.slip), c.voice);
    588         this.setPrompt(LINES.instrRead);
    589         this.audio.speak(LINES.instrRead);
    590       } else {
    591         this.$bubble.innerHTML = img(o.word.art);
    592         this.$bubble.classList.add('show');
    593         await this.audio.speak(pick(LINES.returning), c.voice);
    594         await this.audio.playWord(o.word.w);
    595         this.$slipTitle.innerHTML = 'Return card';
    596         this._renderBoxes();
    597         this.$slip.classList.add('show');
    598         this.audio.pageFlip();
    599         this.setPrompt(LINES.instrHear);
    600         this.audio.speak(LINES.instrHear);
    601       }
    602       this.state = 'read'; this.busy = false;
    603     }
    604 
    605     // Replay the demonstration for the current box.
    606     hint() {
    607       if (this.busy) return;
    608       const o = this.order;
    609       this.audio.cancel();
    610       if (o.mode === 'return') {
    611         this.audio.playWord(o.word.w).then(() => this.audio.playSegmented(o.units, { gap: 0.3, onUnit: (i) => this._flashBox(i) }));
    612       } else {
    613         this._flashBox(o.idx);
    614         let t = 0;
    615         o.units[o.idx].forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.45 }) + 0.04; });
    616       }
    617     }
    618 
    619     _flashBox(i, ms) {
    620       const b = this.$boxes.querySelector('[data-i="' + i + '"]');
    621       if (!b) return;
    622       b.classList.add('speaking');
    623       if (this.order.mode === 'return') b.classList.add('reveal');
    624       setTimeout(() => b.classList.remove('speaking', 'reveal'), T(ms || 650));
    625     }
    626 
    627     typeLetter(ch) {
    628       if (this.busy) return;
    629       const o = this.order;
    630       const expected = o.graphemes[o.idx];
    631       const attempt = o.buffer + ch;
    632       if (expected.indexOf(attempt) === 0) {
    633         o.buffer = attempt;
    634         if (attempt === expected) this._acceptGrapheme();
    635         else { this._updateBoxes(); this.audio.tick(); }
    636       } else {
    637         this._wrongKey();
    638       }
    639     }
    640 
    641     _acceptGrapheme() {
    642       const o = this.order;
    643       SS.recordGrapheme(this.profile, o.graphemes[o.idx], !o.boxMissed);
    644       o.boxMissed = false;
    645       const unit = o.units[o.idx];
    646       let t = 0;
    647       unit.forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.4 }) + 0.03; });
    648       this.audio.stampLetter();
    649       o.idx += 1; o.buffer = '';
    650       this._updateBoxes();
    651       const box = this.$boxes.querySelector('[data-i="' + (o.idx - 1) + '"]');
    652       if (box) { box.classList.add('stamped'); box.classList.add('reveal'); }
    653       this.$stamp.classList.remove('press'); void this.$stamp.offsetWidth; this.$stamp.classList.add('press');
    654       if (o.idx >= o.graphemes.length) this._wordComplete();
    655     }
    656 
    657     async _wordComplete() {
    658       const o = this.order;
    659       this.state = 'blend'; this.busy = false;     // SPACE works straight away; the prompt just follows
    660       this.$slip.classList.add('ready');
    661       await sleep(450);
    662       if (this.state !== 'blend') return;
    663       this.setPrompt(o.mode === 'read' ? 'Press <kbd>space</kbd> to read it!' : 'Press <kbd>space</kbd> to shelve it!');
    664       this.audio.speak(o.mode === 'read' ? LINES.readIt : LINES.shelveIt);
    665     }
    666 
    667     async _wrongKey() {
    668       const o = this.order;
    669       o.missed = true;
    670       if (!o.boxMissed) { o.boxMissed = true; SS.recordGrapheme(this.profile, o.graphemes[o.idx], false); }
    671       o.buffer = '';
    672       this._updateBoxes();
    673       this.busy = true;
    674       this.audio.cancel();
    675       this.audio.wrong();
    676       this.$slip.classList.remove('wobble'); void this.$slip.offsetWidth; this.$slip.classList.add('wobble');
    677       const box = this.$boxes.querySelector('[data-i="' + o.idx + '"]');
    678       if (box) { box.classList.add('hint'); setTimeout(() => box.classList.remove('hint'), T(1000)); }
    679       if (o.mode === 'read') {
    680         await this.audio.speak('This one says');
    681         this._flashBox(o.idx, 900);
    682         let t = 0;
    683         o.units[o.idx].forEach((p) => { t += this.audio.playPhoneme(p, { delay: t, len: 0.6 }) + 0.04; });
    684         await sleep(t * 1000 + 200);
    685       } else {
    686         await this.audio.speak('Listen again.');
    687         await this.audio.playWord(o.word.w);
    688         await this.audio.playSegmented(o.units, { gap: 0.35, onUnit: (i) => this._flashBox(i) });
    689         await this.audio.speak('Which sound comes next?');
    690       }
    691       this.busy = false;
    692     }
    693 
    694     // SPACE: sound the title out. The finger slides under each box as its
    695     // sound plays; each pass is faster and closer until the boxes touch.
    696     async soundOut() {
    697       if (this.busy) return;
    698       const o = this.order;
    699       this.busy = true; this.state = 'sounding';
    700       this.setPrompt('');
    701       this.$slip.classList.remove('ready');
    702       this.$slip.classList.add('reading');
    703       this.$finger.classList.add('show');
    704       const boxes = Array.from(this.$boxes.children);
    705       await this.audio.blend(o.units, (ev) => {
    706         if (ev.kind === 'pass') { this.$boxes.className = 'ss-boxes pass' + ev.pass; this._moveFinger(boxes[0]); }
    707         if (ev.kind === 'phoneme') {
    708           const x = boxes[ev.unit];
    709           if (x) { x.classList.add('speaking', 'reveal'); setTimeout(() => x.classList.remove('speaking'), T(ev.len * 1000)); this._moveFinger(x); }
    710         }
    711         if (ev.kind === 'done') {
    712           this.$boxes.classList.add('fused');
    713           boxes.forEach((x) => x.classList.add('done', 'reveal'));
    714           this.$finger.classList.remove('show');
    715           this.audio.sparkle();
    716           this.fx('✨', 46, 58); this.fx('✨', 54, 56);
    717         }
    718       });
    719       this.$slip.classList.remove('reading');
    720       await sleep(400);
    721       if (o.mode === 'read') this.choose(); else this.shelve();
    722     }
    723 
    724     _moveFinger(box) {
    725       if (!box) return;
    726       const r = box.getBoundingClientRect(), s = this.$slip.getBoundingClientRect();
    727       if (!s.width) return;
    728       this.$finger.style.left = ((r.left + r.width / 2 - s.left) / s.width * 100) + '%';
    729     }
    730 
    731     // Which book on the cart matches what was just read?
    732     choose() {
    733       const o = this.order;
    734       const cards = [o.word].concat(SS.distractors(o.word, this.profile, Math.random));
    735       for (let i = cards.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [cards[i], cards[j]] = [cards[j], cards[i]]; }
    736       o.cards = cards; this.sel = 0;
    737       this._renderCards();
    738       this.$cart.classList.add('show');
    739       this.$cart.parentNode.classList.add('choosing');
    740       this.audio.slide();
    741       this.setPrompt('Which book is it?');
    742       this.audio.speak(LINES.choose);
    743       this.state = 'choose'; this.busy = false;
    744     }
    745     _renderCards() {
    746       this.$cart.innerHTML = this.order.cards.map((w, i) => '<div class="ss-book' + (i === this.sel ? ' sel' : '') + '" data-w="' + w.w + '" style="--c:' + BOX_COLORS[SS.hashStr(w.w) % BOX_COLORS.length] + '">' +
    747         '<div class="ss-cover">' + img(w.art) + '</div><span class="ss-num">' + (i + 1) + '</span></div>').join('');
    748     }
    749     _keyChoose(k) {
    750       if (this.busy) return;
    751       const n = this.order.cards.length;
    752       if (k === 'ArrowLeft' || k === 'ArrowUp')    { this.sel = (this.sel + n - 1) % n; this._renderCards(); this.audio.tick(); }
    753       if (k === 'ArrowRight' || k === 'ArrowDown') { this.sel = (this.sel + 1) % n; this._renderCards(); this.audio.tick(); }
    754       if (/^[1-9]$/.test(k) && +k <= n) { this.sel = +k - 1; this._renderCards(); this._confirmChoice(); }
    755       if (k === 'Enter' || k === ' ') this._confirmChoice();
    756     }
    757     async _confirmChoice() {
    758       const o = this.order, chosen = o.cards[this.sel];
    759       this.busy = true;
    760       const cardEls = Array.from(this.$cart.children);
    761       if (chosen.w === o.word.w) {
    762         cardEls[this.sel].classList.add('yes');
    763         this.audio.ding();
    764         await sleep(450);
    765         this.checkout();
    766         return;
    767       }
    768       o.missed = true; o.wrongPicks += 1;
    769       cardEls[this.sel].classList.add('no');
    770       this.audio.wrong();
    771       this.$customer.classList.remove('puzzled'); void this.$customer.offsetWidth; this.$customer.classList.add('puzzled');
    772       await this.audio.speak(pick(LINES.wrongBook) + ' That one is about a ' + chosen.w + '.', o.patron.voice);
    773       if (o.wrongPicks === 2 && o.cards.length > 2) {
    774         // Take the wrong book away so the next try is between two.
    775         o.cards = o.cards.filter((c) => c.w !== chosen.w);
    776         this.sel = 0;
    777         this._renderCards();
    778         await this.audio.speak('Let me take that one away.', o.patron.voice);
    779       } else {
    780         cardEls[this.sel].classList.remove('no');
    781       }
    782       await this.audio.speak('Read it again.');
    783       this.$finger.classList.add('show');
    784       const boxes = Array.from(this.$boxes.children);
    785       await this.audio.playSegmented(o.units, { gap: 0.1, len: 0.25, onUnit: (i) => { this._flashBox(i, 300); this._moveFinger(boxes[i]); } });
    786       this.$finger.classList.remove('show');
    787       if (o.wrongPicks >= 3) {
    788         // No fail state: after three tries the word is given and the book pointed out.
    789         await this.audio.speak('It says');
    790         await this.audio.playWord(o.word.w);
    791         const right = o.cards.findIndex((c) => c.w === o.word.w);
    792         this.sel = right; this._renderCards();
    793         this.$cart.children[right].classList.add('point');
    794       }
    795       this.busy = false;
    796     }
    797 
    798     // Read mode success: stamp the book and hand it over.
    799     async checkout() {
    800       const o = this.order;
    801       this.state = 'stamp'; this.busy = true;
    802       this.setPrompt('');
    803       const right = o.cards.findIndex((c) => c.w === o.word.w);
    804       const book = this.$cart.children[right];
    805       this.$cart.classList.add('picked');
    806       Array.from(this.$cart.children).forEach((b, i) => { if (i !== right) b.classList.add('gone'); });
    807       this.$boxes.querySelectorAll('.ss-box').forEach((b) => b.classList.add('done', 'reveal'));
    808       await sleep(350);
    809       // The stamp comes down on the cover.
    810       this.$stamp.classList.add('over');
    811       await sleep(350);
    812       this.$stamp.classList.add('slam');
    813       this.audio.bigStamp();
    814       if (book) book.classList.add('stamped');
    815       this.root.classList.remove('shake'); void this.root.offsetWidth; this.root.classList.add('shake');
    816       await sleep(500);
    817       this.$stamp.classList.remove('over', 'slam');
    818       await this.audio.speak('Checked out!');
    819       if (book) book.classList.add('handed');
    820       this.audio.whoosh();
    821       await sleep(600);
    822       this.$bubble.innerHTML = img(o.word.art);
    823       this.$customer.classList.add('happy');
    824       await this.audio.speak('Yes! ' + o.word.w + '! ' + pick(LINES.happy), o.patron.voice);
    825       this.serve();
    826     }
    827 
    828     // Return mode success: the book flies back to its shelf.
    829     async shelve() {
    830       const o = this.order;
    831       this.state = 'stamp'; this.busy = true;
    832       this.$bookOut.innerHTML = '<div class="ss-book stamped" style="--c:' + BOX_COLORS[SS.hashStr(o.word.w) % BOX_COLORS.length] + '"><div class="ss-cover">' + img(o.word.art) + '</div></div>';
    833       this.$bookOut.classList.add('show');
    834       this.$stamp.classList.add('over');
    835       await sleep(350);
    836       this.$stamp.classList.add('slam');
    837       this.audio.bigStamp();
    838       await sleep(450);
    839       this.$stamp.classList.remove('over', 'slam');
    840       this.$bookOut.classList.add('fly');
    841       this.audio.whoosh();
    842       await sleep(700);
    843       this.$customer.classList.add('happy');
    844       await this.audio.speak('Back on the shelf! ' + pick(LINES.happy), o.patron.voice);
    845       this.serve();
    846     }
    847 
    848     async serve() {
    849       const o = this.order, p = this.profile;
    850       this.state = 'serve'; this.busy = true;
    851       this.audio.fanfare();
    852       this.fx('⭐', 68, 40); this.fx('❤️', 74, 44); this.fx('⭐', 62, 42);
    853 
    854       const wasMastered = SS.isMastered(SS.wordRecord(p, o.word.w));
    855       const rec = SS.recordOrder(p, o.word.w, !o.missed, this.now());
    856       this.booksToday += 1;
    857       this.todayWords.push(o.word.w);
    858       this.save();
    859       this._hud();
    860 
    861       if (!wasMastered && SS.isMastered(rec)) {
    862         this.toast('📚 ' + esc(o.word.w) + ' is on your shelf!');
    863         this.audio.sparkle();
    864         await this.audio.speak('A new book for your shelf!');
    865       }
    866       const fresh = this._applyLibrary(false);
    867       for (const u of fresh) {
    868         this.toast('🎉 The library got ' + u.label + '!');
    869         this.audio.fanfare();
    870         await this.audio.speak('The library got ' + u.label + '!');
    871         await sleep(600);
    872       }
    873       this.$customer.classList.remove('in'); this.$customer.classList.add('out');
    874       this.$bubble.classList.remove('show');
    875       await sleep(800);
    876       if (this.booksToday >= SS.BOOKS_PER_DAY) this.dayEnd();
    877       else this.showQueue();
    878     }
    879 
    880     async dayEnd() {
    881       this.state = 'dayend'; this.busy = false;
    882       this.root.classList.add('ss-dusk');
    883       this._resetDesk();
    884       const pics = this.todayWords.map((w) => '<span class="ss-day-book">' + img(SS.WORD_BY_NAME[w].art) + '<i>' + esc(w) + '</i></span>').join('');
    885       const sounds = this.todaySounds.length ? '<p>New sounds: <b>' + this.todaySounds.join(', ') + '</b></p>' : '';
    886       this._panel('<h2>Closing time!</h2><div class="ss-summary">' + pics + '</div>' + sounds +
    887         '<p>' + SS.masteredWords(this.profile).length + ' books on your shelf</p>' +
    888         '<p><kbd>Enter</kbd> open the library again · <kbd>⌫</kbd> change librarian</p>');
    889       this.audio.fanfare();
    890       await this.audio.speak('The library is closed for today. Great reading, ' + this.profile.name + '! Press enter to open again, or take a break.');
    891     }
    892 
    893     newDay() {
    894       this.$overlay.hidden = true;
    895       this.root.classList.remove('ss-dusk');
    896       this.booksToday = 0; this.todayWords = []; this.todaySounds = [];
    897       this._hud();
    898       this.showQueue();
    899     }
    900   }
    901 
    902   document.addEventListener('DOMContentLoaded', () => {
    903     const container = document.getElementById('game-container');
    904     root.__SS_GAME__ = new StoryStamp(container);
    905   });
    906   root.StoryStamp = StoryStamp;
    907 }(window));