diff --git a/README.md b/README.md
index 3cf0f31..97ac37c 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ Free. No login. No downloads. No sound needed.
| ✂️ **Half & Half** | Cut a group of geometric shapes into two equal portions. How close can you get to 50/50? |
| ✍️ **Line memory** | Watch a path vanish. Trace it from memory. |
| 👁️ **Perspective** | Follow the lines to their vanishing point. |
-| ▭ **Proportions** | Stretch a rectangle to match a ratio. |
+| ▭ **Size it up** | Use a reference square to judge width and height. |
| 🥁 **Rhythm** | Watch the pulse. Tap the tempo. Completely silent. |
| 📐 **Angles** | One number. A dial. Trust your eye. |
| 🎨 **Colour memory** | Remember a swatch. Find it on the colour wheel. |
@@ -39,7 +39,7 @@ Free. No login. No downloads. No sound needed.
- **One shared Daily Five.** Five different puzzle types, determined by your device’s date. Players on the same date get the same challenge.
- **Close counts.** Every round is worth 100 points. See your guess and the target together, then inspect small comparisons in your final results.
- **Compare with friends.** Share your Daily Five results as a compact scorecard with the date, puzzle emojis, and your total. No answer spoilers.
-- **Practice when you feel like it.** Open the Practice menu for a fresh mix or an individual skill. Trivia practice draws from past Daily Fives, keeping upcoming questions out of the pool.
+- **Practice when you feel like it.** Open the game menu, then Practice, for a fresh mix or an individual skill. Trivia practice draws from past Daily Fives, keeping upcoming questions out of the pool.
- **Pick your atmosphere.** Night Lab or Focus. Your choice sticks between visits.
- **Come back later.** Daily progress, streaks, and recent results stay in your browser. No account required; no cross-device sync.
@@ -51,6 +51,8 @@ The game opens straight into today’s challenge. It works on phones and desktop
Perception is a static HTML/CSS/JavaScript game, hosted on GitHub Pages. Procedural puzzles use independent date-seeded generators. Trivia comes from a sourced question bank and a permanent daily schedule, so adding questions doesn’t rewrite published challenges.
+**Size it up replaces Proportions in current and future game plans.** The internal `proportion` identifier remains for schedule, save and challenge compatibility; earlier dated rounds keep their original mechanic. See [maintenance guidance](docs/MAINTENANCE.md#game-plans-size-it-up-replaces-proportions).
+
Self-hosted fonts, a compact mobile layout, and two themes keep the puzzle front and centre. Minimal Umami analytics measure daily visits, starts, completions with scores, and one share-button click per browser per day.
Want to work on the game? Python and Node are needed for building and checks; players only need a browser.
@@ -63,7 +65,7 @@ python3 -m http.server 4175 --directory public
Open `http://localhost:4175/` to play your build. The complete playable site lives in `public/`; editable game sources live in `src/`.
-[Maintenance & daily trivia](docs/MAINTENANCE.md) · [Analytics](docs/ANALYTICS.md)
+[Game shell & adapters](docs/GAME-SHELL.md) · [Maintenance & daily trivia](docs/MAINTENANCE.md) · [Analytics](docs/ANALYTICS.md)
---
diff --git a/docs/GAME-SHELL.md b/docs/GAME-SHELL.md
new file mode 100644
index 0000000..f9f6584
--- /dev/null
+++ b/docs/GAME-SHELL.md
@@ -0,0 +1,150 @@
+# Game shell and adapters
+
+Active rounds share one full-screen layout. A game supplies its puzzle stage and
+controls; the shell supplies the header, navigation, progress, prompt, flexible
+stage, bottom controls, and feedback slot. Summary pages retain normal scrolling.
+Both themes use the same markup and layout.
+
+## Files and responsibilities
+
+- `src/design-options/game-shell.js`: registry, slot mounting, phases, cleanup,
+ memory-ready helpers, and accessible header navigation.
+- `src/design-options/game-adapters.js`: each game's renderer, presentation,
+ optional ready artwork, and visual reveal. Swap an existing game's `render`
+ and `reveal` here without changing the round controller or page layout.
+- `src/design-options/app-layout.css`: viewport sizing and shared chrome. Game
+ CSS styles its own content, without setting page or stage heights.
+- Existing game modules: input state, drawing, pointer/keyboard interaction.
+- Existing controller: generation, committed guesses, score calculation,
+ persistence, round progression, and summary generation.
+- `shell_extension.py`: integrates these modules into the existing static build.
+ Editable sources stay in `src/`; regenerate `public/` with the build script.
+
+## Mounting a game
+
+A renderer can use the object API directly:
+
+```js
+function exampleRound(q) {
+ const ui = mountGameShell({
+ question: q,
+ stage: '
',
+ controls: '
Drag to adjust. Arrow keys also work.
',
+ phase: 'play'
+ });
+ // Bind input and draw inside ui.stage. Commit with reveal(value).
+ // Register observers and external listeners with ui.onCleanup(...).
+}
+
+registerGame('example', {
+ render: exampleRound,
+ presentation: (q, phase) => ({
+ title: q.title,
+ subtitle: 'A short explanation of the task.'
+ }),
+ reveal: (q, value, score) => drawExample(value, q.answer)
+});
+```
+
+Existing games use `shell(q, stageHTML, controlsHTML, {phase})`, which delegates
+to that same object API. `spatialCanvas` also accepts the phase options. Keep IDs
+unique within the mounted round. Prompt text is escaped by the shell; newlines
+in a presentation title become line breaks. Stage and control HTML are trusted
+renderer templates; escape any external text inserted into them.
+
+The returned handles are `stage`, `controls`, `feedback`, `setPhase`, `onCleanup`, and `onRedraw`. These refer to the current mount; do not keep them after mounting
+another phase or changing games. The registry rejects duplicate or invalid
+registrations and reports an unknown game rather than displaying a blank stage.
+
+## Phases and resource lifecycle
+
+- `ready`: initial state; memory games keep the target hidden until Start.
+- `preview`: a timed memory preview, with its existing countdown.
+- `play`: a player's interactive answer, including memory-game pickers. Direct-interaction games start here by default.
+- `reveal`: the committed guess and target, with common feedback and Next.
+- `empty`: the trivia archive's empty state, with a return-to-daily action.
+
+`prepareMemoryStart` and `beginMemoryPreview` manage the shared memory-ready
+illustration and phase. The primary action stays in the controls slot throughout.
+Memory adapters set `initialPhase: 'ready'`. Optional `ready: {artwork, caption}` in an adapter supplies a neutral illustration.
+
+Use `afterEffect` for timed work so navigation cancels old previews and animations.
+Use `onGameCleanup` (or the mount's `onCleanup`) for observers and external event
+listeners. Cleanup runs once when a mount is replaced or the player leaves the round.
+A reveal keeps its mount and responsive observers until Next. Listeners attached only to a removed stage element do
+not need a separate external-listener cleanup.
+
+Register `onGameRedraw(paint)` (or `ui.onRedraw`) to repaint from the current
+input state when a theme stylesheet finishes loading. The same callback handles
+a locked reveal, without recreating the stage or resetting a guess.
+
+Canvas coordinates remain fixed and use `canvasPointerPoint` /
+`canvasDisplayScale`, which account for uniform scaling and letterboxing. High-density canvases can
+set `data-logical-width` and `data-logical-height` to keep input in game units
+while rendering at the display’s pixel density. Size it up uses this to keep
+rectangle strokes sharp, and redraws after responsive resizing. Avoid
+mapping pointer coordinates directly through the element's width and height:
+that stretches coordinates when the shared stage changes shape. The year picker
+and resize handle register their responsive observers with the shared lifecycle,
+with window-resize listeners as a fallback when ResizeObserver is unavailable.
+
+## Generation and compatibility
+
+The registry does not alter saved question objects or select daily games. Daily
+seeds, IDs, scoring, the trivia schedule, and the save format remain owned by the
+existing modules. Legacy proportions, balance, typography and choice renderers
+continue through adapters. Presentation titles do not overwrite stored titles.
+
+Replacing the view of an existing game requires only its adapter and renderer.
+Adding a new scored game also requires a generator, schedule/practice selection,
+scoring/error feedback, summary comparison, and saved-data validation; those
+changes are deliberately explicit rather than silently accepting a new schema.
+
+## Verification
+
+```sh
+python3 scripts/build-site.py
+for test in tests/verify-*.cjs; do node "$test" || exit 1; done
+python3 -m unittest discover -s tests -p 'test_*.py'
+python3 -m http.server 4175 --directory public
+# In another terminal; requires Playwright and Chromium:
+node tests/browser-shell.cjs
+node tests/browser-size.cjs
+```
+
+Browser checks cover all eleven games from ready to reveal and Next at 375×667
+and 390×844, actual touch coordinates, keyboard alternatives, appearance changes,
+modal focus/Escape, navigation during a preview, extensibility/cleanup, daily and
+completed-game reloads, summaries, and larger/narrower viewports. Screenshots are
+written outside the repository by default; set `PERCEPTION_SCREENSHOTS` to choose
+a destination. `CHROMIUM_PATH` overrides the default Chromium executable.
+
+## Theme layout
+
+`theme-layout.css` supplies the shared structure and typography for Light and
+Dark. Keep theme differences in `focus-palette.css` and `night-lab-palette.css`
+as colour tokens only. Both themes preload the same UI fonts so switching
+appearance preserves text measurements as well as game progress.
+
+Run `node tests/browser-theme-layout.cjs` against the local build server to
+compare all eleven games, summaries, and navigation at mobile and desktop sizes.
+It also verifies the light palette and that switching themes preserves progress.
+
+## Game appearance
+
+`game-skin.css` owns the shared play surface, type scale, readouts, control
+finishes, and reveal treatment. Keep those rules separate from the geometry and
+phase layout in `app-layout.css`. `game-paint.js` exposes the same palette, fonts,
+and line weights to canvas renderers through `gamePaint()`. Resolve it when
+painting so theme changes redraw the existing guess. Preserve intrinsic stimulus
+colours (colour memory, 3D pieces, and shapes), coordinates, and stored data.
+
+## Result labels and spacing
+
+`result-layout.css` owns the score-first result panel and completion cards.
+`round-feedback.js` supplies named error metrics, units, direction notes, and
+separate width/height metrics. Use `resultMetricHTML` for new numeric feedback;
+keep scoring and saved question data independent from display text. Optional
+registry `label` overrides a game name consistently in prompts and summary cards.
+Run `node tests/browser-results.cjs` to check exact/opposing size errors, long
+values, source disclosures, and summaries in both themes on small phones.
diff --git a/docs/MAINTENANCE.md b/docs/MAINTENANCE.md
index 16c181d..e81a2da 100644
--- a/docs/MAINTENANCE.md
+++ b/docs/MAINTENANCE.md
@@ -8,6 +8,24 @@
The no-repeat guarantee covers trivia in **Daily five** from the schedule's start date, not historical games before that date or practice. Individual games are practice-only. Trivia practice draws only from Daily Fives dated before the current device-calendar day, with up to five unique questions per session; mixed practice follows the same restriction. The ledger lives in GitHub, independently of browser history. If a bank is exhausted, that trivia type is omitted from newly scheduled days. Beyond the scheduled horizon, the game falls back to five distinct procedural types instead of recycling trivia.
+## Game plans: Size it up replaces Proportions
+
+Use **Size it up** in all future game plans, mockups, instructions and roadmaps.
+It replaces the aspect-ratio game: players measure width and height against a
+visible reference square, with both dimensions scored independently. Do not plan
+Proportions as a separate current game.
+
+The daily schedule retains the internal type `proportion`. For dates on or after
+October 15, 2026, that slot generates Size it up (`measurementVersion: 2`), including
+already scheduled future days, newly extended schedules and the procedural
+fallback beyond the published horizon. Current practice also uses Size it up.
+Do not rename or reshuffle schedule entries to make this player-facing change;
+the identifier preserves seeded challenges, saved results and permanent daily
+assignments. Earlier dated rounds retain the historical ratio mechanic.
+
+`tests/verify-size.cjs` checks every scheduled measurement slot from the changeover
+onward, future generation beyond the schedule, and historical fixtures.
+
## Weekly automation
[Maintain and publish Perception](https://github.com/chipgpt/perception/actions/workflows/perception.yml) runs Mondays at **07:23 UTC**, on relevant changes to `main`, and through **Run workflow**. It extends the published buffer to 60 days ahead, validates the bank and schedule, builds and tests the game, commits changed assignments and publishes GitHub Pages. The job warns when fewer than 20 unused facts remain in either bank.
diff --git a/public/about.html b/public/about.html
index 3ea424d..9221fcb 100644
--- a/public/about.html
+++ b/public/about.html
@@ -36,7 +36,7 @@
Eleven ways to test your judgement
Half & Half
Cut a shape into two equal portions. Scores compare visible area.
Line memory
Watch a path disappear, then trace it from memory.
Perspective
Find the vanishing point where the lines meet.
-
Proportions
Resize a rectangle to match a width-to-height ratio.
+
Size it up
Start with one square. Drag the corner to make a rectangle the requested number of squares wide and tall; the shaded starting square stays fixed as your reference.
Rhythm
Watch a silent pulse and tap its tempo.
Angles
Rotate a line to match the requested angle.
Colour memory
Remember a swatch, then find it on a colour wheel.
@@ -45,7 +45,7 @@
Eleven ways to test your judgement
How long?
Estimate a duration from sport, space, films, or everyday life.
How scoring works
-
Closer guesses earn more points. Each game measures error in its own units—degrees, seconds, years, position, or proportion—then uses the same scoring curve. An error of 10% of that game’s zero-point bound earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. An exact answer earns 100.
+
Size it up gives width and height equal weight: each dimension earns up to 50 points using squared closeness. A 5% error in both dimensions earns 81/100; a 50% error in one dimension earns zero for that dimension. The reveal shows your dashed outline and the solid target grid.
Closer guesses earn more points. Each game measures error in its own units—degrees, seconds, years, position, or proportion—then uses the same scoring curve. An error of 10% of that game’s zero-point bound earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. An exact answer earns 100.
The game’s “How scoring works” instructions list the bounds for each puzzle. Your results show small comparisons so you can see where you were close.
A little more practice?
Open the Practice menu to pick an individual game or a fresh mixed five. Daily Five is the only daily challenge; individual games are practice-only. Trivia practice uses questions from previous Daily Fives, keeping today’s, upcoming, and unused trivia out of the practice pool. As the archive grows, sessions offer up to five different past questions.
')
-common_css=mobile_play+Path('src/design-options/trivia.css').read_text()+Path('src/design-options/theme-switcher.css').read_text()+Path('src/design-options/summary-comparisons.css').read_text()+Path('src/design-options/share-results.css').read_text()+Path('src/design-options/player-storage.css').read_text()+Path('src/design-options/play-navigation.css').read_text()+Path('src/design-options/memory-countdown.css').read_text()+Path('src/design-options/view-idle.css').read_text()+Path('src/design-options/app-layout.css').read_text()
+common_css=mobile_play+Path('src/design-options/trivia.css').read_text()+Path('src/design-options/theme-switcher.css').read_text()+Path('src/design-options/summary-comparisons.css').read_text()+Path('src/design-options/share-results.css').read_text()+Path('src/design-options/player-storage.css').read_text()+Path('src/design-options/play-navigation.css').read_text()+Path('src/design-options/memory-countdown.css').read_text()+Path('src/design-options/view-idle.css').read_text()+Path('src/design-options/app-layout.css').read_text()+Path('src/design-options/size-game.css').read_text()+Path('src/design-options/game-skin.css').read_text()+Path('src/design-options/result-layout.css').read_text()
theme_assets={key:key+'.css?v='+hashlib.sha256((base+css+common_css).encode()).hexdigest()[:12] for key,css in styles.items()}
theme_preference='window.perceptionThemeAssets='+json.dumps(theme_assets)+';\n'+theme_preference
for key,css in styles.items():
diff --git a/src/design-options/focus-palette.css b/src/design-options/focus-palette.css
new file mode 100644
index 0000000..50539c8
--- /dev/null
+++ b/src/design-options/focus-palette.css
@@ -0,0 +1,42 @@
+:root {
+ color-scheme: light;
+ --paper: #fafafa;
+ --surface: #fff;
+ --canvas: #f4f5f7;
+ --line: #e9eaed;
+ --ink: #191b21;
+ --muted: #737782;
+ --accent: #3b52ec;
+ --draw: #3b52ec;
+ --target: #a4be21;
+ --header-background: #fafafa;
+ --tagline-ink: #737782;
+ --circle-line: #e9eaed;
+ --nav-ink: #737782;
+ --practice-background: #eceffe;
+ --practice-line: transparent;
+ --practice-hover: #fafafa;
+ --tabs-background: #f0f1f3;
+ --tabs-active-background: #fff;
+ --game-line: #e9eaed;
+ --game-shadow: #202c5a04;
+ --gamebar-ink: #191b21;
+ --progress-track: #e9eaed;
+ --progress-glow: transparent;
+ --canvas-line: #e9eaed;
+ --primary-ink: #fff;
+ --primary-hover: #3b52ec;
+ --secondary-line: #e9eaed;
+ --secondary-background: #fff;
+ --overlay-fill: rgba(164,190,33,.12);
+ --swatch-line: #e9eaed;
+ --pulse-line: #e9eaed;
+ --option-line: #e9eaed;
+ --tabs-active-ink: #191b21;
+ --circle-background: #fff;
+ --primary-background: #20232d;
+ --option-background: #fff;
+ --beat-ink: var(--muted);
+ --time-ink: var(--ink);
+ --progress-opacity: .45;
+}
diff --git a/src/design-options/game-adapters.js b/src/design-options/game-adapters.js
new file mode 100644
index 0000000..ba4dcab
--- /dev/null
+++ b/src/design-options/game-adapters.js
@@ -0,0 +1,33 @@
+// Per-game presentation and lifecycle adapters. Register a replacement here;
+// the controller and the shared shell do not need to know its markup.
+const memoryArtwork={
+ motion:'',
+ colour:''
+};
+registerGame('view3d',{initialPhase:'ready',reveal:(q,value)=>animateViewResult(q,value),render:q=>viewRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember this view.':'Match the view.',subtitle:phase==='ready'?'Look closely. Then turn it back.':'Rotate the shape to match what you saw.',hint:'Study the shape. You’ll have three seconds.'})});
+registerGame('balance',{reveal:revealBalance,render:q=>balanceRound(q),presentation:q=>q.fruit?{title:'Make an even split.',subtitle:'One cut. Two equal portions.'}:{title:q.title,subtitle:'Find the centre of balance.'}});
+registerGame('halfhalf',{reveal:revealBalance,render:q=>halfRound(q),presentation:()=>({title:'Make an even split.',subtitle:'One cut. Two equal portions.'})});
+registerGame('motion',{initialPhase:'ready',reveal:(q,value)=>drawTrace(value,q.answer),ready:{artwork:memoryArtwork.motion,caption:'A line will appear here.'},render:q=>motionRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember the line.':'Trace it from memory.',subtitle:phase==='ready'?'Look once. Then trace it from memory.':'One stroke. As close as you can.'})});
+registerGame('perspective',{reveal:(q,value)=>drawPerspective(q,value,true),render:q=>perspectiveRound(q),presentation:()=>({title:'Where do the lines meet?',subtitle:'Find the vanishing point.'})});
+registerGame('proportion',{reveal:(q,value)=>drawProportion(value,q,true),render:q=>proportionRound(q),presentation:q=>isSizeQuestion(q)?{title:q.wide+' '+(q.wide===1?'square':'squares')+' wide.\n'+q.tall+' '+(q.tall===1?'square':'squares')+' tall.',subtitle:'The shaded square is your reference.'}:{title:q.title,subtitle:'Stretch a rectangle to match the ratio.'}});
+registerGame('rhythm',{render:q=>rhythmRound(q),presentation:()=>({title:'Find your rhythm.',subtitle:'Watch the pulse. Then tap it back.',hint:'Five flashes. Five taps. No sound.'})});
+registerGame('angle',{reveal:(q,value)=>drawAngle(value,q.answer),render:q=>angleRound(q),presentation:q=>({title:'Make a '+q.answer+'° angle.',subtitle:'One number. Trust your eye.'})});
+registerGame('colour',{initialPhase:'ready',reveal:q=>{$('wheel').setAttribute('aria-disabled','true');drawWheel(q.answer);},ready:{artwork:memoryArtwork.colour,caption:'A colour will appear here.'},render:q=>memoryRound(q),presentation:(q,phase)=>({title:phase==='ready'?'Remember a colour.':'Find that colour.',subtitle:phase==='ready'?'Look closely. Then find it on the wheel.':'Choose the hue and how vivid it is.',hint:phase==='ready'?'You’ll have two seconds to remember it.':''})});
+registerGame('time',{label:'Time',reveal:(q,value)=>{$('game-stage').querySelector('.time-cue').innerHTML=`${value.toFixed(2)}SECONDS ELAPSED`;},render:q=>timeRound(q),presentation:()=>({title:'Trust your internal clock.',subtitle:'Start. Wait. Stop.'})});
+registerGame('timeline',{render:q=>timelineRound(q),presentation:()=>({subtitle:'A moment in history. Your best guess.',hint:'Scroll to a year. The centre is your pick.'})});
+registerGame('duration',{render:q=>durationRound(q),presentation:q=>({subtitle:q.caption,hint:'Turn the dial to your best guess.'})});
+// Older saved rounds still use the same shared slots.
+registerGame('typography',{reveal:revealTypography,render:q=>typographyRound(q)});
+registerGame('choices',{render:q=>choicesRound(q)});
+
+function revealBalance(q,value,score){
+ if(q.fruit){drawHalfFruit($('balance-canvas'),q,value,true);$('balance-canvas').setAttribute('aria-label',halfSplitText(value,q)+'. Your line is unchanged. The parallel green line shows an even cut.');return;}
+ const tilt=score===100?0:Math.sign(q.answer-value)*18;
+ if(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches){drawBalance(q,value,tilt,true);return;}
+ for(let i=0;i<=15;i++)afterEffect(()=>drawBalance(q,value,tilt*i/15,true),i*20);
+}
+function revealTypography(q,value){
+ const preview=$('type-preview');preview.classList.add('type-overlay');
+ preview.innerHTML=typeHTML(q.word,value).replace('class="type-word"','class="type-word type-overlay-guess"')+typeHTML(q.word,q.answer).replace('class="type-word"','class="type-word type-overlay-target"');
+ preview.setAttribute('aria-label','Your text with the target overlaid as a lime outline.');
+}
diff --git a/src/design-options/game-paint.js b/src/design-options/game-paint.js
new file mode 100644
index 0000000..dd16282
--- /dev/null
+++ b/src/design-options/game-paint.js
@@ -0,0 +1,8 @@
+// Canvas renderers use the same palette, typography and strokes as DOM controls.
+// Resolve on each draw so appearance changes preserve the current game state.
+function gamePaint(){
+ const css=getComputedStyle(document.documentElement),token=name=>css.getPropertyValue('--'+name).trim();
+ return {ink:token('ink'),muted:token('muted'),line:token('line'),draw:token('draw'),target:token('target'),canvas:token('canvas'),
+ mark:Number(token('mark-width'))||3,guide:Number(token('guide-width'))||1.5,
+ font:(size,weight=400,mono=false)=>`${weight} ${size}px ${token(mono?'font-mono':'font')||(mono?'"IBM Plex Mono",monospace':'"Manrope",sans-serif')}`};
+}
diff --git a/src/design-options/game-shell.js b/src/design-options/game-shell.js
new file mode 100644
index 0000000..0c6dbe2
--- /dev/null
+++ b/src/design-options/game-shell.js
@@ -0,0 +1,68 @@
+// Shared contract: a game registers its renderer and presentation, then mounts
+// its stage and controls. Only this shell owns page layout, phase and chrome.
+const gameRegistry=new Map();
+let activeGameShell=null;
+function registerGame(type,definition){
+ if(gameRegistry.has(type))throw new Error('Game already registered: '+type);
+ if(typeof definition.render!=='function')throw new Error('A game needs a renderer: '+type);
+ gameRegistry.set(type,Object.freeze({...definition}));
+}
+function renderGame(q){
+ const game=gameRegistry.get(q.type);
+ if(!game)throw new Error('Unknown puzzle type: '+q.type);
+ game.render(q);
+}
+function revealGame(q,value,score){gameRegistry.get(q.type)?.reveal?.(q,value,score);}
+function disposeGameShell(){
+ const previous=activeGameShell;activeGameShell=null;
+ if(previous)for(const cleanup of previous.cleanups)cleanup();
+}
+function onGameRedraw(paint){if(activeGameShell)activeGameShell.redraw=paint;}
+function onGameCleanup(cleanup){if(activeGameShell)activeGameShell.cleanups.push(cleanup);}
+function setGamePhase(phase){$('playarea').dataset.phase=phase;}
+function leaveGameShell(){
+ disposeGameShell();
+ document.body?.removeAttribute('data-game-active');
+ $('playarea').removeAttribute?.('data-phase');
+}
+function mountGameShell({question:q,stage,controls='',phase}){
+ disposeGameShell();
+ const game=gameRegistry.get(q.type)||{};phase=phase||game.initialPhase||'play';
+ const presentation=phase==='empty'?{}:game.presentation?.(q,phase)||{};
+ const title=presentation.title||q.title,subtitle=presentation.subtitle||'';
+ const text=value=>escapeHTML(value).replace(/\n/g,' ');
+ document.body.dataset.gameActive='true';
+ $('playarea').dataset.puzzle=q.type;setGamePhase(phase);gamebar();
+ $('playarea').innerHTML=`