diff --git a/CLAUDE.md b/CLAUDE.md
index 50157c4..1079d27 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -20,7 +20,7 @@ This is the project root for Labyrinth Game Solver, a responsive, mobile-friendl
- **Mobile Standalone / PWA**: Configured with Apple Web App meta tags to allow launching full-screen from the iPhone home screen. Max viewport bounds prevent bouncing and double-tap zoom.
- **Path Resolving**: In `vite.config.ts`, `base: './'` is configured so that files compile with relative links to support hosting on GitHub Pages subpaths. Alias `@/` maps to the `src/` folder.
- **Safe Deep Copy**: `useLabyrinthHistory.ts` uses a custom `deepClone` (via `JSON.parse(JSON.stringify(...))`) instead of solver's `cloneBoard()` to avoid crash errors with `null` grid values.
-- **Auto-Save / Game Storage**: The state synchronizes to the browser's `localStorage` database under `labyrinth_saved_slots_list` and `labyrinth_strategist_state`.
+- **Auto-Save / Game Storage**: The board layout and game state are autosaved to the browser's `localStorage` under `labyrinth_strategist_state` and restored on reload. User preferences (theme, accent color, mute, 3D, active players) use separate keys (`labyrinth_theme`, `labyrinth_accent_color`, etc.).
## Release Pipeline
diff --git a/CONTEXT.md b/CONTEXT.md
index 45e7aa2..9eaade3 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -42,4 +42,4 @@ The Labyrinth project manages its interface states directly inside `App.tsx` and
- **[src/components/Tile.tsx](src/components/Tile.tsx)**: Generates CSS-based corridors (straight, corner, t-junction) based on rotation degrees, and overlays pawn tokens.
- **[src/solver.js](src/solver.js)**: Legacy pathfinder algorithm (implements graph BFS and slide shifts).
- **[src/constants.ts](src/constants.ts)**: Configures diagnostic starts (colors, diagonal alignment presets, named treasures).
-- **[src/hooks/useLabyrinthStorage.ts](src/hooks/useLabyrinthStorage.ts)**: Interacts with the browser's `LocalStorage` database to save and load named slots and the auto-save.
+- **[src/hooks/useLabyrinthStorage.ts](src/hooks/useLabyrinthStorage.ts)**: Autosaves board and game state to `localStorage` (`labyrinth_strategist_state`) and restores it on reload.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index e4cb603..b0ead2b 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -50,7 +50,7 @@ Complex pathfinder searches run inside `solver.worker.js` (Web Worker).
### 2. State & History Synchronization
- **`useLabyrinthHistory`**: Keeps deep cloned snapshots of `AppGameState` to manage custom undo/redo actions.
-- **`useLabyrinthStorage`**: Provides quick read/write tools to standard `localStorage` to save game slots and sync setup options.
+- **`useLabyrinthStorage`**: Autosaves board and game state to `localStorage` (`labyrinth_strategist_state`) and restores it on reload.
### 3. Native Web Audio Synth
Audio feedback is synthesized dynamically using the Web Audio API in `src/utils/audio.ts` (minimizing app footprint by not packing static mp3/wav files).
diff --git a/src/App.tsx b/src/App.tsx
index 63a3259..e5b5837 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -43,11 +43,8 @@ import {
} from "lucide-react";
import { cn } from "./lib/utils";
-// Search depth for the solver worker. Always searches this many turns ahead
-// rather than exposing it as a user-facing option — solveAllHand already
-// ranks shorter/safer paths first, so a wider search can only surface better
-// suggestions, never worse ones.
-const SOLVER_MAX_TURNS = 3;
+// Default solver depth. Can be overridden via the Advanced settings panel.
+const DEFAULT_SOLVER_DEPTH = 3;
export default function App() {
const sensors = useSensors(
@@ -91,7 +88,11 @@ export default function App() {
const [turnPhase, setTurnPhase] = useState<"slide" | "move">("slide");
const [stagedArrow, setStagedArrow] = useState(null);
const [stagedRotation, setStagedRotation] = useState<0 | 90 | 180 | 270>(0);
- const [showOneMoveTargets, setShowOneMoveTargets] = useState(false);
+ const [showOneMoveTargets, setShowOneMoveTargets] = useState(true);
+ const [solverDepth, setSolverDepthState] = useState(() => {
+ const saved = parseInt(localStorage.getItem("labyrinth_solver_depth") ?? "");
+ return [1, 2, 3, 4, 5].includes(saved) ? saved : DEFAULT_SOLVER_DEPTH;
+ });
const [mobilePanelStop, setMobilePanelStop] = useState<"peek" | "expanded">("expanded");
const [hasShownSetupHint, setHasShownSetupHint] = useState(false);
const [showWelcomeGuide, setShowWelcomeGuide] = useState(
@@ -152,6 +153,15 @@ export default function App() {
}
}, [baseTheme]);
+ const setSolverDepth = useCallback((depth: number) => {
+ setSolverDepthState(depth);
+ try {
+ localStorage.setItem("labyrinth_solver_depth", String(depth));
+ } catch {
+ /* storage full */
+ }
+ }, []);
+
// ── Accent color ──────────────────────────────────────────────────────────────
const applyAccentColor = useCallback((hex: string) => {
if (!hex) return;
@@ -309,11 +319,6 @@ export default function App() {
game.handleRedo();
return;
}
- if (ctrl && e.key === "s") {
- e.preventDefault();
- showToast("Autosave keeps your layout ready next time ✨");
- return;
- }
if (e.key === "?" && !ctrl) {
e.preventDefault();
setIsSettingsOpen(true);
@@ -441,7 +446,7 @@ export default function App() {
pawnPos: currentPawnCoord,
handCards,
lastShiftArrowId: game.lastShiftArrowId,
- maxTurns: SOLVER_MAX_TURNS,
+ maxTurns: solverDepth,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -455,6 +460,7 @@ export default function App() {
game.getSolverFormattedBoard,
game.getSolverFormattedSpare,
game.customTargetCoords,
+ solverDepth,
]);
// ── Drag and Drop ─────────────────────────────────────────────────────────────
@@ -789,6 +795,8 @@ export default function App() {
onToggleTimer={toggleTimer}
is3D={is3D}
onToggle3D={toggle3D}
+ solverDepth={solverDepth}
+ onSetSolverDepth={setSolverDepth}
/>
diff --git a/src/components/AppHeader.tsx b/src/components/AppHeader.tsx
index 88ba3a2..4b05c24 100644
--- a/src/components/AppHeader.tsx
+++ b/src/components/AppHeader.tsx
@@ -19,6 +19,7 @@ import {
Settings2,
HelpCircle,
Clock,
+ BarChart2,
} from "lucide-react";
export interface AppHeaderProps {
@@ -56,6 +57,8 @@ export interface AppHeaderProps {
onToggleTimer?: () => void;
is3D?: boolean;
onToggle3D: () => void;
+ solverDepth?: number;
+ onSetSolverDepth?: (depth: number) => void;
}
const iconBtnCls =
@@ -70,8 +73,8 @@ const STEPS = [
},
{
id: "game" as const,
- label: "Play Game",
- shortLabel: "Play",
+ label: "Game",
+ shortLabel: "Game",
icon: ,
},
];
@@ -81,7 +84,7 @@ export function AppHeader({
canUndo,
canRedo,
isMuted,
- showStats: _showStats,
+ showStats,
baseTheme: _baseTheme,
activePlayers,
activePawn,
@@ -96,7 +99,7 @@ export function AppHeader({
onRedo,
onOpenHistory,
onRotateBoard,
- onToggleStats: _onToggleStats,
+ onToggleStats,
onStartGame,
onEndGame,
onToggleMute,
@@ -111,6 +114,8 @@ export function AppHeader({
onToggleTimer,
is3D: _is3D,
onToggle3D: _onToggle3D,
+ solverDepth,
+ onSetSolverDepth,
}: AppHeaderProps) {
const [showEndGameConfirm, setShowEndGameConfirm] = useState(false);
@@ -135,7 +140,7 @@ export function AppHeader({
- {/* Center — Step Nav (timer baked into Play Game button) */}
+ {/* Center — Step Nav (timer integrated in Game button) */}
{STEPS.map((s) => {
@@ -272,8 +277,8 @@ export function AppHeader({
)}
- {/* Desktop actions toolbar */}
-
+ {/* Tablet/desktop actions toolbar — visible at md+ */}
+
+ {isGameStarted && onToggleStats && (
+ <>
+
+
+ { if (!isMuted) playClickSound(); onToggleStats(); }}
+ className={cn(iconBtnCls, showStats ? "bg-theme-primary-10 text-theme-primary border-theme-primary-20" : "")}
+ aria-label="Toggle game statistics"
+ >
+
+
+
+ >
+ )}
diff --git a/src/components/board/Board.tsx b/src/components/board/Board.tsx
index 5489873..6eab376 100644
--- a/src/components/board/Board.tsx
+++ b/src/components/board/Board.tsx
@@ -272,9 +272,9 @@ export const Board: React.FC
= ({
className={cn(
"w-full h-full max-w-[70%] max-h-[70%] mx-auto p-0.5 rounded-lg transition-all focus:outline-none flex items-center justify-center",
isForbidden
- ? "opacity-20 cursor-not-allowed border border-stone-850 text-stone-600 bg-stone-950/60"
+ ? "opacity-20 cursor-not-allowed border border-stone-700 text-stone-600 bg-stone-950/60"
: turnPhase === "move"
- ? "opacity-25 cursor-not-allowed border border-stone-850 text-stone-600 bg-stone-950/60"
+ ? "opacity-25 cursor-not-allowed border border-stone-700 text-stone-600 bg-stone-950/60"
: isStaged
? "neo-brutalism-button border-stone-950 bg-theme-primary text-stone-950 scale-105 translate-x-[1px] translate-y-[1px] shadow-[1px_1px_0_0_#000000] cursor-pointer"
: isHighlighted
diff --git a/src/components/modals/BoardScanModal.tsx b/src/components/modals/BoardScanModal.tsx
index 5b40063..86dfe7a 100644
--- a/src/components/modals/BoardScanModal.tsx
+++ b/src/components/modals/BoardScanModal.tsx
@@ -324,7 +324,7 @@ export function BoardScanModal({ open, onClose, onApply }: Props) {
y: c.y * scanH,
})) as [CornerPoint, CornerPoint, CornerPoint, CornerPoint];
- const scanResults = await scanBoard(scanSource, absCorners, "", setProgress);
+ const scanResults = await scanBoard(scanSource, absCorners, setProgress);
setResults(scanResults);
setStep("results");
} catch (err) {
diff --git a/src/components/modals/SettingsDialog.tsx b/src/components/modals/SettingsDialog.tsx
index 6fab235..4a5ba18 100644
--- a/src/components/modals/SettingsDialog.tsx
+++ b/src/components/modals/SettingsDialog.tsx
@@ -24,6 +24,8 @@ interface SettingsDialogProps {
setAccentColor: (hex: string) => void;
is3D?: boolean;
onToggle3D?: () => void;
+ solverDepth?: number;
+ onSetSolverDepth?: (depth: number) => void;
}
const ACCENT_PRESETS = [
@@ -44,7 +46,6 @@ const KEYBOARD_SHORTCUTS = [
shortcuts: [
{ keys: ["Ctrl", "Z"], desc: "Undo last action" },
{ keys: ["Ctrl", "Y"], desc: "Redo last action" },
- { keys: ["Ctrl", "S"], desc: "Show autosave reminder" },
],
},
{
@@ -67,6 +68,8 @@ export function SettingsDialog({
setAccentColor,
is3D = false,
onToggle3D,
+ solverDepth = 3,
+ onSetSolverDepth,
}: SettingsDialogProps) {
const [settingsTab, setSettingsTab] =
useState<"preferences" | "appearance" | "application">("preferences");
@@ -288,6 +291,34 @@ export function SettingsDialog({
{is3D ? "On" : "Off"}
+ {onSetSolverDepth && (
+
+
+
Solver Search Depth
+
+ Turns looked ahead. Higher values find more creative solutions but take longer to compute.
+
+
+
+ {[1, 2, 3, 4, 5].map((d) => (
+ { if (!isMuted) playClickSound(); onSetSolverDepth(d); }}
+ className={`neo-brutalism-button rounded-lg w-9 h-9 text-xs font-bold cursor-pointer transition-all ${
+ solverDepth === d
+ ? "bg-theme-primary border-stone-950 text-stone-950 translate-x-[1px] translate-y-[1px] shadow-[1px_1px_0_0_#000000]"
+ : "bg-card border-stone-950 text-stone-400 hover:text-stone-200"
+ }`}
+ >
+ {d}
+
+ ))}
+
+ {solverDepth === 3 ? "(default)" : solverDepth > 3 ? "(slower)" : "(faster)"}
+
+
+
+ )}
)}
@@ -529,9 +560,8 @@ export function SettingsDialog({
Troubleshooting
- If you encounter issues with saved games or stale settings, clearing the
- app cache will reset all local data. Your disk-saved game files (.json)
- remain untouched.
+ If you encounter issues with stale settings or a corrupted board layout,
+ clearing the app cache will reset your saved board and all preferences.
Privacy & Disclaimers
Labyrinth Game Solver processes and stores all data locally on your
- system. No board data, saved games, settings, or metadata are ever uploaded
+ system. No board data, settings, or metadata are ever uploaded
to remote servers.
diff --git a/src/components/modals/WelcomeGuide.tsx b/src/components/modals/WelcomeGuide.tsx
index d13427d..e10ee07 100644
--- a/src/components/modals/WelcomeGuide.tsx
+++ b/src/components/modals/WelcomeGuide.tsx
@@ -1,4 +1,4 @@
-import { Compass, Layers, MapPin, Play, Sparkles } from "lucide-react";
+import { Compass, Layers, MapPin, Play, Sparkles, ArrowRightLeft } from "lucide-react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../ui/dialog";
import guideStep1Tiles from "../../assets/guide-step1-tiles.png";
@@ -30,6 +30,12 @@ const STEPS = [
body: "Slide a tile in, then move your pawn toward its target treasure. The panel beside (or below) the board always shows the solver's best slide-and-move combo to reach your current target in the fewest turns.",
preview: guideStep3Solve,
},
+ {
+ icon: ArrowRightLeft,
+ title: "4. Each turn has two phases",
+ body: "First, slide: tap an arrow on the board edge to stage a shift, then confirm it. Second, move: tap any green-highlighted cell to walk your pawn there. Both phases must complete before the turn ends. To set a custom target, tap any tile on the board during your move phase.",
+ preview: null,
+ },
];
export function WelcomeGuide({ open, onOpenChange, onDismiss }: WelcomeGuideProps) {
@@ -65,11 +71,13 @@ export function WelcomeGuide({ open, onOpenChange, onDismiss }: WelcomeGuideProp
{step.title}
{step.body}
-
+ {step.preview && (
+
+ )}
))}
diff --git a/src/components/panels/SetupPanel.tsx b/src/components/panels/SetupPanel.tsx
index 11307a8..cf1d5d2 100644
--- a/src/components/panels/SetupPanel.tsx
+++ b/src/components/panels/SetupPanel.tsx
@@ -2,7 +2,7 @@
// sheet (< md) and the tablet/desktop side column (md+). Unprefixed classes
// target the phone sheet; `md:` targets the tablet column; `lg:` targets the
// wider desktop column. Interactive controls get a 44px phone floor.
-import { Sparkles, Layers, Users, Compass, Play, RotateCw, Camera } from "lucide-react";
+import { Sparkles, Layers, Users, Compass, Play, RefreshCcw, Camera } from "lucide-react";
import { SidePanel } from "./SidePanel";
import { Button } from "../ui/button";
import { PAWNS, TREASURES } from "../../constants";
@@ -128,7 +128,7 @@ export function SetupPanel({
className={`flex items-center justify-center gap-1.5 px-3 md:px-4 py-2 md:py-1.5 min-h-11 md:min-h-0 rounded-lg text-xs md:text-sm font-bold transition-all cursor-pointer border-2 ${
setupTab === tab.id
? "bg-theme-primary text-stone-950 border-stone-950 shadow-[2px_2px_0_0_#000000]"
- : "text-stone-500 hover:text-foreground hover:bg-stone-100 dark:hover:bg-stone-850 border-transparent"
+ : "text-stone-500 hover:text-foreground hover:bg-stone-800 border-transparent"
}`}
>
{tab.icon}
@@ -170,7 +170,7 @@ export function SetupPanel({
aria-label="Reset layout"
className="border-stone-800 text-stone-400 hover:text-stone-200 hover:bg-stone-900 min-h-11 w-11 shrink-0 px-0 cursor-pointer"
>
-
+
@@ -285,7 +285,7 @@ export function SetupPanel({
size="sm"
variant={alreadyInHand ? "secondary" : "outline"}
onClick={() => (alreadyInHand ? onRemoveCard(t.id) : onAddCard(t.id))}
- className={`text-[10px] md:text-xs py-1 justify-start h-11 md:h-9 lg:h-8 px-2 truncate neo-brutalism-button bg-card hover:bg-stone-100 dark:hover:bg-stone-850 text-foreground border-2 border-stone-950 shadow-[2px_2px_0_0_#000000] ${alreadyInHand ? "bg-theme-primary text-stone-950 shadow-[1px_1px_0_0_#000000] translate-x-[1px] translate-y-[1px]" : ""}`}
+ className={`text-[10px] md:text-xs py-1 justify-start h-11 md:h-9 lg:h-8 px-2 truncate neo-brutalism-button bg-card hover:bg-stone-800 text-foreground border-2 border-stone-950 shadow-[2px_2px_0_0_#000000] ${alreadyInHand ? "bg-theme-primary text-stone-950 shadow-[1px_1px_0_0_#000000] translate-x-[1px] translate-y-[1px]" : ""}`}
>
{t.name}
diff --git a/src/components/panels/SolverPanel.tsx b/src/components/panels/SolverPanel.tsx
index f933b0d..af4345d 100644
--- a/src/components/panels/SolverPanel.tsx
+++ b/src/components/panels/SolverPanel.tsx
@@ -299,9 +299,8 @@ export function SolverPanel({
{TREASURES.find((t) => t.id === (sol as { cardId?: string }).cardId)?.name ?? (sol as { cardId?: string }).cardId}
{(sol as { sequenceOrder?: string[] }).sequenceOrder && (sol as { sequenceOrder?: string[] }).sequenceOrder!.length > 1 && (
-
- → then {(sol as { sequenceOrder?: string[] }).sequenceOrder!.slice(1, 3).map((id) => TREASURES.find((t) => t.id === id)?.name ?? id).join(", ")}
- {(sol as { sequenceOrder?: string[] }).sequenceOrder!.length > 3 ? "…" : ""}
+
+ → then {(sol as { sequenceOrder?: string[] }).sequenceOrder!.slice(1).map((id) => TREASURES.find((t) => t.id === id)?.name ?? id).join(" → ")}
)}
@@ -332,7 +331,7 @@ export function SolverPanel({
key={p.id}
variant={activePawn === p.id ? "default" : "outline"}
onClick={() => { if (!isMuted) playClickSound(); setActivePawn(p.id); }}
- className={`neo-brutalism-button bg-card hover:bg-stone-100 dark:hover:bg-stone-850 text-foreground border-stone-950 h-11 md:h-9 ${activePawn === p.id ? p.colorClass + " text-stone-950 font-extrabold translate-x-[1px] translate-y-[1px] shadow-[1px_1px_0_0_#000000]" : ""}`}
+ className={`neo-brutalism-button bg-card hover:bg-stone-100 hover:bg-stone-800 text-foreground border-stone-950 h-11 md:h-9 ${activePawn === p.id ? p.colorClass + " text-stone-950 font-extrabold translate-x-[1px] translate-y-[1px] shadow-[1px_1px_0_0_#000000]" : ""}`}
>
{p.name}
diff --git a/src/lib/boardScanner.ts b/src/lib/boardScanner.ts
index b32fe0b..1bd47d5 100644
--- a/src/lib/boardScanner.ts
+++ b/src/lib/boardScanner.ts
@@ -262,7 +262,6 @@ export function isFixedCell(row: number, col: number): boolean {
export async function scanBoard(
boardImageElement: HTMLImageElement | HTMLCanvasElement,
corners: [CornerPoint, CornerPoint, CornerPoint, CornerPoint],
- _atlasUrl: string, // kept for API compatibility, no longer used
onProgress?: (pct: number) => void
): Promise {
const templates = await loadTileTemplates();
diff --git a/src/types.ts b/src/types.ts
index 1babc83..a73bbad 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -48,12 +48,6 @@ export interface AppGameState {
pawnPositions: PawnPositions;
}
-export interface SaveSlot {
- name: string;
- key: string;
- timestamp: number;
-}
-
export interface BoardScanCell {
row: number;
col: number;