Daily Puzzles 3D “The Daily” Desk — SP1 Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace the flat 2D Daily Puzzles hub with an immersive 3D “office desk + newspaper” scene whose front-page clippings (Temple-style 3D buttons) launch puzzles rendered embedded on the newspaper page, with the Spectrum game wired end-to-end as the proof.
Architecture: A persistent R3F <Canvas> desk scene is hoisted into the /daily layout route so it survives hub↔game navigation. A small zustand store holds the focused mode; the front-page maps a shared mode array into 3D Button3D clippings; selecting one navigates and leans the camera in; the active game renders inside a drei <Html transform> panel mapped onto the newspaper page in perspective, re-skinned with a newsprint CSS theme. Temple of Joy’s ui3d toolkit is copied and re-themed into components/daily-puzzles/three/ (Temple untouched).
Tech Stack: TanStack Start + TanStack Router, React 19, TypeScript, Tailwind v4, three@^0.183, @react-three/fiber@^9, @react-three/drei@^10, zustand, vitest (node env), react-i18next.
Global Constraints¶
No DOM test environment. vitest runs
environment: 'node'with include globlib/__tests__/**/*.test.ts. Do NOT write.test.tsx/component DOM tests — they won’t run. Put testable logic in purelib/helpers; verify components via typecheck + lint +vite build+ manual browser.Run binaries directly (pnpm wrappers are blocked):
./node_modules/.bin/tsc --noEmit,./node_modules/.bin/vitest run <file>,./node_modules/.bin/eslint <files>,./node_modules/.bin/vite build.Route-tree is generated. Adding/changing file routes makes
tscerror untilapp/routeTree.gen.tsregenerates, which only happens when Vite runs. Run./node_modules/.bin/vite buildto regenerate, then commit the updatedrouteTree.gen.ts. If the build also editspnpm-workspace.yaml, revert that file (unrelated build-approval noise).Asset-light: no external HDR/model/font downloads. All textures procedural via 2D canvas (
canvasLabel/newsprintTexture/glowTexture); all geometry from three/drei primitives. Matches repo convention.Mobile-first: capped/adaptive DPR,
touch-action: none,useTaptap-vs-drag discrimination on every clickable mesh.Do NOT modify anything under
components/temple-of-joy/orlib/temple-of-joy/. The toolkit is copied, not shared.i18n: user-facing strings use
useTranslation('c-daily-puzzles')withdefaultValue, mirroring existing daily code.Visual identity: cool slate + ink, crisp serif, soft daylight (desk lamp), low bloom. Per-puzzle accent colors pop against the neutral world.
File Structure¶
New pure logic (unit-tested):
lib/daily-puzzles/desk-layout.ts— grid layout math for clippings.lib/daily-puzzles/desk-modes.ts— shared mode definitions with cool accent colors + helpers.lib/daily-puzzles/desk-store.ts— zustand store forfocusedMode.
New 3D components (verified via typecheck/build/manual):
components/daily-puzzles/three/ui3d/Button3D.tsx— copied, audio-agnostic, re-themed.components/daily-puzzles/three/ui3d/Label3D.tsx,Panel3D.tsx,canvasLabel.ts,overlay.tsx— copied.components/daily-puzzles/three/useTap.ts,glowTexture.ts— copied/re-themed.components/daily-puzzles/three/newsprintTexture.ts— procedural newsprint page texture.components/daily-puzzles/three/DeskEnvironment.tsx— cool daylight lighting.components/daily-puzzles/three/DeskProps.tsx— desk, lamp, coffee, pencils, sticky notes.components/daily-puzzles/three/Newspaper.tsx— page plane + masthead + progress stamp.components/daily-puzzles/three/FrontPage.tsx— clipping buttons (hub) using desk-layout.components/daily-puzzles/three/DeskGameFrame.tsx—<Html transform>embed + camera lean.components/daily-puzzles/three/DeskScene.tsx—<Canvas>assembling the world.components/daily-puzzles/desk-newsprint.css— newsprint CSS theme for embedded games.
Modified routes:
app/routes/daily.tsx— hosts the persistentDeskScene.app/routes/daily/index.tsx— clears focus (front page).app/routes/daily/spectrum.tsx— mountsDeskGameFramewithSpectrumGame.
Task 1: Front-page grid layout helper¶
Pure math that positions N clippings in a centered responsive grid on the newspaper page. Extracted from Temple’s TabBar inline math so it is unit-testable.
Files:
Create:
lib/daily-puzzles/desk-layout.tsTest:
lib/__tests__/desk-layout.test.ts
Interfaces:
Produces:
gridLayout(count: number, areaW: number, opts?: { cellW?: number; cellH?: number; gapX?: number; gapY?: number; maxPerRow?: number }): { positions: [number, number][]; cellW: number; cellH: number; perRow: number; rows: number }—positions[i]is the[x, y]center of celliin local units, the grid centered on origin, row 0 at the top.Step 1: Write the failing test
// lib/__tests__/desk-layout.test.ts
import { describe, it, expect } from 'vitest';
import { gridLayout } from '../daily-puzzles/desk-layout';
describe('gridLayout', () => {
it('centers a single row horizontally around x=0', () => {
const { positions, perRow, rows } = gridLayout(3, 10, { cellW: 2, gapX: 0.5, maxPerRow: 6 });
expect(perRow).toBe(3);
expect(rows).toBe(1);
const xs = positions.map((p) => p[0]);
expect(xs[0]).toBeCloseTo(-2.5);
expect(xs[1]).toBeCloseTo(0);
expect(xs[2]).toBeCloseTo(2.5);
// single row sits on the horizontal axis
expect(positions.every((p) => Math.abs(p[1]) < 1e-9)).toBe(true);
});
it('wraps into rows with the top row highest in y', () => {
const { positions, perRow, rows } = gridLayout(6, 5, { cellW: 2, cellH: 1, gapX: 0.2, gapY: 0.4, maxPerRow: 2 });
expect(perRow).toBe(2);
expect(rows).toBe(3);
// item 0 (top row) has greater y than item 4 (bottom row)
expect(positions[0][1]).toBeGreaterThan(positions[4][1]);
});
it('never exceeds maxPerRow and stays >= 1 per row', () => {
const { perRow } = gridLayout(6, 100, { cellW: 1, maxPerRow: 3 });
expect(perRow).toBe(3);
const narrow = gridLayout(6, 0.1, { cellW: 1, maxPerRow: 3 });
expect(narrow.perRow).toBeGreaterThanOrEqual(1);
});
});
Step 2: Run test to verify it fails
Run: ./node_modules/.bin/vitest run lib/__tests__/desk-layout.test.ts
Expected: FAIL — cannot resolve ../daily-puzzles/desk-layout.
Step 3: Write the implementation
// lib/daily-puzzles/desk-layout.ts
export interface GridOptions {
cellW?: number;
cellH?: number;
gapX?: number;
gapY?: number;
maxPerRow?: number;
}
export interface GridResult {
positions: [number, number][];
cellW: number;
cellH: number;
perRow: number;
rows: number;
}
/**
* Lays out `count` equal cells in a centered grid (origin at the grid center,
* row 0 at the top). `perRow` is chosen to fit `areaW`, clamped to
* [1, maxPerRow]. Returns each cell's [x, y] center in local units.
*/
export function gridLayout(count: number, areaW: number, opts: GridOptions = {}): GridResult {
const cellW = opts.cellW ?? 2;
const cellH = opts.cellH ?? 1;
const gapX = opts.gapX ?? 0.2;
const gapY = opts.gapY ?? 0.2;
const maxPerRow = opts.maxPerRow ?? count;
const fit = Math.floor((areaW + gapX) / (cellW + gapX));
const perRow = Math.max(1, Math.min(maxPerRow, count, isFinite(fit) ? fit : 1));
const rows = Math.ceil(count / perRow);
const stepX = cellW + gapX;
const stepY = cellH + gapY;
const positions: [number, number][] = [];
for (let i = 0; i < count; i++) {
const row = Math.floor(i / perRow);
const inRow = Math.min(perRow, count - row * perRow);
const col = i % perRow;
const x = (col - (inRow - 1) / 2) * stepX;
const y = ((rows - 1) / 2 - row) * stepY;
positions.push([x, y]);
}
return { positions, cellW, cellH, perRow, rows };
}
Step 4: Run test to verify it passes
Run: ./node_modules/.bin/vitest run lib/__tests__/desk-layout.test.ts
Expected: PASS (3 tests).
Step 5: Commit
git add lib/daily-puzzles/desk-layout.ts lib/__tests__/desk-layout.test.ts
git commit -m "feat(daily-3d): add front-page grid layout helper"
Task 3: Focused-mode store¶
A tiny zustand store holding which puzzle is focused (drives the camera lean and which game is mounted). zustand runs in node, so this is unit-testable.
Files:
Create:
lib/daily-puzzles/desk-store.tsTest:
lib/__tests__/desk-store.test.ts
Interfaces:
Produces:
useDeskStore(zustand hook) with state{ focusedMode: string | null; setFocusedMode(id: string | null): void }. Imperative access viauseDeskStore.getState().Step 1: Write the failing test
// lib/__tests__/desk-store.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { useDeskStore } from '../daily-puzzles/desk-store';
describe('useDeskStore', () => {
beforeEach(() => useDeskStore.getState().setFocusedMode(null));
it('defaults to no focused mode', () => {
expect(useDeskStore.getState().focusedMode).toBeNull();
});
it('sets and clears the focused mode', () => {
useDeskStore.getState().setFocusedMode('spectrum');
expect(useDeskStore.getState().focusedMode).toBe('spectrum');
useDeskStore.getState().setFocusedMode(null);
expect(useDeskStore.getState().focusedMode).toBeNull();
});
});
Step 2: Run test to verify it fails
Run: ./node_modules/.bin/vitest run lib/__tests__/desk-store.test.ts
Expected: FAIL — cannot resolve module.
Step 3: Write the implementation
// lib/daily-puzzles/desk-store.ts
import { create } from 'zustand';
interface DeskState {
focusedMode: string | null;
setFocusedMode: (id: string | null) => void;
}
/** Which puzzle the desk camera is leaning into. null = front page. */
export const useDeskStore = create<DeskState>((set) => ({
focusedMode: null,
setFocusedMode: (id) => set({ focusedMode: id }),
}));
Step 4: Run test to verify it passes
Run: ./node_modules/.bin/vitest run lib/__tests__/desk-store.test.ts
Expected: PASS (2 tests).
Step 5: Commit
git add lib/daily-puzzles/desk-store.ts lib/__tests__/desk-store.test.ts
git commit -m "feat(daily-3d): add focused-mode desk store"
Task 4: Copy & re-theme the ui3d toolkit (audio-agnostic)¶
Copy Temple’s primitives into Daily and strip the templeAudio coupling. No behavior change beyond the audio hook becoming an optional prop and themed defaults.
Files:
Create:
components/daily-puzzles/three/useTap.ts(verbatim copy ofcomponents/temple-of-joy/three/useTap.ts)Create:
components/daily-puzzles/three/glowTexture.ts(copy; cool gradient — see below)Create:
components/daily-puzzles/three/ui3d/canvasLabel.ts(verbatim copy of Temple’s)Create:
components/daily-puzzles/three/ui3d/overlay.tsx(verbatim copy of Temple’sui3d/overlay.tsx)Create:
components/daily-puzzles/three/ui3d/Label3D.tsx(verbatim copy)Create:
components/daily-puzzles/three/ui3d/Panel3D.tsx(verbatim copy of Temple’s)Create:
components/daily-puzzles/three/ui3d/Button3D.tsx(copy, de-coupled — see below)
Interfaces:
Produces:
Button3Dwith props{ label, onClick, width?, height?, enabled?, color?, textColor?, pulse?, fontSize?, position?, billboard?, onPlaySound? }.useTap(onTap),getGlowTexture(),CameraOverlay,useOverlaySize,makeLabelTexture,Label3D,Panel3D— same signatures as Temple’s.Step 1: Copy the unchanged primitives
Copy these files verbatim (only the file location changes — internal relative imports already resolve within the new three/ dir):
components/temple-of-joy/three/useTap.ts→components/daily-puzzles/three/useTap.tscomponents/temple-of-joy/three/ui3d/canvasLabel.ts→components/daily-puzzles/three/ui3d/canvasLabel.tscomponents/temple-of-joy/three/ui3d/overlay.tsx→components/daily-puzzles/three/ui3d/overlay.tsxcomponents/temple-of-joy/three/ui3d/Label3D.tsx→components/daily-puzzles/three/ui3d/Label3D.tsxcomponents/temple-of-joy/three/ui3d/Panel3D.tsx→components/daily-puzzles/three/ui3d/Panel3D.tsx
mkdir -p components/daily-puzzles/three/ui3d
cp components/temple-of-joy/three/useTap.ts components/daily-puzzles/three/useTap.ts
cp components/temple-of-joy/three/ui3d/canvasLabel.ts components/daily-puzzles/three/ui3d/canvasLabel.ts
cp components/temple-of-joy/three/ui3d/overlay.tsx components/daily-puzzles/three/ui3d/overlay.tsx
cp components/temple-of-joy/three/ui3d/Label3D.tsx components/daily-puzzles/three/ui3d/Label3D.tsx
cp components/temple-of-joy/three/ui3d/Panel3D.tsx components/daily-puzzles/three/ui3d/Panel3D.tsx
Step 2: Create the cool-themed glow texture
// components/daily-puzzles/three/glowTexture.ts
import * as THREE from 'three';
// Cool radial-gradient sprite texture for cheap halos/bloom on the desk. Slate
// core fading to transparent — generated at runtime, no network asset.
let cached: THREE.CanvasTexture | null = null;
export function getGlowTexture(): THREE.CanvasTexture {
if (cached) return cached;
const size = 128;
const canvas = document.createElement('canvas');
canvas.width = canvas.height = size;
const ctx = canvas.getContext('2d')!;
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0.0, 'rgba(255,255,255,1)');
g.addColorStop(0.2, 'rgba(210,224,245,0.9)');
g.addColorStop(0.5, 'rgba(150,176,220,0.32)');
g.addColorStop(1.0, 'rgba(150,176,220,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
cached = tex;
return tex;
}
Step 3: Create the audio-agnostic, re-themed Button3D
This is Temple’s Button3D with two changes: (a) the templeAudio import is removed and replaced by an optional onPlaySound prop (default no-op), and (b) the default color and the slab base color are cool/slate.
// components/daily-puzzles/three/ui3d/Button3D.tsx
'use client';
import { useMemo, useRef, useState } from 'react';
import * as THREE from 'three';
import { useFrame, type ThreeEvent } from '@react-three/fiber';
import { Billboard, RoundedBox } from '@react-three/drei';
import { makeLabelTexture } from './canvasLabel';
import { getGlowTexture } from '../glowTexture';
import { useTap } from '../useTap';
interface Button3DProps {
label: string;
onClick: () => void;
width?: number;
height?: number;
enabled?: boolean;
/** Accent/emissive colour. */
color?: string;
textColor?: string;
/** Energetic pulsing glow (e.g. unsolved / ready). */
pulse?: boolean;
fontSize?: number;
position?: [number, number, number];
billboard?: boolean;
/** Optional click sound hook (default: silent). */
onPlaySound?: () => void;
}
/**
* A real 3D button: a rounded slab with a canvas-text face, a glow halo and
* springy hover/press animation. Audio-agnostic (pass onPlaySound to wire a
* click sound). Tap vs camera-drag is handled by useTap.
*/
export function Button3D({
label,
onClick,
width = 2,
height = 0.6,
enabled = true,
color = '#8fb0dc',
textColor = '#f4f6fb',
pulse = false,
fontSize = 44,
position = [0, 0, 0],
billboard = true,
onPlaySound,
}: Button3DProps) {
const lbl = useMemo(() => makeLabelTexture(label, { fontSize, color: textColor, maxWidth: 520 }), [label, fontSize, textColor]);
const glow = useMemo(() => getGlowTexture(), []);
const accent = useMemo(() => new THREE.Color(color), [color]);
const group = useRef<THREE.Group>(null);
const backMat = useRef<THREE.MeshStandardMaterial>(null);
const halo = useRef<THREE.Sprite>(null);
const [hovered, setHovered] = useState(false);
const punch = useRef(0);
const maxW = width * 0.88;
const maxH = height * 0.62;
let lw = maxW;
let lh = lw / lbl.aspect;
if (lh > maxH) { lh = maxH; lw = lh * lbl.aspect; }
useFrame((state, dt) => {
const t = state.clock.elapsedTime;
punch.current = Math.max(0, punch.current - dt * 4);
if (group.current) {
const target = (hovered && enabled ? 1.09 : 1) * (1 - punch.current * 0.12);
group.current.scale.x += (target - group.current.scale.x) * Math.min(1, dt * 14);
group.current.scale.y = group.current.scale.z = group.current.scale.x;
}
if (backMat.current) {
const base = enabled ? (pulse ? 0.5 + Math.sin(t * 5) * 0.35 : 0.24) : 0.05;
backMat.current.emissiveIntensity = base + (hovered && enabled ? 0.5 : 0);
}
if (halo.current) {
const m = halo.current.material as THREE.SpriteMaterial;
m.opacity = (pulse && enabled ? 0.34 + Math.sin(t * 5) * 0.18 : 0.1) + (hovered && enabled ? 0.26 : 0);
halo.current.scale.set(width * 1.5, height * 2.2, 1);
}
});
const tap = useTap(() => { if (enabled) { onPlaySound?.(); onClick(); } });
const content = (
<group ref={group} position={position}>
<sprite ref={halo} position={[0, 0, -0.08]} scale={[width * 1.5, height * 2.2, 1]}>
<spriteMaterial map={glow} color={accent} transparent depthWrite={false} blending={THREE.AdditiveBlending} toneMapped={false} opacity={0.1} />
</sprite>
<RoundedBox
args={[width, height, 0.14]}
radius={Math.min(0.12, height * 0.28)}
smoothness={3}
{...tap}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
onPointerDown={(e: ThreeEvent<PointerEvent>) => { punch.current = 1; tap.onPointerDown(e); }}
>
<meshStandardMaterial
ref={backMat}
color={enabled ? '#26303c' : '#1a2129'}
emissive={accent}
emissiveIntensity={0.24}
metalness={0.45}
roughness={0.4}
transparent
opacity={0.96}
/>
</RoundedBox>
<mesh position={[0, 0, 0.085]} renderOrder={3}>
<planeGeometry args={[lw, lh]} />
<meshBasicMaterial map={lbl.texture} transparent depthWrite={false} toneMapped={false} opacity={enabled ? 1 : 0.5} />
</mesh>
</group>
);
return billboard ? <Billboard>{content}</Billboard> : content;
}
Step 4: Typecheck and lint the new toolkit
Run: ./node_modules/.bin/tsc --noEmit
Expected: PASS (no errors referencing components/daily-puzzles/three/**).
Run: ./node_modules/.bin/eslint components/daily-puzzles/three
Expected: PASS (no errors).
Step 5: Commit
git add components/daily-puzzles/three
git commit -m "feat(daily-3d): copy + re-theme ui3d toolkit (audio-agnostic)"
Task 5: Newsprint page texture + Newspaper component¶
The hero prop: a textured page plane for THE DAILY with masthead, date, puzzle number, column rules and a halftone filler — all drawn to a canvas. Plus a “X / 6 SOLVED” progress stamp.
Files:
Create:
components/daily-puzzles/three/newsprintTexture.tsCreate:
components/daily-puzzles/three/Newspaper.tsx
Interfaces:
Consumes:
getPuzzleNumber,formatDateKey,getTodayESTfrom@/lib/daily-puzzles/seed.Produces:
makeNewsprintTexture(opts: { title: string; dateText: string; issueText: string }): THREE.CanvasTexture— a 1024×1448 (≈ portrait page) newsprint texture, cached by its option key.Newspaper({ solvedCount, total }: { solvedCount: number; total: number })— a<group>containing the page plane (lying flat on the desk, normal up) and aLabel3D“SOLVED” stamp.
Step 1: Implement the newsprint texture
// components/daily-puzzles/three/newsprintTexture.ts
import * as THREE from 'three';
const cache = new Map<string, THREE.CanvasTexture>();
export interface NewsprintOptions {
title: string;
dateText: string;
issueText: string;
}
/** Draws a stylized newspaper front page to a CanvasTexture (cached by key). */
export function makeNewsprintTexture(opts: NewsprintOptions): THREE.CanvasTexture {
const key = `${opts.title}|${opts.dateText}|${opts.issueText}`;
const hit = cache.get(key);
if (hit) return hit;
const W = 1024;
const H = 1448;
const canvas = document.createElement('canvas');
canvas.width = W;
canvas.height = H;
const ctx = canvas.getContext('2d')!;
// Paper
ctx.fillStyle = '#efeae0';
ctx.fillRect(0, 0, W, H);
// Subtle vignette / aged tint
const vg = ctx.createRadialGradient(W / 2, H / 2, H * 0.2, W / 2, H / 2, H * 0.7);
vg.addColorStop(0, 'rgba(0,0,0,0)');
vg.addColorStop(1, 'rgba(120,110,90,0.12)');
ctx.fillStyle = vg;
ctx.fillRect(0, 0, W, H);
const ink = '#1c1a16';
const margin = 56;
// Masthead
ctx.fillStyle = ink;
ctx.textAlign = 'center';
ctx.font = "700 124px Georgia, 'Times New Roman', serif";
ctx.fillText(opts.title, W / 2, 150);
// Rules under masthead
ctx.fillRect(margin, 188, W - margin * 2, 4);
ctx.fillRect(margin, 198, W - margin * 2, 2);
// Date / issue line
ctx.font = "italic 28px Georgia, serif";
ctx.fillText(`${opts.dateText} · ${opts.issueText} · PUZZLES EDITION`, W / 2, 236);
// Column rules
const colTop = 280;
const colBottom = H - margin;
for (const cx of [W / 3, (W * 2) / 3]) {
ctx.fillStyle = 'rgba(28,26,22,0.35)';
ctx.fillRect(cx - 1, colTop, 2, colBottom - colTop);
}
// Halftone-ish filler text (rows of short ink dashes) so empty areas read as newsprint
ctx.fillStyle = 'rgba(28,26,22,0.22)';
for (let y = colTop + 24; y < colBottom; y += 26) {
for (let band = 0; band < 3; band++) {
const bx = margin + band * (W / 3) - (band > 0 ? 8 : 0);
const bw = W / 3 - margin * 0.8;
let x = bx;
while (x < bx + bw) {
const wlen = 12 + ((x * 7 + y * 13) % 40);
ctx.fillRect(x, y, Math.min(wlen, bx + bw - x), 8);
x += wlen + 8;
}
}
}
const tex = new THREE.CanvasTexture(canvas);
tex.colorSpace = THREE.SRGBColorSpace;
tex.anisotropy = 4;
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.generateMipmaps = true;
if (cache.size > 8) {
const first = cache.keys().next().value as string | undefined;
if (first) { cache.get(first)?.dispose(); cache.delete(first); }
}
cache.set(key, tex);
return tex;
}
Step 2: Implement the Newspaper component
The page lies flat on the desk (rotated -Math.PI/2 about X so its +Z normal points up). Page is ~6 wide × ~8.5 tall in world units. The SOLVED stamp sits in the masthead’s top-right, billboard off (it’s printed on the page) and tilted to match the page.
// components/daily-puzzles/three/Newspaper.tsx
'use client';
import { useMemo } from 'react';
import * as THREE from 'three';
import { makeNewsprintTexture } from './newsprintTexture';
import { Label3D } from './ui3d/Label3D';
import { formatDateKey, getPuzzleNumber, getTodayEST } from '@/lib/daily-puzzles/seed';
export const PAGE_W = 6;
export const PAGE_H = PAGE_W * (1448 / 1024); // ≈ 8.48, matches texture aspect
export function Newspaper({ solvedCount, total }: { solvedCount: number; total: number }) {
const today = getTodayEST();
const dateText = formatDateKey(today);
const issueText = `No. ${getPuzzleNumber(today)}`;
const tex = useMemo(
() => makeNewsprintTexture({ title: 'THE DAILY', dateText, issueText }),
[dateText, issueText],
);
return (
// Lay the page flat: rotate so the printed face points up (+Y).
<group rotation={[-Math.PI / 2, 0, 0]}>
<mesh receiveShadow>
<planeGeometry args={[PAGE_W, PAGE_H]} />
<meshStandardMaterial map={tex} roughness={0.95} metalness={0} side={THREE.DoubleSide} />
</mesh>
{/* SOLVED stamp, printed on the page near the masthead's right edge */}
<Label3D
text={`${solvedCount} / ${total} SOLVED`}
height={0.34}
billboard={false}
options={{ color: solvedCount === total ? '#1f7a4d' : '#b23b2e', fontSize: 64, bold: true }}
position={[PAGE_W / 2 - 1.4, PAGE_H / 2 - 0.55, 0.01]}
/>
</group>
);
}
Step 3: Typecheck and lint
Run: ./node_modules/.bin/tsc --noEmit
Expected: PASS.
Run: ./node_modules/.bin/eslint components/daily-puzzles/three/Newspaper.tsx components/daily-puzzles/three/newsprintTexture.ts
Expected: PASS.
Step 4: Commit
git add components/daily-puzzles/three/newsprintTexture.ts components/daily-puzzles/three/Newspaper.tsx
git commit -m "feat(daily-3d): newsprint page texture + Newspaper hero prop"
Task 6: Desk environment + desk props¶
Cool daylight lighting (the desk lamp as key) and asset-light props that frame the newspaper: desk surface, lamp, coffee cup + ring, pencils, sticky notes.
Files:
Create:
components/daily-puzzles/three/DeskEnvironment.tsxCreate:
components/daily-puzzles/three/DeskProps.tsx
Interfaces:
Produces:
DeskEnvironment()andDeskProps()— both<group>/fragment scene content (no props).Step 1: Implement DeskEnvironment
// components/daily-puzzles/three/DeskEnvironment.tsx
'use client';
import { ContactShadows, Environment, Lightformer } from '@react-three/drei';
/** Cool daylight lighting for the desk: soft sky fill, a warm-white desk-lamp
* key light, low bloom, baked PBR reflections (no external HDR). */
export function DeskEnvironment() {
return (
<>
<color attach="background" args={['#cdd4de']} />
<fog attach="fog" args={['#cdd4de', 22, 60]} />
{/* Cool sky / cool ground fill */}
<hemisphereLight intensity={0.85} color="#eaf0fb" groundColor="#9aa3af" />
{/* Desk lamp key (warm white, from upper-left where the lamp sits) */}
<directionalLight position={[-6, 11, 5]} intensity={1.8} color="#fff4e0" />
{/* Cool rim for separation */}
<pointLight position={[7, 5, -4]} intensity={6} color="#a9c4ff" distance={30} decay={2} />
<Environment resolution={256} frames={1}>
<Lightformer form="rect" intensity={2.4} color="#ffffff" position={[-5, 8, 4]} scale={7} />
<Lightformer form="circle" intensity={1.6} color="#dfe9ff" position={[6, 4, 5]} scale={5} />
<Lightformer form="circle" intensity={1.1} color="#cdd9f2" position={[-7, 2, -3]} scale={4} />
</Environment>
<ContactShadows position={[0, -0.01, 0]} opacity={0.34} scale={40} blur={2.6} far={10} resolution={512} color="#2a2f38" />
</>
);
}
Step 2: Implement DeskProps
All primitive geometry; positioned so the newspaper (centered at origin, lying flat just above the desk surface) is the focus. The desk surface sits at y ≈ -0.05.
// components/daily-puzzles/three/DeskProps.tsx
'use client';
import { RoundedBox } from '@react-three/drei';
/** Asset-light desk dressing around the newspaper: desk surface, lamp, coffee,
* pencils, sticky notes. Pure primitives — no external models. */
export function DeskProps() {
return (
<group>
{/* Desk surface (large felt/wood slab) */}
<mesh position={[0, -0.08, 0]} rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
<planeGeometry args={[40, 30]} />
<meshStandardMaterial color="#5b4a3a" roughness={0.85} metalness={0.05} />
</mesh>
{/* Desk edge lip (front) */}
<mesh position={[0, -0.2, 11]}>
<boxGeometry args={[40, 0.4, 0.6]} />
<meshStandardMaterial color="#4a3b2d" roughness={0.8} />
</mesh>
{/* Desk lamp (upper-left): base, arm, head */}
<group position={[-6.2, 0, -3.5]}>
<mesh position={[0, 0.05, 0]}>
<cylinderGeometry args={[0.7, 0.8, 0.12, 24]} />
<meshStandardMaterial color="#2b3340" metalness={0.6} roughness={0.4} />
</mesh>
<mesh position={[0.6, 1.4, 0.3]} rotation={[0, 0, -0.5]}>
<cylinderGeometry args={[0.06, 0.06, 3, 12]} />
<meshStandardMaterial color="#39424f" metalness={0.7} roughness={0.3} />
</mesh>
<mesh position={[1.7, 2.7, 0.9]} rotation={[0.9, 0, -0.8]}>
<coneGeometry args={[0.6, 0.8, 24, 1, true]} />
<meshStandardMaterial color="#2b3340" metalness={0.6} roughness={0.35} side={2} />
</mesh>
{/* warm bulb glow */}
<pointLight position={[1.7, 2.5, 1.2]} intensity={5} color="#ffe3b0" distance={12} decay={2} />
</group>
{/* Coffee cup + saucer ring (right) */}
<group position={[4.6, 0, 3.2]}>
<mesh position={[0, 0.45, 0]}>
<cylinderGeometry args={[0.5, 0.42, 0.9, 24]} />
<meshStandardMaterial color="#e8e4dc" roughness={0.5} />
</mesh>
<mesh position={[0, 0.84, 0]}>
<cylinderGeometry args={[0.44, 0.44, 0.06, 24]} />
<meshStandardMaterial color="#3a2a1c" roughness={0.3} />
</mesh>
{/* coffee ring stain on the desk */}
<mesh position={[-1.1, -0.06, 0.4]} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.34, 0.46, 24]} />
<meshStandardMaterial color="#6b4a2e" transparent opacity={0.5} roughness={1} />
</mesh>
</group>
{/* Pencils (bottom-right) */}
{[0, 0.18, -0.16].map((rot, i) => (
<mesh key={i} position={[3.4 + i * 0.22, 0.02, 5.4]} rotation={[-Math.PI / 2, 0, rot + 0.4]}>
<cylinderGeometry args={[0.05, 0.05, 2.2, 6]} />
<meshStandardMaterial color={['#e0b53a', '#d96b3a', '#3a72d9'][i]} roughness={0.6} />
</mesh>
))}
{/* Sticky notes (upper-right) */}
{[
{ p: [4.2, 0.01, -3.8] as [number, number, number], c: '#f2e15a', r: 0.12 },
{ p: [5.0, 0.012, -3.2] as [number, number, number], c: '#7fe0a8', r: -0.2 },
].map((n, i) => (
<RoundedBox key={i} args={[1.1, 0.02, 1.1]} radius={0.02} position={n.p} rotation={[0, n.r, 0]}>
<meshStandardMaterial color={n.c} roughness={0.8} />
</RoundedBox>
))}
</group>
);
}
Step 3: Typecheck and lint
Run: ./node_modules/.bin/tsc --noEmit
Expected: PASS.
Run: ./node_modules/.bin/eslint components/daily-puzzles/three/DeskEnvironment.tsx components/daily-puzzles/three/DeskProps.tsx
Expected: PASS.
Step 4: Commit
git add components/daily-puzzles/three/DeskEnvironment.tsx components/daily-puzzles/three/DeskProps.tsx
git commit -m "feat(daily-3d): cool desk environment + asset-light desk props"
Task 8: DeskGameFrame — embed a game on the page¶
Renders an existing React game component inside a drei <Html transform> panel mapped onto the page plane in perspective, wrapped in a newsprint CSS theme, with a 3D “← Front page” Button3D.
Files:
Create:
components/daily-puzzles/desk-newsprint.cssCreate:
components/daily-puzzles/three/DeskGameFrame.tsx
Interfaces:
Consumes:
Button3D(Task 4),PAGE_W/PAGE_H(Task 5),useDeskStore(Task 3),Htmlfrom@react-three/drei,useNavigate.Produces:
DeskGameFrame({ children, onBack }: { children: React.ReactNode; onBack: () => void })— scene content placingchildren(the game UI) on the page and a back button.Step 1: Implement the newsprint CSS theme
/* components/daily-puzzles/desk-newsprint.css */
.desk-newsprint {
width: 720px;
min-height: 1000px;
padding: 32px 36px;
background: #efeae0;
color: #1c1a16;
font-family: Georgia, 'Times New Roman', serif;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.25);
border: 1px solid #d8d2c4;
column-rule: 1px solid rgba(28, 26, 22, 0.25);
}
/* Tone the embedded game toward ink-on-paper without rewriting each game:
neutralize dark backgrounds and map accents to ink. */
.desk-newsprint :is(h1, h2, h3) {
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: -0.01em;
}
.desk-newsprint a { color: #5a3a86; }
Step 2: Implement DeskGameFrame
The <Html transform> panel is positioned just above the page surface and rotated flat so the DOM is mapped onto the paper in perspective. distanceFactor controls DOM-px → world-unit scale. occlude hides it behind geometry when rotated away.
// components/daily-puzzles/three/DeskGameFrame.tsx
'use client';
import { Html } from '@react-three/drei';
import { useTranslation } from 'react-i18next';
import { Button3D } from './ui3d/Button3D';
import { PAGE_H } from './Newspaper';
import '../desk-newsprint.css';
export function DeskGameFrame({ children, onBack }: { children: React.ReactNode; onBack: () => void }) {
const { t } = useTranslation('c-daily-puzzles');
return (
<group>
{/* Game UI mapped onto the page surface, lying flat (face up). */}
<group rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.02, 0]}>
<Html
transform
occlude
distanceFactor={6}
position={[0, -0.3, 0.03]}
style={{ pointerEvents: 'auto' }}
>
<div className="desk-newsprint">{children}</div>
</Html>
</group>
{/* 3D back button floating above the page's top edge (billboarded). */}
<Button3D
label={t('back-to-front-page', { defaultValue: '← Front page' })}
onClick={onBack}
width={2}
height={0.5}
fontSize={34}
color="#8fb0dc"
position={[0, 0.4, PAGE_H / 2 + 0.6]}
/>
</group>
);
}
Note:
distanceFactor,position, and.desk-newsprintwidth are the three knobs to tune at manual-verification time so the page fills the frame without clipping. Start at the values above.
Step 3: Typecheck and lint
Run: ./node_modules/.bin/tsc --noEmit
Expected: PASS.
Run: ./node_modules/.bin/eslint components/daily-puzzles/three/DeskGameFrame.tsx
Expected: PASS.
Step 4: Commit
git add components/daily-puzzles/desk-newsprint.css components/daily-puzzles/three/DeskGameFrame.tsx
git commit -m "feat(daily-3d): DeskGameFrame embeds game UI on the page"
Task 9: DeskScene — assemble the world¶
The persistent <Canvas> that contains the environment, props, newspaper, OrbitControls, and either the FrontPage (no focus) or the focused game’s DeskGameFrame (children passed in). Camera leans between an overview pose and a focused pose based on focusedMode.
Files:
Create:
components/daily-puzzles/three/DeskScene.tsx
Interfaces:
Consumes: everything above +
useDeskStore,hasCompleted,DESK_MODES.Produces:
DeskScene({ children }: { children?: React.ReactNode })— full-screen Canvas. Whenchildrenis provided (a route mounted a game) it is rendered insideDeskGameFrame; otherwise theFrontPageshows. The component also drives the camera lean each frame.Step 1: Implement DeskScene
// components/daily-puzzles/three/DeskScene.tsx
'use client';
import { Suspense, useRef, useState } from 'react';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { OrbitControls, AdaptiveDpr, AdaptiveEvents, PerformanceMonitor } from '@react-three/drei';
import * as THREE from 'three';
import { useNavigate } from '@tanstack/react-router';
import { DeskEnvironment } from './DeskEnvironment';
import { DeskProps } from './DeskProps';
import { Newspaper } from './Newspaper';
import { FrontPage } from './FrontPage';
import { DeskGameFrame } from './DeskGameFrame';
import { useDeskStore } from '@/lib/daily-puzzles/desk-store';
import { DESK_MODES } from '@/lib/daily-puzzles/desk-modes';
import { hasCompleted } from '@/lib/daily-puzzles/persistence';
import { formatDateKey, getTodayEST } from '@/lib/daily-puzzles/seed';
// Two camera poses: overview (front page) and leaned-in (playing).
const OVERVIEW = new THREE.Vector3(0, 9.5, 7.5);
const FOCUSED = new THREE.Vector3(0, 6.2, 4.4);
const OVERVIEW_TGT = new THREE.Vector3(0, 0, 0);
const FOCUSED_TGT = new THREE.Vector3(0, 0, -0.3);
function CameraRig({ focused }: { focused: boolean }) {
const { camera } = useThree();
const controls = useRef<any>(null);
useFrame((_, dt) => {
const k = Math.min(1, dt * 3);
const destPos = focused ? FOCUSED : OVERVIEW;
camera.position.lerp(destPos, k);
if (controls.current) {
const tgt = focused ? FOCUSED_TGT : OVERVIEW_TGT;
controls.current.target.lerp(tgt, k);
controls.current.update();
}
});
return (
<OrbitControls
ref={controls}
makeDefault
enablePan={false}
enableDamping
dampingFactor={0.08}
minDistance={4}
maxDistance={16}
minPolarAngle={0.15}
maxPolarAngle={1.15}
rotateSpeed={0.6}
zoomSpeed={0.6}
/>
);
}
function Contents({ children }: { children?: React.ReactNode }) {
const navigate = useNavigate();
const focusedMode = useDeskStore((s) => s.focusedMode);
const playing = !!children && !!focusedMode;
const dateKey = formatDateKey(getTodayEST());
const solved = DESK_MODES.filter((m) => hasCompleted(m.id, dateKey)).length;
const back = () => {
useDeskStore.getState().setFocusedMode(null);
navigate({ to: '/daily' });
};
return (
<>
<DeskEnvironment />
<DeskProps />
<Newspaper solvedCount={solved} total={DESK_MODES.length} />
{playing ? (
<DeskGameFrame onBack={back}>{children}</DeskGameFrame>
) : (
<FrontPage onSelect={() => {}} />
)}
<CameraRig focused={playing} />
</>
);
}
/** Full-screen persistent desk world. Mobile-optimized: capped/adaptive DPR,
* touch-action none so gestures drive the camera. */
export function DeskScene({ children }: { children?: React.ReactNode }) {
const [dpr, setDpr] = useState(1.5);
return (
<Canvas
shadows={false}
dpr={dpr}
gl={{ antialias: true, powerPreference: 'high-performance', alpha: false }}
camera={{ position: [0, 9.5, 7.5], fov: 45, near: 0.1, far: 140 }}
style={{ touchAction: 'none' }}
>
<PerformanceMonitor onDecline={() => setDpr(1)} onIncline={() => setDpr(1.5)} />
<AdaptiveDpr pixelated />
<AdaptiveEvents />
<Suspense fallback={null}>
<Contents>{children}</Contents>
</Suspense>
</Canvas>
);
}
Step 2: Typecheck and lint
Run: ./node_modules/.bin/tsc --noEmit
Expected: PASS.
Run: ./node_modules/.bin/eslint components/daily-puzzles/three/DeskScene.tsx
Expected: PASS.
Step 3: Commit
git add components/daily-puzzles/three/DeskScene.tsx
git commit -m "feat(daily-3d): assemble persistent DeskScene with camera lean"
Task 10: Wire the routes (persistent desk + Spectrum proof)¶
Hoist the desk into the /daily layout so it persists; the index route just clears focus (front page); the spectrum route mounts the existing SpectrumGame inside the desk via DeskScene children.
Files:
Modify:
app/routes/daily.tsxModify:
app/routes/daily/index.tsxModify:
app/routes/daily/spectrum.tsx
Interfaces:
Consumes:
DeskScene(Task 9),useDeskStore(Task 3), existingSpectrumGame,GameErrorBoundary,GameLoadingFallback.Step 1: Make the
/dailylayout host the persistent desk
The layout renders the full-screen DeskScene once; child routes render into it via a context so a mounted game becomes the scene’s children. Use a simple module-level approach: the layout reads useDeskStore.focusedMode; the index route clears it, the game route sets it and provides the game element through a render context.
Replace app/routes/daily.tsx entirely:
// app/routes/daily.tsx
/** Daily Puzzles Layout — hosts the persistent 3D desk; child routes feed it. */
import { createContext, useContext, useState, type ReactNode } from 'react';
import { createFileRoute, Outlet } from '@tanstack/react-router';
import { lazy, Suspense } from 'react';
import { GameErrorBoundary } from '@/components/shared/GameErrorBoundary';
import { GameLoadingFallback } from '@/components/shared/GameLoadingFallback';
const DeskScene = lazy(() =>
import('@/components/daily-puzzles/three/DeskScene').then((m) => ({ default: m.DeskScene })),
);
// Child routes register the game element (or null for the hub) here.
const DeskSlotCtx = createContext<(node: ReactNode) => void>(() => {});
export const useDeskSlot = () => useContext(DeskSlotCtx);
function DailyLayout() {
const [gameNode, setGameNode] = useState<ReactNode>(null);
return (
<div className="fixed inset-0 bg-site-bg text-site-text">
<GameErrorBoundary gameName="Daily Puzzles">
<Suspense fallback={<GameLoadingFallback />}>
<DeskScene>{gameNode}</DeskScene>
</Suspense>
</GameErrorBoundary>
{/* Hidden mount point: child route runs effects to register its game node */}
<DeskSlotCtx.Provider value={setGameNode}>
<Outlet />
</DeskSlotCtx.Provider>
</div>
);
}
export const Route = createFileRoute('/daily')({
component: DailyLayout,
});
Step 2: Index route clears focus (front page)
// app/routes/daily/index.tsx
import { useEffect } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { useDeskSlot } from '../daily';
import { useDeskStore } from '@/lib/daily-puzzles/desk-store';
function DailyIndex() {
const setSlot = useDeskSlot();
useEffect(() => {
useDeskStore.getState().setFocusedMode(null);
setSlot(null);
return () => setSlot(null);
}, [setSlot]);
return null;
}
export const Route = createFileRoute('/daily/')({
component: DailyIndex,
});
Step 3: Spectrum route mounts the game into the desk
// app/routes/daily/spectrum.tsx
import { lazy, Suspense, useEffect } from 'react';
import { createFileRoute } from '@tanstack/react-router';
import { GameErrorBoundary } from '@/components/shared/GameErrorBoundary';
import { GameLoadingFallback } from '@/components/shared/GameLoadingFallback';
import { useDeskSlot } from '../daily';
import { useDeskStore } from '@/lib/daily-puzzles/desk-store';
const SpectrumGame = lazy(() =>
import('@/components/daily-puzzles/SpectrumGame').then((m) => ({ default: m.SpectrumGame })),
);
function SpectrumRoute() {
const setSlot = useDeskSlot();
useEffect(() => {
useDeskStore.getState().setFocusedMode('spectrum');
setSlot(
<GameErrorBoundary gameName="Spectrum">
<Suspense fallback={<GameLoadingFallback />}>
<SpectrumGame />
</Suspense>
</GameErrorBoundary>,
);
return () => setSlot(null);
}, [setSlot]);
return null;
}
export const Route = createFileRoute('/daily/spectrum')({
component: SpectrumRoute,
});
Step 4: Regenerate the route tree and build
Run: ./node_modules/.bin/vite build
Expected: Build succeeds; app/routeTree.gen.ts regenerated. If pnpm-workspace.yaml was modified by the build, revert it: git checkout pnpm-workspace.yaml.
Step 5: Typecheck
Run: ./node_modules/.bin/tsc --noEmit
Expected: PASS (route-tree types now resolve).
Step 6: Commit
git add app/routes/daily.tsx app/routes/daily/index.tsx app/routes/daily/spectrum.tsx app/routeTree.gen.ts
git commit -m "feat(daily-3d): host persistent desk in /daily layout; wire Spectrum proof"
Task 11: Manual browser verification & tuning¶
R3F/visual behavior can’t be unit-tested here; this task is the explicit manual gate before the SP1 PR. (No new code unless tuning is needed.)
Files:
Possibly tune:
components/daily-puzzles/three/DeskGameFrame.tsx(distanceFactor,position,.desk-newsprintwidth),DeskScene.tsx(camera poses).Step 1: Launch the app
Use the project’s run path (per the run skill / repo convention). Navigate to /daily.
Step 2: Verify the hub
Confirm: the desk renders with newspaper, lamp, coffee, pencils, sticky notes; cool daylight look (not Temple’s gold); masthead reads “THE DAILY” + today’s date + “No. N”; six clipping buttons laid out on the page; unsolved clippings pulse; “X / 6 SOLVED” stamp shows the right count.
Step 3: Verify interaction
Confirm: dragging orbits the desk (does not click a clipping); tapping a clipping navigates to /daily/<id> and the camera leans in. Tapping Spectrum embeds the live Spectrum game on the page in perspective and it is playable. The “← Front page” button leans back out and returns to /daily. Tune distanceFactor/positions if the page clips or is too small.
Step 4: Verify persistence + mobile
Complete the Spectrum puzzle; return to the hub; confirm the Spectrum clipping shows “✓” and the SOLVED count increments. Open dev tools device emulation (mobile); confirm the scene is responsive and gestures don’t scroll the page.
Step 5: Final checks + commit any tuning
Run: ./node_modules/.bin/tsc --noEmit → PASS
Run: ./node_modules/.bin/vitest run lib/__tests__/desk-layout.test.ts lib/__tests__/desk-modes.test.ts lib/__tests__/desk-store.test.ts → PASS
Run: ./node_modules/.bin/vite build → succeeds
git add -A
git commit -m "chore(daily-3d): SP1 tuning after manual verification"
Step 6: Senior review before PR
Per repo workflow prefs, run the senior-swe-reviewer agent on the branch diff before opening the PR. Address findings, then open the PR.
Notes for SP2–SP7 (out of scope here)¶
Each remaining puzzle (Alibi, Spectrum*, Outcast, Chainlink, Impostor, Lights Out) gets its own route wired exactly like Task 10’s Spectrum route (set focus + register the game node via useDeskSlot) and a newsprint pass on its component. (Spectrum is done in SP1 as the proof.) No further toolkit/world work should be needed; if a game’s UI fights the embed (size/scroll), tune DeskGameFrame rather than the toolkit.
Self-Review¶
Spec coverage: toolkit copy/re-theme (T4), desk world (T5/T6), front-page hub mapping DESK_MODES (T7), embedded games via
<Html transform>(T8), persistent layout route (T10), progress stamp secondary surface (T5/T9), one game wired end-to-end (T10), cool identity (T4/T6). Leaderboard/past-issues/share as 3D desk props are intentionally deferred past SP1 (the games already render their own leaderboard/share UI inside the embed); only the progress stamp is re-homed in SP1 — consistent with “secondary surfaces re-homed” without expanding SP1’s scope.Placeholder scan: none — all steps have concrete code/commands.
Type consistency:
gridLayout→{positions, cellW, cellH, perRow, rows}used in T7;DeskMode.accentused in T7;useDeskStore.setFocusedModeused in T7/T9/T10;DeskScene({children}),DeskGameFrame({children,onBack}),Newspaper({solvedCount,total}),FrontPage({onSelect}),useDeskSlot()all consistent across tasks.