diff --git a/projects/lumen-atelier-8f3c/JOURNAL.md b/projects/lumen-atelier-8f3c/JOURNAL.md index 7710459b..d05ae56b 100644 --- a/projects/lumen-atelier-8f3c/JOURNAL.md +++ b/projects/lumen-atelier-8f3c/JOURNAL.md @@ -22,7 +22,11 @@ An atmospheric puzzle game about restoring floating gardens with light. The camp - [x] Custom static catalog thumbnail - [x] Engine and browser interaction verification - [x] Run exact repository gate and publish a PR -- [ ] Future: a freeform tile editor and community puzzle exchange +- [x] Freeform garden designer with verified, shareable puzzles +- [x] Per-garden resume with persisted undo/redo +- [x] Distinct chapter scenery, animated light, and visible light frontiers +- [x] Overhead board view for clearer play on small screens +- [x] Updated mechanic guidance and resume shortcuts ## Session log @@ -33,9 +37,14 @@ An atmospheric puzzle game about restoring floating gardens with light. The camp ## Follow-up notes - The canonical guide turn count is a known valid solution budget; it is not an optimality proof. -- The workshop generates reproducible boards from seeds. A freeform tile editor remains a separate future feature. -- Save data is device-local. Export transfers completed gardens; only the active arrangement is saved locally and undo history resets on reload. +- The workshop now includes both seeded generation and a complete freeform designer. Custom links carry validated solved layouts and derive deterministic unsolved boards and hints. Portal pairs are visibly labeled A/B. +- Save data is device-local. Export transfers completed awards. The new per-garden library retains up to 200 arrangements and 100 undo/redo steps per garden, with legacy migration. Designer drafts persist; editor undo lasts for that editor session. - Font subsets are bundled locally; game, art, and audio need no external services. - See README.md for development and verification commands. - 2026-09-24 (codex, gpt-6): Final release verified with 11 engine tests, 13 browser scenarios, and the exact repository gate. Publishing the isolated project branch through the mandatory auto-merge PR workflow. + +- 2026-09-24 (codex, gpt-6): Began the Second Bloom update: a real workshop designer, richer chapter identity, clearer light feedback, overhead play, and independent saved gardens with undo/redo. Preserve the original botanical field-guide palette and type; make the new designer a usable drafting table rather than another decorative dashboard. + +- 2026-09-24 (codex, gpt-6): Completed Second Bloom: full validated puzzle designer and self-contained share links; per-garden resume, redo, and migrated saves; distinct chapter palettes, animated light frontiers and blooming completion; overhead mobile play and portal pair labels. Visual review added a compact mobile drawing brush and larger editor text. Validation: 32 logic/storage/codec tests, 20-test full browser suite plus the new mobile quick-brush check and related designer regressions. +- 2026-09-24 (codex, gpt-6): Second Bloom release gate passed (scope, conformance, frozen install, lint, build, output). Publishing version 1.1.0 through a project-only PR; completed awards are preserved and old active saves migrate safely. diff --git a/projects/lumen-atelier-8f3c/README.md b/projects/lumen-atelier-8f3c/README.md index 1b96d317..b8d28b61 100644 --- a/projects/lumen-atelier-8f3c/README.md +++ b/projects/lumen-atelier-8f3c/README.md @@ -8,7 +8,9 @@ An unhurried light-routing puzzle game. Rotate carved stone channels on a floati - **Six evolving rules:** turning channels, branches, rooted stones, directional gates, isolated bridges, and paired portals. - **A daily garden** shared by UTC date, with date links that remain replayable. - **Seeded workshop gardens:** choose a chapter's rules and any 32-bit seed; share the resulting URL. -- **Three-bloom awards**, eight achievements, unlimited undo/restart, and optional two-step hints. +- **A complete garden designer:** compose 5×5, 6×6, or 7×7 boards, test their scrambled form, and share self-contained puzzle links. +- **Six distinct chapter palettes**, visible light frontiers, awakened flowers, and an overhead view with larger square targets for small screens. +- **Three-bloom awards**, eight achievements, undo/redo, restart, and optional two-step hints. - **Local saves and portable backups**, keyboard support, reduced motion, contrast settings, and optional synthesized sound and ambience. - Every chapter is open from the beginning. There are no timers, accounts, ads, or network game services. @@ -20,6 +22,8 @@ An unhurried light-routing puzzle game. Rotate carved stone channels on a floati | Turn counterclockwise | Shift + click | Shift + Enter / Space | | Move focus across stones | — | Arrow keys | | Undo | Undo | Z or U | +| Redo | Redo | Y or Shift + Z | +| Toggle overhead view | View toggle | V | | Restart | Restart | R | | Hint | A gentle nudge | H | | Field guide | Help / Field guide | ? | @@ -27,7 +31,15 @@ An unhurried light-routing puzzle game. Rotate carved stone channels on a floati A hint first identifies a useful stone. Request it again to rotate that stone to the guide solution. Hints reduce the current attempt's award to one bloom. Three blooms require a solution within the displayed turn guide without hints; any other unassisted solution earns two. The guide is a known-solution budget, not a claim of a mathematically shortest solution. Undo preserves the hint count; restarting begins a new attempt. Best awards remain saved when replaying. -Progress and the current garden's arrangement are saved locally. Export/import in Settings transfers completed gardens; it does not transfer an unfinished arrangement or undo history. Importing preserves existing best scores. If storage is unavailable, playing still works and the footer advises creating a backup. Saved ambience begins after the next player interaction; audio never starts on page load. +Each garden saves its own arrangement, hint state, and up to 100 undo and redo steps. Switching gardens or reloading keeps those records. The atlas offers resume shortcuts. The library retains the most recent 200 gardens; completed awards are stored separately. The previous single-garden save migrates automatically. Export/import in Settings transfers completed gardens; it does not transfer unfinished arrangements or undo history. Importing preserves existing best scores. If storage is unavailable, playing still works and the footer advises creating a backup. Saved ambience begins after the next player interaction; audio never starts on page load. + +## Design and share a puzzle + +Open **Workshop → Design a garden**. Start with the editable example or clear the stones, choose a size, and place sun wells, crystals, channels, branches, gates, bridges, and portals. The **Turn** tool can orient any stone while designing, including fixed terminals. Use the brush rotation and fixed toggle before placing stones. Portals can belong to pair A or pair B; each used pair needs exactly two portals. A compact brush selector sits beside the grid on mobile. + +Connect every crystal in the blueprint. Validation explains missing terminals, disconnected crystals, incomplete portal pairs, or arrangements that remain solved regardless of turns. A valid design can be tested as a deterministic scramble or copied as a link. Shared links contain the whole puzzle and need no server or account. The game provides hints and awards for custom gardens too. + +Your draft saves locally after edits. Arrow keys move around the editor, R turns the focused stone, Shift + R turns it backward, Delete clears it, and Ctrl/Command + Z undoes an edit. Resizing and clearing can be undone while the designer remains open. Designer edit history is separate from game undo history and does not survive closing the designer. ## Development @@ -36,7 +48,7 @@ pnpm install pnpm dev pnpm lint pnpm build -node --test tests/game.test.mjs +node --test tests/*.test.mjs pnpm exec playwright install chromium pnpm exec playwright test ``` @@ -52,9 +64,11 @@ node scripts/verify-project.mjs lumen-atelier-8f3c ## Implementation - `src/game.ts`: deterministic generation, solutions, light propagation, and hints. Sources and receivers are fixed; light requires reciprocal ports. A bridge keeps its two channels isolated, gates accept only the correct incoming direction, and matched portals connect distant components. Visited entry states prevent infinite cycles. -- `src/Board.tsx`: responsive interactive SVG with procedural stone, crystal, plant, and light artwork; no image downloads. +- `src/Board.tsx` and `src/boardThemes.ts`: responsive interactive SVG, chapter palettes, isometric/overhead projections, light frontiers, and procedural stone, crystal, plant, and light artwork. - `src/App.tsx`: campaign, atlas, workshop, journal, settings, awards, hash routing, and current-session state. - `src/progress.ts`: validated persistent records and portable, mergeable backups. +- `src/sessions.ts`: validated per-garden arrangements, undo/redo history, summaries, bounded storage, and legacy save migration. +- `src/CustomGarden.tsx` and `src/custom.ts`: live puzzle designer, strict share validation, portable codec, deterministic scrambling, and guide solutions. - `src/audio.ts`: small Web Audio instrument with lazy creation and explicit cleanup. All imagery, typography, and code are served from the app's relative Vite base. The only runtime persistence is browser local storage. No tracking or third-party requests. diff --git a/projects/lumen-atelier-8f3c/package.json b/projects/lumen-atelier-8f3c/package.json index 39fa338a..a2651d40 100644 --- a/projects/lumen-atelier-8f3c/package.json +++ b/projects/lumen-atelier-8f3c/package.json @@ -1,7 +1,7 @@ { "name": "lumen-lost-gardens", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "packageManager": "pnpm@11.5.2", "scripts": { diff --git a/projects/lumen-atelier-8f3c/project.json b/projects/lumen-atelier-8f3c/project.json index cc846604..aea2ae29 100644 --- a/projects/lumen-atelier-8f3c/project.json +++ b/projects/lumen-atelier-8f3c/project.json @@ -1,8 +1,12 @@ { "title": "Lumen: The Lost Gardens", - "description": "Restore floating gardens with light in 48 atmospheric puzzles. Discover six mechanics, a daily ritual, and endless shareable gardens.", + "description": "Restore 48 luminous gardens, create and share your own puzzles, and explore daily challenges with six mechanics and two board views.", "agent": "codex", "model": "gpt-6", - "tags": ["puzzle", "games", "creative"], + "tags": [ + "puzzle", + "games", + "creative" + ], "createdAt": "2026-09-24" } diff --git a/projects/lumen-atelier-8f3c/src/App.css b/projects/lumen-atelier-8f3c/src/App.css index 90e4666f..61c22443 100644 --- a/projects/lumen-atelier-8f3c/src/App.css +++ b/projects/lumen-atelier-8f3c/src/App.css @@ -340,3 +340,39 @@ @media(max-width:700px){ .main-nav button{font-size:11px}.page-heading p{font-size:11px}.stage-coordinate{font-size:8px}.stage-coordinate small{font-size:7px}.stage-weather{font-size:12px}.light-status{font-size:9px}.interaction-note{font-size:8px;flex-wrap:wrap;row-gap:3px}.interaction-note>span:last-child,.interaction-note>i:last-of-type{display:none}.garden-toolbar button{font-size:11px}.toolbar-end .hint-button{font-size:10px}.garden-notes{grid-template-columns:43px 1fr 108px;gap:0 10px}.garden-notes .objective-sigil{width:43px}.garden-notes>.eyebrow{font-size:6px;letter-spacing:.7px}.garden-notes>.objective-copy{font-size:10px}.garden-notes>h2{font-size:26px}.garden-notes .reward-guide>span:last-child{font-size:7px;line-height:1.6}.garden-notes .move-stats small{font-size:6px}.trail-name{font-size:7px;line-height:1.4;min-height:28px}.trail-heading .eyebrow,.trail-heading>span:last-child{font-size:8px}.page-footer>span:first-child{display:none}.page-footer>span:last-child{font-size:8px;margin:auto}.trail-levels{row-gap:17px}.stage-top{inset:15px 16px auto}.stage-bottom{left:16px;right:16px;bottom:13px}.chapter-card-body>p{font-size:10px;min-height:72px}.chapter-card-body .text-button{font-size:9px}.atlas-intro p{font-size:11px}.card-level-list button{font-size:10px}.workshop-form>p{font-size:12px}.workshop-form label{font-size:11px}.select-wrap select{font-size:11px}.workshop-form .input-note{font-size:10px}.workshop-promise{font-size:9px}.memory>p{font-size:12px}.memory .text-button{font-size:10px}.guide-steps p{font-size:11px}.guide-steps h3{font-size:12px}.mechanics-grid{grid-template-columns:1fr}.mechanics-grid p{font-size:12px}.mechanics-grid h4{font-size:13px}.guide-bottom{flex-direction:column;gap:0}.keyboard-guide{padding-top:0}.guide-bottom p{font-size:12px}.guide-content>.primary-button{width:100%}.guide-steps{grid-template-columns:1fr;gap:9px}.guide-steps>div{padding:15px 15px 15px 56px;min-height:82px}.guide-steps svg{position:absolute;left:17px;top:19px}.guide-steps>div>span{display:none}.toast{font-size:12px}.win-panel>.primary-button{font-size:10px} } + +/* Second Bloom: tools for building, revisiting, and reading the garden. */ +.stage-view-tools{display:flex;gap:7px;pointer-events:auto} +.view-toggle.selected{background:#baca9238;color:#f2e8b4;border-color:#c5d59a77} +.mechanic-reminder{display:flex;align-items:flex-start;gap:16px;border-top:1px solid var(--line);padding:13px 2px 11px;color:#6a7e5c} +.mechanic-reminder>span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap;font-size:10px;font-weight:600;line-height:1.7;color:#70855f} +.mechanic-reminder>p{font-size:11px;line-height:1.7;margin:0;color:#77866a} +.interaction-note kbd{font:inherit;border:1px solid #cfd9c1;padding:1px 3px;border-radius:2px} +.trail-dots.in-progress{font:500 8px 'DM Sans',sans-serif;color:#71894e;margin-top:4px;line-height:1.4} +.resume-tag{margin-left:auto;color:#688549;font-size:8px;padding:3px 5px;border:1px solid #d0ddbc;border-radius:3px;white-space:nowrap} +.card-level-list button>.resume-tag+svg{margin-left:0} +.resume-gardens{border:1px solid #d5dfc8;border-radius:5px;padding:20px;margin:0 0 25px;background:#e9efdf99} +.resume-heading{display:flex;align-items:center;gap:11px;margin-bottom:16px;color:#7a9160} +.resume-heading h2{font-family:var(--serif);font-size:24px;line-height:1.2;font-weight:500;margin:0;color:#50683e} +.resume-heading p{font-size:11px;color:#859572;margin:4px 0 0;line-height:1.6} +.resume-list{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px} +.resume-list>button{background:#f4f6ec;display:flex;align-items:center;gap:11px;text-align:left;border:1px solid #d9e3cb;padding:13px;border-radius:4px;min-width:0} +.resume-list>button:hover{background:#f9fbf1;border-color:#a7ba90} +.resume-list>button>.sigil{width:35px;flex-shrink:0;color:#93a676} +.resume-list>button>span{min-width:0;flex:1} +.resume-list strong{display:block;font-weight:500;font-size:11px;color:#607a4d} +.resume-list small{display:block;font-size:9px;color:#8c9c79;margin-top:5px;line-height:1.6} +.resume-list>button>svg:last-child{color:#809867;flex-shrink:0} +.workshop-tabs{display:flex;align-items:center;gap:5px;border-bottom:1px solid #d4dfc8;margin:-4px 0 26px;padding-bottom:10px} +.workshop-tabs>button{display:flex;align-items:center;gap:9px;padding:11px 17px;background:none;font-size:12px;color:#899a76;border-radius:4px;border:1px solid transparent} +.workshop-tabs>button:hover{color:#4f6b3a;background:#e5ecda} +.workshop-tabs>button.selected{color:#4f6b3a;background:#e2ebd5;border-color:#cddcbc} +.new-label{font-size:7px;letter-spacing:1px;line-height:1;background:#b6c898;color:#42622b;padding:4px;border-radius:2px;margin-left:3px} +.high-contrast .mechanic-reminder>p,.high-contrast .resume-heading p,.high-contrast .resume-list small{color:#506b40} +@media(max-width:1250px){.garden-toolbar .tool-group{gap:6px}.resume-list{grid-template-columns:1fr}.resume-list>button{padding:10px 13px}.resume-list small{font-size:10px}.resume-list strong{font-size:12px}.resume-list>button>.sigil{width:36px}} +@media(max-width:700px){.garden-toolbar{gap:5px;flex-wrap:wrap}.garden-toolbar .tool-group{gap:5px}.garden-toolbar .toolbar-end{gap:5px;margin-left:auto}.garden-toolbar button{font-size:10px;gap:4px;padding:3px}.toolbar-end .hint-button{font-size:10px;padding:8px}.tool-divider{margin:0 2px}.mechanic-reminder{flex-direction:column;gap:4px;padding:12px 0}.mechanic-reminder>span{font-size:10px}.mechanic-reminder>p{font-size:11px;line-height:1.7}.interaction-note{padding-top:3px}.stage-view-tools{gap:5px}.trail-dots.in-progress{font-size:6px}.workshop-tabs{gap:5px;margin-bottom:20px}.workshop-tabs>button{padding:10px 9px;font-size:11px;gap:6px}.workshop-tabs .new-label{font-size:6px;margin-left:0}.resume-gardens{padding:17px}.resume-heading h2{font-size:23px}.resume-heading p{font-size:10px}.resume-list{gap:8px}} +@media(max-width:390px){.garden-toolbar button{font-size:9px}.toolbar-end .hint-button{font-size:9px;padding:8px 6px}.garden-toolbar .tool-group{gap:3px}.garden-toolbar .toolbar-end{gap:3px}.garden-toolbar .sound-toggle{padding:2px}.stage-coordinate{max-width:175px}.new-label{display:none!important}} +.interaction-note .view-shortcut{display:inline-flex;align-items:center;gap:5px;background:none;font-size:inherit;padding:3px;color:#70865b;border-bottom:1px dotted #b9c7a8;border-radius:0} +.interaction-note .view-shortcut:hover{color:#405d2f} +@media(max-width:700px){.interaction-note>span:nth-of-type(2),.interaction-note>i{display:none}.interaction-note .view-shortcut{font-size:9px}.interaction-note>span:first-child{font-size:9px}} +.high-contrast .garden-tile .tile-surface{stroke:var(--garden-deep);stroke-width:1.8px} diff --git a/projects/lumen-atelier-8f3c/src/App.tsx b/projects/lumen-atelier-8f3c/src/App.tsx index 61384c30..2e7c0d9b 100644 --- a/projects/lumen-atelier-8f3c/src/App.tsx +++ b/projects/lumen-atelier-8f3c/src/App.tsx @@ -1,9 +1,13 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' -import { ArrowDownToLine, ArrowRight, ArrowUpFromLine, BookOpen, Check, ChevronDown, ChevronRight, CircleHelp, Compass, Copy, Flower2, Grid2X2, Headphones, Leaf, Lightbulb, Maximize, Moon, RotateCcw, Settings2, Shuffle, Sparkles, Sprout, Sun, Trophy, Undo2, Volume2, VolumeX, WandSparkles, Wind, X } from 'lucide-react' +import { ArrowDownToLine, ArrowRight, ArrowUpFromLine, BookOpen, Check, ChevronDown, ChevronRight, CircleHelp, Compass, Copy, Flower2, Grid2X2, Headphones, Leaf, Lightbulb, Maximize, Moon, PencilRuler, Redo2, RotateCcw, Settings2, Shuffle, Sparkles, Sprout, Sun, Trophy, Undo2, Volume2, VolumeX, WandSparkles, Wind, X } from 'lucide-react' import Board from './Board' import { CHAPTERS, LEVELS, dailyLevel, generateLevel, getHint, rotateTile, simulate } from './game' -import type { Level, Tile } from './game' +import type { Level } from './game' +import CustomGarden from './CustomGarden' +import { decodeCustomLevel } from './custom' +import { getSessionSummaries, loadSession, newSession, saveSession } from './sessions' +import type { GardenSession as Session } from './sessions' import { exportProgress, importProgress, loadProgress, saveProgress } from './progress' import type { Progress } from './progress' import { GardenAudio } from './audio' @@ -11,40 +15,36 @@ import './App.css' type Page = 'play' | 'atlas' | 'daily' | 'workshop' | 'journal' type Modal = 'guide' | 'settings' | 'achievements' | null -type Snapshot = { tiles: Tile[]; moves: number } -type Session = Snapshot & { level: Level; history: Snapshot[]; hints: number; hintId: string | null; hintRotation: number | null; won: boolean } -const SESSION_KEY = 'lumen-garden-session-v1' const ROMAN = ['I', 'II', 'III', 'IV', 'V', 'VI'] const MECHANICS = ['Light & turns', 'Branching paths', 'Rooted stones', 'One-way gates', 'Woven channels', 'Paired portals'] const today = () => new Date().toISOString().slice(0, 10) const countCompleted = (progress: Progress) => LEVELS.filter(level => progress.completed[level.id]).length -function makeSession(level: Level, restore = false): Session { - const base: Session = { level, tiles: level.tiles.map(tile => ({ ...tile })), moves: 0, history: [], hints: 0, hintId: null, hintRotation: null, won: false } - if (restore) { - try { - const saved = JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') - if (saved?.id === level.id && Array.isArray(saved.rotations) && saved.rotations.length === level.tiles.length && saved.rotations.every((n: unknown) => Number.isInteger(n) && Number(n) >= 0 && Number(n) <= 3) && Number.isInteger(saved.moves) && saved.moves >= 0 && Number.isInteger(saved.hints) && saved.hints >= 0) { - base.tiles = base.tiles.map((tile, i) => ({ ...tile, rotation: tile.fixed || ['source', 'crystal', 'rock'].includes(tile.kind) ? tile.rotation : saved.rotations[i] })) - base.moves = saved.moves - base.hints = saved.hints - base.won = simulate(base.tiles).solved - } - } catch { /* A damaged session must never prevent a fresh garden. */ } - } - return base +function loadOverheadPreference(): boolean { + try { return localStorage.getItem('lumen-overhead-view') === 'true' } catch { return false } +} + +function isCalendarDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false + const date = new Date(`${value}T00:00:00.000Z`) + return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value } -function parseRoute(hash: string, progress: Progress): { page: Page; level: Level } { +function parseRoute(hash: string, progress: Progress): { page: Page; level: Level; error?: string; workshopMode?: 'seed' | 'design' } { const parts = hash.replace(/^#\/?/, '').split('/') const fallback = LEVELS.find(level => level.id === progress.currentLevel) || LEVELS[0] - if (parts[0] === 'daily') return { page: 'daily', level: dailyLevel(/^\d{4}-\d{2}-\d{2}$/.test(parts[1] || '') ? parts[1] : today()) } + if (parts[0] === 'daily') return { page: 'daily', level: dailyLevel(isCalendarDate(parts[1] || '') ? parts[1] : today()) } + if (parts[0] === 'custom') { + const level = decodeCustomLevel(parts[1] || '') + return level ? { page: 'play', level } : { page: 'workshop', level: fallback, workshopMode: 'design', error: 'This garden link is incomplete or invalid. Ask for a new link, or design a garden of your own.' } + } if (parts[0] === 'seed') { const seed = /^\d{1,10}$/.test(parts[1] || '') ? Number(parts[1]) >>> 0 : 108 const chapter = /^[0-5]$/.test(parts[2] || '') ? Number(parts[2]) : 0 return { page: 'play', level: generateLevel(seed, chapter) } } - if (['atlas', 'workshop', 'journal'].includes(parts[0])) return { page: parts[0] as Page, level: fallback } + if (parts[0] === 'workshop') return { page: 'workshop', level: fallback, workshopMode: parts[1] === 'design' ? 'design' : 'seed' } + if (['atlas', 'journal'].includes(parts[0])) return { page: parts[0] as Page, level: fallback } return { page: 'play', level: LEVELS.find(level => level.id === parts[1]) || fallback } } @@ -111,10 +111,13 @@ export default function App() { }) const [progress, setProgress] = useState(initial.progress) const [page, setPage] = useState(initial.route.page) - const [session, setSession] = useState(() => makeSession(initial.route.level, true)) + const [session, setSession] = useState(() => loadSession(initial.route.level)) const [modal, setModal] = useState(null) - const [toast, setToast] = useState('') + const [toast, setToast] = useState(initial.route.error || '') const [focusMode, setFocusMode] = useState(false) + const [overhead, setOverhead] = useState(loadOverheadPreference) + const [savedGardens, setSavedGardens] = useState(getSessionSummaries) + const [workshopMode, setWorkshopMode] = useState<'seed' | 'design'>(initial.route.workshopMode || (initial.route.level.id.startsWith('custom-') ? 'design' : 'seed')) const [atlasChapter, setAtlasChapter] = useState(null) const [workshopChapter, setWorkshopChapter] = useState(2) const [seed, setSeed] = useState('314159') @@ -135,6 +138,8 @@ export default function App() { const totalBlooms = LEVELS.reduce((sum, level) => sum + (progress.completed[level.id]?.stars || 0), 0) const isGarden = page === 'play' || page === 'daily' const isCampaign = LEVELS.some(level => level.id === session.level.id) + const isCustom = session.level.id.startsWith('custom-') + const unfinished = LEVELS.filter(level => savedGardens[level.id]?.moves > 0 && !savedGardens[level.id].won && !progress.completed[level.id]).sort((a, b) => savedGardens[b.id].updatedAt.localeCompare(savedGardens[a.id].updatedAt)).slice(0, 3) const chapterLevels = LEVELS.filter(level => level.chapter === session.level.chapter) const chapterComplete = chapterLevels.filter(level => progress.completed[level.id]).length const activeLevelNumber = isCampaign ? session.level.index % 8 + 1 : null @@ -157,9 +162,11 @@ export default function App() { const handleHash = () => { const route = parseRoute(window.location.hash, progressRef.current) setPage(route.page) + if (route.workshopMode) setWorkshopMode(route.workshopMode) + if (route.error) { setToast(route.error); setWorkshopMode('design') } setMobileChapters(false) if (route.page === 'play' || route.page === 'daily') { - setSession(makeSession(route.level, true)) + setSession(loadSession(route.level)) if (LEVELS.some(level => level.id === route.level.id)) setProgress(current => ({ ...current, currentLevel: route.level.id })) } } @@ -181,10 +188,9 @@ export default function App() { }, [progress]) useEffect(() => { if (!isGarden) return - let saved = true - try { localStorage.setItem(SESSION_KEY, JSON.stringify({ id: session.level.id, rotations: session.tiles.map(tile => tile.rotation), moves: session.moves, hints: session.hints })) } catch { saved = false } + const saved = saveSession(session) let active = true - queueMicrotask(() => { if (active) setSessionStorageOk(saved) }) + queueMicrotask(() => { if (active) { setSessionStorageOk(saved); setSavedGardens(getSessionSummaries()) } }) return () => { active = false } }, [session, isGarden]) useEffect(() => { @@ -200,7 +206,9 @@ export default function App() { if (window.location.hash === `#/${path}`) { const route = parseRoute(`#/${path}`, progress) setPage(route.page) - setSession(makeSession(route.level, true)) + if (route.workshopMode) setWorkshopMode(route.workshopMode) + if (route.error) setToast(route.error) + setSession(loadSession(route.level)) } else window.location.hash = `/${path}` window.scrollTo({ top: 0, behavior: 'instant' }) } @@ -222,7 +230,7 @@ export default function App() { if (!tile || tile.fixed || ['source', 'crystal', 'rock'].includes(tile.kind)) return const difference = target === undefined ? 1 : Math.min((target - tile.rotation + 4) % 4, (tile.rotation - target + 4) % 4) const tiles = current.tiles.map(item => item.id === id ? target === undefined ? rotateTile(item, delta) : { ...item, rotation: target } : item) - const next: Session = { ...current, tiles, moves: current.moves + difference, history: [...current.history.slice(-499), { tiles: current.tiles, moves: current.moves }], hintId: null, hintRotation: null, won: simulate(tiles).solved } + const next: Session = { ...current, tiles, moves: current.moves + difference, history: [...current.history.slice(-99), { tiles: current.tiles, moves: current.moves }], future: [], hintId: null, hintRotation: null, won: simulate(tiles).solved } stateRef.current = next setSession(next) if (next.won) rememberWin(next) @@ -233,14 +241,31 @@ export default function App() { const current = stateRef.current const previous = current.history.at(-1) if (!previous) return - const next = { ...current, ...previous, history: current.history.slice(0, -1), won: false, hintId: null, hintRotation: null } + const next = { ...current, ...previous, history: current.history.slice(0, -1), future: [...current.future.slice(-99), { tiles: current.tiles, moves: current.moves }], won: simulate(previous.tiles).solved, hintId: null, hintRotation: null } stateRef.current = next setSession(next) audio.current?.play('rotate') } + function redo() { + const current = stateRef.current + const following = current.future.at(-1) + if (!following) return + const next: Session = { ...current, ...following, history: [...current.history.slice(-99), { tiles: current.tiles, moves: current.moves }], future: current.future.slice(0, -1), won: simulate(following.tiles).solved, hintId: null, hintRotation: null } + stateRef.current = next + setSession(next) + if (next.won) rememberWin(next) + else audio.current?.play('rotate') + } + + function toggleOverhead() { + const next = !overhead + setOverhead(next) + try { localStorage.setItem('lumen-overhead-view', String(next)) } catch { /* View remains usable without storage. */ } + } + function restart() { - const next = makeSession(stateRef.current.level) + const next = newSession(stateRef.current.level) stateRef.current = next setSession(next) audio.current?.play('reset') @@ -266,10 +291,12 @@ export default function App() { useEffect(() => { const handleKey = (event: KeyboardEvent) => { if (event.ctrlKey || event.metaKey || event.altKey || modal || !isGarden || (event.target instanceof HTMLElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName))) return - if (event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'u') { event.preventDefault(); undo() } + if (event.key.toLowerCase() === 'y' || (event.shiftKey && event.key.toLowerCase() === 'z')) { event.preventDefault(); redo() } + else if (event.key.toLowerCase() === 'z' || event.key.toLowerCase() === 'u') { event.preventDefault(); undo() } if (event.key.toLowerCase() === 'r') { event.preventDefault(); restart() } if (event.key.toLowerCase() === 'h') { event.preventDefault(); hint() } if (event.key === '?') setModal('guide') + if (event.key.toLowerCase() === 'v') toggleOverhead() if (event.key === 'Escape') setFocusMode(false) } window.addEventListener('keydown', handleKey) @@ -300,7 +327,7 @@ export default function App() { } function nextGarden() { - if (!isCampaign) { navigate(page === 'daily' ? 'atlas' : 'workshop'); return } + if (!isCampaign) { navigate(page === 'daily' ? 'atlas' : isCustom ? 'workshop/design' : 'workshop'); return } const next = LEVELS[session.level.index + 1] if (next) navigate(`play/${next.id}`) else navigate('journal') @@ -347,18 +374,19 @@ export default function App() {
{isGarden && <> -

{session.level.title}✳

{session.level.subtitle}

+

{session.level.title}✳

{session.level.subtitle}

-
{page === 'daily' ? 'TODAY’S GARDEN' : isCampaign ? `GARDEN ${String(session.level.index + 1).padStart(2, '0')}` : 'THE WORKSHOP'}{session.level.size} × {session.level.size} · {MECHANICS[session.level.chapter]}
- -
0 ? 'lit' : ''}`} />{simulation.litCrystals} / {simulation.totalCrystals} crystals awake{session.won ? 'The garden remembers.' : 'Follow the light.'}
+
{page === 'daily' ? 'TODAY’S GARDEN' : isCustom ? 'THE GARDEN STUDIO' : isCampaign ? `GARDEN ${String(session.level.index + 1).padStart(2, '0')}` : 'THE WORKSHOP'}{session.level.size} × {session.level.size} · {MECHANICS[session.level.chapter]}
+ +
0 ? 'lit' : ''}`} />{simulation.litCrystals} / {simulation.totalCrystals} crystals awake{session.won ? 'The garden remembers.' : 'Follow the light.'}
{session.won &&
A little corner of the world, alight.
}
-
-

