This document describes the Svelte component APIs, reactive stores, keyboard controls, and command palette integration. For page descriptions and user flows, see Product: Pages & Layout.
Current state (post-Phase 6): CubeViewer, PlaybackControls, ThemeToggle, AlgorithmList, AlgorithmCard, Navbar, and CommandPalette are all implemented.
The 3D cube canvas mount point. See rendering.md for full details on the Three.js integration.
Key responsibilities:
- Creates a
<canvas>element sized to fit its container, inside arelative-positioned wrapper - Instantiates
CubeScene,CubeMesh, andCubeAnimatorinonMount(Three.js must not run server-side) - Registers/unregisters the
CubeAnimatorwithcubeStoreviacubeStore.setAnimator()/cubeStore.clearAnimator() - Attaches a
ResizeObserverfor responsive canvas sizing - Shows a DaisyUI loading spinner overlay until
onMountcompletes; shows an error state if WebGL is unavailable - Sets
touch-action: noneon the canvas to prevent browser scroll interception during orbit - Toggles
cursor: grab/cursor: grabbingon the container viamousedown/mouseup - Handles double-click to reset the camera to its default position (400ms ease-out cubic tween)
- Syncs the scene background with the current theme via a
$effectthat runs wheneverthemeStore.themechanges - Calls
scene.dispose()on component destroy to clean up WebGL resources
Props: None. CubeViewer takes no props. All interaction (load, step, play, reset) is driven through cubeStore.
PlaybackControls reads and writes cubeStore; CubeViewer registers its animator with the store on mount so the store can delegate animation to it.
Renders a grouped grid of AlgorithmCard components for a single algorithm category.
Props:
interface AlgorithmListProps {
algorithms: Algorithm[]; // All OLL or all PLL cases — pre-filtered by the page
}Rendering behavior:
- Groups items by
algorithm.groupusing aMap<string, Algorithm[]>built once at render time; preserves the natural order of the data array (do not sort groups alphabetically — group order in the source data is intentional) - Renders a
<h2>section header for each group, then a CSS grid ofAlgorithmCardbeneath it - Grid is responsive: 2 columns on mobile, 3 on
sm, 4 onmd, 5 onlg - No internal state — purely a presentation component driven by its
algorithmsprop
A clickable card representing a single algorithm case. Links to the detail page.
Props:
interface AlgorithmCardProps {
algorithm: Algorithm;
}Rendering:
- Uses
resolve()from$app/pathsto build the href (/oll/{id}/or/pll/{id}/) — required for the GitHub Pages subpath - Derives the href from
algorithm.categoryandalgorithm.id:resolve(\/${algorithm.category}/${algorithm.id}/`)` - Displays the case name (
algorithm.name) and group label (algorithm.group) - Renders a 2D pattern thumbnail (see Pattern Thumbnails below)
- Shows
algorithm.probabilityas a secondary label
Both AlgorithmCard variants render a 2D thumbnail as an inline SVG — no canvas, no external images, no runtime dependencies.
The OLL pattern field is a boolean[9] in row-major order. Render it as a 3x3 grid:
- Each cell is a colored square: yellow (
oklch(85% 0.2 95)) iftrue, grey (oklch(40% 0 0)) iffalse - Draw a thin white border between cells
- The center cell (
pattern[4]) is alwaystrue - Size: 48x48px intrinsic, scales with CSS. No viewBox tricks — use a fixed
viewBox="0 0 3 3"with1unit per cell
This is the simplest possible approach: 9 <rect> elements, no library, no runtime logic beyond indexing the boolean array.
The PLL pattern field is a PermutationArrow[]. Each arrow shows where a piece moves. Render:
- A 3x3 grid background (all grey cells — top face is already solved in PLL)
- One
<line>or curved<path>per arrow, drawn from the center of thefromcell to the center of thetocell - Arrowhead: use an SVG
<marker>withmarkerEnd - Use a distinct color (e.g., accent color via
currentColor) for arrows so they read clearly over the grey grid
Cell center positions follow the same 3x3 grid as OLL. Position index to (col, row): col = index % 3, row = Math.floor(index / 3), center = (col + 0.5, row + 0.5) in the 0 0 3 3 viewBox.
Why SVG (not Canvas or CSS grid): SVG is markup — it renders server-side, works with adapter-static prerendering, requires no onMount, and scales cleanly at any DPI. Canvas requires onMount and would need a ref per card (expensive at 57 OLL + 21 PLL cards on the list page). CSS grid can do the OLL thumbnail but cannot draw PLL arrows. Inline SVG handles both cases uniformly with no runtime cost.
Not a reusable component — this is the SvelteKit page file for each detail route. The two routes share the same layout and composition, differing only in which data array they read from.
Structure:
[id]/+page.svelte
├── <svelte:head> — page title + meta description
├── Navbar (from layout)
├── <h1> case name + breadcrumb
├── <p> notation string
├── Two-column layout (same pattern as current home page)
│ ├── CubeViewer (left / top on mobile)
│ └── PlaybackControls (right / below on mobile)
└── Keyboard controls (same as home page, copied pattern)
Data loading: use SvelteKit's +page.ts (not +page.server.ts — this is a static site). The load function imports from $lib/data/oll or $lib/data/pll, finds the matching case by id, and returns it. If not found, call error(404) from @sveltejs/kit.
The page's onMount calls cubeStore.loadAlgorithm(algorithm.notation) — same pattern as the current home page's T_PERM demo.
Transport controls for stepping through an algorithm:
| Button | Action |
|---|---|
| Play / Pause | Start or pause auto-playback of the algorithm |
| Step Forward | Advance one move and animate it |
| Step Back | Revert one move (apply the inverse) |
| Reset | Return the cube to the initial unsolved state |
Playback state is managed by cubeStore.svelte.ts, which tracks the current algorithm, the step index, and the play/pause flag.
Top navigation bar using DaisyUI's navbar component:
- Logo/title linking to home
- Navigation links to OLL and PLL pages
ThemeTogglecomponent for dark/light mode switching- All links use
resolve()from$app/pathsto build hrefs (required for GitHub Pages subpath deployment)
A toggle switch for dark/light mode:
- Reads and writes the theme preference via
themeStore.svelte.ts - Sets the
data-themeattribute on the<html>element - Persists the user's choice to
localStorage - See
theme-integration.mdfor full details on the theming system
Wraps the ninja-keys web component. See the Command Palette section below for full details.
The command palette is powered by ninja-keys, a framework-agnostic web component that provides a Cmd+K / Ctrl+K search interface with nested menus.
CommandPalette.svelte is mounted once in +layout.svelte so it's available on every page.
SSR safety is handled in two ways:
ninja-keysis imported dynamically insideonMountso it never runs server-side.- The
<ninja-keys>element is guarded by{#if browser}so it is not rendered during prerendering.
After the dynamic import resolves, a Promise.resolve() microtask yield gives Svelte 5 time to bind ninjaEl via bind:this before the code tries to access it. This is necessary because Svelte 5's bind:this is applied after the current microtask completes.
<script>
import { onMount } from 'svelte';
import { browser } from '$app/environment';
import { buildCommands } from '$lib/commands/commandData.js';
import { commandPaletteStore } from '$lib/stores/commandPaletteStore.svelte.js';
let ninjaEl: HTMLElement | undefined = $state(undefined);
onMount(async () => {
await import('ninja-keys');
await Promise.resolve(); // yield for Svelte 5 bind:this timing
if (!ninjaEl) return;
(ninjaEl as any).data = buildCommands();
commandPaletteStore.setOpenFn(() => (ninjaEl as any)?.open());
ninjaEl.addEventListener('ninja:open', () => commandPaletteStore.setOpen(true));
ninjaEl.addEventListener('ninja:close', () => commandPaletteStore.setOpen(false));
});
</script>
{#if browser}
<ninja-keys bind:this={ninjaEl}></ninja-keys>
{/if}buildCommands() constructs and returns the full NinjaCommand[] array. It is called once inside onMount and the result is assigned to ninjaEl.data. The command hierarchy has four sections:
NAVIGATION
Home → goto(resolve('/'))
OLL Algorithms → goto(resolve('/oll/'))
PLL Algorithms → goto(resolve('/pll/'))
ALGORITHMS
OLL ▸ → opens OLL groups sub-menu
Dot Cases ▸ → opens cases under this group
OLL 1 → goto(resolve('/oll/oll-1/'))
…
T-Shape ▸ … (all OLL groups, 57 cases total)
PLL ▸ → opens PLL groups sub-menu
Adjacent Corner Swap ▸
Aa Perm → goto(resolve('/pll/pll-aa/'))
…
… (all PLL groups, 21 cases total)
THEME
Dark → themeStore.setTheme('dark')
Light → themeStore.setTheme('light')
Groups use an intermediate parent entry (no handler) so ninja-keys drills into them as sub-menus. Individual case commands include keywords with the group name, category, notation string, and (for OLL) any nicknames from the algorithm data — enabling search by move sequence and common names.
All navigation uses goto(resolve('/path/')) where resolve comes from $app/paths. This is required for the GitHub Pages subpath deployment.
Commands follow the NinjaCommand interface (defined in commandData.ts):
interface NinjaCommand {
id: string; // unique, e.g. 'oll-1', 'theme-dark', 'nav-home'
title: string; // display label, e.g. 'OLL 1', 'T Perm', 'Dark'
parent?: string; // id of parent command for nesting
keywords?: string; // additional search terms (space-separated)
section?: string; // section header label
handler?: () => void; // action on selection; omit for sub-menu-only entries
}ninja-keys provides built-in fuzzy search across title and keywords. The keywords field enhances discoverability:
- Searching "T Perm" finds the T Perm PLL case (title match)
- Searching "R U R'" finds algorithms whose notation contains those moves (keyword match)
- Searching "dot" finds OLL cases in the "Dot Cases" group (keyword match)
- OLL cases include their
nicknamesin keywords so common alternate names are searchable
ninja-keys dispatches ninja:open and ninja:close DOM events when the palette opens and closes. CommandPalette.svelte listens for both and calls commandPaletteStore.setOpen() to keep the shared store in sync. Keyboard control handlers on detail pages read commandPaletteStore.open to suppress cube shortcuts while the palette is active (see Keyboard Controls below).
When viewing an algorithm detail page, keyboard shortcuts allow quick interaction:
| Key | Action |
|---|---|
| Space | Play / Pause |
| → (Right Arrow) | Step forward one move |
| ← (Left Arrow) | Step back one move |
| R | Reset to initial state |
| Escape | Close command palette (if open) |
Keyboard shortcuts must be disabled in two situations to prevent conflicts:
-
Command palette is open: When ninja-keys is open, all keypresses should go to its search input, not the cube. Listen for the palette's open/close events and set a flag.
-
Text input is focused: If a user is typing in any
<input>,<textarea>, orcontenteditableelement, keyboard shortcuts should not fire. Checkdocument.activeElementbefore processing keystrokes.
function handleKeydown(event: KeyboardEvent) {
// Don't intercept when command palette is open
if (commandPaletteOpen) return;
// Don't intercept when typing in an input
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
if ((document.activeElement as HTMLElement)?.isContentEditable) return;
switch (event.key) {
case ' ':
event.preventDefault();
togglePlayback();
break;
case 'ArrowRight':
stepForward();
break;
case 'ArrowLeft':
stepBack();
break;
case 'r':
resetCube();
break;
}
}The keydown listener is added in onMount and removed in onDestroy to prevent memory leaks and stale handlers when navigating between pages.
Manages the cube state and playback using Svelte 5 runes:
let cubeState = $state(solved()); // Current cube state (number[54])
let moves = $state<Move[]>([]); // Parsed moves of the loaded algorithm
let moveTokens = $state<string[]>([]); // Notation tokens for the UI display
let initialState = $state<number[]>(solved()); // Pre-algorithm starting state
let stepIndex = $state(0); // Current step (0 = no moves applied)
let isPlaying = $state(false); // Whether auto-playback is active
let playbackStatus = $state<'idle' | 'playing' | 'paused'>('idle');
let speed = $state<SpeedSetting>('normal');
let history = $state<number[][]>([]); // State history for undo/step-back
// (module-level, not $state — not reactive)
let playGeneration = 0; // Generation counter for play loop cancellation
let animationInFlight = false; // True while play() is mid-await on an animationKey operations:
- loadAlgorithm(notation: string): Parse notation, apply the inverse to a solved cube to get the unsolved starting state, reset all playback state, sync the animator via
animator.loadAlgorithm(). - stepForward(): Checks
animationInFlightfirst — if the play loop is currently awaiting an animation, that loop has already applied the move and we must not double-apply it. If no animation is in-flight, pushes state to history, applies the move, advancesstepIndex, and callsanimator.animate(move, [...cubeState]). Always passes the post-move state to the animator (see targetState pattern inrendering.md). - stepBack(): Pops history to restore previous state, decrements
stepIndex, callsanimator.loadAlgorithm()to resync the animator queue. - play(): Runs an async loop using a generation counter. Each call increments
playGenerationand captures the current generation. After everyawait animator.animate(...), the loop checks if its generation is still current — ifpause()or any interruption fired during the await, the generation will differ and the loop exits without touching state. SetsanimationInFlight = truearound each await sostepForward()can detect the in-flight animation. - pause(): Increments
playGenerationto invalidate the current play loop. The loop detects this on its next generation check and exits. - reset(): Restores
initialState, clears history andstepIndex, syncs the animator viaanimator.loadAlgorithm(). - setAnimator(anim): Called by
CubeViewerafteronMount. Registers the animator and loads any current algorithm into it. - clearAnimator(): Called by
CubeVieweron destroy. IncrementsplayGenerationto cancel any running loop and nullifies the animator reference.
Note: The store owns the playback sequencing loop (not the animator). The CubeAnimator handles the visual face-turn animation for individual moves; the store drives the step-by-step logic. The store is the single source of truth for logical cube state — it always passes targetState to animator.animate() rather than letting the animator compute its own state.
Manages the dark/light mode preference. See theme-integration.md for details.
Shared reactive state for the command palette. Bridges CommandPalette.svelte (which owns the ninja-keys DOM element) with the Navbar (which triggers open) and detail page keyboard handlers (which need to suppress shortcuts while the palette is visible).
export const commandPaletteStore = {
get open(): boolean, // true while the palette is visible
setOpen(value: boolean): void, // called by ninja:open / ninja:close events
setOpenFn(fn: () => void): void, // registered by CommandPalette after onMount
openPalette(): void, // called by Navbar button; delegates to openFn
};Key design: CommandPalette.svelte registers an openFn closure (which calls ninjaEl.open()) via setOpenFn() after mounting. openPalette() then calls that closure, so the Navbar never needs a direct DOM reference to ninja-keys.
The open getter is a Svelte 5 $state-backed reactive value — any component that reads commandPaletteStore.open will re-run its $effect or template when it changes.
The home page transitions from a hardcoded T Perm demo to a proper landing page. The cube on the home page does not auto-rotate or run through an algorithm on its own — it loads in the solved state and sits idle. This keeps the home page simple and avoids surprising the user with motion they did not initiate.
Recommended home page structure for Phase 5:
- Navbar (moved out of
+page.svelteinto+layout.svelte) - Hero section: centered
CubeViewershowing the solved cube, with a headline and brief description - Call-to-action links to
/oll/and/pll/ - No
PlaybackControlson the home page — the cube is not in playback mode
The solved cube hero is purely visual. CubeViewer already supports this: if cubeStore.loadAlgorithm() is never called, the cube renders in the solved state with no animation. The home page onMount should call nothing on cubeStore.
All pages use <svelte:head> to set the title and meta description. These are rendered into the static HTML at prerender time and are fully SEO-compatible.
| Route | Title | Description |
|---|---|---|
/ |
CubeHill — Speedcubing Algorithm Visualizer |
Visualize OLL and PLL algorithms with an interactive 3D Rubik's cube. |
/oll/ |
OLL Algorithms — CubeHill |
All 57 OLL cases with 3D visualizer and step-by-step playback. |
/pll/ |
PLL Algorithms — CubeHill |
All 21 PLL cases with 3D visualizer and step-by-step playback. |
/oll/[id]/ |
{name} — OLL — CubeHill |
{name}: {group}. Algorithm: {notation} |
/pll/[id]/ |
{name} — PLL — CubeHill |
{name}: {group}. Algorithm: {notation} |
The detail page title uses the algorithm's name field (e.g., "OLL 1" or "T Perm"). The description includes the notation so the algorithm moves appear in search snippets — useful for cubers who search by move sequence.
No structured data (JSON-LD) is needed for Phase 5. The static title and description are sufficient.
The Phase 4 Svelte integration design (component hierarchy, store contracts, and interaction patterns) is documented in designs/phase4-svelte-integration.md. Wireframes showing how these components are laid out on each page are in designs/phase4-wireframes.md. The Figma source is in file fiCCEbCrIIZqYVIm9XTjiD, page Phase 4 — CubeViewer & PlaybackControls:
- Desktop wireframe (Figma) — frame
16:463 - Mobile wireframe (Figma) — frame
16:512

