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 & HalfCut a shape into two equal portions. Scores compare visible area. Line memoryWatch a path disappear, then trace it from memory. PerspectiveFind the vanishing point where the lines meet. -ProportionsResize a rectangle to match a width-to-height ratio. +Size it upStart 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. RhythmWatch a silent pulse and tap its tempo. AnglesRotate a line to match the requested angle. Colour memoryRemember 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.

diff --git a/public/focus.css b/public/focus.css index 108a0f5..101081d 100644 --- a/public/focus.css +++ b/public/focus.css @@ -43,10 +43,108 @@ @media(max-width:600px){.gamebar{gap:8px}.gamebar #roundlabel,.gamebar .score{white-space:nowrap}.gamebar .progress{gap:4px}.gamebar .progress i{width:12px}} .option-switch[hidden]{display:none}body:has(.feedback) .mixed-controls>button:disabled,body:has(.feedback) .preview-actions{display:none} - -:root{--paper:#fafafa;--canvas:#f4f5f7;--line:#e9eaed;--ink:#191b21;--muted:#737782;--accent:#3b52ec}header{height:86px;max-width:1280px;margin:auto;padding:0 46px;border-bottom:0}.brand{font-size:25px;font-weight:700;letter-spacing:-1.2px}.brand::before{content:'';height:18px;width:18px;border:5px solid var(--accent);border-radius:50%;margin-right:3px}main{max-width:1080px;padding:0 34px 70px;margin:auto}.minigames{justify-content:flex-start;margin:8px 0 22px;border-bottom:1px solid var(--line);gap:2px}.minigames button{padding:10px 12px;font-size:13px;border-radius:7px}.minigames .active{background:#eceffe;color:var(--accent)}.topline{max-width:680px;margin:0 auto 28px}.tabs{background:#f0f1f3;padding:3px;border-radius:10px}.tabs .active{box-shadow:0 1px 5px #00000008}.layout{display:block;max-width:680px;margin:auto}.game{background:var(--surface);border:1px solid var(--line);border-radius:24px;padding:26px 40px 32px;box-shadow:0 16px 60px #202c5a04}.gamebar{padding-bottom:24px}.mixed-question{padding:18px 0 22px}.mixed-question h1{font-size:36px;font-weight:700}.mixed-question .eyebrow{font-size:12px;letter-spacing:1.6px;color:var(--accent)}.spatial-wrap{max-width:460px}.spatial-wrap canvas{border-radius:14px}.primary{border-radius:12px;background:#20232d}.primary:hover{background:var(--accent)}aside{display:none}footer{max-width:680px;margin:26px auto 0}.memory-stage{max-width:460px}.circle{border-color:var(--line)}.pulse.lit{background:var(--accent)}@media(max-width:600px){header{height:72px;padding:0 22px}main{padding:0 16px 80px}.minigames{margin-bottom:20px}.topline{margin-bottom:20px}.game{padding:22px 22px 24px;border-radius:20px}.gamebar{padding-bottom:18px}.mixed-question{padding:15px 0 20px}.mixed-question h1{font-size:30px}.brand{font-size:23px}.option-switch span{display:none}} - -.brand{text-transform:lowercase} +: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; +} +/* Shared geometry and typography. Theme files contain palette tokens only. */ +body{background:var(--paper)} +header{height:83px;position:relative;padding:0 42px 0 36px;border-bottom:1px solid var(--line);background:var(--header-background)} +.brand{font-size:23px;font-weight:700} +.brand::before{content:'';width:18px;height:18px;border-radius:50%;border:2px solid var(--accent);box-shadow:inset 0 0 0 4px var(--header-background)} +.tagline{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--tagline-ink)} +.circle{background:var(--circle-background);border-color:var(--circle-line);border-radius:9px} +main{display:grid;grid-template-columns:210px minmax(0,1fr);max-width:1400px;margin:auto;padding:0 36px 70px;column-gap:44px} +.minigames{grid-column:1;grid-row:1 / span 4;flex-direction:column;overflow:visible;gap:5px;margin:30px 0 0;padding-right:24px;border-right:1px solid var(--line)} +.minigames::before{content:'PLAY MODES';font:12px 'IBM Plex Mono',monospace;color:var(--muted);letter-spacing:1px;margin:0 0 14px 12px} +.minigames button{text-align:left;padding:12px 14px;color:var(--nav-ink);font-size:14px;border-radius:9px;font-weight:500} +.minigames .active{background:var(--practice-background);color:var(--accent);border:1px solid var(--practice-line);padding:11px 13px} +.minigames button:hover{color:var(--ink);background:var(--practice-hover)} +.minigames .active:hover{color:var(--accent);background:var(--practice-background)} +.topline{grid-column:2;margin:30px 0 20px;max-width:850px;width:100%;align-self:start} +.tabs{background:var(--tabs-background);padding:4px;border-radius:9px} +.tabs .active{background:var(--tabs-active-background);color:var(--tabs-active-ink)} +.layout{grid-column:2;display:block;max-width:850px;width:100%} +.game{background:var(--surface);border:1px solid var(--game-line);border-radius:18px;padding:24px 36px 30px;box-shadow:0 25px 80px var(--game-shadow)} +.gamebar{font-family:'IBM Plex Mono',monospace;color:var(--gamebar-ink);border-bottom:1px solid var(--line);padding-bottom:21px} +.score{color:var(--ink)} +.progress i{background:var(--progress-track)} +.progress .current{opacity:var(--progress-opacity);background:var(--accent);box-shadow:0 0 9px var(--progress-glow)} +.mixed-question{padding:28px 0 22px} +.eyebrow{font-family:'IBM Plex Mono',monospace;color:var(--accent)} +.mixed-question h1{font-size:38px;font-weight:600} +.spatial-wrap{max-width:470px} +.spatial-wrap canvas{border:1px solid var(--canvas-line);border-radius:12px} +.primary{color:var(--primary-ink);background:var(--primary-background);border-radius:9px} +.primary:hover{background:var(--primary-hover)} +.secondary{border-color:var(--secondary-line);background:var(--secondary-background)} +.points{color:var(--accent)} +.type-overlay-target{color:var(--overlay-fill);-webkit-text-stroke-color:var(--target)} +.chosen-swatch{border-color:var(--swatch-line)} +.colour-caption{color:var(--nav-ink)} +.memory-stage,.type-specimen{border:1px solid var(--canvas-line);border-radius:12px} +.pulse{border:1px solid var(--pulse-line);background:var(--canvas)} +.pulse.lit{background:var(--accent);border-color:var(--accent)} +.beat-count{color:var(--beat-ink)} +aside{display:none} +footer{grid-column:2;max-width:850px;margin-top:24px;font-family:'IBM Plex Mono',monospace;font-size:12px} +.option-switch{background:var(--option-background);border-color:var(--option-line)} +.time-cue strong{color:var(--time-ink);font-family:'IBM Plex Mono',monospace;font-size:104px;letter-spacing:-7px} +.bigscore{font-family:'IBM Plex Mono',monospace} +.minigames button:focus-visible{outline-offset:0} +@media(max-width:850px){header{padding:0 24px;height:73px} +main{display:block;padding:0 20px 75px} +.minigames{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line);padding:0 0 12px;margin:20px 0} +.minigames::before{display:none} +.topline{margin:20px 0} +.game{padding:24px} +.mixed-question h1{font-size:32px} +footer{margin-top:24px} +} +@media(max-width:600px){.brand{font-size:22px} +.game{padding:20px;border-radius:14px} +.gamebar{gap:8px} +.mixed-question{padding-top:24px} +.option-switch span{display:none} +} /* Compact play layout: reserve the viewport for the puzzle and its controls. */ @media(max-width:760px){ header{height:54px;padding:0 18px}.brand{font-size:23px;letter-spacing:-1px}.brand::after{font-size:25px}.tagline{display:none}.circle{width:34px;height:34px} @@ -75,13 +173,13 @@ .wheel-wrap{width:min(100%,clamp(170px,calc(100svh - 470px),280px))} } .trivia-caption{font-size:14px;color:var(--muted);margin:0 0 14px;text-align:center}.timeline-stage,.duration-stage{max-width:460px;margin:auto}.timeline-stage canvas,.duration-stage canvas{width:100%;height:auto;display:block;touch-action:none;border-radius:12px}.trivia-value{display:block;text-align:center;font-size:28px;font-weight:600;margin:12px 0 0;font-variant-numeric:tabular-nums}.timeline-zoom{display:flex;gap:14px;align-items:center;justify-content:center;margin:0 0 14px}.timeline-zoom span{font-size:13px;color:var(--muted)}.timeline-zoom button{width:48px;padding:8px;font-size:22px;min-height:44px} -@media(max-width:760px){.trivia-caption{font-size:12px;line-height:1.4;margin:0 0 8px}.timeline-stage{width:100%;max-width:400px}.duration-stage{width:min(100%,calc(clamp(150px,100svh - 405px,270px)*4/3))}.trivia-value{font-size:23px;margin:6px 0 0}.timeline-zoom{margin-bottom:10px;gap:12px}body:has(.feedback) .duration-stage{width:min(100%,calc(clamp(120px,100svh - 505px,230px)*4/3))}body:has(.feedback) .timeline-stage{max-width:290px}body:has(.feedback) .trivia-value{display:none}} +@media(max-width:760px){.trivia-caption{font-size:12px;line-height:1.4;margin:0 0 8px}.timeline-stage{width:100%;max-width:400px}.duration-stage{width:min(100%,calc(clamp(150px,100svh - 405px,270px)*4/3))}.trivia-value{font-size:23px;margin:6px 0 0}.timeline-zoom{margin-bottom:10px;gap:12px}body:has(.feedback) .duration-stage{width:min(100%,calc(clamp(120px,100svh - 505px,230px)*4/3))}body:has(.feedback) .trivia-value{display:none}} @media(max-width:760px){ [data-puzzle="timeline"] .mixed-question h1,[data-puzzle="duration"] .mixed-question h1{font-size:25px} .timeline-stage{width:min(100%,clamp(230px,100svh - 400px,400px))}.trivia-value{font-size:21px;margin-top:2px}.timeline-zoom{margin-bottom:8px} .duration-stage{width:min(100%,calc(clamp(150px,100svh - 475px,270px)*4/3))} body:has(.feedback) .trivia-caption{display:none} - body:has(.feedback) .timeline-stage{width:min(100%,clamp(220px,100svh - 410px,340px));max-width:340px} + } #timeline-canvas{cursor:grab}#timeline-canvas.dragging{cursor:grabbing}body:has(.feedback) #timeline-canvas{cursor:default} @@ -119,7 +217,7 @@ main{display:block;max-width:920px;margin:0 auto;padding:28px 24px 36px} .practice-menu{position:relative}.practice-menu>summary{list-style:none;justify-content:center}.practice-menu>summary>span{line-height:20px}.practice-chevron{display:block;flex:none;width:16px;height:16px}.practice-menu[open] .practice-chevron{transform:rotate(180deg)}.practice-menu>summary::-webkit-details-marker{display:none} .practice-menu[open]>summary{color:var(--ink);border-color:var(--accent)} .practice-menu .minigames{position:absolute;top:calc(100% + 8px);left:0;z-index:20;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));width:320px;max-width:calc(100vw - 32px);margin:0;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--surface);box-shadow:0 12px 32px #0003;overflow:visible;gap:4px} -.practice-menu:not([open]) .minigames{display:none}.practice-menu .minigames::before{display:none} +details.practice-menu:not([open]) .minigames{display:none}.practice-menu .minigames::before{display:none} .practice-menu .minigames button{margin:0;min-height:44px;padding:10px;text-align:left;border:0;border-radius:7px;font:500 14px/1.4 var(--font);color:var(--ink);background:transparent;white-space:normal} .practice-menu .minigames button:hover,.practice-menu .minigames button.active{background:var(--canvas);color:var(--ink);box-shadow:none} .site-links{display:flex;justify-content:center;flex-wrap:wrap;gap:20px;color:var(--muted);font-size:12px;margin:12px auto 24px}.site-links a{text-decoration:none}.site-links a:hover{text-decoration:underline} @@ -135,13 +233,6 @@ main{display:block;max-width:920px;margin:0 auto;padding:28px 24px 36px} } .mixed-controls>.memory-countdown:disabled{position:relative;overflow:hidden;opacity:1;background:var(--canvas);color:var(--ink);border:1px solid var(--line);font-variant-numeric:tabular-nums} .memory-countdown::after{content:'';position:absolute;bottom:0;left:0;height:3px;width:calc(var(--memory-remaining,1)*100%);background:var(--accent)} -.view-start-stage{position:relative} -.view-start-stage>.view-start-overlay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);min-width:120px;min-height:48px;box-shadow:0 4px 24px #0004} -.view-start-stage>.view-start-overlay:hover{transform:translate(-50%,-50%)} - -.view-start-waiting::after{content:'';position:absolute;inset:0;border-radius:12px;background:#0007;pointer-events:none} -.view-start-stage>.view-start-overlay{z-index:1} - .view-result-stage{position:relative} .view-result-phase{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);padding:4px 10px;border-radius:6px;background:var(--surface);color:var(--muted);font-size:12px;white-space:nowrap;pointer-events:none} .round-feedback .feedbacktop{display:block} @@ -153,44 +244,131 @@ main{display:block;max-width:920px;margin:0 auto;padding:28px 24px 36px} .round-result-points{border-left:1px solid var(--line)} .round-verdict{margin:14px 0 18px;text-align:center;color:var(--ink);font-size:14px} @media(min-width:761px){.round-result-stats strong{font-size:56px}.round-result-stats span{font-size:13px}.round-verdict{font-size:16px}} -/* The active round fills the screen; summaries keep their natural scrolling layout. */ -body:has(.mixed-question){min-height:100vh;min-height:100svh;display:flex;flex-direction:column} -body:has(.mixed-question)>header{flex:none} -body:has(.mixed-question)>main{display:flex;flex-direction:column;flex:1;width:100%;margin:0 auto;padding-bottom:max(12px,env(safe-area-inset-bottom))} -body:has(.mixed-question) .topline{flex:none} -body:has(.mixed-question) .layout{display:flex;flex-direction:column;flex:1;width:100%;margin:0 auto} -body:has(.mixed-question) .game{display:flex;flex-direction:column;flex:1} -body:has(.mixed-question) .gamebar{flex:none} -body:has(.mixed-question) #playarea{display:flex;flex-direction:column;flex:1} -body:has(.mixed-question) .mixed-question{display:flex;flex-direction:column;flex:1} -body:has(.mixed-question) .mixed-question>.spatial-wrap,body:has(.mixed-question) .mixed-question>.angle-wrap{margin-block:auto} -body:has(.mixed-question) .mixed-controls{margin:12px auto 0;flex:none} -body:has(.mixed-question) .mixed-controls:empty{margin:0} -body:has(.mixed-question) .actionarea{flex:none} -body:has(.mixed-question) footer,body:has(.mixed-question)>.site-links{display:none} -/* Use the available width, with height caps to keep essential controls in view. */ -body:has(.mixed-question)>main{max-width:1200px;padding-inline:8px} -body:has(.mixed-question) .layout{max-width:none} -body:has(.mixed-question) .game{padding-inline:12px} -body:has(.mixed-question) .spatial-wrap{width:min(100%,calc(clamp(180px,100svh - 350px,450px)*4/3));max-width:none} -body:has(.mixed-question):has(.feedback) .spatial-wrap{width:min(100%,calc(clamp(120px,100svh - 470px,330px)*4/3))} +/* Shared chrome. Game renderers provide content, never viewport geometry. */ +:root{--game-stage-min:100px;--game-content-width:600px;--canvas:var(--paper)} +body>header{height:60px;padding:0 22px;background:var(--paper);border:0} +body>header .brand{font-size:16px;letter-spacing:-.5px;gap:9px} +body>header .brand::before{width:14px;height:14px;border-width:1.5px;box-shadow:inset 0 0 0 3px var(--paper)} +body>header .header-right{gap:4px} +body>header .tagline{display:none} +body>header .circle{width:44px;height:44px;min-height:44px;background:transparent} +body>header .option-switch{position:relative;inset:auto;width:44px;height:44px;min-height:44px;padding:0;background:transparent;border:0;display:grid;place-items:center} +body>header .option-switch select{inset:0;width:44px;height:44px} +body[data-game-active="true"]{height:100vh;height:100svh;min-height:0;display:flex;flex-direction:column} +body[data-game-active="true"]>header{flex:none} +body[data-game-active="true"]>main{display:flex;flex-direction:column;flex:1;min-height:0;width:100%;max-width:var(--game-content-width);margin:0 auto;padding:0 24px max(20px,env(safe-area-inset-bottom))} +body[data-game-active="true"] .layout,body[data-game-active="true"] .game{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0;width:100%;margin:0;padding:0;border:0;border-radius:0;box-shadow:none;background:transparent} +body[data-game-active="true"] .gamebar{flex:none;min-height:43px;padding:0 0 15px;margin:0;border-bottom:1px solid var(--line);font-size:10px;gap:8px} +body[data-game-active="true"] .gamebar .score{font-size:12px;white-space:nowrap} +body[data-game-active="true"] .gamebar .score small{font-size:10px} +body[data-game-active="true"] .progress{gap:4px} +body[data-game-active="true"] .progress i{width:17px;height:3px} +body[data-game-active="true"] #playarea{display:flex;flex-direction:column;flex:1;min-height:0;min-width:0} +.game-round{display:flex;flex-direction:column;flex:1;min-height:0;padding:28px 0 0;text-align:center} +.game-prompt{flex:none} +.game-round .eyebrow{font-size:10px;letter-spacing:2.1px;margin:0 0 12px;color:var(--accent)} +.game-round h1{font-family:var(--font);font-size:clamp(24px,6.8vw,32px);font-weight:600;line-height:1.23;letter-spacing:-1.1px;margin:0 0 14px;overflow-wrap:anywhere} +.game-subtitle{margin:0 auto;color:var(--muted);font:13px/1.6 var(--font);max-width:450px} +.game-stage{display:flex;position:relative;flex:1;min-height:var(--game-stage-min);min-width:0;margin:14px 0;isolation:isolate} +.game-stage>:is(.spatial-wrap,.angle-wrap,.wheel-wrap,.timeline-stage,.duration-stage,.memory-stage,.pulse-stage,.time-cue,.type-specimen){position:relative;flex:1;min-height:0;width:100%;max-width:none;height:auto;margin:0;padding:0;border:0;border-radius:0;background:transparent;overflow:hidden} +.game-stage>:is(.spatial-wrap,.angle-wrap,.wheel-wrap,.duration-stage) canvas{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;border:0;border-radius:0;background:transparent;touch-action:none} +.game-stage>.memory-stage,.game-stage>.pulse-stage,.game-stage>.time-cue{display:flex;flex-direction:column;justify-content:center;align-items:center} +body[data-game-active="true"] .game-controls{flex:none;display:flex;flex-direction:column;align-items:stretch;gap:0;width:100%;max-width:none;margin:0;padding:0} +.game-controls>.small-instruction{order:-1;font:12px/1.6 var(--font);color:var(--muted);text-align:center;margin:0 0 14px} +.game-controls .primary,.game-feedback .primary{width:100%;min-height:52px;height:auto;margin:0;border-radius:9px;font:700 14px/1.4 var(--font);padding:15px 16px} +.game-controls .primary:disabled{background:var(--surface);color:var(--muted);border:1px solid var(--line);opacity:.65} +.game-controls .preview-actions{display:flex;gap:12px} +.game-controls .preview-actions .primary{flex:1;width:auto} +.game-controls .preview-actions .secondary{min-height:52px} +.game-feedback{flex:none;margin:0;padding:0} +.game-feedback:empty{display:none} +#playarea[data-phase="reveal"] .game-controls{display:none} +#playarea[data-phase="reveal"] .game-stage>.pulse-stage{display:flex} +#playarea[data-phase="reveal"] .game-round{padding-top:16px} +#playarea[data-phase="reveal"] .game-round h1{font-size:22px;margin-bottom:8px} +#playarea[data-phase="reveal"] .game-subtitle{display:none} +#playarea[data-phase="reveal"] .game-stage{margin-block:8px} +#playarea[data-phase="reveal"] .game-feedback{max-height:55svh;overflow:auto;overscroll-behavior:contain} +.game-feedback .feedback{padding:8px 0 0;margin:0;border:0;background:transparent} +.game-feedback .round-result-stats strong{font-size:34px} +.game-feedback .round-result-stats strong small{font-size:15px} +.game-feedback .round-verdict{margin:8px 0;font-size:13px} +.game-feedback .round-answer{margin-bottom:10px} +.game-feedback .math-detail{font-size:12px;margin:0 0 10px} +.game-feedback #next{position:sticky;bottom:0;z-index:1} +body[data-game-active="true"] main>footer,body[data-game-active="true"]>.site-links{display:none} +body[data-game-active="true"] .friend-challenge:not([hidden]),body[data-game-active="true"] #save-note:not([hidden]){font-size:11px;margin:6px 0;padding:6px 8px} +/* Ready, preview and play share the same stage; no game draws its own chrome. */ +.memory-ready-art{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:28px;pointer-events:none;color:var(--draw)} +.memory-ready-art svg{width:104px;height:104px;opacity:.8} +.memory-ready-art span{font:12px/1.5 var(--font);color:var(--muted)} +.memory-ready-illustration canvas{visibility:hidden} +.game-stage .time-cue strong{font-family:var(--font);font-size:clamp(80px,25vw,136px);font-weight:500;line-height:1;letter-spacing:-6px} +.game-stage .time-cue span{font-size:11px;letter-spacing:3px;color:var(--muted);margin-top:22px} +.game-stage .pulse-stage{gap:28px} +.game-stage .pulse{width:112px;height:112px;background:transparent;border:1px solid var(--line);box-shadow:0 0 0 24px color-mix(in srgb,var(--draw) 2%,transparent),0 0 0 49px color-mix(in srgb,var(--draw) 1%,transparent);position:relative} +.game-stage .pulse::after{content:'';position:absolute;inset:36px;border-radius:50%;background:var(--draw);opacity:.8} +.game-stage .pulse.lit{background:var(--accent);border-color:var(--accent)} +.game-stage .pulse.lit::after{background:var(--accent)} +.game-stage #pulse-label{font:12px var(--font);color:var(--muted)} +.game-stage .beat-count{font-size:20px;letter-spacing:8px;color:var(--muted);margin:0} +.game-stage .colour-caption{display:none} +.game-controls .colour-adjust{margin:0 0 10px} +.game-controls .keyboard-colour{font-size:12px;margin:0 0 10px} +.game-controls .trivia-nudge{margin:0 0 12px;gap:16px} +/* The chooser is a compact destination: daily ritual first, every skill visible. */ +.game-menu{width:min(520px,calc(100vw - 24px));max-height:calc(100svh - 24px);margin:auto;padding:24px;border:1px solid var(--line);border-radius:24px;background:var(--paper);color:var(--ink);overflow:auto;box-shadow:0 32px 100px #0004} +.game-menu::backdrop{background:#0009;backdrop-filter:blur(10px)} +.game-menu-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:24px} +.game-menu-eyebrow{margin:0 0 5px;color:var(--muted);font:500 10px/1.4 'IBM Plex Mono',monospace;text-transform:uppercase;letter-spacing:1.8px} +.game-menu-heading h2{margin:0;font:600 30px/1.15 var(--font);letter-spacing:-1.2px} +.game-menu .circle{width:44px;height:44px;min-height:44px;border-radius:50%;background:transparent;color:var(--muted);font-size:22px;float:none} +.game-menu .topline{display:block;margin:0;width:100%;max-width:none} +.game-menu .play-navigation{display:grid;grid-template-columns:minmax(0,1fr) 64px;gap:10px} +.game-menu .daily-home{grid-column:1;min-height:82px;display:flex;justify-content:flex-start;gap:14px;padding:16px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));border-radius:14px;background:color-mix(in srgb,var(--accent) 9%,var(--surface));color:var(--ink);text-align:left;white-space:normal} +.game-menu .daily-home.active{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)} +.daily-copy{display:flex;flex:1;flex-direction:column;gap:5px} +.daily-copy strong{font-size:17px;line-height:1.2;letter-spacing:-.5px} +.daily-copy small{font:400 11px/1.4 var(--font);color:var(--muted)} +.daily-art{display:grid;flex:none;width:28px;height:28px;grid-template-columns:repeat(3,1fr);gap:3px;transform:rotate(-10deg)} +.daily-art i{width:7px;height:7px;border-radius:50%;background:var(--accent)} +.daily-art i:nth-child(2){grid-column:3}.daily-art i:nth-child(3){grid-column:2}.daily-art i:nth-child(5){grid-column:3} +.daily-arrow svg{display:block;width:20px;height:20px} +.daily-arrow{align-self:flex-start;font-size:22px;line-height:1;color:var(--accent)} +.game-menu .history-button{grid-column:2;grid-row:1;width:64px;height:auto;min-height:82px;display:flex;flex-direction:column;justify-content:center;gap:7px;border-radius:14px;background:transparent;color:var(--muted)} +.game-menu .history-button span{font:500 10px/1.2 var(--font)} +.game-menu .history-button:hover{background:var(--surface);color:var(--ink)} +.game-menu .practice-menu{grid-column:1 / -1;grid-row:2;width:100%;min-width:0} +.game-menu-section{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:18px 0 12px} +.game-menu-section h3{margin:0;font:600 13px/1.4 var(--font);letter-spacing:-.2px} +.game-menu-section>span{color:var(--muted);font:400 10px/1.4 var(--font)} +.game-menu .practice-menu .minigames{position:static;width:100%;max-width:none;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0;padding:0;box-shadow:none;border:0;border-radius:0;background:transparent;overflow:visible} +.game-menu .minigames button{position:relative;display:flex;align-items:center;gap:10px;min-width:0;min-height:66px;padding:11px 10px;white-space:normal;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink);font-family:var(--font)} +.game-menu .minigames button:hover{background:color-mix(in srgb,var(--accent) 5%,var(--surface));border-color:color-mix(in srgb,var(--accent) 45%,var(--line))} +.game-menu .minigames button.active{background:color-mix(in srgb,var(--accent) 10%,var(--surface));border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)} +.game-menu .minigames button.active::after{content:'✓';position:absolute;right:6px;top:3px;color:var(--accent);font:700 10px/1 var(--font)} +.game-option-icon{display:grid;place-items:center;flex:none;width:26px;height:30px;color:var(--muted)} +.game-option-icon svg{display:block;width:24px;height:24px} +.game-menu .minigames button.active .game-option-icon{color:var(--accent)} +.game-option-copy{display:flex;flex-direction:column;min-width:0;gap:4px;text-align:left} +.game-option-copy strong{font-size:12px;font-weight:600;line-height:1.2;letter-spacing:-.2px} +.game-option-copy small{font-size:9px;font-weight:400;line-height:1.4;color:var(--muted)} +.game-menu .meta{display:none} +@media(max-width:400px){.game-menu{padding:18px}.game-menu .minigames button{gap:8px;padding-inline:8px}.game-option-icon{width:23px}.game-option-copy small{font-size:8.5px}.daily-copy small{font-size:10px}.game-menu .daily-home{gap:10px;padding:12px}.daily-arrow{font-size:19px}} +@media(max-height:740px){.game-menu{padding:16px}.game-menu-heading{margin-bottom:16px}.game-menu-heading h2{font-size:27px}.game-menu .daily-home,.game-menu .history-button{min-height:70px}.game-menu-section{margin:12px 0 10px}.game-menu .minigames button{min-height:54px;padding-block:7px}.game-menu .practice-menu .minigames{gap:6px}} +@media(max-height:700px){ + .game-round{padding-top:18px}.game-round h1{font-size:25px;margin-bottom:10px}.game-round .eyebrow{margin-bottom:9px}.game-subtitle{font-size:12px} + .game-stage{margin-block:8px}.game-controls>.small-instruction{margin-bottom:10px;font-size:11px} + body[data-game-active="true"]>main{padding-bottom:max(14px,env(safe-area-inset-bottom))} + #playarea[data-phase="reveal"] .game-prompt .eyebrow{display:none}.game-feedback .round-result-stats strong{font-size:28px} + .game-feedback .round-verdict{margin:6px 0}.game-stage .pulse-stage{gap:18px}.game-stage .pulse{width:92px;height:92px}.game-stage .pulse::after{inset:28px} +} @media(min-width:761px){ - body:has(.mixed-question)>main{padding-inline:24px;padding-bottom:24px} - body:has(.mixed-question) .topline{margin-top:0;margin-bottom:12px} - body:has(.mixed-question) .game{padding:18px 24px 20px} - body:has(.mixed-question) .mixed-question{padding-block:14px 12px} - body:has(.mixed-question) .mixed-question h1{margin-bottom:12px} - body:has(.mixed-question) .spatial-wrap{width:min(100%,720px,calc((100svh - 320px)*4/3))} - body:has(.mixed-question):has(.feedback) .spatial-wrap{width:min(100%,720px,calc(max(180px,100svh - 610px)*4/3))} -} -/* Every puzzle gets the same flexible stage; drawings retain their own aspect ratio. */ -body:has(.mixed-question) .mixed-question>:is(.spatial-wrap,.angle-wrap,.wheel-wrap,.timeline-stage,.duration-stage){position:relative;flex:1;width:100%;max-width:none;min-height:180px;margin:0;background:var(--canvas);border:1px solid var(--line);border-radius:12px;overflow:hidden} -body:has(.mixed-question) .mixed-question>:is(.spatial-wrap,.angle-wrap,.wheel-wrap,.timeline-stage,.duration-stage) canvas{position:absolute;inset:0;display:block;width:100%;height:100%;object-fit:contain;border:0;border-radius:0;background:var(--canvas)} -body:has(.mixed-question) .mixed-question>:is(.memory-stage,.time-cue,.pulse-stage,.type-specimen){flex:1;width:100%;max-width:none;height:auto;min-height:150px;margin:0;border:1px solid var(--line);border-radius:12px;background:var(--canvas);padding:16px;justify-content:center;align-items:center} -body:has(.mixed-question) .mixed-question>.time-cue{display:flex;flex-direction:column} -body:has(.mixed-question) .mixed-question>.pulse-stage{display:flex;flex-direction:column} -body:has(.mixed-question):has(.feedback) .mixed-question>.pulse-stage{display:flex} -body:has(.mixed-question):has(.feedback) .mixed-controls{display:none} + body[data-game-active="true"]>main{padding-inline:28px;padding-bottom:24px}.game-round{padding-top:28px}.game-round h1{font-size:34px} + body>header{padding-inline:32px}body>header .brand{font-size:18px} +} +@media(prefers-reduced-motion:reduce){.game-stage *, .game-controls *{animation:none!important;transition:none!important}} + .year-picker{isolation:isolate} .year-wheel{position:absolute;inset:0;overflow-y:auto;scroll-snap-type:y mandatory;overscroll-behavior:contain;touch-action:none;user-select:none;-webkit-user-select:none;cursor:grab;scrollbar-width:none;font-variant-numeric:tabular-nums;mask-image:linear-gradient(transparent,#000 35%,#000 65%,transparent)} .year-wheel-dragging{scroll-snap-type:none;cursor:grabbing} @@ -212,3 +390,144 @@ body:has(.mixed-question):has(.feedback) .mixed-controls{display:none} .rhythm-result-comparison span,.rhythm-result-comparison small{color:var(--muted);font-size:13px} .rhythm-result-target{border-left:1px dashed var(--target);color:var(--target)} @media(min-width:761px){.round-feedback .round-result-stats .compact-error{font-size:32px}} +.size-handle{position:absolute;transform:translate(-50%,-50%);width:48px;height:48px;min-height:48px;padding:0;border:0;background:transparent;touch-action:none;cursor:nwse-resize;display:grid;place-items:center;z-index:1} +.size-handle span{display:grid;place-items:center;width:14px;height:14px;border:1px solid var(--ink);border-radius:3px;background:var(--accent);color:var(--canvas);font-size:20px;line-height:1} +.size-handle:focus-visible{outline:none} +.size-handle:focus-visible span{outline:2px solid var(--draw);outline-offset:3px} +.size-handle[hidden]{display:none} +/* One visual vocabulary for every game. No game-specific layout or content. */ +:root{ + --canvas:var(--surface); + --game-radius:18px; + --control-radius:12px; + --font-mono:'IBM Plex Mono',monospace; + --mark-width:3; + --guide-width:1.5; +} +body[data-game-active="true"] .game-prompt h1{ + font:600 clamp(24px,6.8vw,32px)/1.23 var(--font); + letter-spacing:-1.1px; +} +.game-prompt .eyebrow{font-family:var(--font-mono);font-weight:500} +.game-subtitle{max-width:420px} +.game-stage{ + background:var(--canvas); + border-radius:var(--game-radius); +} +.game-stage::after{content:'';position:absolute;inset:0;border:1px solid var(--line);border-radius:inherit;pointer-events:none;z-index:3} +.game-stage>:is(.spatial-wrap,.angle-wrap,.wheel-wrap,.timeline-stage,.duration-stage,.memory-stage,.pulse-stage,.time-cue,.type-specimen){border-radius:inherit} +/* The surface belongs to the stage, including transparent canvas letterboxing. */ +.game-stage canvas{background:transparent} +.memory-ready-art{color:var(--draw);gap:24px} +.memory-ready-art svg{opacity:1} +.memory-ready-art span,.game-stage #pulse-label{font:400 12px/1.5 var(--font);color:var(--muted)} +.game-stage .time-cue strong{font-family:var(--font-mono);font-size:clamp(72px,23vw,124px);font-weight:400;letter-spacing:-5px;color:var(--ink);font-variant-numeric:tabular-nums} +.game-stage .time-cue span{font:400 10px/1.5 var(--font-mono);letter-spacing:2px;margin-top:20px} +.game-stage .pulse{border-color:var(--canvas-line);box-shadow:0 0 0 24px color-mix(in srgb,var(--draw) 3%,transparent),0 0 0 49px color-mix(in srgb,var(--draw) 1.5%,transparent)} +.game-stage .pulse::after{opacity:1} +.game-stage .beat-count{font-family:var(--font-mono);font-weight:400} +.year-row{font-family:var(--font-mono);font-size:28px;font-weight:400;letter-spacing:-1px} +.year-selection{left:14px;right:14px;border:1px solid var(--draw);border-radius:var(--control-radius);background:color-mix(in srgb,var(--draw) 8%,transparent)} +.year-answer,.view-result-phase{border-radius:8px;font-family:var(--font-mono);font-size:12px;line-height:1.5} +.game-controls>.small-instruction{font-size:12px;line-height:1.6;color:var(--muted)} +.game-controls :is(.primary,.secondary),.game-feedback .primary{ + min-height:52px;padding:15px 16px;border-radius:var(--control-radius); + font:600 14px/1.4 var(--font);letter-spacing:-.1px;box-shadow:none; + transition:background .15s,border-color .15s; +} +.game-controls .primary:hover,.game-feedback .primary:hover{transform:none;filter:none} +.game-controls .primary:disabled{background:var(--surface);color:var(--muted);border:1px solid var(--line);opacity:1} +.game-controls .secondary,.game-controls .nudge-button{background:var(--surface);border:1px solid var(--line);color:var(--ink)} +.game-controls .secondary:hover,.game-controls .nudge-button:hover{border-color:var(--muted)} +.game-controls .nudge-button{border-radius:var(--control-radius);font:400 24px/1 var(--font-mono)} +.game-controls :is(.trivia-nudge,.colour-adjust),.game-controls .keyboard-colour{font-size:12px;line-height:1.5} +.game-controls :is(#duration-step,#sat-value){font-family:var(--font-mono);font-size:11px;font-variant-numeric:tabular-nums} +.size-handle span{background:var(--draw);color:var(--canvas);border-color:var(--canvas);box-shadow:0 0 0 1px var(--draw)} +.game-stage .rhythm-result-comparison strong{font-family:var(--font-mono);font-weight:400;letter-spacing:-1px} +.game-stage .rhythm-result-comparison span,.game-stage .rhythm-result-comparison small{font-size:11px} +@media(max-height:700px){ + body[data-game-active="true"] .game-prompt h1{font-size:25px} +} +#playarea[data-phase="reveal"] .game-prompt h1{font-size:22px} +@media(min-width:761px){body[data-game-active="true"] .game-prompt h1{font-size:34px}#playarea[data-phase="reveal"] .game-prompt h1{font-size:24px}} +@media(prefers-reduced-motion:reduce){.game-controls *,.game-feedback *{transition:none!important}} +/* Score, measurement, explanation, action: the same reading order every round. */ +.game-feedback .feedback{display:flex;flex-direction:column;gap:12px;padding-top:8px} +.game-feedback .feedbacktop{display:flex;flex-direction:column;gap:10px} +.game-feedback .round-result-stats{display:grid;grid-template-columns:minmax(90px,.8fr) minmax(0,1.2fr);align-items:start;width:100%;gap:16px;padding:16px 0;border-block:1px solid var(--line);text-align:left} +.game-feedback .round-result-stats>div{min-width:0} +.game-feedback .round-result-points{border:0;padding:0} +.game-feedback .round-result-error{border-left:1px solid var(--line);padding-left:16px} +.game-feedback .round-result-stats .result-metric-label{display:block;margin:0 0 7px;font:500 11px/1.4 var(--font);color:var(--muted)} +.game-feedback .round-result-stats strong{display:block;font:400 30px/1.15 var(--font-mono);letter-spacing:-1.4px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums} +.game-feedback .round-result-points strong{font-size:38px;color:var(--accent)} +.game-feedback .round-result-stats strong small{margin-left:4px;font:400 13px/1 var(--font-mono);letter-spacing:-.3px;color:var(--muted)} +.game-feedback .round-result-stats .compact-error{font-size:22px;line-height:1.4;letter-spacing:-.7px} +.game-feedback .round-result-stats .result-metric-note{display:block;margin:6px 0 0;font:400 11px/1.4 var(--font);color:var(--muted)} +.result-axes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +.game-feedback .result-axes strong{font-size:21px;letter-spacing:-1px;white-space:nowrap} +.game-feedback .result-axes strong small{font-size:11px;margin-left:1px} +.game-feedback .round-verdict{margin:0;font:500 13px/1.5 var(--font);text-align:left;align-self:flex-start} +.game-feedback .round-answer{margin:0;font:400 12px/1.6 var(--font);text-align:left;color:var(--muted)} +.game-feedback .colour-comparison{margin:0;justify-content:flex-start} +.game-feedback .wheel-result-legend{justify-content:flex-start;margin:0;gap:20px} +.game-feedback .wheel-result-legend span{font-size:12px} +.game-feedback .math-detail{margin:0;font:400 12px/1.6 var(--font)} +.game-feedback .math-detail summary{padding:4px 0;min-height:28px} +.game-feedback .math-detail p{margin:8px 0 4px} +.game-feedback #next{flex:none;margin:0;background:var(--primary-background);color:var(--primary-ink)} +.game-feedback #next:hover{background:var(--primary-hover)} +.game-feedback #next:focus-visible{outline:2px solid var(--primary-ink);outline-offset:-5px} +#playarea[data-phase="reveal"] .game-feedback{max-height:55svh;padding-bottom:1px} +#playarea[data-phase="reveal"] .game-round{padding-top:18px} +#playarea[data-phase="reveal"] .game-prompt .eyebrow{margin-bottom:8px} +#playarea[data-phase="reveal"] .game-stage{margin:12px 0} +/* Completion pages use the same metric and surface vocabulary, with room to scroll. */ +.summary{max-width:680px;margin:0 auto;padding:12px 0 24px} +.summary>.eyebrow{font:500 10px/1.5 var(--font-mono);letter-spacing:1.8px;color:var(--accent);margin:0 0 12px} +.summary h1{font-size:32px;line-height:1.2;letter-spacing:-1px;margin:0 0 20px} +.summary .bigscore{font:400 clamp(56px,17vw,80px)/1.1 var(--font-mono);letter-spacing:-4px;color:var(--accent)} +.summary .bigscore small{font-size:20px;letter-spacing:-1px;margin-left:8px} +.summary-intro{font-size:13px;line-height:1.6;margin:12px 0 0} +.summary .share-panel{margin:24px 0 20px} +.summary .share-panel>button,.summary-actions button{min-height:48px;border-radius:var(--control-radius);font:600 14px/1.4 var(--font)} +.summary .share-panel>button:first-child{margin-top:0} +.summary-legend{margin:0 0 12px;gap:20px;font-size:11px} +.summary .results{display:grid;gap:12px;margin:0 0 24px} +.summary .result{padding:16px;border:1px solid var(--line);border-radius:var(--game-radius);background:var(--surface)} +.resulthead{align-items:start;font-size:13px;line-height:1.5;gap:12px} +.resulthead>span:first-child{min-width:0;font-weight:600} +.resulthead small{display:block;margin-top:3px;font-size:12px;font-weight:400;color:var(--muted)} +.resulthead>span:last-child{flex:none;white-space:nowrap;font:400 14px/1.5 var(--font-mono);color:var(--accent)} +.result-body{gap:14px;margin-top:14px;align-items:center} +.result-body>p{font-size:12px;line-height:1.6;overflow-wrap:anywhere;white-space:pre-line} +.result-comparison-image{width:112px;height:90px;border:1px solid var(--line);border-radius:10px} +.result-comparison-chart text{font-family:var(--font-mono);font-size:10px} +.summary .tiny{font-size:11px;line-height:1.7;margin:16px 0 0} +@media(max-width:600px){.result-body:has(.result-comparison-chart){display:flex;flex-direction:column;align-items:stretch;gap:10px}.result-body:has(.result-comparison-chart)>p{margin:0}.result-comparison-image{width:96px;height:80px}} +@media(max-height:700px){ + .game-feedback .feedback{gap:8px;padding-top:4px} + .game-feedback .feedbacktop{gap:8px} + .game-feedback .round-result-stats{padding:10px 0;gap:12px} + .game-feedback .round-result-error{padding-left:12px} + .game-feedback .round-result-points strong{font-size:32px} + .game-feedback .round-result-stats strong{font-size:26px} + .game-feedback .round-result-stats .compact-error{font-size:20px} + .game-feedback .result-axes strong{font-size:19px} + .game-feedback .round-result-stats .result-metric-label{margin-bottom:5px} + .game-feedback .round-result-stats .result-metric-note{margin-top:4px;font-size:10px} + #playarea[data-phase="reveal"] .game-round{padding-top:12px} + #playarea[data-phase="reveal"] .game-stage{margin-block:8px} +} +/* Completion keeps the same page width and chrome as an active round. */ +body:has(.summary)>main{max-width:var(--game-content-width);padding:0 24px 28px} +body:has(.summary) .game{padding:0;border:0;border-radius:0;background:transparent;box-shadow:none} +body:has(.summary) .gamebar{min-height:43px;padding:0 0 15px;font-size:10px;gap:8px} +body:has(.summary) .gamebar .score{font-size:12px} +body:has(.summary) .gamebar .score small{font-size:10px} +body:has(.summary) .progress{gap:4px} +body:has(.summary) .progress i{width:17px;height:3px} +.summary{padding-top:24px} +.summary .share-status:empty{min-height:0;margin:0} +#playarea[data-phase="reveal"] .size-handle{display:none} +@media(min-width:761px){body:has(.summary)>main{padding-inline:28px}} diff --git a/public/focus.html b/public/focus.html index 54094f5..6c6f7a3 100644 --- a/public/focus.html +++ b/public/focus.html @@ -1,5 +1,5 @@ - -
p.PERCEPTION
A daily perception game
-
Practice
-
ROUND 01 / 05
0 / 500
+
p.PERCEPTION
A daily perception game
+
ROUND 01 / 05
0 / 500
-
See a little sharper.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

-
p.PERCEPTION
A daily perception game
-
Practice
-
ROUND 01 / 05
0 / 500
+
p.PERCEPTION
A daily perception game
+
ROUND 01 / 05
0 / 500
-
See a little sharper.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

-
p.PERCEPTION
A daily perception game
-
Practice
-
ROUND 01 / 05
0 / 500
+
p.PERCEPTION
A daily perception game
+
ROUND 01 / 05
0 / 500
-
See a little sharper.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

-
p.PERCEPTION
A daily perception game
-
Practice
-
ROUND 01 / 05
0 / 500
+
p.PERCEPTION
A daily perception game
+
ROUND 01 / 05
0 / 500
-
See a little sharper.
-

WELCOME TO PERCEPTION

Guess. Reveal. Get sharper.

Daily Five is the shared daily challenge: five different puzzle types, worth 500 points. Open Practice to choose a skill or play a fresh mix. Individual games are practice-only.

3D view: remember the view, then drag the shape to turn it and drag the green handle around the outer ring to tilt it. Half & Half: cut all the shapes into two equal visible areas. Line memory: look at a generated path for three seconds, then trace it in one stroke on the blank canvas. Perspective: place the vanishing point. Proportions: resize a rectangle to match the ratio. Each reveal shows the target. All games are silent.

Colour memory: look at a swatch for two seconds, then find it on the wheel. Hue goes around the wheel; saturation increases towards the edge. Arrow keys or the keyboard controls also work.

Scoring: every round is worth 100 points. We divide your error by the game’s zero-point bound, then square the remaining closeness. An error of 10% earns 81 points; 25% earns 56; 50% earns 25. Scores are rounded to whole points. Five rounds total 500 points.

Zero-point bounds

3D and angles: 180°. Half & Half: 20 percentage points from an even split (70/30); a 50/50 split earns full points. Line memory: 20% of the canvas diagonal (100 units). Perspective: 40% of the diagonal (200 units). Time: 3 seconds early or late. Rhythm and proportions: 1.5 times or two-thirds of the target. Colour: straight-line distance of one wheel radius. When?: 50 years early or late. How long?: four times or one-quarter of the target duration.

Rhythm: watch five silent flashes, then tap five beats at the same pace. Angles: rotate a line to the target angle. Both reward closeness.

When? Scroll the year wheel beneath the centre marker to choose an event’s year. Up and down arrows adjust by one year. How long? Turn the dial from seconds to days. Use minus, plus or arrow keys to fine tune; Shift and arrows adjust more broadly. Both award up to 100 points for closeness, with the answer and source shown after you commit. Daily five uses each trivia fact once. Trivia practice draws only from past Daily Fives, with up to five different questions per session. Today’s and future questions stay out of the practice pool.

Time: Start, wait, Stop. Points fall with your timing error using the same curve as every other game.

Switching away resets an active memory flash or timer.

Recent results

', '

Recent results

') -themed+='\nbindHistoryNavigation();\n' +themed+='\nbindHistoryNavigation();\nbindGameChrome();\n' html=html.replace('
','
') -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=`

${escapeHTML(game.label||q.skill)}

${text(title)}

${subtitle?`

${text(subtitle)}

`:''}
${stage}
${controls}
`; + if(presentation.hint&&!$('controls').querySelector('.small-instruction'))$('controls').insertAdjacentHTML('afterbegin',`

${text(presentation.hint)}

`); + activeGameShell={cleanups:[],redraw:null}; + return {stage:$('game-stage'),controls:$('controls'),feedback:$('actionarea'),setPhase:setGamePhase,onCleanup:onGameCleanup,onRedraw:onGameRedraw}; +} +function prepareMemoryStart(buttonId='show-view',canvasId='view-canvas'){ + const stage=$(canvasId).closest('.spatial-wrap,.memory-stage'),intro=gameRegistry.get($('playarea').dataset.puzzle)?.ready; + stage.querySelector('.memory-prompt')?.remove();stage.classList.add('memory-ready'); + if(intro){stage.classList.add('memory-ready-illustration');stage.insertAdjacentHTML('beforeend',``);} +} +function beginMemoryPreview(buttonId='show-view',canvasId='view-canvas'){ + const stage=$(canvasId).closest('.spatial-wrap,.memory-stage'); + stage.classList.remove('memory-ready','memory-ready-illustration');stage.querySelector('.memory-ready-art')?.remove(); + setGamePhase('preview'); +} +function bindGameChrome(){ + const menu=$('game-menu'),toggle=$('game-menu-toggle'),close=$('game-menu-close'); + toggle.addEventListener('click',()=>{ + for(const button of [$('daily'),...menu.querySelectorAll('[data-mini]')]){ + const selected=button.id==='daily'?mode==='daily':mode==='practice'&&button.dataset.mini===mini; + button.classList.toggle('active',selected); + if(selected)button.setAttribute('aria-current','true');else button.removeAttribute('aria-current'); + } + menu.showModal();toggle.setAttribute('aria-expanded','true'); + }); + close.addEventListener('click',()=>menu.close()); + menu.addEventListener('cancel',()=>toggle.setAttribute('aria-expanded','false')); + menu.addEventListener('close',()=>{toggle.setAttribute('aria-expanded','false');$('practice-menu').open=false;}); + menu.addEventListener('click',event=>{if(event.target===menu){const r=menu.getBoundingClientRect();if(event.clientXr.right||event.clientYr.bottom)menu.close();}}); + // Selecting a destination keeps existing navigation handlers and save behavior. + $('theme-stylesheet').addEventListener('load',()=>activeGameShell?.redraw?.()); + for(const button of [$('daily'),$('history-button'),...menu.querySelectorAll('[data-mini]')])button.addEventListener('click',()=>menu.close()); +} diff --git a/src/design-options/game-skin.css b/src/design-options/game-skin.css new file mode 100644 index 0000000..c31843a --- /dev/null +++ b/src/design-options/game-skin.css @@ -0,0 +1,56 @@ +/* One visual vocabulary for every game. No game-specific layout or content. */ +:root{ + --canvas:var(--surface); + --game-radius:18px; + --control-radius:12px; + --font-mono:'IBM Plex Mono',monospace; + --mark-width:3; + --guide-width:1.5; +} +body[data-game-active="true"] .game-prompt h1{ + font:600 clamp(24px,6.8vw,32px)/1.23 var(--font); + letter-spacing:-1.1px; +} +.game-prompt .eyebrow{font-family:var(--font-mono);font-weight:500} +.game-subtitle{max-width:420px} +.game-stage{ + background:var(--canvas); + border-radius:var(--game-radius); +} +.game-stage::after{content:'';position:absolute;inset:0;border:1px solid var(--line);border-radius:inherit;pointer-events:none;z-index:3} +.game-stage>:is(.spatial-wrap,.angle-wrap,.wheel-wrap,.timeline-stage,.duration-stage,.memory-stage,.pulse-stage,.time-cue,.type-specimen){border-radius:inherit} +/* The surface belongs to the stage, including transparent canvas letterboxing. */ +.game-stage canvas{background:transparent} +.memory-ready-art{color:var(--draw);gap:24px} +.memory-ready-art svg{opacity:1} +.memory-ready-art span,.game-stage #pulse-label{font:400 12px/1.5 var(--font);color:var(--muted)} +.game-stage .time-cue strong{font-family:var(--font-mono);font-size:clamp(72px,23vw,124px);font-weight:400;letter-spacing:-5px;color:var(--ink);font-variant-numeric:tabular-nums} +.game-stage .time-cue span{font:400 10px/1.5 var(--font-mono);letter-spacing:2px;margin-top:20px} +.game-stage .pulse{border-color:var(--canvas-line);box-shadow:0 0 0 24px color-mix(in srgb,var(--draw) 3%,transparent),0 0 0 49px color-mix(in srgb,var(--draw) 1.5%,transparent)} +.game-stage .pulse::after{opacity:1} +.game-stage .beat-count{font-family:var(--font-mono);font-weight:400} +.year-row{font-family:var(--font-mono);font-size:28px;font-weight:400;letter-spacing:-1px} +.year-selection{left:14px;right:14px;border:1px solid var(--draw);border-radius:var(--control-radius);background:color-mix(in srgb,var(--draw) 8%,transparent)} +.year-answer,.view-result-phase{border-radius:8px;font-family:var(--font-mono);font-size:12px;line-height:1.5} +.game-controls>.small-instruction{font-size:12px;line-height:1.6;color:var(--muted)} +.game-controls :is(.primary,.secondary),.game-feedback .primary{ + min-height:52px;padding:15px 16px;border-radius:var(--control-radius); + font:600 14px/1.4 var(--font);letter-spacing:-.1px;box-shadow:none; + transition:background .15s,border-color .15s; +} +.game-controls .primary:hover,.game-feedback .primary:hover{transform:none;filter:none} +.game-controls .primary:disabled{background:var(--surface);color:var(--muted);border:1px solid var(--line);opacity:1} +.game-controls .secondary,.game-controls .nudge-button{background:var(--surface);border:1px solid var(--line);color:var(--ink)} +.game-controls .secondary:hover,.game-controls .nudge-button:hover{border-color:var(--muted)} +.game-controls .nudge-button{border-radius:var(--control-radius);font:400 24px/1 var(--font-mono)} +.game-controls :is(.trivia-nudge,.colour-adjust),.game-controls .keyboard-colour{font-size:12px;line-height:1.5} +.game-controls :is(#duration-step,#sat-value){font-family:var(--font-mono);font-size:11px;font-variant-numeric:tabular-nums} +.size-handle span{background:var(--draw);color:var(--canvas);border-color:var(--canvas);box-shadow:0 0 0 1px var(--draw)} +.game-stage .rhythm-result-comparison strong{font-family:var(--font-mono);font-weight:400;letter-spacing:-1px} +.game-stage .rhythm-result-comparison span,.game-stage .rhythm-result-comparison small{font-size:11px} +@media(max-height:700px){ + body[data-game-active="true"] .game-prompt h1{font-size:25px} +} +#playarea[data-phase="reveal"] .game-prompt h1{font-size:22px} +@media(min-width:761px){body[data-game-active="true"] .game-prompt h1{font-size:34px}#playarea[data-phase="reveal"] .game-prompt h1{font-size:24px}} +@media(prefers-reduced-motion:reduce){.game-controls *,.game-feedback *{transition:none!important}} diff --git a/src/design-options/game_menu.py b/src/design-options/game_menu.py new file mode 100644 index 0000000..cb107b0 --- /dev/null +++ b/src/design-options/game_menu.py @@ -0,0 +1,33 @@ +"""Presentation for the game chooser; existing buttons retain their navigation IDs.""" +import re +from html import escape + +# Each miniature suggests the actual interaction, using the same stroke language. +GAMES = { + 'mix': ('A bit of everything', ''), + 'view3d': ('Turn it in your mind', ''), + 'balance': ('Find an even split', ''), + 'motion': ('Look. Remember. Trace.', ''), + 'perspective': ('Find the vanishing point', ''), + 'proportion': ('Measure with your eye', ''), + 'rhythm': ('Catch the beat', ''), + 'angle': ('Feel the degrees', ''), + 'memory': ('Hold a colour in mind', ''), + 'time': ('Count on your instinct', ''), + 'timeline': ('Place it in history', ''), + 'duration': ('Put time in perspective', ''), +} + +def icon(paths): + return '' + +def enhance_menu(navigation): + def tile(match): + opening, game, label = match.groups() + subtitle, paths = GAMES[game] + return opening + '' + icon(paths) + '' + label + '' + escape(subtitle) + '' + navigation = re.sub(r'(]*data-mini="([^"]+)"[^>]*>)(.*?)', tile, navigation) + navigation = navigation.replace('>Daily Five', '>Daily FiveOne day. Five ways to see.') + navigation = navigation.replace('');const canvas=", "',{phase:'play'});const canvas=", 1) + elif name == 'tracePicker': + block = block.replace("
');const canvas=", "
',{phase:'play'});const canvas=", 1) + else: + block = block.replace('`);', "`,{phase:'play'});", 1) + script = script[:start] + block + script[end:] + script = script.replace("overlayViewStart(", "prepareMemoryStart(").replace("beginViewPreview(", "beginMemoryPreview(") + script = script.replace("function spatialCanvas(q,id,controls=''){", "function spatialCanvas(q,id,controls='',options={}){") + script = script.replace('>
`,controls);}', '>
`,controls,options);}') + redraws = { + 'viewRound': ("drawShape3D($('view-canvas'),{yaw:0,pitch:0,roll:0},{shape:q.shape});", "onGameRedraw(()=>drawShape3D($('view-canvas'),$('playarea').dataset.phase==='preview'?q.answer:{yaw:0,pitch:0,roll:0},{shape:q.shape}));"), + 'viewPicker': ('redraw();}', "onGameRedraw(()=>{if(locked){drawShape3D(canvas,orientation,{shape:q.shape});drawShape3D(canvas,q.answer,{shape:q.shape,overlay:true,outlineOnly:true,dashed:true});}else redraw();});redraw();}"), + 'balanceRound': ('drawBalance(q,support);}', 'onGameRedraw(()=>drawBalance(q,support,locked&&scoreFor(support,q)!==100?Math.sign(q.answer-support)*18:0,locked));drawBalance(q,support);}'), + 'motionRound': ('drawTrace([]);', "onGameRedraw(()=>drawTrace($('playarea').dataset.phase==='preview'?q.answer:[]));drawTrace([]);"), + 'tracePicker': ('drawTrace([]);}', 'onGameRedraw(()=>drawTrace(points,locked?q.answer:undefined));drawTrace([]);}'), + 'perspectiveRound': ('drawPerspective(q);}', 'onGameRedraw(()=>drawPerspective(q,point,locked));drawPerspective(q);}'), + 'legacyProportionRound': ('legacyDrawProportion(size);}', 'onGameRedraw(()=>legacyDrawProportion(size,locked?q.answer:undefined));legacyDrawProportion(size);}'), + 'colourPicker': ('drawWheel();\n}', 'onGameRedraw(()=>drawWheel(locked?q.answer:undefined));drawWheel();\n}') + } + for name, (old, new) in redraws.items(): + start = script.index('function ' + name + '(q){') + end = script.index('\nfunction ', start + 1) + block = script[start:end] + if old not in block: + raise ValueError('Missing redraw integration for ' + name) + # viewRound adds a hook after its initial draw; other entries replace it. + replacement = old + new if name == 'viewRound' else new + before, _, after = block.rpartition(old) + block = before + replacement + after + script = script[:start] + block + script[end:] + # Visual reveals belong to the registered adapter, not the controller. + reveal_start = script.index('function reveal(value)') + start = script.index(" if(q.type==='time')", reveal_start) + end = script.index(" $('controls').querySelectorAll", start) + script = script[:start] + script[end:] + start = script.index(' gamebar();const feedback=', reveal_start) + end = script.index(" if(q.fruit)", start) + script = script[:start] + " gamebar();const feedback=score===100?'Nailed it.':'Your result.',answerLabel=roundAnswerDisplay(q,value)||q.tip||'';\n" + script[end:] + start = script.index(" if(q.fruit)", reveal_start) + end = script.index(" $('actionarea').innerHTML", start) + script = script[:start] + " revealGame(q,value,score);\n document.querySelectorAll('.spatial-input,.type-slider,.colour-input').forEach(el=>el.disabled=true);\n" + script[end:] + script = script.replace("'use strict';", "'use strict';\n" + Path('src/design-options/game-paint.js').read_text() + '\n' + Path('src/design-options/game-shell.js').read_text() + '\n' + Path('src/design-options/game-adapters.js').read_text(), 1) + + # Standardize submit labels and the completion view without touching game data. + for old in ['Choose your angle', 'Choose a year', 'Choose a duration', 'Place your point', 'Draw a cut']: + script = script.replace('disabled>' + old + '', 'disabled>Lock it in') + script = script.replace('>Start', '>Start timer').replace("$('timer').textContent='Stop'", "$('timer').textContent='Stop timer'") + script = script.replace('id="show-view" class="primary">Start timer', 'id="show-view" class="primary">Show shape') + script = script.replace('Start. Wait. Stop.

', 'The number above is your target.

') + script = script.replace('Cut half the total coloured area onto each side.', 'Drag a cut. Adjust the line or its handles.') + script = script.replace('Tap where the receding lines would meet.', 'Tap to place your point.') + start = script.index('function finish(){') + end = script.index('\nfunction ', start + 1) + block = script[start:end] + block = re.sub(r'(
).*?()', r'\1${summaryResultTitle(r.q)}\2', block) + begin = block.index('

${', block.index('

')) + stop = block.index('

', begin) + block = block[:begin] + '

${escapeHTML(summaryResultDescription(r))}' + block[stop:] + block = block.replace("'MIX COMPLETE'", "mode==='daily'?'DAILY COMPLETE':'PRACTICE COMPLETE'") + block = block.replace("'DAILY MIX'", "'DAILY FIVE'") + block = block.replace('Daily mix follows your device’s date.', 'Daily Five resets at midnight Central Time.') + script = script[:start] + block + script[end:] + + # Navigation keeps its IDs and listeners, but lives behind accessible chrome. + start = html.index('

') + end = html.index('
', start) + navigation = html[start:end] + navigation = re.sub(r'
.*?', '
Practice', navigation, count=1) + navigation = navigation.replace('
', '', 1) + navigation = enhance_menu(navigation) + html = html[:start] + html[end:] + menu = '

Play Perception

Trust your eye.

' + navigation + '
' + html = html.replace('
', '
' + menu, 1) + html = html.replace('
', + '

Drag the corner. Trust your eye.

'); + const canvas=$('proportion-canvas'),handle=$('size-handle'); + const positionHandle=()=>{const r=canvas.getBoundingClientRect(),scale=canvasDisplayScale(canvas); + handle.style.left=((r.width-400*scale)/2+(SIZE_BOUNDS.x+size.width)*scale)+'px'; + handle.style.top=((r.height-400*scale)/2+(SIZE_BOUNDS.y+size.height)*scale)+'px';}; + const draw=()=>{drawProportion(size,q);positionHandle();}; + handle.addEventListener('pointerdown',e=>{if(locked||drag||e.isPrimary===false||e.button>0)return;e.preventDefault();handle.focus();handle.setPointerCapture(e.pointerId);drag={id:e.pointerId,point:canvasPointerPoint(canvas,e),size:{...size}};}); + handle.addEventListener('pointermove',e=>{if(locked||!drag||drag.id!==e.pointerId)return; + const p=canvasPointerPoint(canvas,e);size=clampSize({width:drag.size.width+p.x-drag.point.x,height:drag.size.height+p.y-drag.point.y});draw();}); + const release=e=>{if(drag?.id===e.pointerId)drag=null;}; + for(const event of ['pointerup','pointercancel','lostpointercapture'])handle.addEventListener(event,release); + handle.addEventListener('keydown',e=>{if(locked||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;e.preventDefault();const amount=e.shiftKey?10:1; + const axis=['ArrowLeft','ArrowRight'].includes(e.key)?'width':'height';size=clampSize({...size,[axis]:size[axis]+(['ArrowLeft','ArrowUp'].includes(e.key)?-amount:amount)});draw();}); + $('proportion-lock').addEventListener('click',()=>{if(!locked){handle.hidden=true;reveal({...size});}}); + const onResize=()=>{drag=null;drawProportion(size,q,locked);positionHandle();}; + if(typeof ResizeObserver!=='undefined'){const observer=new ResizeObserver(onResize);observer.observe(canvas);onGameCleanup(()=>observer.disconnect());} + if(typeof window!=='undefined'){window.addEventListener('resize',onResize);onGameCleanup(()=>window.removeEventListener('resize',onResize));} + onGameRedraw(()=>drawProportion(size,q,locked));draw(); +} +function paintSize(canvas,size,q,revealed){ + // Keep game geometry in 400 logical units; only the backing bitmap changes. + if(canvas.isConnected){const r=canvas.getBoundingClientRect(),pixels=Math.max(1,Math.round(Math.min(r.width,r.height)*(window.devicePixelRatio||1)));if(canvas.width!==pixels||canvas.height!==pixels){canvas.width=pixels;canvas.height=pixels;}} + const ctx=canvas.getContext('2d'),c=gamePaint(),{x,y}=SIZE_BOUNDS,scale=canvas.width/400; + ctx.setTransform(scale,0,0,scale,0,0); + // Align both edges of each stroke to device pixels without rounding the guess. + const outline=(left,top,width,height,weight)=>{const pixels=Math.max(1,Math.round(weight*scale)),snap=n=>(Math.round(n*scale-pixels/2)+pixels/2)/scale,l=snap(left),t=snap(top);ctx.lineWidth=pixels/scale;ctx.strokeRect(l,t,snap(left+width)-l,snap(top+height)-t);}; + ctx.clearRect(0,0,400,400); + ctx.fillStyle=c.muted;ctx.font=c.font(13);ctx.fillText('Reference square',x,y-18); + ctx.fillStyle=c.draw;ctx.globalAlpha=.12;ctx.fillRect(x,y,q.reference,q.reference);ctx.globalAlpha=1; + if(!revealed){ctx.strokeStyle=c.muted;ctx.setLineDash([3,3]);outline(x,y,q.reference,q.reference,c.guide);ctx.setLineDash([]);} + if(revealed){ctx.strokeStyle=c.target;ctx.lineWidth=c.guide; + for(let col=0;col24+(v-low)/(high-low||1)*272; - return ` + return ` - You: ${escapeHTML(format(guess))} + Your guess: ${escapeHTML(format(guess))} Target: ${escapeHTML(format(q.answer))} `; } @@ -38,6 +38,7 @@ function restoredComparison({q,guess}){ if(q.type==='motion')content=``; if(q.type==='perspective')content=line([guess.x,guess.y],[q.answer.x,q.answer.y],true)+``; if(q.type==='balance'){content=``+line([q.answer,30],[q.answer,235],true)+``;} + if(q.type==='proportion'&&isSizeQuestion(q)){const canvas=document.createElement('canvas');canvas.width=400;canvas.height=400;paintSize(canvas,guess,q,true);return `${escapeHTML(sizeFeedback(guess,q))}. Dashed: your rectangle; solid grid: target.`;} if(q.type==='proportion'){const a=guess.width/guess.height,b=q.answer,h=Math.min(160,330/Math.max(a,b));content=``;} if(q.type==='colour')content=`YouTarget`; return `${content}`; diff --git a/src/design-options/theme-layout.css b/src/design-options/theme-layout.css new file mode 100644 index 0000000..efa5423 --- /dev/null +++ b/src/design-options/theme-layout.css @@ -0,0 +1,60 @@ +/* Shared geometry and typography. Theme files contain palette tokens only. */ +body{background:var(--paper)} +header{height:83px;position:relative;padding:0 42px 0 36px;border-bottom:1px solid var(--line);background:var(--header-background)} +.brand{font-size:23px;font-weight:700} +.brand::before{content:'';width:18px;height:18px;border-radius:50%;border:2px solid var(--accent);box-shadow:inset 0 0 0 4px var(--header-background)} +.tagline{font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--tagline-ink)} +.circle{background:var(--circle-background);border-color:var(--circle-line);border-radius:9px} +main{display:grid;grid-template-columns:210px minmax(0,1fr);max-width:1400px;margin:auto;padding:0 36px 70px;column-gap:44px} +.minigames{grid-column:1;grid-row:1 / span 4;flex-direction:column;overflow:visible;gap:5px;margin:30px 0 0;padding-right:24px;border-right:1px solid var(--line)} +.minigames::before{content:'PLAY MODES';font:12px 'IBM Plex Mono',monospace;color:var(--muted);letter-spacing:1px;margin:0 0 14px 12px} +.minigames button{text-align:left;padding:12px 14px;color:var(--nav-ink);font-size:14px;border-radius:9px;font-weight:500} +.minigames .active{background:var(--practice-background);color:var(--accent);border:1px solid var(--practice-line);padding:11px 13px} +.minigames button:hover{color:var(--ink);background:var(--practice-hover)} +.minigames .active:hover{color:var(--accent);background:var(--practice-background)} +.topline{grid-column:2;margin:30px 0 20px;max-width:850px;width:100%;align-self:start} +.tabs{background:var(--tabs-background);padding:4px;border-radius:9px} +.tabs .active{background:var(--tabs-active-background);color:var(--tabs-active-ink)} +.layout{grid-column:2;display:block;max-width:850px;width:100%} +.game{background:var(--surface);border:1px solid var(--game-line);border-radius:18px;padding:24px 36px 30px;box-shadow:0 25px 80px var(--game-shadow)} +.gamebar{font-family:'IBM Plex Mono',monospace;color:var(--gamebar-ink);border-bottom:1px solid var(--line);padding-bottom:21px} +.score{color:var(--ink)} +.progress i{background:var(--progress-track)} +.progress .current{opacity:var(--progress-opacity);background:var(--accent);box-shadow:0 0 9px var(--progress-glow)} +.mixed-question{padding:28px 0 22px} +.eyebrow{font-family:'IBM Plex Mono',monospace;color:var(--accent)} +.mixed-question h1{font-size:38px;font-weight:600} +.spatial-wrap{max-width:470px} +.spatial-wrap canvas{border:1px solid var(--canvas-line);border-radius:12px} +.primary{color:var(--primary-ink);background:var(--primary-background);border-radius:9px} +.primary:hover{background:var(--primary-hover)} +.secondary{border-color:var(--secondary-line);background:var(--secondary-background)} +.points{color:var(--accent)} +.type-overlay-target{color:var(--overlay-fill);-webkit-text-stroke-color:var(--target)} +.chosen-swatch{border-color:var(--swatch-line)} +.colour-caption{color:var(--nav-ink)} +.memory-stage,.type-specimen{border:1px solid var(--canvas-line);border-radius:12px} +.pulse{border:1px solid var(--pulse-line);background:var(--canvas)} +.pulse.lit{background:var(--accent);border-color:var(--accent)} +.beat-count{color:var(--beat-ink)} +aside{display:none} +footer{grid-column:2;max-width:850px;margin-top:24px;font-family:'IBM Plex Mono',monospace;font-size:12px} +.option-switch{background:var(--option-background);border-color:var(--option-line)} +.time-cue strong{color:var(--time-ink);font-family:'IBM Plex Mono',monospace;font-size:104px;letter-spacing:-7px} +.bigscore{font-family:'IBM Plex Mono',monospace} +.minigames button:focus-visible{outline-offset:0} +@media(max-width:850px){header{padding:0 24px;height:73px} +main{display:block;padding:0 20px 75px} +.minigames{flex-direction:row;overflow-x:auto;border-right:0;border-bottom:1px solid var(--line);padding:0 0 12px;margin:20px 0} +.minigames::before{display:none} +.topline{margin:20px 0} +.game{padding:24px} +.mixed-question h1{font-size:32px} +footer{margin-top:24px} +} +@media(max-width:600px){.brand{font-size:22px} +.game{padding:20px;border-radius:14px} +.gamebar{gap:8px} +.mixed-question{padding-top:24px} +.option-switch span{display:none} +} diff --git a/src/design-options/theme-preference.js b/src/design-options/theme-preference.js index cf090b0..7af571a 100644 --- a/src/design-options/theme-preference.js +++ b/src/design-options/theme-preference.js @@ -5,8 +5,8 @@ const theme=preference==='system'?(window.matchMedia?.('(prefers-color-scheme: dark)').matches?'night-lab':'focus'):preference; document.documentElement.dataset.themePreference=preference; document.documentElement.dataset.theme=theme; - // Both themes use Manrope. Only Night Lab needs the mono UI fonts up front. - const fonts=['manrope-latin',...(theme==='night-lab'?['ibm-plex-mono-400-latin','ibm-plex-mono-500-latin','ibm-plex-mono-600-latin']:[])]; + // Both palettes share typography, so preload the same UI fonts before first paint. + const fonts=['manrope-latin','ibm-plex-mono-400-latin','ibm-plex-mono-500-latin','ibm-plex-mono-600-latin']; document.write(fonts.map(name=>'').join('')); // This inline head script runs during HTML parsing. A parser-inserted link // blocks first paint and the later game script until the chosen CSS is ready. diff --git a/src/design-options/trivia-games.js b/src/design-options/trivia-games.js index f785c27..b86ddd8 100644 --- a/src/design-options/trivia-games.js +++ b/src/design-options/trivia-games.js @@ -4,7 +4,7 @@ function pastTriviaIds(type,before=calendarDay()){ } function triviaDeck(random,type){return shuffleWith(random,pastTriviaIds(type)).slice(0,5).map(scheduledTriviaQuestion);} function emptyTriviaPractice(){ - shell({type:mini,skill:mini==='timeline'?'When?':'How long?',title:'The practice archive is growing.'},'

Practice uses questions from past Daily Fives. There are no past questions of this type yet. Play today’s Daily Five while the archive grows.

',''); + shell({type:mini,skill:mini==='timeline'?'When?':'How long?',title:'The practice archive is growing.'},'

Practice uses questions from past Daily Fives. There are no past questions of this type yet. Play today’s Daily Five while the archive grows.

','',{phase:'empty'}); $('roundlabel').textContent='PRACTICE';$('scoremax').textContent=''; $('archive-daily').addEventListener('click',()=>{mini='mix';document.querySelectorAll('[data-mini]').forEach(b=>b.classList.toggle('active',b.dataset.mini===mini));start('daily');updateAside();}); } @@ -20,13 +20,14 @@ function nudgeDuration(seconds,direction){return Math.max(1,Math.min(5184000,Mat function timelineRound(q){ const low=1850,high=2026,rowHeight=48; let selected=1970,touched=false; - shell(q,'

Scroll to a year. The centre is your pick.

'+Array.from({length:high-low+1},(_,i)=>'').join('')+'
',''); + shell(q,'
'+Array.from({length:high-low+1},(_,i)=>'').join('')+'
',''); const wheel=$('year-wheel'),stage=wheel.parentElement; const update=()=>{wheel.setAttribute('aria-valuenow',selected);wheel.setAttribute('aria-valuetext',String(selected));if(touched){$('trivia-lock').disabled=false;$('trivia-lock').textContent='Lock it in';}}; const choose=year=>{if(locked)return;motion++;wheel.classList.remove('year-wheel-dragging');selected=timelineYear(year);touched=true;wheel.scrollTop=(selected-low)*rowHeight;update();}; const fit=()=>{if(!wheel.isConnected){observer?.disconnect();return;}wheel.style.paddingBlock=Math.max(0,(stage.clientHeight-rowHeight)/2)+'px';wheel.scrollTop=(selected-low)*rowHeight;}; let observer=null; - if(typeof ResizeObserver!=='undefined'){observer=new ResizeObserver(fit);observer.observe(stage);} + if(typeof ResizeObserver!=='undefined'){observer=new ResizeObserver(fit);observer.observe(stage);onGameCleanup(()=>observer.disconnect());} + else if(typeof window!=='undefined'){window.addEventListener('resize',fit);onGameCleanup(()=>window.removeEventListener('resize',fit));} fit();update(); const touch=()=>{if(locked)return;touched=true;update();}; let drag=null,suppressClick=false,motion=0; @@ -47,13 +48,13 @@ function timelineRound(q){ function durationRound(q){ let selected=60,touched=false; const maxLog=Math.log(60*86400),start=135*Math.PI/180,sweep=270*Math.PI/180; - shell(q,`

${escapeHTML(q.caption)}

`,'
1 s steps
'); + shell(q,`
`,'
1 s steps
'); const canvas=$('duration-canvas'),ctx=canvas.getContext('2d'),angle=v=>start+Math.log(v)/maxLog*sweep; const draw=target=>{ - const c=triviaPalette();ctx.clearRect(0,0,400,300);ctx.fillStyle=c.canvas;ctx.fillRect(0,0,400,300);ctx.lineWidth=12;ctx.strokeStyle=c.line;ctx.beginPath();ctx.arc(200,145,100,start,start+sweep);ctx.stroke();ctx.font='12px sans-serif';ctx.textAlign='center';ctx.textBaseline='middle'; + const c=gamePaint();ctx.clearRect(0,0,400,300);ctx.fillStyle=c.canvas;ctx.fillRect(0,0,400,300);ctx.lineWidth=4;ctx.strokeStyle=c.line;ctx.beginPath();ctx.arc(200,145,100,start,start+sweep);ctx.stroke();ctx.font=c.font(11,400,true);ctx.textAlign='center';ctx.textBaseline='middle'; for(const [v,label]of [[1,'1 s'],[60,'1 min'],[3600,'1 h'],[86400,'1 day'],[5184000,'60 days']]){const a=angle(v);ctx.fillStyle=c.muted;ctx.fillText(label,200+Math.cos(a)*132,145+Math.sin(a)*132);} - const mark=(v,colour,r)=>{const a=angle(v);ctx.strokeStyle=colour;ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(200+Math.cos(a)*72,145+Math.sin(a)*72);ctx.lineTo(200+Math.cos(a)*112,145+Math.sin(a)*112);ctx.stroke();ctx.fillStyle=colour;ctx.beginPath();ctx.arc(200+Math.cos(a)*100,145+Math.sin(a)*100,r,0,Math.PI*2);ctx.fill();}; - mark(selected,c.draw,9);if(target!==undefined)mark(target,c.target,6);ctx.fillStyle=c.ink;ctx.font='bold 26px sans-serif';ctx.fillText(touched?durationText(selected):'How long?',200,138);ctx.fillStyle=c.muted;ctx.font='13px sans-serif';ctx.fillText(target!==undefined?'You · target marked':touched?'YOUR GUESS':'TURN THE DIAL',200,169); + const mark=(v,colour,r)=>{const a=angle(v);ctx.strokeStyle=colour;ctx.lineWidth=c.mark;ctx.beginPath();ctx.moveTo(200+Math.cos(a)*72,145+Math.sin(a)*72);ctx.lineTo(200+Math.cos(a)*112,145+Math.sin(a)*112);ctx.stroke();ctx.fillStyle=colour;ctx.beginPath();ctx.arc(200+Math.cos(a)*100,145+Math.sin(a)*100,r,0,Math.PI*2);ctx.fill();}; + mark(selected,c.draw,9);if(target!==undefined)mark(target,c.target,6);ctx.fillStyle=c.ink;ctx.font=c.font(25,500,true);ctx.fillText(touched?durationText(selected):'How long?',200,138);ctx.fillStyle=c.muted;ctx.font=c.font(11,400,true);ctx.fillText(target!==undefined?'You · target marked':touched?'YOUR GUESS':'TURN THE DIAL',200,169); }; const choose=v=>{selected=durationValue(Math.max(1,Math.min(5184000,v)));touched=true;$('trivia-lock').disabled=false;$('trivia-lock').textContent='Lock it in';const step=durationStep(selected),label=step===8640?'0.1 day':step===60?'1 min':step===1?'1 s':'0.1 s';$('duration-step').textContent=label+' steps';$('duration-minus').setAttribute('aria-label','Decrease by '+label);$('duration-plus').setAttribute('aria-label','Increase by '+label);canvas.setAttribute('aria-valuenow',selected);canvas.setAttribute('aria-valuetext',durationText(selected));draw();}; const nudge=direction=>{if(!locked)choose(nudgeDuration(selected,direction));}; @@ -62,5 +63,5 @@ function durationRound(q){ const point=e=>{const p=canvasPointerPoint(canvas,e),x=p.x-200,y=p.y-145;let a=(Math.atan2(y,x)-start+Math.PI*2)%(Math.PI*2);if(a>sweep)a=a<(sweep+Math.PI*2)/2?sweep:0;choose(Math.exp(a/sweep*maxLog));}; canvas.addEventListener('pointerdown',e=>{if(locked)return;e.preventDefault();dragging=true;canvas.setPointerCapture(e.pointerId);point(e);});canvas.addEventListener('pointermove',e=>{if(dragging&&!locked)point(e);});canvas.addEventListener('pointerup',()=>dragging=false);canvas.addEventListener('pointercancel',()=>dragging=false); canvas.addEventListener('keydown',e=>{if(locked||!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key))return;e.preventDefault();const direction=['ArrowRight','ArrowUp'].includes(e.key)?1:-1;if(e.shiftKey)choose(selected*Math.exp(direction*.02));else nudge(direction);}); - $('trivia-lock').addEventListener('click',()=>{if(touched){reveal(selected);draw(q.answer);}});draw(); + $('trivia-lock').addEventListener('click',()=>{if(touched){reveal(selected);draw(q.answer);}});onGameRedraw(()=>draw(locked?q.answer:undefined));draw(); } diff --git a/src/design-options/trivia.css b/src/design-options/trivia.css index ca61d3a..c02e1fb 100644 --- a/src/design-options/trivia.css +++ b/src/design-options/trivia.css @@ -1,11 +1,11 @@ .trivia-caption{font-size:14px;color:var(--muted);margin:0 0 14px;text-align:center}.timeline-stage,.duration-stage{max-width:460px;margin:auto}.timeline-stage canvas,.duration-stage canvas{width:100%;height:auto;display:block;touch-action:none;border-radius:12px}.trivia-value{display:block;text-align:center;font-size:28px;font-weight:600;margin:12px 0 0;font-variant-numeric:tabular-nums}.timeline-zoom{display:flex;gap:14px;align-items:center;justify-content:center;margin:0 0 14px}.timeline-zoom span{font-size:13px;color:var(--muted)}.timeline-zoom button{width:48px;padding:8px;font-size:22px;min-height:44px} -@media(max-width:760px){.trivia-caption{font-size:12px;line-height:1.4;margin:0 0 8px}.timeline-stage{width:100%;max-width:400px}.duration-stage{width:min(100%,calc(clamp(150px,100svh - 405px,270px)*4/3))}.trivia-value{font-size:23px;margin:6px 0 0}.timeline-zoom{margin-bottom:10px;gap:12px}body:has(.feedback) .duration-stage{width:min(100%,calc(clamp(120px,100svh - 505px,230px)*4/3))}body:has(.feedback) .timeline-stage{max-width:290px}body:has(.feedback) .trivia-value{display:none}} +@media(max-width:760px){.trivia-caption{font-size:12px;line-height:1.4;margin:0 0 8px}.timeline-stage{width:100%;max-width:400px}.duration-stage{width:min(100%,calc(clamp(150px,100svh - 405px,270px)*4/3))}.trivia-value{font-size:23px;margin:6px 0 0}.timeline-zoom{margin-bottom:10px;gap:12px}body:has(.feedback) .duration-stage{width:min(100%,calc(clamp(120px,100svh - 505px,230px)*4/3))}body:has(.feedback) .trivia-value{display:none}} @media(max-width:760px){ [data-puzzle="timeline"] .mixed-question h1,[data-puzzle="duration"] .mixed-question h1{font-size:25px} .timeline-stage{width:min(100%,clamp(230px,100svh - 400px,400px))}.trivia-value{font-size:21px;margin-top:2px}.timeline-zoom{margin-bottom:8px} .duration-stage{width:min(100%,calc(clamp(150px,100svh - 475px,270px)*4/3))} body:has(.feedback) .trivia-caption{display:none} - body:has(.feedback) .timeline-stage{width:min(100%,clamp(220px,100svh - 410px,340px));max-width:340px} + } #timeline-canvas{cursor:grab}#timeline-canvas.dragging{cursor:grabbing}body:has(.feedback) #timeline-canvas{cursor:default} diff --git a/src/design-options/view-idle.css b/src/design-options/view-idle.css index 067ba38..9960c27 100644 --- a/src/design-options/view-idle.css +++ b/src/design-options/view-idle.css @@ -1,10 +1,3 @@ -.view-start-stage{position:relative} -.view-start-stage>.view-start-overlay{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);min-width:120px;min-height:48px;box-shadow:0 4px 24px #0004} -.view-start-stage>.view-start-overlay:hover{transform:translate(-50%,-50%)} - -.view-start-waiting::after{content:'';position:absolute;inset:0;border-radius:12px;background:#0007;pointer-events:none} -.view-start-stage>.view-start-overlay{z-index:1} - .view-result-stage{position:relative} .view-result-phase{position:absolute;bottom:10px;left:50%;transform:translateX(-50%);padding:4px 10px;border-radius:6px;background:var(--surface);color:var(--muted);font-size:12px;white-space:nowrap;pointer-events:none} .round-feedback .feedbacktop{display:block} diff --git a/src/design-options/view-idle.js b/src/design-options/view-idle.js index 925ae42..acce9da 100644 --- a/src/design-options/view-idle.js +++ b/src/design-options/view-idle.js @@ -1,8 +1,8 @@ function canvasDisplayScale(canvas){ - const r=canvas.getBoundingClientRect();return Math.min(r.width/canvas.width,r.height/canvas.height); + const r=canvas.getBoundingClientRect();return Math.min(r.width/(Number(canvas.dataset?.logicalWidth)||canvas.width),r.height/(Number(canvas.dataset?.logicalHeight)||canvas.height)); } function canvasPointerPoint(canvas,e){ - const r=canvas.getBoundingClientRect(),scale=canvasDisplayScale(canvas),w=canvas.width,h=canvas.height; + const r=canvas.getBoundingClientRect(),scale=canvasDisplayScale(canvas),w=Number(canvas.dataset?.logicalWidth)||canvas.width,h=Number(canvas.dataset?.logicalHeight)||canvas.height; return {x:(e.clientX-r.left-(r.width-w*scale)/2)/scale,y:(e.clientY-r.top-(r.height-h*scale)/2)/scale}; } function viewPointerPoint(canvas,e){return canvasPointerPoint(canvas,e);} @@ -16,12 +16,3 @@ function spinIdleView(q){ }; frame(); } - -function overlayViewStart(buttonId='show-view',canvasId='view-canvas'){ - const button=$(buttonId),stage=$(canvasId).closest('.spatial-wrap,.memory-stage'); - stage.querySelector('.memory-prompt')?.remove(); - stage.classList.add('view-start-stage','view-start-waiting');button.classList.add('view-start-overlay');stage.appendChild(button); -} -function beginViewPreview(buttonId='show-view',canvasId='view-canvas'){ - const button=$(buttonId);$(canvasId).closest('.spatial-wrap,.memory-stage').classList.remove('view-start-waiting');button.classList.remove('view-start-overlay');$('controls').appendChild(button); -} diff --git a/tests/browser-results.cjs b/tests/browser-results.cjs new file mode 100644 index 0000000..acaae6b --- /dev/null +++ b/tests/browser-results.cjs @@ -0,0 +1,25 @@ +// Requires a local build server on port 4175 and Playwright/Chromium. +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{ + const page=await browser.newPage({viewport:{width:375,height:667},isMobile:true,hasTouch:true,reducedMotion:'reduce'}),errors=[];page.on('pageerror',e=>errors.push(e.message));await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort()); + const out=process.env.PERCEPTION_SCREENSHOTS||'/workspace/artifacts/results';fs.mkdirSync(out,{recursive:true}); + const check=async label=>{const m=await page.evaluate(()=>({body:document.body.scrollHeight,width:document.body.scrollWidth,height:innerHeight,viewWidth:innerWidth,next:document.getElementById('next').getBoundingClientRect().toJSON(),metrics:[...document.querySelectorAll('.round-result-stats>div,.result-axes>div')].map(e=>({width:e.clientWidth,content:e.scrollWidth})),stage:document.getElementById('game-stage').getBoundingClientRect().height}));assert(m.width<=m.viewWidth,label+' page overflow');assert(m.body<=m.height+1,label+' vertical overflow');assert(m.stage>=96,label+' collapsed visual');assert(m.next.top>=0&&m.next.bottom<=m.height+1,label+' hidden next action');for(const e of m.metrics)assert(e.content<=e.width+1,label+' overflowing result value');}; + for(const theme of ['focus','night-lab'])for(const viewport of [{width:375,height:667},{width:390,height:844}]){ + await page.setViewportSize(viewport);await page.goto('http://127.0.0.1:4175/?game=proportion');await page.selectOption('#theme-select',theme);await page.waitForTimeout(80); + for(const exact of [false,true]){await page.evaluate(exact=>{const q={...sizeQuestion(rng(42),0),wide:3,tall:2,reference:40,answer:{width:120,height:80}};deck=[q,q];index=0;results=[];render();reveal(exact?q.answer:{width:114,height:89.6});},exact);await check('size '+theme+' '+viewport.width);const axes=await page.locator('.result-axes>div').allInnerTexts();if(exact){assert(axes.every(s=>s.includes('Spot on')));assert.equal(await page.evaluate(()=>results[0].score),100);}else{assert(/Width\s+5\.0%\s+Too narrow/.test(axes[0]));assert(/Height\s+12\.0%\s+Too tall/.test(axes[1]));}if(!exact&&viewport.width===390)await page.screenshot({path:`${out}/size-${theme}.png`});} + for(const game of ['angle','timeline','duration']){ + await page.evaluate(game=>{deck=makeDeck('practice:result-layout',game);index=0;results=[];render();},game); + if(game==='angle'){await page.locator('#angle-dial').press('ArrowLeft');await page.locator('#angle-lock').click();} + if(game==='timeline'){await page.locator('#year-wheel').press('End');await page.locator('#trivia-lock').click();} + if(game==='duration'){await page.evaluate(()=>reveal(5184000));} + await check(game+' '+theme+' '+viewport.width);const committed=await page.evaluate(()=>JSON.stringify(results)); + if(game!=='angle'){await page.locator('.math-detail summary').click();await check(game+' expanded source');assert.equal(await page.evaluate(()=>JSON.stringify(results)),committed);await page.locator('.math-detail summary').click();} + if(viewport.width===390)await page.screenshot({path:`${out}/${game}-${theme}.png`}); + } + } + // A mixed completion page shows consistent names and factual subtitles, and can scroll. + await page.evaluate(()=>{const games=['proportion','angle','time','timeline','duration'];deck=games.map(g=>makeDeck('practice:result-summary',g)[0]);results=deck.map(q=>({q,guess:q.answer,score:100}));mini='mix';mode='daily';finish();}); + assert.equal(await page.locator('.result').count(),5);assert.equal(await page.locator('.resulthead small').count(),2);assert.equal(await page.locator('#roundlabel').innerText(),'DAILY COMPLETE');assert((await page.locator('.summary').innerText()).includes('Daily Five resets at midnight Central Time.')); + for(const theme of ['focus','night-lab']){await page.selectOption('#theme-select',theme);await page.waitForTimeout(80);assert(await page.evaluate(()=>document.body.scrollWidth<=innerWidth));await page.screenshot({path:`${out}/summary-${theme}.png`,fullPage:true});} + assert.deepEqual(errors,[]);console.log('Verified readable exact/opposing size metrics, long duration/angle/year values, visible Next buttons, expandable sources and mixed summary cards in both themes at two phone sizes.'); + }finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser-shell.cjs b/tests/browser-shell.cjs new file mode 100644 index 0000000..2baa0f7 --- /dev/null +++ b/tests/browser-shell.cjs @@ -0,0 +1,70 @@ +// Run after building and serving public/: node tests/browser-shell.cjs +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +const games=['view3d','balance','motion','perspective','proportion','rhythm','angle','memory','time','timeline','duration']; +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{ + const page=await browser.newPage({viewport:{width:390,height:844},isMobile:true,hasTouch:true,reducedMotion:'reduce'}),errors=[]; + page.on('pageerror',e=>errors.push(e.message));await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort()); + await page.addInitScript(()=>localStorage.setItem('perception-theme','night-lab'));await page.clock.install(); + const output=process.env.PERCEPTION_SCREENSHOTS||'/workspace/artifacts/game-shell';fs.mkdirSync(output,{recursive:true}); + const phase=()=>page.locator('#playarea').getAttribute('data-phase'); + const fit=async(label)=>{const m=await page.evaluate(()=>({height:document.body.scrollHeight,width:document.body.scrollWidth,viewport:{height:innerHeight,width:innerWidth},stage:document.getElementById('game-stage').getBoundingClientRect().toJSON(),buttons:[...document.querySelectorAll('#controls button, #next')].filter(b=>b.getClientRects().length).map(b=>({id:b.id,rect:b.getBoundingClientRect().toJSON()}))}));assert(m.height<=m.viewport.height+1,label+' body overflow '+m.height);assert(m.width<=m.viewport.width+1,label+' horizontal overflow');assert(m.stage.height>=96,label+' collapsed stage '+m.stage.height);for(const b of m.buttons)assert(b.rect.bottom<=m.viewport.height+1&&b.rect.top>=0,label+' button outside viewport: '+b.id);}; + for(const game of games){ + await page.goto('http://127.0.0.1:4175/?game='+game);await page.evaluate(game=>{deck=makeDeck('practice:shell-regression',game);index=0;results=[];render();},game); + const original=await page.evaluate(()=>JSON.stringify(deck)); + for(const viewport of [{width:375,height:667},{width:390,height:844}]){await page.setViewportSize(viewport);await page.waitForTimeout(60);await fit(game+' ready '+viewport.height);} + await page.screenshot({path:output+'/'+game+'-start.png'}); + if(game==='view3d'){await page.locator('#show-view').click();assert.equal(await phase(),'preview');await page.clock.fastForward(3100);assert.equal(await phase(),'play');await page.locator('#view-canvas').press('ArrowRight');await page.locator('#view-canvas').press('Shift+ArrowRight');await fit('3D play');await page.locator('#view-lock').click();} + if(game==='balance'){await page.locator('#balance-canvas').press('ArrowRight');await page.locator('#half-lock').click();} + if(game==='motion'){await page.locator('#show-line').click();assert.equal(await phase(),'preview');await page.clock.fastForward(3100);assert.equal(await phase(),'play');const c=page.locator('#trace-canvas');await c.press('Space');for(let i=0;i<5;i++)await c.press('ArrowRight');await c.press('Space');await fit('trace play');await page.locator('#trace-lock').click();} + if(game==='perspective'){await page.locator('#perspective-canvas').press('ArrowRight');await page.locator('#perspective-lock').click();} + if(game==='proportion'){await page.locator('#size-handle').press('Shift+ArrowRight');await page.locator('#proportion-lock').click();} + if(game==='rhythm'){await page.locator('#rhythm-action').click();const period=await page.evaluate(()=>60000/deck[0].answer);await page.clock.fastForward(4*period+550);for(let i=0;i<5;i++){if(i)await page.clock.fastForward(period);await page.locator('#rhythm-action').click();}} + if(game==='angle'){await page.locator('#angle-dial').press('ArrowLeft');await page.locator('#angle-lock').click();} + if(game==='memory'){await page.locator('#show-colour').click();assert.equal(await phase(),'preview');await page.clock.fastForward(2100);assert.equal(await phase(),'play');await page.locator('#wheel').press('ArrowRight');await fit('colour play');await page.locator('#colour-lock').click();} + if(game==='time'){await page.locator('#timer').click();await page.clock.fastForward(await page.evaluate(()=>deck[0].answer*1000));await page.locator('#timer').click();assert(await page.evaluate(()=>results[0].score>=95),'timer should stop close to target');} + if(game==='timeline'){await page.locator('#year-wheel').press('ArrowDown');await page.locator('#trivia-lock').click();} + if(game==='duration'){await page.locator('#duration-plus').click();await page.locator('#duration-canvas').press('ArrowUp');await page.locator('#trivia-lock').click();} + assert.equal(await phase(),'reveal',game);if(game==='rhythm')assert(await page.locator('.rhythm-result-comparison').isVisible());assert.equal(await page.evaluate(()=>results.length),1);assert.equal(await page.evaluate(()=>JSON.stringify(deck)),original,'presentation must not change the stored deck'); + for(const viewport of [{width:375,height:667},{width:390,height:844}]){await page.setViewportSize(viewport);await page.waitForTimeout(60);await fit(game+' reveal '+viewport.height);if(game==='timeline'){const delta=await page.locator('#year-wheel').evaluate(w=>{const r=w.getBoundingClientRect(),a=w.querySelector('[data-year="'+w.getAttribute('aria-valuenow')+'"]').getBoundingClientRect();return Math.abs(a.top+a.height/2-r.top-r.height/2)});assert(delta<2,'locked year must stay centred on resize: '+delta);}} + const committed=await page.evaluate(()=>JSON.stringify(results));await page.selectOption('#theme-select','focus');await page.waitForTimeout(60);assert.equal(await page.evaluate(()=>JSON.stringify(results)),committed,game+' appearance change reset result');await fit(game+' light reveal');if(['view3d','duration'].includes(game)){const pixel=await page.locator(game==='view3d'?'#view-canvas':'#duration-canvas').evaluate(c=>Array.from(c.getContext('2d').getImageData(0,0,1,1).data));assert(pixel[0]>230&&pixel[3]===255,game+' did not repaint for the light theme');} + await page.selectOption('#theme-select','night-lab');await page.waitForTimeout(60);await page.screenshot({path:output+'/'+game+'-reveal.png'}); + await page.locator('#next').click();assert.equal(await page.evaluate(()=>index),1);assert(await page.locator('.game-stage').isVisible()); + console.log('Verified '+game+' ready → play → reveal → next at two mobile sizes.'); + } + // Real touch coordinates use the same letterboxed stage geometry as painting. + const touch=async(id,points)=>{ + const coords=await page.locator('#'+id).evaluate((canvas,points)=>{const r=canvas.getBoundingClientRect(),width=canvas.width||400,height=canvas.height||300,scale=Math.min(r.width/width,r.height/height);return points.map(p=>({x:r.x+(r.width-width*scale)/2+p.x*scale,y:r.y+(r.height-height*scale)/2+p.y*scale}));},points); + const session=await page.context().newCDPSession(page); + await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[coords[0]]}); + for(const point of coords.slice(1))await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[point]}); + await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await session.detach(); + }; + for(const game of ['view3d','balance','motion','perspective','angle','memory','timeline','duration']){ + await page.goto('http://127.0.0.1:4175/?game='+game);await page.evaluate(game=>{deck=makeDeck('practice:shell-touch',game);index=0;results=[];render();},game); + if(game==='view3d'){await page.locator('#show-view').click();await page.clock.fastForward(3100);await touch('view-canvas',[{x:190,y:150},{x:220,y:165},{x:245,y:180}]);await page.locator('#view-lock').click();assert(await page.evaluate(()=>orientationError(results[0].guess,{yaw:0,pitch:0,roll:0})>1));} + if(game==='balance'){const points=await page.evaluate(()=>{const a=deck[0].answer;return [-85,85].map(d=>({x:200+(a.x-a.ny*d-200)*.85,y:150+(a.y+a.nx*d-180)*.85}));});await touch('balance-canvas',points);await page.locator('#half-lock').click();assert(await page.evaluate(()=>results[0].score>=98));} + if(game==='motion'){await page.locator('#show-line').click();await page.clock.fastForward(3100);await touch('trace-canvas',await page.evaluate(()=>deck[0].answer));await page.locator('#trace-lock').click();assert(await page.evaluate(()=>results[0].score>=98));} + if(game==='perspective'){await touch('perspective-canvas',[await page.evaluate(()=>deck[0].answer)]);await page.locator('#perspective-lock').click();assert.equal(await page.evaluate(()=>results[0].score),100);} + if(game==='angle'){const point=await page.evaluate(()=>({x:200+Math.cos(deck[0].answer*Math.PI/180)*120,y:200-Math.sin(deck[0].answer*Math.PI/180)*120}));await touch('angle-dial',[point]);await page.locator('#angle-lock').click();assert.equal(await page.evaluate(()=>results[0].score),100);} + if(game==='memory'){await page.locator('#show-colour').click();await page.clock.fastForward(2100);const point=await page.evaluate(()=>{const {h,s}=rgbHsv(deck[0].answer);return {x:240+Math.cos(h*Math.PI/180)*s*230,y:240+Math.sin(h*Math.PI/180)*s*230};});await touch('wheel',[point]);await page.locator('#colour-lock').click();assert.equal(await page.evaluate(()=>results[0].score),100);} + if(game==='timeline'){await touch('year-wheel',[{x:200,y:140},{x:200,y:92}]);await page.locator('#trivia-lock').click();assert.equal(await page.evaluate(()=>results.length),1);} + if(game==='duration'){const angle=135*Math.PI/180+Math.log(5400)/Math.log(5184000)*270*Math.PI/180;await touch('duration-canvas',[{x:200+Math.cos(angle)*100,y:145+Math.sin(angle)*100}]);await page.locator('#trivia-lock').click();assert.equal(await page.evaluate(()=>results[0].guess),5400);} + } + console.log('Verified real touch rotation, cuts, tracing, perspective placement, angle dial, colour wheel, year scrolling, and duration dial inside the shared stage.'); + // Header chrome retains navigation, keyboard dismissal and focus restoration. + await page.locator('#game-menu-toggle').click();assert(await page.locator('#game-menu').evaluate(d=>d.open));assert.equal(await page.locator('#game-menu details').count(),0);for(const option of await page.locator('#game-menu [data-mini]').all())assert(await option.isVisible(),'game list must open fully expanded');assert.equal(await page.locator('#game-menu [aria-current="true"]').getAttribute('data-mini'),'duration');await page.keyboard.press('Escape');await page.waitForFunction(()=>document.getElementById('game-menu-toggle').getAttribute('aria-expanded')==='false');assert.equal(await page.locator('#game-menu-toggle').getAttribute('aria-expanded'),'false');assert(await page.locator('#game-menu-toggle').evaluate(b=>document.activeElement===b)); + await page.locator('#game-menu-toggle').click();await page.locator('#history-button').click();assert(await page.locator('#history-dialog').evaluate(d=>d.open));await page.locator('#history-dialog .history-close').click(); + await page.locator('#game-menu-toggle').click();await page.locator('[data-mini="motion"]').click();await page.locator('#show-line').click(); + await page.locator('#game-menu-toggle').click();await page.locator('[data-mini="angle"]').click();await page.clock.fastForward(4000);assert(await page.locator('#angle-dial').isVisible());assert.equal(await page.locator('#trace-canvas').count(),0,'old preview timer survived navigation'); + // A new game uses the shared slots and cleanup without controller changes. + await page.evaluate(()=>{globalThis.cleanupCount=0;registerGame('shell-test',{render:q=>{shell(q,'
A new game
','');onGameCleanup(()=>globalThis.cleanupCount++);}});renderGame({type:'shell-test',skill:'New game',title:'Shared shell.'});});assert(await page.locator('#game-stage #test-stage-content').isVisible());await page.evaluate(()=>render());assert.equal(await page.evaluate(()=>globalThis.cleanupCount),1); + // Mixed daily progress, summaries, menu return, and reload use the original save format. + await page.evaluate(()=>{mini='mix';start('daily');const q=deck[0];if(['view3d','colour','motion'].includes(q.type)){if(q.type==='view3d')viewPicker(q);if(q.type==='colour')colourPicker(q);if(q.type==='motion')tracePicker(q);}reveal(q.type==='proportion'&&!isSizeQuestion(q)?{width:q.answer,height:1}:q.answer);}); + const saved=await page.evaluate(()=>({day:sessionDay,deck:JSON.stringify(deck),guess:JSON.stringify(playerData.progress.games.mix.results[0].guess)}));await page.goto('http://127.0.0.1:4175/');assert.equal(await page.evaluate(()=>results.length),1);assert.equal(await page.evaluate(()=>JSON.stringify(deck)),saved.deck);assert.equal(await page.evaluate(()=>JSON.stringify(results[0].guess)),saved.guess); + await page.evaluate(()=>{results=deck.map(q=>({q,guess:q.type==='proportion'&&!isSizeQuestion(q)?{width:q.answer,height:1}:q.answer,score:100}));saveDailyGame();finish();});assert.equal(await page.locator('body').getAttribute('data-game-active'),null);assert(await page.locator('.summary').isVisible());await page.reload();assert(await page.locator('.summary').isVisible()); + await page.locator('#game-menu-toggle').click();await page.locator('[data-mini="proportion"]').click();assert(await page.locator('#size-handle').isVisible()); + for(const viewport of [{width:768,height:1024},{width:1280,height:900},{width:360,height:740}]){await page.setViewportSize(viewport);await page.waitForTimeout(60);await fit('responsive shell '+viewport.width);} + await page.addInitScript(()=>{window.ResizeObserver=undefined;});await page.goto('http://127.0.0.1:4175/?game=timeline'); + for(const viewport of [{width:390,height:844},{width:375,height:667}]){await page.setViewportSize(viewport);await page.waitForTimeout(60);const delta=await page.locator('#year-wheel').evaluate(w=>{const r=w.getBoundingClientRect(),a=w.querySelector('[data-year="'+w.getAttribute('aria-valuenow')+'"]').getBoundingClientRect();return Math.abs(a.top+a.height/2-r.top-r.height/2)});assert(delta<2,'year resize fallback failed: '+delta);} + assert.deepEqual(errors,[]);console.log('Verified registry extensibility, cleanup, modal focus, game switching during previews, theme changes, daily reloads, completion reloads, summaries, and tablet/desktop/narrow-phone layouts.'); + }finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser-size-sharpness.cjs b/tests/browser-size-sharpness.cjs new file mode 100644 index 0000000..5ddfa79 --- /dev/null +++ b/tests/browser-size-sharpness.cjs @@ -0,0 +1,3 @@ +// Requires a local build server on port 4175 and Playwright/Chromium. +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{fs.mkdirSync('/workspace/artifacts/size-sharpness',{recursive:true});for(const dpr of [1,2,3]){const page=await browser.newPage({viewport:{width:390,height:844},deviceScaleFactor:dpr,isMobile:true,hasTouch:true,reducedMotion:'reduce'});await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort());await page.goto('http://127.0.0.1:4175/?game=proportion');for(let i=0;i<10;i++)await page.locator('#size-handle').press('Shift+ArrowRight');const before=await page.locator('#size-handle').boundingBox();for(const viewport of [{width:390,height:844},{width:375,height:667},{width:430,height:932}]){await page.setViewportSize(viewport);await page.waitForTimeout(80);for(const theme of ['focus','night-lab']){await page.selectOption('#theme-select',theme);await page.waitForTimeout(80);const info=await page.locator('#proportion-canvas').evaluate(c=>{const r=c.getBoundingClientRect(),scale=c.width/400,q=deck[0],column=Math.round((48+(q.reference+100)/2)*scale),top=Math.floor(90*scale)-5,pixels=c.getContext('2d').getImageData(column,top,1,12).data;return {width:c.width,expected:Math.round(Math.min(r.width,r.height)*devicePixelRatio),alpha:[...pixels].filter((_,i)=>i%4===3)};});assert.equal(info.width,info.expected);assert(info.alpha.includes(255),'missing outline');/* Allow one alpha step for canvas floating-point precision. */assert(info.alpha.every(n=>n<=1||n>=254),'blurred horizontal edge: '+info.alpha);}}await page.setViewportSize({width:390,height:844});await page.waitForTimeout(80);const after=await page.locator('#size-handle').boundingBox();assert(Math.abs(before.x-after.x)<.1&&Math.abs(before.y-after.y)<.1,'resize changed the guess or handle position');if(dpr===3){await page.screenshot({path:'/workspace/artifacts/size-sharpness/mobile-play.png'});await page.locator('#proportion-lock').click();await page.screenshot({path:'/workspace/artifacts/size-sharpness/mobile-reveal.png'});}await page.close();}console.log('Verified DPR 1/2/3 backing resolution, fully sharp horizontal stroke pixels in both themes at three phone sizes, and unchanged handle/guess after responsive resizing.');}finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser-size.cjs b/tests/browser-size.cjs new file mode 100644 index 0000000..c11ae6e --- /dev/null +++ b/tests/browser-size.cjs @@ -0,0 +1,51 @@ +// Requires Playwright and a local build server (default localhost:4175). +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{ + const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']}); + try{ + const context=await browser.newContext({viewport:{width:390,height:844},isMobile:true,hasTouch:true,reducedMotion:'reduce'}); + const page=await context.newPage(),errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/*',route=>new URL(route.request().url()).hostname==='127.0.0.1'?route.continue():route.abort()); + await page.goto(process.env.PERCEPTION_TEST_URL||'http://127.0.0.1:4175/?game=proportion'); + const reset=async()=>{await page.evaluate(()=>{let q;for(let seed=0;seed<100;seed++){q=sizeQuestion(rng(seed),0);if(q.wide===3&&q.tall===2)break;}deck=Array.from({length:5},()=>q);index=0;results=[];render();});await page.waitForTimeout(60);}; + const state=()=>page.evaluate(()=>{const c=document.getElementById('proportion-canvas'),r=c.getBoundingClientRect(),h=document.getElementById('size-handle').getBoundingClientRect(),s=canvasDisplayScale(c);return {q:deck[index],width:(h.x+h.width/2-r.x-(r.width-400*s)/2)/s-SIZE_BOUNDS.x,height:(h.y+h.height/2-r.y-(r.height-400*s)/2)/s-SIZE_BOUNDS.y,stage:document.querySelector('.size-stage').getBoundingClientRect().toJSON(),handle:h.toJSON(),scale:s,body:document.body.scrollHeight};}); + const near=(a,b)=>assert(Math.abs(a-b)<.1,`${a} should be ${b}`); + const dragTo=async(w,h,touch=false)=>{const a=await state(),cx=a.handle.x+24,cy=a.handle.y+24,x=cx+(w-a.width)*a.scale,y=cy+(h-a.height)*a.scale; + if(touch){const session=await context.newCDPSession(page);await session.send('Input.dispatchTouchEvent',{type:'touchStart',touchPoints:[{x:cx,y:cy}]});await session.send('Input.dispatchTouchEvent',{type:'touchMove',touchPoints:[{x,y}]});await session.send('Input.dispatchTouchEvent',{type:'touchEnd',touchPoints:[]});await session.detach();} + else{await page.mouse.move(cx,cy);await page.mouse.down();await page.mouse.move(x,y,{steps:4});await page.mouse.up();} + }; + await reset();let a=await state();assert.equal(a.handle.width,48);assert.equal(a.handle.height,48);assert.equal(a.q.measurementVersion,2);near(a.width,a.q.reference);near(a.height,a.q.reference);assert.equal(await page.locator('.size-handle span').evaluate(el=>el.getBoundingClientRect().width),14); + await page.selectOption('#theme-select','night-lab');await page.waitForTimeout(120);await page.screenshot({path:'/workspace/artifacts/size-game/mobile-start.png'}); + await page.locator('#size-handle').focus();await page.keyboard.press('ArrowRight');let b=await state();near(b.width,a.width+1);near(b.height,a.height); + await page.keyboard.press('Shift+ArrowDown');b=await state();near(b.height,a.height+10); + await dragTo(180,110);b=await state();near(b.width,180);near(b.height,110); + await dragTo(210,110,true);b=await state();near(b.width,210);near(b.height,110); + await dragTo(999,999,true);b=await state();near(b.width,304);near(b.height,207);assert(b.handle.right<=b.stage.right&&b.handle.bottom<=b.stage.bottom); + await dragTo(-100,-100,true);b=await state();near(b.width,32);near(b.height,32);assert(b.handle.left>=b.stage.left&&b.handle.top>=b.stage.top); + await dragTo(180,110);a=await state(); + for(const viewport of [{width:360,height:740},{width:375,height:667},{width:768,height:1024},{width:1280,height:900},{width:390,height:844}]){ + await page.setViewportSize(viewport);await page.waitForTimeout(60);b=await state();near(b.width,a.width);near(b.height,a.height);assert.deepEqual(b.q,a.q);assert(b.handle.left>=b.stage.left&&b.handle.right<=b.stage.right&&b.handle.top>=b.stage.top&&b.handle.bottom<=b.stage.bottom);assert(b.body<=viewport.height+2,`play overflow at ${viewport.width}×${viewport.height}: ${b.body}`); + } + // Cancelling a drag releases input, and locked answers do not change. + await page.mouse.move(b.handle.x+24,b.handle.y+24);await page.mouse.down(); + await page.locator('#size-handle').dispatchEvent('pointercancel',{pointerId:1});await page.locator('#size-handle').dispatchEvent('pointermove',{pointerId:1,clientX:1,clientY:1});near((await state()).width,180);await page.mouse.up(); + await dragTo(a.q.answer.width*.95,a.q.answer.height*1.12); + const output=process.env.PERCEPTION_SCREENSHOTS||'/workspace/artifacts/size-game';fs.mkdirSync(output,{recursive:true}); + await page.selectOption('#theme-select','night-lab');await page.waitForTimeout(120);await page.screenshot({path:output+'/mobile-play.png'}); + const final=await state();await page.locator('#proportion-lock').click();await page.waitForTimeout(60); + const result=await page.evaluate(()=>({guess:results[0].guess,score:results[0].score,text:document.getElementById('actionarea').innerText,body:document.body.scrollHeight,canvas:document.getElementById('proportion-canvas').getAttribute('aria-label')})); + near(result.guess.width,final.width);near(result.guess.height,final.height);const axes=await page.locator('.result-axes>div').allInnerTexts();assert(/Width\s+5\.0%\s+Too narrow/.test(axes[0]));assert(/Height\s+12\.0%\s+Too tall/.test(axes[1]));assert(result.canvas.includes('Dashed outline'));assert(result.body<=846); + await page.screenshot({path:output+'/mobile-reveal.png'}); + await page.setViewportSize({width:375,height:667});await page.waitForTimeout(60);const revealHeight=await page.evaluate(()=>document.body.scrollHeight);assert(revealHeight<=669,`reveal overflow: ${revealHeight}`); + await page.selectOption('#theme-select','focus');await page.waitForTimeout(120);assert.deepEqual(await page.evaluate(()=>results[0].guess),result.guess); + await page.locator('#next').click();assert(await page.locator('#size-handle').isVisible()); + // Exact target is reachable through actual pointer input. + await reset();a=await state();await dragTo(a.q.answer.width,a.q.answer.height,true);await page.locator('#proportion-lock').click();assert.equal(await page.evaluate(()=>results[0].score),100); + // Summary regeneration uses absolute dimensions and the grid, including saved results. + const comparison=await page.evaluate(()=>restoredComparison(results[0]));assert(comparison.includes('solid grid: target')); + await page.evaluate(()=>{const q=deck[0];results=deck.map(q=>({q,guess:q.answer,score:100}));finish();});assert((await page.locator('.results').innerText()).includes('Width: 0.0% · spot on')); + await page.evaluate(()=>{deck=makeDeck('2026-10-14','proportion');index=0;results=[];render();});assert.equal(await page.locator('#proportion-canvas').getAttribute('height'),'300'); + await page.evaluate(()=>reveal({width:deck[0].answer*100,height:100}));assert.equal(await page.evaluate(()=>results[0].score),100); + assert.deepEqual(errors,[]);console.log('Verified Chromium mobile touch, mouse, independent arrows, Shift steps, min/max clamps, pointer cancellation, exact target, five responsive viewports, theme changes, reduced motion, and mobile play/reveal screenshots.'); + }finally{await browser.close();} +})().catch(e=>{console.error(e);process.exit(1);}); diff --git a/tests/browser-theme-layout.cjs b/tests/browser-theme-layout.cjs new file mode 100644 index 0000000..aa8326a --- /dev/null +++ b/tests/browser-theme-layout.cjs @@ -0,0 +1,27 @@ +// Requires a local build server on port 4175 and Playwright/Chromium. +const {chromium}=require('playwright'),fs=require('fs'),assert=require('assert'); +const games=['view3d','balance','motion','perspective','proportion','rhythm','angle','memory','time','timeline','duration']; +// Palettes may change colours, but cannot introduce a second layout. +const light=fs.readFileSync('src/design-options/focus-palette.css','utf8'),dark=fs.readFileSync('src/design-options/night-lab-palette.css','utf8'); +assert.equal(fs.readFileSync('public/focus.css','utf8').replace(light,'PALETTE'),fs.readFileSync('public/night-lab.css','utf8').replace(dark,'PALETTE')); +for(const palette of [light,dark])assert(palette.replace(/\/\*[\s\S]*?\*\//g,'').match(/^:root\s*\{\s*(?:(?:--[\w-]+|color-scheme):[^;{}]+;\s*)+\}\s*$/)); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{ + const page=await browser.newPage({reducedMotion:'reduce'}),errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort()); + await page.addInitScript(()=>localStorage.setItem('perception-theme','night-lab')); + const theme=async value=>{await page.selectOption('#theme-select',value);await page.waitForFunction(value=>document.getElementById('theme-stylesheet').sheet?.href.includes(value),value);await page.evaluate(()=>document.fonts.ready);await page.waitForTimeout(80);}; + const snapshot=()=>page.evaluate(()=>[...document.querySelectorAll('body *:not(script):not(style):not(link):not(option)')].filter(e=>e.getClientRects().length&&!e.closest('svg')).map(e=>{const c=getComputedStyle(e),r=e.getBoundingClientRect();return {tag:e.tagName,id:e.id,rect:[r.x,r.y,r.width,r.height].map(n=>Math.round(n*100)/100),style:Object.fromEntries(['fontFamily','fontSize','fontWeight','letterSpacing','textTransform','padding','gap','borderRadius','borderWidth','display','alignItems','justifyContent'].map(k=>[k,c[k]]))};})); + const parity=async label=>{const state=await page.evaluate(()=>JSON.stringify({deck,index,results}));await theme('night-lab');const a=await snapshot();await theme('focus');const b=await snapshot();if(JSON.stringify(a)!==JSON.stringify(b))console.log(label,JSON.stringify(a.map((v,i)=>JSON.stringify(v)===JSON.stringify(b[i])?null:{dark:v,light:b[i]}).filter(Boolean).slice(0,5),null,2));assert.deepEqual(b,a,label);assert.equal(await page.evaluate(()=>JSON.stringify({deck,index,results})),state,label+' changed progress');}; + for(const viewport of [{width:375,height:667},{width:430,height:932},{width:1280,height:900}]){ + await page.setViewportSize(viewport); + for(const game of games){await page.goto('http://127.0.0.1:4175/?game='+game);await page.evaluate(game=>{deck=makeDeck('practice:theme-parity',game);index=0;results=[];render();},game);await parity(`${game} start ${viewport.width}`);if(game==='balance'){await page.locator('#balance-canvas').press('ArrowRight');await page.locator('#half-lock').click();}else await page.evaluate(()=>{const q=deck[0];if(q.type==='view3d')viewPicker(q);if(q.type==='colour')colourPicker(q);if(q.type==='motion')tracePicker(q);reveal(q.answer);});await parity(`${game} reveal ${viewport.width}`);} + await page.evaluate(()=>{results=deck.map(q=>({q,guess:q.answer,score:100}));finish();});await parity('summary '+viewport.width); + await page.locator('#game-menu-toggle').click();await parity('navigation '+viewport.width); + } + await page.setViewportSize({width:390,height:844});await page.goto('http://127.0.0.1:4175/?game=proportion');await theme('focus');await page.mouse.move(0,0); + const colours=await page.evaluate(()=>({palette:Object.fromEntries(['paper','surface','canvas','line','ink','muted','accent','draw','target'].map(k=>[k,getComputedStyle(document.documentElement).getPropertyValue('--'+k).trim()])),button:[getComputedStyle(document.querySelector('#proportion-lock')).backgroundColor,getComputedStyle(document.querySelector('#proportion-lock')).color]})); + assert.deepEqual(colours.palette,{paper:'#fafafa',surface:'#fff',canvas:'#fff',line:'#e9eaed',ink:'#191b21',muted:'#737782',accent:'#3b52ec',draw:'#3b52ec',target:'#a4be21'});assert.deepEqual(colours.button,['rgb(32, 35, 45)','rgb(255, 255, 255)']); + const output=process.env.PERCEPTION_SCREENSHOTS||'/workspace/artifacts/theme-layout';fs.mkdirSync(output,{recursive:true}); + for(const mode of ['focus','night-lab']){await theme(mode);await page.screenshot({path:output+'/'+mode+'-play.png'});}await page.locator('#proportion-lock').click();for(const mode of ['focus','night-lab']){await theme(mode);await page.screenshot({path:output+'/'+mode+'-reveal.png'});} + assert.deepEqual(errors,[]);console.log('Verified identical light/dark geometry and typography for all 11 games in start/reveal, summaries and navigation at three viewport sizes; light palette and committed progress preserved.'); + }finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/browser-year-layout.cjs b/tests/browser-year-layout.cjs new file mode 100644 index 0000000..cada164 --- /dev/null +++ b/tests/browser-year-layout.cjs @@ -0,0 +1,20 @@ +// Requires a local build server on port 4175 and Playwright/Chromium. +const {chromium}=require('playwright'),assert=require('assert'),fs=require('fs'); +(async()=>{const browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox']});try{ + const page=await browser.newPage({viewport:{width:430,height:932},isMobile:true,hasTouch:true,reducedMotion:'reduce'}),errors=[];page.on('pageerror',e=>errors.push(e.message)); + await page.route('**/*',r=>new URL(r.request().url()).hostname==='127.0.0.1'?r.continue():r.abort());await page.addInitScript(()=>localStorage.setItem('perception-theme','night-lab')); + const check=async label=>{const tint=await page.locator('.year-selection').evaluate(e=>getComputedStyle(e).backgroundColor);assert(/(?:\/\s*0?\.0?8\s*\)|,\s*0?\.0?8\s*\))$/.test(tint),label+': selection tint must remain transparent so the chosen year is visible: '+tint);const m=await page.evaluate(()=>{const rect=e=>{const r=e.getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2,width:r.width}};const w=document.getElementById('year-wheel');return {stage:rect(document.getElementById('game-stage')),picker:rect(w.parentElement),row:rect(w.querySelector('[data-year="'+w.getAttribute('aria-valuenow')+'"]')),marker:rect(document.querySelector('.year-selection')),answer:document.querySelector('.year-answer')?rect(document.querySelector('.year-answer')):null};}); + for(const name of ['picker','row','marker']){assert(Math.abs(m[name].x-m.stage.x)<1,`${label}: ${name} off centre horizontally by ${m[name].x-m.stage.x}px`);assert(Math.abs(m[name].y-m.stage.y)<1,`${label}: ${name} off centre vertically by ${m[name].y-m.stage.y}px`);} + if(m.answer)assert(Math.abs(m.answer.x-m.stage.x)<1,label+': answer badge off centre'); + }; + const output=process.env.PERCEPTION_SCREENSHOTS||'/workspace/artifacts/year-selector';fs.mkdirSync(output,{recursive:true}); + for(const theme of ['night-lab','focus']){ + await page.goto('http://127.0.0.1:4175/?game=timeline');await page.selectOption('#theme-select',theme);await page.waitForTimeout(80);await check(theme+' starting state'); + await page.locator('#year-wheel').press('ArrowDown');await page.locator('#trivia-lock').click();const committed=await page.evaluate(()=>JSON.stringify(results[0])); + for(const viewport of [{width:430,height:932},{width:414,height:896},{width:390,height:844},{width:375,height:667},{width:760,height:900},{width:761,height:900},{width:1280,height:900}]){ + await page.setViewportSize(viewport);await page.waitForTimeout(60);await check(`${theme} reveal ${viewport.width}×${viewport.height}`);assert.equal(await page.evaluate(()=>JSON.stringify(results[0])),committed,'resizing changed the committed result'); + if(viewport.width===430)await page.screenshot({path:output+'/reveal-'+theme+'.png'}); + } + } + assert.deepEqual(errors,[]);console.log('Verified year picker, selected year, marker, and answer badge remain centered before/after reveal in both themes across seven viewport sizes; committed guesses stay unchanged.'); + }finally{await browser.close();}})().catch(e=>{console.error(e);process.exit(1)}); diff --git a/tests/fixtures/size-legacy.json b/tests/fixtures/size-legacy.json new file mode 100644 index 0000000..f3c5550 --- /dev/null +++ b/tests/fixtures/size-legacy.json @@ -0,0 +1,1714 @@ +[ + { + "day": "2026-10-9", + "proportion": [ + { + "id": "proportion-0", + "type": "proportion", + "answer": 0.8571428571428571, + "label": "6 : 7", + "skill": "Proportions", + "title": "Make 6 : 7.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-1", + "type": "proportion", + "answer": 0.6, + "label": "3 : 5", + "skill": "Proportions", + "title": "Make 3 : 5.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-2", + "type": "proportion", + "answer": 0.8888888888888888, + "label": "8 : 9", + "skill": "Proportions", + "title": "Make 8 : 9.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-3", + "type": "proportion", + "answer": 2, + "label": "2 : 1", + "skill": "Proportions", + "title": "Make 2 : 1.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-4", + "type": "proportion", + "answer": 1.1666666666666667, + "label": "7 : 6", + "skill": "Proportions", + "title": "Make 7 : 6.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + } + ], + "mix": [ + { + "id": "motion-0", + "type": "motion", + "skill": "Line memory", + "title": "Trace that path.", + "answer": [ + { + "x": 126.66666666666666, + "y": 265 + }, + { + "x": 131.09924768518516, + "y": 263.13191372025585 + }, + { + "x": 136.50231481481484, + "y": 260.9825424382716 + }, + { + "x": 142.78385416666669, + "y": 258.5715830813172 + }, + { + "x": 149.85185185185185, + "y": 255.91873257666268 + }, + { + "x": 157.6142939814815, + "y": 253.04368785157806 + }, + { + "x": 165.97916666666666, + "y": 249.96614583333334 + }, + { + "x": 174.85445601851853, + "y": 246.70580344919853 + }, + { + "x": 184.14814814814815, + "y": 243.28235762644366 + }, + { + "x": 193.76822916666666, + "y": 239.7155052923387 + }, + { + "x": 203.6226851851852, + "y": 236.0249433741537 + }, + { + "x": 213.61950231481484, + "y": 232.2303687991587 + }, + { + "x": 223.66666666666669, + "y": 228.35147849462368 + }, + { + "x": 233.67216435185185, + "y": 224.4079693878186 + }, + { + "x": 243.5439814814815, + "y": 220.41953840601352 + }, + { + "x": 253.19010416666669, + "y": 216.40588247647852 + }, + { + "x": 262.5185185185186, + "y": 212.38669852648349 + }, + { + "x": 271.43721064814815, + "y": 208.3816834832985 + }, + { + "x": 279.85416666666663, + "y": 204.41053427419354 + }, + { + "x": 287.6773726851852, + "y": 200.49294782643867 + }, + { + "x": 294.8148148148148, + "y": 196.64862106730388 + }, + { + "x": 301.1744791666667, + "y": 192.89725092405914 + }, + { + "x": 306.6643518518519, + "y": 189.2585343239745 + }, + { + "x": 311.1924189814814, + "y": 185.75216819432003 + }, + { + "x": 314.66666666666663, + "y": 182.3978494623656 + }, + { + "x": 317.2032214506173, + "y": 179.13875938060036 + }, + { + "x": 319.01003086419746, + "y": 175.90398185483872 + }, + { + "x": 320.1328125, + "y": 172.69206779233872 + }, + { + "x": 320.61728395061726, + "y": 169.5015681003584 + }, + { + "x": 320.50916280864203, + "y": 166.33103368615593 + }, + { + "x": 319.85416666666663, + "y": 163.17901545698928 + }, + { + "x": 318.69801311728395, + "y": 160.0440643201165 + }, + { + "x": 317.08641975308643, + "y": 156.92473118279574 + }, + { + "x": 315.0651041666667, + "y": 153.81956695228496 + }, + { + "x": 312.67978395061726, + "y": 150.72712253584228 + }, + { + "x": 309.97617669753083, + "y": 147.64594884072585 + }, + { + "x": 307, + "y": 144.57459677419357 + }, + { + "x": 303.7969714506173, + "y": 141.51161724350362 + }, + { + "x": 300.4128086419753, + "y": 138.45556115591398 + }, + { + "x": 296.89322916666663, + "y": 135.40497941868284 + }, + { + "x": 293.28395061728395, + "y": 132.35842293906813 + }, + { + "x": 289.6306905864197, + "y": 129.31444262432797 + }, + { + "x": 285.97916666666663, + "y": 126.27158938172045 + }, + { + "x": 282.3750964506173, + "y": 123.22841411850361 + }, + { + "x": 278.8641975308642, + "y": 120.18346774193552 + }, + { + "x": 275.4921875, + "y": 117.13530115927419 + }, + { + "x": 272.30478395061726, + "y": 114.0824652777778 + }, + { + "x": 269.34770447530866, + "y": 111.02351100470432 + }, + { + "x": 266.6666666666667, + "y": 107.95698924731185 + }, + { + "x": 264.0348186728395, + "y": 104.82803127177917 + }, + { + "x": 261.20910493827165, + "y": 101.59307484567903 + }, + { + "x": 258.2109375, + "y": 98.2676306283602 + }, + { + "x": 255.06172839506175, + "y": 94.8672092791717 + }, + { + "x": 251.78288966049382, + "y": 91.4073214574622 + }, + { + "x": 248.39583333333334, + "y": 87.9034778225807 + }, + { + "x": 244.92197145061726, + "y": 84.37118903387599 + }, + { + "x": 241.38271604938274, + "y": 80.82596575069697 + }, + { + "x": 237.79947916666669, + "y": 77.28331863239251 + }, + { + "x": 234.19367283950618, + "y": 73.75875833831142 + }, + { + "x": 230.5867091049383, + "y": 70.26779552780269 + }, + { + "x": 227, + "y": 66.82594086021508 + }, + { + "x": 223.4549575617284, + "y": 63.448704994897476 + }, + { + "x": 219.9729938271605, + "y": 60.15159859119869 + }, + { + "x": 216.57552083333331, + "y": 56.9501323084677 + }, + { + "x": 213.28395061728395, + "y": 53.8598168060534 + }, + { + "x": 210.1196952160494, + "y": 50.896162743304416 + }, + { + "x": 207.10416666666669, + "y": 48.07468077956992 + }, + { + "x": 204.25877700617286, + "y": 45.41088157419852 + }, + { + "x": 201.6049382716049, + "y": 42.92027578653922 + }, + { + "x": 199.1640625, + "y": 40.61837407594081 + }, + { + "x": 196.95756172839512, + "y": 38.52068710175227 + }, + { + "x": 195.00684799382714, + "y": 36.64272552332236 + }, + { + "x": 193.33333333333334, + "y": 35 + } + ], + "previewMs": 3000, + "tip": "Your trace and the remembered path are overlaid.", + "explain": "A generated path with three to five connected sections, using either corners or smooth bends. It stays within the canvas and can be drawn in one stroke. Scores compare the complete paths; speed and direction do not matter." + }, + { + "id": "angle-0", + "type": "angle", + "answer": 14, + "skill": "Angles", + "title": "14°", + "tip": "The lime line shows the target angle.", + "explain": "0° points right. Measure counterclockwise from that baseline. The target can be any whole degree from 5° to 175°. Scores measure the angular distance from your guess." + }, + { + "id": "time-0", + "type": "time", + "answer": 6, + "skill": "Time awareness", + "title": "Feel 6 seconds.", + "unit": " s", + "tip": "Notice whether you stop early or late.", + "explain": "Any whole duration from 2 to 15 seconds. The clock measures Start to Stop; switching away resets a running round." + }, + { + "id": "perspective-0", + "skill": "Perspective", + "type": "perspective", + "title": "Vanishing point?", + "answer": { + "x": 185.01191646791995, + "y": 106.72352191060781 + }, + "tip": "The lime point is where the receding lines meet.", + "explain": "These parallel edges recede toward one vanishing point. The dashed extensions reveal their intersection. Scores compare the distance from your point to that intersection." + }, + { + "id": "half-half-0", + "type": "halfhalf", + "skill": "Half & Half", + "title": "Cut it in half.", + "fruit": { + "kind": "blob-3", + "base": [ + { + "x": 148.647, + "y": 102.489 + }, + { + "x": 154.482, + "y": 90.782 + }, + { + "x": 162.164, + "y": 80.058 + }, + { + "x": 171.561, + "y": 70.5 + }, + { + "x": 182.514, + "y": 62.272 + }, + { + "x": 194.834, + "y": 55.514 + }, + { + "x": 208.311, + "y": 50.342 + }, + { + "x": 222.715, + "y": 46.845 + }, + { + "x": 237.798, + "y": 45.082 + }, + { + "x": 253.302, + "y": 45.084 + }, + { + "x": 268.963, + "y": 46.85 + }, + { + "x": 284.513, + "y": 50.351 + }, + { + "x": 299.685, + "y": 55.526 + }, + { + "x": 314.219, + "y": 62.287 + }, + { + "x": 327.868, + "y": 70.518 + }, + { + "x": 340.398, + "y": 80.079 + }, + { + "x": 351.593, + "y": 90.805 + }, + { + "x": 361.263, + "y": 102.514 + }, + { + "x": 369.243, + "y": 115.005 + }, + { + "x": 375.394, + "y": 128.063 + }, + { + "x": 379.614, + "y": 141.467 + }, + { + "x": 381.828, + "y": 154.986 + }, + { + "x": 382, + "y": 168.389 + }, + { + "x": 380.126, + "y": 181.447 + }, + { + "x": 376.239, + "y": 193.936 + }, + { + "x": 370.404, + "y": 205.643 + }, + { + "x": 362.722, + "y": 216.367 + }, + { + "x": 353.325, + "y": 225.925 + }, + { + "x": 342.372, + "y": 234.154 + }, + { + "x": 330.052, + "y": 240.911 + }, + { + "x": 316.575, + "y": 246.083 + }, + { + "x": 302.171, + "y": 249.58 + }, + { + "x": 287.088, + "y": 251.343 + }, + { + "x": 271.584, + "y": 251.341 + }, + { + "x": 255.923, + "y": 249.575 + }, + { + "x": 240.373, + "y": 246.074 + }, + { + "x": 225.201, + "y": 240.899 + }, + { + "x": 210.667, + "y": 234.138 + }, + { + "x": 197.018, + "y": 225.907 + }, + { + "x": 184.488, + "y": 216.346 + }, + { + "x": 173.293, + "y": 205.62 + }, + { + "x": 163.623, + "y": 193.911 + }, + { + "x": 155.643, + "y": 181.421 + }, + { + "x": 149.492, + "y": 168.362 + }, + { + "x": 145.272, + "y": 154.958 + }, + { + "x": 143.058, + "y": 141.439 + }, + { + "x": 142.886, + "y": 128.036 + }, + { + "x": 144.76, + "y": 114.978 + } + ], + "regions": [ + [ + { + "x": 148.647, + "y": 102.489 + }, + { + "x": 154.482, + "y": 90.782 + }, + { + "x": 162.164, + "y": 80.058 + }, + { + "x": 171.561, + "y": 70.5 + }, + { + "x": 182.514, + "y": 62.272 + }, + { + "x": 194.834, + "y": 55.514 + }, + { + "x": 208.311, + "y": 50.342 + }, + { + "x": 222.715, + "y": 46.845 + }, + { + "x": 237.798, + "y": 45.082 + }, + { + "x": 253.302, + "y": 45.084 + }, + { + "x": 268.963, + "y": 46.85 + }, + { + "x": 284.513, + "y": 50.351 + }, + { + "x": 299.685, + "y": 55.526 + }, + { + "x": 314.219, + "y": 62.287 + }, + { + "x": 327.868, + "y": 70.518 + }, + { + "x": 340.398, + "y": 80.079 + }, + { + "x": 351.593, + "y": 90.805 + }, + { + "x": 361.263, + "y": 102.514 + }, + { + "x": 369.243, + "y": 115.005 + }, + { + "x": 375.394, + "y": 128.063 + }, + { + "x": 379.614, + "y": 141.467 + }, + { + "x": 381.828, + "y": 154.986 + }, + { + "x": 382, + "y": 168.389 + }, + { + "x": 380.126, + "y": 181.447 + }, + { + "x": 376.239, + "y": 193.936 + }, + { + "x": 370.404, + "y": 205.643 + }, + { + "x": 362.722, + "y": 216.367 + }, + { + "x": 353.325, + "y": 225.925 + }, + { + "x": 342.372, + "y": 234.154 + }, + { + "x": 330.052, + "y": 240.911 + }, + { + "x": 316.575, + "y": 246.083 + }, + { + "x": 302.171, + "y": 249.58 + }, + { + "x": 287.088, + "y": 251.343 + }, + { + "x": 271.584, + "y": 251.341 + }, + { + "x": 255.923, + "y": 249.575 + }, + { + "x": 240.373, + "y": 246.074 + }, + { + "x": 225.201, + "y": 240.899 + }, + { + "x": 210.667, + "y": 234.138 + }, + { + "x": 197.018, + "y": 225.907 + }, + { + "x": 184.488, + "y": 216.346 + }, + { + "x": 173.293, + "y": 205.62 + }, + { + "x": 163.623, + "y": 193.911 + }, + { + "x": 155.643, + "y": 181.421 + }, + { + "x": 149.492, + "y": 168.362 + }, + { + "x": 145.272, + "y": 154.958 + }, + { + "x": 143.058, + "y": 141.439 + }, + { + "x": 142.886, + "y": 128.036 + }, + { + "x": 144.76, + "y": 114.978 + } + ], + [ + { + "x": 167.914, + "y": 249.035 + }, + { + "x": 70.173, + "y": 314.918 + }, + { + "x": 19.903, + "y": 240.34 + }, + { + "x": 117.644, + "y": 174.457 + } + ], + [ + { + "x": 25.592, + "y": 79.103 + }, + { + "x": 28.447, + "y": 76.505 + }, + { + "x": 31.651, + "y": 74.331 + }, + { + "x": 35.149, + "y": 72.618 + }, + { + "x": 38.881, + "y": 71.394 + }, + { + "x": 42.783, + "y": 70.681 + }, + { + "x": 46.788, + "y": 70.491 + }, + { + "x": 50.828, + "y": 70.828 + }, + { + "x": 54.833, + "y": 71.685 + }, + { + "x": 58.736, + "y": 73.047 + }, + { + "x": 62.469, + "y": 74.893 + }, + { + "x": 65.968, + "y": 77.189 + }, + { + "x": 69.174, + "y": 79.896 + }, + { + "x": 72.032, + "y": 82.969 + }, + { + "x": 74.492, + "y": 86.355 + }, + { + "x": 76.513, + "y": 89.996 + }, + { + "x": 78.061, + "y": 93.829 + }, + { + "x": 79.108, + "y": 97.789 + }, + { + "x": 79.636, + "y": 101.808 + }, + { + "x": 79.638, + "y": 105.818 + }, + { + "x": 79.112, + "y": 109.749 + }, + { + "x": 78.068, + "y": 113.535 + }, + { + "x": 76.523, + "y": 117.111 + }, + { + "x": 74.504, + "y": 120.415 + }, + { + "x": 72.046, + "y": 123.391 + }, + { + "x": 69.191, + "y": 125.989 + }, + { + "x": 65.986, + "y": 128.163 + }, + { + "x": 62.489, + "y": 129.876 + }, + { + "x": 58.757, + "y": 131.1 + }, + { + "x": 54.855, + "y": 131.813 + }, + { + "x": 50.85, + "y": 132.003 + }, + { + "x": 46.81, + "y": 131.666 + }, + { + "x": 42.804, + "y": 130.809 + }, + { + "x": 38.902, + "y": 129.447 + }, + { + "x": 35.169, + "y": 127.601 + }, + { + "x": 31.67, + "y": 125.305 + }, + { + "x": 28.464, + "y": 122.598 + }, + { + "x": 25.606, + "y": 119.524 + }, + { + "x": 23.146, + "y": 116.139 + }, + { + "x": 21.124, + "y": 112.498 + }, + { + "x": 19.577, + "y": 108.665 + }, + { + "x": 18.53, + "y": 104.705 + }, + { + "x": 18.001, + "y": 100.686 + }, + { + "x": 18, + "y": 96.676 + }, + { + "x": 18.526, + "y": 92.745 + }, + { + "x": 19.57, + "y": 88.959 + }, + { + "x": 21.115, + "y": 85.383 + }, + { + "x": 23.133, + "y": 82.079 + } + ] + ], + "colors": [ + "#4d70ff", + "#3dcec3", + "#e965a0" + ], + "rotation": 0, + "sx": 1, + "sy": 1, + "skew": 0 + }, + "points": [ + { + "x": 148.647, + "y": 102.489 + }, + { + "x": 154.482, + "y": 90.782 + }, + { + "x": 162.164, + "y": 80.058 + }, + { + "x": 171.561, + "y": 70.5 + }, + { + "x": 182.514, + "y": 62.272 + }, + { + "x": 194.834, + "y": 55.514 + }, + { + "x": 208.311, + "y": 50.342 + }, + { + "x": 222.715, + "y": 46.845 + }, + { + "x": 237.798, + "y": 45.082 + }, + { + "x": 253.302, + "y": 45.084 + }, + { + "x": 268.963, + "y": 46.85 + }, + { + "x": 284.513, + "y": 50.351 + }, + { + "x": 299.685, + "y": 55.526 + }, + { + "x": 314.219, + "y": 62.287 + }, + { + "x": 327.868, + "y": 70.518 + }, + { + "x": 340.398, + "y": 80.079 + }, + { + "x": 351.593, + "y": 90.805 + }, + { + "x": 361.263, + "y": 102.514 + }, + { + "x": 369.243, + "y": 115.005 + }, + { + "x": 375.394, + "y": 128.063 + }, + { + "x": 379.614, + "y": 141.467 + }, + { + "x": 381.828, + "y": 154.986 + }, + { + "x": 382, + "y": 168.389 + }, + { + "x": 380.126, + "y": 181.447 + }, + { + "x": 376.239, + "y": 193.936 + }, + { + "x": 370.404, + "y": 205.643 + }, + { + "x": 362.722, + "y": 216.367 + }, + { + "x": 353.325, + "y": 225.925 + }, + { + "x": 342.372, + "y": 234.154 + }, + { + "x": 330.052, + "y": 240.911 + }, + { + "x": 316.575, + "y": 246.083 + }, + { + "x": 302.171, + "y": 249.58 + }, + { + "x": 287.088, + "y": 251.343 + }, + { + "x": 271.584, + "y": 251.341 + }, + { + "x": 255.923, + "y": 249.575 + }, + { + "x": 240.373, + "y": 246.074 + }, + { + "x": 225.201, + "y": 240.899 + }, + { + "x": 210.667, + "y": 234.138 + }, + { + "x": 197.018, + "y": 225.907 + }, + { + "x": 184.488, + "y": 216.346 + }, + { + "x": 173.293, + "y": 205.62 + }, + { + "x": 163.623, + "y": 193.911 + }, + { + "x": 155.643, + "y": 181.421 + }, + { + "x": 149.492, + "y": 168.362 + }, + { + "x": 145.272, + "y": 154.958 + }, + { + "x": 143.058, + "y": 141.439 + }, + { + "x": 142.886, + "y": 128.036 + }, + { + "x": 144.76, + "y": 114.978 + } + ], + "answer": { + "x": 228.79328392759288, + "y": 0, + "nx": 1, + "ny": 0 + }, + "tip": "The green line shows an even cut at your angle.", + "explain": "Split the total filled area into equal halves: count all the shapes together. Empty space does not count. Every colour counts equally. We measure the shape geometry, so screen size and pixel density do not affect your score. A 50/50 split earns 100 points. The same scoring curve as the other games applies: a 53/47 split earns 72 points, a 60/40 split earns 25 points, and a 70/30 split or worse earns zero." + } + ] + }, + { + "day": "2026-10-14", + "proportion": [ + { + "id": "proportion-0", + "type": "proportion", + "answer": 0.8, + "label": "4 : 5", + "skill": "Proportions", + "title": "Make 4 : 5.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-1", + "type": "proportion", + "answer": 0.6666666666666666, + "label": "2 : 3", + "skill": "Proportions", + "title": "Make 2 : 3.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-2", + "type": "proportion", + "answer": 1.1111111111111112, + "label": "10 : 9", + "skill": "Proportions", + "title": "Make 10 : 9.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-3", + "type": "proportion", + "answer": 0.9090909090909091, + "label": "10 : 11", + "skill": "Proportions", + "title": "Make 10 : 11.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "proportion-4", + "type": "proportion", + "answer": 2, + "label": "2 : 1", + "skill": "Proportions", + "title": "Make 2 : 1.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + } + ], + "mix": [ + { + "id": "motion-0", + "type": "motion", + "skill": "Line memory", + "title": "Trace that path.", + "answer": [ + { + "x": 45, + "y": 95 + }, + { + "x": 48.5625, + "y": 98.58333333333333 + }, + { + "x": 52.125, + "y": 102.16666666666667 + }, + { + "x": 55.6875, + "y": 105.75 + }, + { + "x": 59.25, + "y": 109.33333333333333 + }, + { + "x": 62.8125, + "y": 112.91666666666667 + }, + { + "x": 66.375, + "y": 116.5 + }, + { + "x": 69.9375, + "y": 120.08333333333334 + }, + { + "x": 73.5, + "y": 123.66666666666666 + }, + { + "x": 77.0625, + "y": 127.25 + }, + { + "x": 80.625, + "y": 130.83333333333334 + }, + { + "x": 84.1875, + "y": 134.41666666666666 + }, + { + "x": 87.75, + "y": 138 + }, + { + "x": 91.3125, + "y": 141.58333333333331 + }, + { + "x": 94.875, + "y": 145.16666666666669 + }, + { + "x": 98.4375, + "y": 148.75 + }, + { + "x": 102, + "y": 152.33333333333331 + }, + { + "x": 105.5625, + "y": 155.91666666666669 + }, + { + "x": 109.125, + "y": 159.5 + }, + { + "x": 112.6875, + "y": 163.08333333333331 + }, + { + "x": 116.25, + "y": 166.66666666666669 + }, + { + "x": 119.8125, + "y": 170.25 + }, + { + "x": 123.375, + "y": 173.83333333333331 + }, + { + "x": 126.9375, + "y": 177.41666666666669 + }, + { + "x": 130.5, + "y": 181 + }, + { + "x": 133.5625, + "y": 178.70833333333334 + }, + { + "x": 136.625, + "y": 176.41666666666666 + }, + { + "x": 139.6875, + "y": 174.125 + }, + { + "x": 142.75, + "y": 171.83333333333334 + }, + { + "x": 145.8125, + "y": 169.54166666666666 + }, + { + "x": 148.875, + "y": 167.25 + }, + { + "x": 151.9375, + "y": 164.95833333333334 + }, + { + "x": 155, + "y": 162.66666666666666 + }, + { + "x": 158.0625, + "y": 160.375 + }, + { + "x": 161.125, + "y": 158.08333333333334 + }, + { + "x": 164.1875, + "y": 155.79166666666666 + }, + { + "x": 167.25, + "y": 153.5 + }, + { + "x": 170.3125, + "y": 151.20833333333334 + }, + { + "x": 173.375, + "y": 148.91666666666666 + }, + { + "x": 176.4375, + "y": 146.625 + }, + { + "x": 179.5, + "y": 144.33333333333334 + }, + { + "x": 182.5625, + "y": 142.04166666666666 + }, + { + "x": 185.625, + "y": 139.75 + }, + { + "x": 188.6875, + "y": 137.45833333333334 + }, + { + "x": 191.75, + "y": 135.16666666666666 + }, + { + "x": 194.8125, + "y": 132.875 + }, + { + "x": 197.875, + "y": 130.58333333333334 + }, + { + "x": 200.9375, + "y": 128.29166666666666 + }, + { + "x": 204, + "y": 126 + }, + { + "x": 206.77083333333334, + "y": 128.29166666666666 + }, + { + "x": 209.54166666666666, + "y": 130.58333333333334 + }, + { + "x": 212.3125, + "y": 132.875 + }, + { + "x": 215.08333333333334, + "y": 135.16666666666666 + }, + { + "x": 217.85416666666666, + "y": 137.45833333333334 + }, + { + "x": 220.625, + "y": 139.75 + }, + { + "x": 223.39583333333334, + "y": 142.04166666666666 + }, + { + "x": 226.16666666666666, + "y": 144.33333333333334 + }, + { + "x": 228.9375, + "y": 146.625 + }, + { + "x": 231.70833333333334, + "y": 148.91666666666666 + }, + { + "x": 234.47916666666666, + "y": 151.20833333333334 + }, + { + "x": 237.25, + "y": 153.5 + }, + { + "x": 240.02083333333331, + "y": 155.79166666666666 + }, + { + "x": 242.79166666666669, + "y": 158.08333333333334 + }, + { + "x": 245.5625, + "y": 160.375 + }, + { + "x": 248.33333333333331, + "y": 162.66666666666666 + }, + { + "x": 251.10416666666669, + "y": 164.95833333333334 + }, + { + "x": 253.875, + "y": 167.25 + }, + { + "x": 256.6458333333333, + "y": 169.54166666666666 + }, + { + "x": 259.4166666666667, + "y": 171.83333333333334 + }, + { + "x": 262.1875, + "y": 174.125 + }, + { + "x": 264.9583333333333, + "y": 176.41666666666666 + }, + { + "x": 267.7291666666667, + "y": 178.70833333333334 + }, + { + "x": 270.5, + "y": 181 + }, + { + "x": 274.0208333333333, + "y": 178.70833333333334 + }, + { + "x": 277.5416666666667, + "y": 176.41666666666666 + }, + { + "x": 281.0625, + "y": 174.125 + }, + { + "x": 284.5833333333333, + "y": 171.83333333333334 + }, + { + "x": 288.1041666666667, + "y": 169.54166666666666 + }, + { + "x": 291.625, + "y": 167.25 + }, + { + "x": 295.1458333333333, + "y": 164.95833333333334 + }, + { + "x": 298.6666666666667, + "y": 162.66666666666666 + }, + { + "x": 302.1875, + "y": 160.375 + }, + { + "x": 305.7083333333333, + "y": 158.08333333333334 + }, + { + "x": 309.2291666666667, + "y": 155.79166666666666 + }, + { + "x": 312.75, + "y": 153.5 + }, + { + "x": 316.2708333333333, + "y": 151.20833333333334 + }, + { + "x": 319.7916666666667, + "y": 148.91666666666666 + }, + { + "x": 323.3125, + "y": 146.625 + }, + { + "x": 326.8333333333333, + "y": 144.33333333333334 + }, + { + "x": 330.3541666666667, + "y": 142.04166666666666 + }, + { + "x": 333.875, + "y": 139.75 + }, + { + "x": 337.3958333333333, + "y": 137.45833333333334 + }, + { + "x": 340.9166666666667, + "y": 135.16666666666666 + }, + { + "x": 344.4375, + "y": 132.875 + }, + { + "x": 347.9583333333333, + "y": 130.58333333333334 + }, + { + "x": 351.4791666666667, + "y": 128.29166666666666 + }, + { + "x": 355, + "y": 126 + } + ], + "previewMs": 3000, + "tip": "Your trace and the remembered path are overlaid.", + "explain": "A generated path with three to five connected sections, using either corners or smooth bends. It stays within the canvas and can be drawn in one stroke. Scores compare the complete paths; speed and direction do not matter." + }, + { + "id": "proportion-0", + "type": "proportion", + "answer": 0.8, + "label": "4 : 5", + "skill": "Proportions", + "title": "Make 4 : 5.", + "tip": "The lime outline shows the target proportion.", + "explain": "The ratio compares width with height using whole numbers, reduced to their simplest form. Any size works. Scores measure ratio error independently of size." + }, + { + "id": "timeline-mandela-becomes-president", + "type": "timeline", + "skill": "When?", + "title": "Mandela becomes president", + "answer": 1994, + "caption": "Place it in history.", + "explain": "Nelson Mandela became South Africa’s president in 1994.", + "source": [ + "Nelson Mandela Foundation", + "https://www.nelsonmandela.org/news/entry/parliament-elected-mandela-20-years-ago" + ], + "tip": "Answer: 1994" + }, + { + "id": "time-0", + "type": "time", + "answer": 8, + "skill": "Time awareness", + "title": "Feel 8 seconds.", + "unit": " s", + "tip": "Notice whether you stop early or late.", + "explain": "Any whole duration from 2 to 15 seconds. The clock measures Start to Stop; switching away resets a running round." + }, + { + "id": "angle-0", + "type": "angle", + "answer": 167, + "skill": "Angles", + "title": "167°", + "tip": "The lime line shows the target angle.", + "explain": "0° points right. Measure counterclockwise from that baseline. The target can be any whole degree from 5° to 175°. Scores measure the angular distance from your guess." + } + ] + } +] diff --git a/tests/verify-game-shell.cjs b/tests/verify-game-shell.cjs new file mode 100644 index 0000000..f669b8e --- /dev/null +++ b/tests/verify-game-shell.cjs @@ -0,0 +1,13 @@ +const fs=require('fs'),vm=require('vm'),assert=require('assert'); +const source=fs.readFileSync('src/design-options/game-themed.js','utf8').replace(/const demoGame=new URLSearchParams[\s\S]*$/,''); +const el={addEventListener(){},classList:{toggle(){}},dataset:{}}; +const ctx=vm.createContext({document:{getElementById(){return el},addEventListener(){},querySelectorAll(){return[]}},Date,Math,Number,setTimeout,clearTimeout,performance});vm.runInContext(source,ctx);const run=s=>vm.runInContext(s,ctx); +assert.throws(()=>run("renderGame({type:'missing'})"),/Unknown puzzle type/); +assert.throws(()=>run("registerGame('angle',{render(){}})"),/already registered/); +assert.throws(()=>run("registerGame('invalid',{})"),/needs a renderer/); +run("globalThis.calls=[];registerGame('new-game',{render:q=>calls.push(['render',q.answer]),reveal:(q,value,score)=>calls.push(['reveal',q.answer,value,score])});renderGame({type:'new-game',answer:42});revealGame({type:'new-game',answer:42},40,95)"); +assert.deepEqual(JSON.parse(run('JSON.stringify(calls)')),[['render',42],['reveal',42,40,95]]); +run('globalThis.disposals=0;activeGameShell={cleanups:[]};onGameCleanup(()=>disposals++);revealGame({type:"new-game",answer:42},40,95)');assert.equal(run('disposals'),0,'reveal retains the stage and its resize observers');run('disposeGameShell();disposeGameShell()');assert.equal(run('disposals'),1,'each mounted resource cleans up once'); +for(const type of ['view3d','balance','halfhalf','motion','perspective','proportion','rhythm','angle','colour','time','timeline','duration','typography','choices'])assert(run(`gameRegistry.has('${type}')`),type+' has an adapter'); +const q=run("makeDeck('practice:adapter-presentation','proportion')[0]"),before=JSON.stringify(q);ctx.q=q;const presentation=run("gameRegistry.get('proportion').presentation(q,'ready')");assert(presentation.title.includes('square'));assert.equal(JSON.stringify(q),before,'view presentation cannot rewrite the saved question'); +console.log('Verified new-game render/reveal dispatch, duplicate and unknown game handling, legacy adapters, immutable presentation, and stage resource cleanup across reveal/unmount.'); diff --git a/tests/verify-generated.cjs b/tests/verify-generated.cjs index 414c16c..a3484d1 100644 --- a/tests/verify-generated.cjs +++ b/tests/verify-generated.cjs @@ -8,11 +8,11 @@ for(let day=1;day<=100;day++){ const key='sample-day-'+day; for(const type of Object.keys(targets)){ const a=run(`makeDeck('${key}','${type}')`),b=run(`makeDeck('${key}','${type}')`);assert.equal(JSON.stringify(a),JSON.stringify(b));assert.equal(a.length,5); - for(const q of a){targets[type].add(JSON.stringify(q.answer));assert.equal(run(`scoreFor(${JSON.stringify(type==='proportion'?{width:q.answer,height:1}:q.answer)},${JSON.stringify(q)})`),100); + for(const q of a){targets[type].add(JSON.stringify(q.answer));assert.equal(run(`scoreFor(${JSON.stringify(type==='proportion'&&q.measurementVersion!==2?{width:q.answer,height:1}:q.answer)},${JSON.stringify(q)})`),100); if(type==='angle')assert(Number.isInteger(q.answer)&&q.answer>=5&&q.answer<=175); if(type==='time')assert(Number.isInteger(q.answer)&&q.answer>=2&&q.answer<=15); if(type==='rhythm')assert(Number.isInteger(q.answer)&&q.answer>=60&&q.answer<=144); - if(type==='proportion'){assert(q.answer>=.55&&q.answer<=2.4);assert(/^\d+ : \d+$/.test(q.label));const [w,h]=q.label.split(' : ').map(Number);assert.equal(q.answer,w/h);assert(w<=12&&h<=12);} + if(type==='proportion'){assert.equal(q.measurementVersion,2);assert.equal(q.answer.width,q.wide*q.reference);assert.equal(q.answer.height,q.tall*q.reference);assert(q.answer.width<=260&&q.answer.height<=175);assert(q.reference>=32&&q.reference<=54);} if(type==='motion'){assert(q.answer.length>=73&&q.answer.length<=121);for(const p of q.answer)assert(p.x>=25&&p.x<=375&&p.y>=25&&p.y<=275);} } } diff --git a/tests/verify-half-half.cjs b/tests/verify-half-half.cjs index 67a37e3..5dd626b 100644 --- a/tests/verify-half-half.cjs +++ b/tests/verify-half-half.cjs @@ -21,7 +21,7 @@ const plate={type:'balance',answer:50,points:[[0,0],[100,0],[100,100],[0,100]],s assert.equal(run(`scoreFor(50,${JSON.stringify(plate)})`),100,'Legacy saved balance scores remain valid'); const fruit=run("makeDeck('test','halfhalf')[0]");assert.equal(run(`scoreFor({x:0,y:0,nx:0,ny:0},${JSON.stringify(fruit)})`),0,'Invalid cut earns zero'); assert.equal(run(`scoreFor({x:1000,y:1000,nx:1,ny:0},${JSON.stringify(fruit)})`),0,'Cut missing the fruit earns zero'); -const html=fs.readFileSync('public/index.html','utf8');assert(html.includes('data-mini="balance">Half & Half'));assert(html.includes('20 percentage points from an even split (70/30)')); +const html=fs.readFileSync('public/index.html','utf8');assert((/]*data-mini="balance"[^>]*>[\s\S]*?Half & Half<\/strong>/.test(html)));assert(html.includes('20 percentage points from an even split (70/30)')); console.log('Verified 500 deterministic fruit puzzles, 4,000 cut orientations, exact area splits, bounded saves, invalid cuts, and legacy score compatibility.'); const drawing={};for(const name of ['clearRect','save','restore','translate','scale','rotate','transform','beginPath','moveTo','lineTo','closePath','clip','fillRect','fill','stroke','arc','ellipse','bezierCurveTo','quadraticCurveTo','setLineDash'])drawing[name]=()=>{};drawing.createLinearGradient=()=>({addColorStop(){}});ctx.renderCanvas={getContext(){return drawing;}};for(const kind of [...run('HALF_OBJECTS'),'pear','strawberry','mango','watermelon','peach']){run(`q=halfQuestion(rng(42),0,'${kind}');drawHalfFruit(renderCanvas,q,null);drawHalfFruit(renderCanvas,q,q.answer,true)`);}console.log('Verified initial and split rendering for all five fruits.'); diff --git a/tests/verify-play-navigation.cjs b/tests/verify-play-navigation.cjs index 1f7c333..52d0f87 100644 --- a/tests/verify-play-navigation.cjs +++ b/tests/verify-play-navigation.cjs @@ -12,9 +12,9 @@ assert(!el('practice-menu').classList.contains('active'));assert(el('daily').cla run("mini='mix';start('practice')");assert.equal(run('mode'),'practice');assert.equal(run('deck.length'),5); assert(el('practice-menu').classList.contains('active')); for(const page of ['index','focus','night-lab','studio']){ - const html=fs.readFileSync('public/'+page+'.html','utf8');assert(html.includes('
'));assert(!html.includes('role="group" aria-label="Game mode"'));assert(html.includes('aria-label="Practice games"'));assert(html.includes('Fresh mix'));assert(html.includes('id="history-button"'));assert(html.includes('aria-haspopup="dialog"'));assert(html.includes('