Click a stone to rotateShift + click to turn backTake your time.

+
+
{MECHANICS[session.level.chapter]}

{chapter.mechanic}

+

Tap or click a stone to rotateShift + click to turn back

- {isCampaign ?
{chapter.title.toUpperCase()}{chapterComplete} of 8 restored
{chapterLevels.map((level, i) => )}
:
{page === 'daily' ? 'The same sky. The same garden. A new possibility every day.' : 'Every seed is a different place to begin.'}
} + {isCampaign ?
{chapter.title.toUpperCase()}{chapterComplete} of 8 restored
{chapterLevels.map((level, i) => )}
:
{page === 'daily' ? 'The same sky. The same garden. A new possibility every day.' : isCustom ? 'A little light, left here by another keeper.' : 'Every seed is a different place to begin.'}
} } {page === 'atlas' && <>
AN EXPEDITION IN SIX CHAPTERS

A world waiting for light.

Forty-eight quiet places. A thousand ways to begin.

{completed}/48GARDENS RESTORED

Every chapter is open. Follow the journey, or wander wherever your curiosity takes you.

{totalBlooms} / 144 blooms
+ {unfinished.length > 0 &&

Right where you left the light.

Your turns and undo history are waiting in each garden.

{unfinished.map(level => )}
}
{CHAPTERS.map((item, i) => { const levels = LEVELS.filter(level => level.chapter === i) const done = levels.filter(level => progress.completed[level.id]).length - return
CHAPTER {ROMAN[i]} · 8 GARDENS

{item.title}

{item.description}

{done} / 8
{atlasChapter === i &&
{levels.map((level, n) => )}
}
+ return
CHAPTER {ROMAN[i]} · 8 GARDENS

{item.title}

{item.description}

{done} / 8
{atlasChapter === i &&
{levels.map((level, n) => )}
}
})}
} {page === 'workshop' && <>
BEYOND THE KNOWN GARDENS

Make room for wonder.

A seed, a little curiosity, and a garden that has never been yours before.

+
+ {workshopMode === 'design' ? navigate(`custom/${code}`)} onNotice={setToast} onShare={code => { void copyText(`${window.location.href.split('#')[0]}#/custom/${code}`, 'Puzzle link copied. Your garden is ready for a friend.') }} /> : <>
POSSIBILITY TAKES ROOT

One seed.
Endless beginnings.

Nº {seed || '0'}
YOUR GARDEN RECIPE

Plant a new puzzle.

Choose a chapter’s rules and give your garden a seed. The same recipe always grows the same puzzle, ready to share.

setSeed(event.target.value.replace(/\D/g, ''))} onKeyDown={event => { if (event.key === 'Enter') launchWorkshop() }} />

Any whole number from 0 to 4,294,967,295.

Every garden has a verified path to the light.
A FEW SEEDS TO GET YOU STARTED
{[{ seed: 1729, chapter: 0, title: 'An easy morning', description: 'A small, quiet place to find your rhythm.' }, { seed: 271828, chapter: 3, title: 'The long way home', description: 'One-way paths reward a patient eye.' }, { seed: 1618033, chapter: 5, title: 'Somewhere, elsewhere', description: 'Step through a door in the light.' }].map(item => )}
+ } } {page === 'journal' && <> @@ -412,7 +444,7 @@ export default function App() {
{modal && - {modal === 'guide' &&

Nothing is lost. Nothing is timed. Turn the channels until the light reaches every crystal.

01

Find the source

The golden lantern is always alight. Follow its channel into the garden.

02

Turn the stones

Click a stone to rotate it clockwise. Its channel must meet the next stone’s channel.

03

Wake every crystal

Connect all the crystals to the source. Light travels automatically along joined channels.

The shapes of a garden

{[{ symbol: '└', name: 'Channels', text: 'Straight stones and corners carry light through their open ends.' }, { symbol: '┬', name: 'Branches', text: 'A branch sends light down every connected arm. Find more than one path.' }, { symbol: '◈', name: 'Rooted stones', text: 'Stones marked with small pins cannot turn. Sources and crystals stay still, too.' }, { symbol: '↑', name: 'One-way gates', text: 'Light can only pass in the arrow’s direction. Turning the gate changes its direction.' }, { symbol: '╪', name: 'Bridges', text: 'Two channels cross but never join. North connects south; east connects west.' }, { symbol: '◎', name: 'Portals', text: 'Matching letters share a doorway. Light entering one leaves through its partner.' }].map(item =>
{item.symbol}

{item.name}

{item.text}

)}

A little help is always here.

Hints first mark a stone, then offer to turn it for you. Earn three blooms within the gardener’s guide without hints; two for any unassisted solution; one with a helping hand. Every garden remains open.

Tab Select stoneEnter / Space TurnShift + click / Enter · ReverseZ Undo R Restart H Hint
} + {modal === 'guide' &&

Nothing is timed. Turn the channels until the light reaches every crystal. Tiny golden marks show where the light is waiting for a connection. Switch to overhead view for a clearer view of every channel.

01

Find the source

The golden lantern is always alight. Follow its channel into the garden.

02

Turn the stones

Click a stone to rotate it clockwise. Its channel must meet the next stone’s channel.

03

Wake every crystal

Connect all the crystals to the source. Light travels automatically along joined channels.

The shapes of a garden

{[{ symbol: '└', name: 'Channels', text: 'Straight stones and corners carry light through their open ends.' }, { symbol: '┬', name: 'Branches', text: 'A branch sends light down every connected arm. Find more than one path.' }, { symbol: '◈', name: 'Rooted stones', text: 'Stones marked with small pins cannot turn. Sources and crystals stay still, too.' }, { symbol: '↑', name: 'One-way gates', text: 'Light can only pass in the arrow’s direction. Turning the gate changes its direction.' }, { symbol: '╪', name: 'Bridges', text: 'Two channels cross but never join. North connects south; east connects west.' }, { symbol: '◎', name: 'Portals', text: 'Matching letters share a doorway. Light entering one leaves through its partner.' }].map(item =>
{item.symbol}

{item.name}

{item.text}

)}

A little help is always here.

Hints first mark a stone, then offer to turn it for you. Earn three blooms within the gardener’s guide without hints; two for any unassisted solution; one with a helping hand. Every garden remains open.

Tab Select stoneEnter / Space TurnShift + click / Enter · ReverseZ Undo Y RedoR Restart H Hint V View
} {modal === 'settings' &&

A little space, just the way you like it.

changeSetting('sound', !progress.sound)} icon={} />} /> changeSetting('reduceMotion', !progress.reduceMotion)} icon={} /> changeSetting('highContrast', !progress.highContrast)} icon={} />

Keep your journey close.

Your progress stays in this browser. Copy a backup to take your completed gardens to another device.

{backupText && <>