otherplane 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +130 -0
  2. package/LICENSE +21 -0
  3. package/README.md +146 -0
  4. package/bin/otherplane.mjs +489 -0
  5. package/engine/eslint.config.mjs +25 -0
  6. package/engine/next.config.ts +43 -0
  7. package/engine/package-lock.json +6848 -0
  8. package/engine/package.json +36 -0
  9. package/engine/postcss.config.mjs +5 -0
  10. package/engine/src/app/LandingRedirect.tsx +15 -0
  11. package/engine/src/app/[room]/RoomViewer.tsx +413 -0
  12. package/engine/src/app/[room]/page.tsx +30 -0
  13. package/engine/src/app/favicon.ico +0 -0
  14. package/engine/src/app/layout.tsx +45 -0
  15. package/engine/src/app/page.tsx +11 -0
  16. package/engine/src/app/providers.tsx +22 -0
  17. package/engine/src/components/controls/MobileHud.tsx +25 -0
  18. package/engine/src/components/controls/PlayerController.tsx +170 -0
  19. package/engine/src/components/controls/TouchLookController.tsx +93 -0
  20. package/engine/src/components/controls/VirtualStick.tsx +153 -0
  21. package/engine/src/components/edit/EditCapture.tsx +182 -0
  22. package/engine/src/components/edit/EditorPanel.tsx +265 -0
  23. package/engine/src/components/edit/Markers.tsx +91 -0
  24. package/engine/src/components/hud/Button.tsx +228 -0
  25. package/engine/src/components/hud/ClickToPlay.tsx +13 -0
  26. package/engine/src/components/hud/ContentOverlay.tsx +44 -0
  27. package/engine/src/components/hud/NavHeader.module.css +24 -0
  28. package/engine/src/components/scene/Artifacts.tsx +85 -0
  29. package/engine/src/components/scene/Exits.tsx +92 -0
  30. package/engine/src/components/scene/PointerLockBridge.tsx +28 -0
  31. package/engine/src/components/scene/WorldScene.tsx +164 -0
  32. package/engine/src/components/spark/SparkLayer.tsx +112 -0
  33. package/engine/src/components/spark/SplatWorld.tsx +156 -0
  34. package/engine/src/config/audio.ts +11 -0
  35. package/engine/src/data/editApi.ts +73 -0
  36. package/engine/src/data/presets.ts +34 -0
  37. package/engine/src/data/room.ts +100 -0
  38. package/engine/src/data/site.ts +50 -0
  39. package/engine/src/data/universeconfig.ts +19 -0
  40. package/engine/src/icons/ArrowLeft.tsx +20 -0
  41. package/engine/src/icons/ChevronDown.tsx +23 -0
  42. package/engine/src/icons/ChevronLeft.tsx +22 -0
  43. package/engine/src/icons/Home.tsx +22 -0
  44. package/engine/src/icons/Spinner.module.css +13 -0
  45. package/engine/src/icons/Spinner.tsx +28 -0
  46. package/engine/src/icons/VolumeMax.tsx +21 -0
  47. package/engine/src/icons/VolumeX.tsx +22 -0
  48. package/engine/src/icons/icons.interface.ts +7 -0
  49. package/engine/src/icons/index.ts +27 -0
  50. package/engine/src/physics/RapierProvider.tsx +302 -0
  51. package/engine/src/physics/index.ts +2 -0
  52. package/engine/src/physics/types.ts +9 -0
  53. package/engine/src/providers/audio.tsx +215 -0
  54. package/engine/src/providers/edit.tsx +357 -0
  55. package/engine/src/providers/pointerLock.tsx +88 -0
  56. package/engine/src/styles/globals.css +88 -0
  57. package/engine/tailwind.config.js +184 -0
  58. package/engine/tsconfig.json +27 -0
  59. package/otherplane.config.example.json +6 -0
  60. package/package.json +56 -0
  61. package/schema/room.schema.json +77 -0
  62. package/scripts/gen_world.py +147 -0
  63. package/skill.md +94 -0
@@ -0,0 +1,265 @@
1
+ 'use client';
2
+
3
+ // The edit-mode authoring panel — a DOM overlay (outside the Canvas) that turns
4
+ // the room's marks into editable lists: name/move/delete entryways, wire exits by
5
+ // menu or arbitrary URL, promote an entryway into a doorway, and set artifact
6
+ // URLs. Position comes from the world (press C/B, or "set here" which reads the
7
+ // live floor-snap); this panel owns everything else. Every change persists to
8
+ // room.json through the provider. Only mounts in edit mode.
9
+
10
+ import { useEffect, useState } from 'react';
11
+ import { useEdit, type MarkerKind } from '@/providers/edit';
12
+ import type { Vec3 } from '@/data/room';
13
+
14
+ const vec = (p: Vec3) => `${p[0]}, ${p[1]}, ${p[2]}`;
15
+
16
+ // "../green/#from-red" → { slug: "green", entryId: "from-red" }; null if not an
17
+ // internal room link.
18
+ function parseInternal(to: string): { slug: string; entryId: string } | null {
19
+ const m = to.match(/^\.\.\/([^/]+)\/#(.+)$/);
20
+ return m ? { slug: m[1], entryId: m[2] } : null;
21
+ }
22
+
23
+ const CUSTOM = '__custom__';
24
+
25
+ export default function EditorPanel() {
26
+ const {
27
+ editMode, draft, draftSlug, rooms, selected, setSelected, saveStatus, liveRef,
28
+ addEntryway, addExit, updateEntryway, updateExit, updateArtifact,
29
+ removeMarker, promoteEntryway, makeTwoWay, entrywayAt,
30
+ undo, canUndo, moveSpeed, setMoveSpeed, scale, setScale,
31
+ } = useEdit();
32
+
33
+ // Room-scale slider: track live for the label, but only commit (resize + rescale
34
+ // markers + save) on release — the collider re-bake is too heavy to run per tick.
35
+ const [scaleInput, setScaleInput] = useState(scale);
36
+ useEffect(() => { setScaleInput(scale); }, [scale]);
37
+ // Move-speed slider: track live, write otherplane.config.json on release.
38
+ const [speedInput, setSpeedInput] = useState(moveSpeed);
39
+ useEffect(() => { setSpeedInput(moveSpeed); }, [moveSpeed]);
40
+
41
+ if (!editMode || !draft) return null;
42
+
43
+ const isSel = (kind: MarkerKind, i: number) => selected?.kind === kind && selected.index === i;
44
+ const rowCls = (kind: MarkerKind, i: number) =>
45
+ `rounded border px-2 py-1.5 ${isSel(kind, i) ? 'border-amber-400 bg-amber-500/10' : 'border-zinc-700 bg-zinc-800/40'}`;
46
+
47
+ // "Add … here" and "set … here" read the live floor-snap the player is standing
48
+ // on (updated every frame by EditCapture).
49
+ const floor = () => liveRef.current.floorPos;
50
+ const yawNow = () => liveRef.current.yaw;
51
+
52
+ const status =
53
+ saveStatus === 'saving' ? 'saving…' :
54
+ saveStatus === 'saved' ? 'saved ✓' :
55
+ saveStatus === 'error' ? 'save failed ✗' : '';
56
+
57
+ const selLabel = selected ? `${selected.kind} #${selected.index + 1}` : 'none';
58
+
59
+ return (
60
+ <div className="pointer-events-auto absolute top-4 left-4 z-30 flex max-h-[92vh] w-80 flex-col gap-2 overflow-y-auto rounded-lg border border-amber-500/40 bg-zinc-900/90 p-3 font-mono text-xs text-zinc-200 backdrop-blur">
61
+ <div className="flex items-center justify-between">
62
+ <span className="font-semibold text-amber-400">EDITOR · {draftSlug}</span>
63
+ <div className="flex items-center gap-2">
64
+ <button
65
+ className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600 disabled:opacity-30"
66
+ disabled={!canUndo}
67
+ title="Undo (⌘Z)"
68
+ onClick={() => undo()}
69
+ >↶ undo</button>
70
+ <span className={saveStatus === 'error' ? 'text-red-400' : 'text-emerald-300'}>{status}</span>
71
+ </div>
72
+ </div>
73
+
74
+ {/* How-to: the one place these live now. */}
75
+ <div className="rounded border border-zinc-700 bg-zinc-800/50 p-2 text-[11px] leading-snug">
76
+ <div className="mb-1 text-zinc-400">
77
+ <b className="text-amber-300">Esc</b> = edit here · click scene = walk (WASD)
78
+ </div>
79
+ <div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-0.5">
80
+ <b className="text-amber-300">F</b><span>select the orb you look at</span>
81
+ <b className="text-amber-300">C</b><span>add entryway · <i>move</i> selected one</span>
82
+ <b className="text-amber-300">B</b><span>add artifact (aim first) · <i>move</i> selected</span>
83
+ <b className="text-amber-300">Del</b><span>delete selected · <b className="text-amber-300">X</b> deselect</span>
84
+ <b className="text-amber-300">Z</b><span>toggle fly (↑/↓) · <b className="text-amber-300">⌘Z</b> undo</span>
85
+ </div>
86
+ <div className="mt-1 text-zinc-400">selected: <span className="text-amber-200">{selLabel}</span></div>
87
+ </div>
88
+
89
+ {/* Walk speed — a per-museum setting, saved to otherplane.config.json so it
90
+ ships to every viewer (not a per-browser preference). */}
91
+ <div className="rounded border border-zinc-700 bg-zinc-800/50 p-2">
92
+ <label className="flex items-center gap-2">
93
+ <span className="w-16 text-zinc-400">speed</span>
94
+ <input type="range" min={2} max={40} step={1} value={speedInput}
95
+ className="flex-1 accent-amber-400"
96
+ onChange={(e) => setSpeedInput(Number(e.target.value))}
97
+ onPointerUp={() => setMoveSpeed(speedInput)}
98
+ onKeyUp={() => setMoveSpeed(speedInput)} />
99
+ <span className="w-10 text-right text-zinc-300">{speedInput}</span>
100
+ </label>
101
+ <div className="mt-1 text-[10px] text-zinc-500">saved to otherplane.config.json</div>
102
+ </div>
103
+
104
+ {/* Room scale — saved to room.json; rescales markers on release. */}
105
+ <div className="rounded border border-zinc-700 bg-zinc-800/50 p-2">
106
+ <label className="flex items-center gap-2">
107
+ <span className="w-16 text-cyan-300">room scale</span>
108
+ <input type="range" min={0.1} max={5} step={0.05} value={scaleInput}
109
+ className="flex-1 accent-cyan-400"
110
+ onChange={(e) => setScaleInput(Number(e.target.value))}
111
+ onPointerUp={() => setScale(scaleInput)}
112
+ onKeyUp={() => setScale(scaleInput)}
113
+ />
114
+ <span className="w-10 text-right text-zinc-300">{scaleInput.toFixed(2)}</span>
115
+ </label>
116
+ <div className="mt-1 text-[10px] text-zinc-500">
117
+ saved to room.json · markers rescale with it · fit the room to a ~1.7m
118
+ player (too small and you won’t fit — you’ll get stuck)
119
+ </div>
120
+ </div>
121
+
122
+ {/* Entryways */}
123
+ <section className="space-y-1">
124
+ <div className="flex items-center justify-between">
125
+ <span className="font-semibold text-emerald-300">Entryways</span>
126
+ <button
127
+ className="rounded bg-emerald-600/30 px-1.5 py-0.5 text-emerald-200 hover:bg-emerald-600/50"
128
+ onClick={() => { const p = floor(); if (p) addEntryway(p, yawNow()); }}
129
+ >+ here</button>
130
+ </div>
131
+ {draft.entryways.map((e, i) => (
132
+ <div key={i} className={rowCls('entryway', i)}>
133
+ <div className="flex items-center gap-1">
134
+ <input
135
+ className="w-full rounded bg-zinc-900 px-1 py-0.5 text-emerald-200 outline-none focus:ring-1 focus:ring-emerald-400"
136
+ value={e.id}
137
+ spellCheck={false}
138
+ onChange={(ev) => updateEntryway(i, { id: ev.target.value })}
139
+ onFocus={() => setSelected({ kind: 'entryway', index: i })}
140
+ />
141
+ </div>
142
+ <div className="mt-1 flex flex-wrap items-center gap-1 text-[10px] text-zinc-400">
143
+ <span>[{vec(e.pos)}] yaw {e.yaw}</span>
144
+ </div>
145
+ <div className="mt-1 flex flex-wrap gap-1">
146
+ <button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
147
+ onClick={() => setSelected({ kind: 'entryway', index: i })}>select</button>
148
+ <button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
149
+ onClick={() => { const p = floor(); if (p) updateEntryway(i, { pos: p, yaw: yawNow() }); }}>set here</button>
150
+ <button className="rounded bg-cyan-700/50 px-1.5 py-0.5 hover:bg-cyan-700"
151
+ onClick={() => promoteEntryway(i)}>make exit</button>
152
+ <button className="rounded bg-red-800/50 px-1.5 py-0.5 hover:bg-red-800"
153
+ onClick={() => removeMarker('entryway', i)}>del</button>
154
+ </div>
155
+ </div>
156
+ ))}
157
+ </section>
158
+
159
+ {/* Exits */}
160
+ <section className="space-y-1">
161
+ <div className="flex items-center justify-between">
162
+ <span className="font-semibold text-cyan-300">Exits</span>
163
+ <button
164
+ className="rounded bg-cyan-600/30 px-1.5 py-0.5 text-cyan-200 hover:bg-cyan-600/50"
165
+ onClick={() => { const p = floor(); if (p) addExit(p); }}
166
+ >+ here</button>
167
+ </div>
168
+ {draft.exits.map((e, i) => {
169
+ const internal = parseInternal(e.to);
170
+ const dropdownValue = internal ? e.to : (e.to ? CUSTOM : '');
171
+ const src = entrywayAt(e.pos);
172
+ const srcIndex = src ? draft.entryways.findIndex((x) => x.id === src.id) : -1;
173
+ return (
174
+ <div key={i} className={rowCls('exit', i)}>
175
+ <select
176
+ className="w-full rounded bg-zinc-900 px-1 py-0.5 text-cyan-200 outline-none focus:ring-1 focus:ring-cyan-400"
177
+ value={dropdownValue}
178
+ onFocus={() => setSelected({ kind: 'exit', index: i })}
179
+ onChange={(ev) => {
180
+ const v = ev.target.value;
181
+ updateExit(i, { to: v === CUSTOM ? (internal ? '' : e.to) : v });
182
+ }}
183
+ >
184
+ <option value="">— pick a destination —</option>
185
+ {rooms.flatMap((r) =>
186
+ r.entryways.map((en) => (
187
+ <option key={`${r.slug}#${en.id}`} value={`../${r.slug}/#${en.id}`}>
188
+ {r.display_name} → {en.id}
189
+ </option>
190
+ )),
191
+ )}
192
+ <option value={CUSTOM}>Custom URL…</option>
193
+ </select>
194
+ {dropdownValue === CUSTOM && (
195
+ <input
196
+ className="mt-1 w-full rounded bg-zinc-900 px-1 py-0.5 text-cyan-200 outline-none focus:ring-1 focus:ring-cyan-400"
197
+ placeholder="https://another-museum.example/room/#entry"
198
+ value={e.to}
199
+ spellCheck={false}
200
+ onChange={(ev) => updateExit(i, { to: ev.target.value })}
201
+ onFocus={() => setSelected({ kind: 'exit', index: i })}
202
+ />
203
+ )}
204
+ <div className="mt-1 text-[10px] text-zinc-400">[{vec(e.pos)}] r {e.radius ?? 1.3}</div>
205
+ <div className="mt-1 flex flex-wrap gap-1">
206
+ <button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
207
+ onClick={() => setSelected({ kind: 'exit', index: i })}>select</button>
208
+ <button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
209
+ onClick={() => { const p = floor(); if (p) updateExit(i, { pos: p }); }}>set here</button>
210
+ {internal && srcIndex >= 0 && (
211
+ <button className="rounded bg-emerald-700/50 px-1.5 py-0.5 hover:bg-emerald-700"
212
+ title={`add the return door in ${internal.slug}`}
213
+ onClick={() => makeTwoWay(srcIndex, internal.slug, internal.entryId)}>two-way</button>
214
+ )}
215
+ <button className="rounded bg-red-800/50 px-1.5 py-0.5 hover:bg-red-800"
216
+ onClick={() => removeMarker('exit', i)}>del</button>
217
+ </div>
218
+ {internal && srcIndex < 0 && (
219
+ <div className="mt-1 text-[10px] text-amber-400/80">
220
+ one-way — add an entryway here (C) to make it two-way
221
+ </div>
222
+ )}
223
+ </div>
224
+ );
225
+ })}
226
+ </section>
227
+
228
+ {/* Artifacts */}
229
+ <section className="space-y-1">
230
+ <div className="flex items-center justify-between">
231
+ <span className="font-semibold text-amber-300">Artifacts</span>
232
+ <span className="text-[10px] text-zinc-500">aim + press B</span>
233
+ </div>
234
+ {draft.artifacts.map((a, i) => (
235
+ <div key={i} className={rowCls('artifact', i)}>
236
+ <input
237
+ className="w-full rounded bg-zinc-900 px-1 py-0.5 text-amber-200 outline-none focus:ring-1 focus:ring-amber-400"
238
+ placeholder="https://…"
239
+ value={a.url}
240
+ spellCheck={false}
241
+ onChange={(ev) => updateArtifact(i, { url: ev.target.value })}
242
+ onFocus={() => setSelected({ kind: 'artifact', index: i })}
243
+ />
244
+ <div className="mt-1 flex items-center gap-2 text-[10px] text-zinc-400">
245
+ <span>[{vec(a.pos)}]</span>
246
+ <label className="flex items-center gap-1">r
247
+ <input type="number" step="0.1" min="0.1" value={a.radius}
248
+ className="w-14 rounded bg-zinc-900 px-1 py-0.5 text-amber-200 outline-none"
249
+ onChange={(ev) => updateArtifact(i, { radius: Number(ev.target.value) || a.radius })}
250
+ onFocus={() => setSelected({ kind: 'artifact', index: i })}
251
+ />
252
+ </label>
253
+ </div>
254
+ <div className="mt-1 flex flex-wrap gap-1">
255
+ <button className="rounded bg-zinc-700 px-1.5 py-0.5 hover:bg-zinc-600"
256
+ onClick={() => setSelected({ kind: 'artifact', index: i })}>select</button>
257
+ <button className="rounded bg-red-800/50 px-1.5 py-0.5 hover:bg-red-800"
258
+ onClick={() => removeMarker('artifact', i)}>del</button>
259
+ </div>
260
+ </div>
261
+ ))}
262
+ </section>
263
+ </div>
264
+ );
265
+ }
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ // Edit-mode-only: render every marker in the current draft as a glowing, labeled
4
+ // orb so the room's structure is readable at a glance. Green = entryway (arrive),
5
+ // cyan = exit (leave), amber = artifact (content). A doorway (entryway + exit at
6
+ // one spot) reads as a stacked green+cyan pair. The selected marker pulses larger.
7
+ // Published builds never mount this (editMode is false).
8
+
9
+ import { useMemo } from 'react';
10
+ import { Billboard, Text } from '@react-three/drei';
11
+ import { useEdit, type MarkerKind } from '@/providers/edit';
12
+ import type { Vec3 } from '@/data/room';
13
+
14
+ const COLORS: Record<MarkerKind, string> = {
15
+ entryway: '#39ff88',
16
+ exit: '#33ccff',
17
+ artifact: '#ffb020',
18
+ };
19
+
20
+ // A short label for an exit target: "../green/#from-red" → "→ green", an external
21
+ // URL → "→ host".
22
+ function exitLabel(to: string): string {
23
+ if (!to) return '→ (unset)';
24
+ try {
25
+ if (/^https?:\/\//.test(to)) return `→ ${new URL(to).host}`;
26
+ } catch { /* fall through */ }
27
+ const slug = to.replace(/^\.\.\//, '').split('/')[0];
28
+ return `→ ${slug || to}`;
29
+ }
30
+
31
+ function artifactLabel(url: string): string {
32
+ if (!url) return 'artifact (no url)';
33
+ try { return new URL(url).host; } catch { return 'artifact'; }
34
+ }
35
+
36
+ function Orb({
37
+ pos, color, label, selected, yOffset = 0,
38
+ }: { pos: Vec3; color: string; label: string; selected: boolean; yOffset?: number }) {
39
+ const p = useMemo<Vec3>(() => [pos[0], pos[1] + yOffset, pos[2]], [pos, yOffset]);
40
+ const r = selected ? 0.16 : 0.11;
41
+ return (
42
+ <group position={p}>
43
+ <mesh renderOrder={999}>
44
+ <sphereGeometry args={[r, 20, 20]} />
45
+ <meshBasicMaterial color={color} toneMapped={false} transparent opacity={0.95} depthTest={false} />
46
+ </mesh>
47
+ {selected && (
48
+ <mesh renderOrder={998}>
49
+ <sphereGeometry args={[r * 1.7, 20, 20]} />
50
+ <meshBasicMaterial color={color} toneMapped={false} transparent opacity={0.2} depthTest={false} />
51
+ </mesh>
52
+ )}
53
+ <Billboard position={[0, r + 0.18, 0]}>
54
+ <Text
55
+ fontSize={0.14}
56
+ color="#ffffff"
57
+ anchorX="center"
58
+ anchorY="middle"
59
+ outlineWidth={0.012}
60
+ outlineColor="#000000"
61
+ renderOrder={1000}
62
+ material-depthTest={false}
63
+ >
64
+ {label}
65
+ </Text>
66
+ </Billboard>
67
+ </group>
68
+ );
69
+ }
70
+
71
+ export default function Markers() {
72
+ const { editMode, draft, selected } = useEdit();
73
+ if (!editMode || !draft) return null;
74
+
75
+ const sel = (kind: MarkerKind, i: number) => selected?.kind === kind && selected.index === i;
76
+
77
+ return (
78
+ <>
79
+ {draft.entryways.map((e, i) => (
80
+ <Orb key={`en-${i}`} pos={e.pos} color={COLORS.entryway} label={e.id || '(unnamed)'} selected={sel('entryway', i)} />
81
+ ))}
82
+ {draft.exits.map((e, i) => (
83
+ // Nudge exits up so a co-located entryway+exit stack reads as two orbs.
84
+ <Orb key={`ex-${i}`} pos={e.pos} color={COLORS.exit} label={exitLabel(e.to)} selected={sel('exit', i)} yOffset={0.34} />
85
+ ))}
86
+ {draft.artifacts.map((a, i) => (
87
+ <Orb key={`ar-${i}`} pos={a.pos} color={COLORS.artifact} label={artifactLabel(a.url)} selected={sel('artifact', i)} />
88
+ ))}
89
+ </>
90
+ );
91
+ }
@@ -0,0 +1,228 @@
1
+ import { MouseEventHandler, forwardRef } from "react";
2
+
3
+ type IconButtonProps = BaseButtonProps & {
4
+ icon: React.ReactNode;
5
+ };
6
+
7
+ type ButtonProps = BaseButtonProps & {
8
+ label: string | React.ReactNode;
9
+ detail?: string;
10
+ icon?: React.ReactNode;
11
+ showLoading?: boolean;
12
+ compact?: boolean;
13
+ type?: 'button' | 'submit' | 'reset';
14
+ };
15
+
16
+ export interface BaseButtonProps {
17
+ style?: ButtonStyle;
18
+ prominence?: ButtonProminence;
19
+ size?: ButtonSize;
20
+ state?: ButtonState;
21
+ disabled?: boolean;
22
+ onClick: MouseEventHandler<HTMLButtonElement>;
23
+ className?: string;
24
+ }
25
+
26
+ export enum ButtonStyle {
27
+ Squared = "square",
28
+ Rounded = "rounded",
29
+ Pill = "pill",
30
+ }
31
+
32
+ export enum ButtonProminence {
33
+ Primary = "primary",
34
+ Secondary = "secondary",
35
+ Tertiary = "tertiary",
36
+ }
37
+
38
+ export enum ButtonSize {
39
+ Small = "small",
40
+ Medium = "medium",
41
+ Large = "large",
42
+ }
43
+
44
+ export enum ButtonState {
45
+ Unselected = "unselected",
46
+ Selected = "selected",
47
+ }
48
+
49
+ const borderStyle = (style: ButtonStyle = ButtonStyle.Rounded) => {
50
+ switch (style) {
51
+ case ButtonStyle.Squared:
52
+ return "rounded-none";
53
+ case ButtonStyle.Rounded:
54
+ return "rounded-lg";
55
+ case ButtonStyle.Pill:
56
+ return "rounded-full";
57
+ default:
58
+ return "rounded-lg";
59
+ }
60
+ };
61
+
62
+ const prominenceStyle = (
63
+ prominence: ButtonProminence = ButtonProminence.Primary,
64
+ disabled: boolean = false
65
+ ) => {
66
+ switch (prominence) {
67
+ case ButtonProminence.Primary:
68
+ return `bg-action-primary sans-heavy ${
69
+ disabled
70
+ ? "text-tertiary bg-action-primary-disabled"
71
+ : "hover:bg-action-primary-hover active:bg-action-primary-active text-inverted-primary"
72
+ }`;
73
+ case ButtonProminence.Secondary:
74
+ return `bg-elevated sans-heavy ${
75
+ disabled
76
+ ? "text-tertiary"
77
+ : "hover:bg-action-hover active:bg-action-active text-primary"
78
+ }`;
79
+ case ButtonProminence.Tertiary:
80
+ return `bg-transparent sans-heavy ${
81
+ disabled
82
+ ? "text-tertiary"
83
+ : "hover:bg-action-hover active:bg-action-active text-primary"
84
+ }`;
85
+ default:
86
+ return `bg-action-primary sans-heavy ${
87
+ disabled
88
+ ? "text-tertiary"
89
+ : "hover:bg-action-primary-hover active:bg-action-primary-active text-inverted-primary"
90
+ }`;
91
+ }
92
+ };
93
+
94
+ const sizeStyle = (
95
+ size: ButtonSize = ButtonSize.Medium,
96
+ hasLabel: boolean = false
97
+ ) => {
98
+ switch (size) {
99
+ case ButtonSize.Small:
100
+ return `p-2 ${hasLabel ? "px-3" : ""}`;
101
+ case ButtonSize.Medium:
102
+ return "p-3";
103
+ case ButtonSize.Large:
104
+ return "p-4";
105
+ default:
106
+ return "p-4";
107
+ }
108
+ };
109
+
110
+ export const iconButtonStyleClass = (
111
+ style: ButtonStyle,
112
+ prominence: ButtonProminence,
113
+ size: ButtonSize,
114
+ disabled: boolean
115
+ ): string => {
116
+ return `${borderStyle(style)} ${prominenceStyle(
117
+ prominence,
118
+ disabled
119
+ )} ${sizeStyle(size)} bg-elevated transition-colors duration-200`;
120
+ };
121
+
122
+ export const buttonStyleClass = (
123
+ style: ButtonStyle,
124
+ prominence: ButtonProminence,
125
+ size: ButtonSize,
126
+ disabled: boolean
127
+ ): string => {
128
+ return `${borderStyle(style)} ${prominenceStyle(
129
+ prominence,
130
+ disabled
131
+ )} ${sizeStyle(
132
+ size,
133
+ true
134
+ )} transition-colors duration-200 flex font-sans-heavy justify-center`;
135
+ };
136
+
137
+ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
138
+ (
139
+ {
140
+ icon,
141
+ style = ButtonStyle.Rounded,
142
+ prominence = ButtonProminence.Secondary,
143
+ size = ButtonSize.Small,
144
+ disabled = false,
145
+ onClick,
146
+ className,
147
+ },
148
+ ref
149
+ ) => {
150
+ return (
151
+ <button
152
+ contentEditable={false}
153
+ className={`${iconButtonStyleClass(
154
+ style,
155
+ prominence,
156
+ size,
157
+ disabled
158
+ )} ${className}`}
159
+ onClick={
160
+ disabled
161
+ ? () => {}
162
+ : (e) => {
163
+ onClick(e);
164
+ }
165
+ }
166
+ disabled={disabled}
167
+ ref={ref}
168
+ >
169
+ {icon}
170
+ </button>
171
+ );
172
+ }
173
+ );
174
+
175
+ IconButton.displayName = "IconButton";
176
+
177
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
178
+ (
179
+ {
180
+ label,
181
+ detail,
182
+ icon,
183
+ style = ButtonStyle.Rounded,
184
+ prominence = ButtonProminence.Primary,
185
+ size = ButtonSize.Large,
186
+ disabled = false,
187
+ onClick,
188
+ className,
189
+ compact = true,
190
+ type = 'button',
191
+ },
192
+ ref
193
+ ) => {
194
+ return (
195
+ <button
196
+ contentEditable={false}
197
+ type={type}
198
+ className={`${buttonStyleClass(
199
+ style,
200
+ prominence,
201
+ size,
202
+ disabled
203
+ )} ${className}`}
204
+ onClick={
205
+ disabled
206
+ ? () => {}
207
+ : (e) => {
208
+ onClick(e);
209
+ }
210
+ }
211
+ disabled={disabled}
212
+ ref={ref}
213
+ >
214
+ {icon && <span className={`${compact ? "mr-0 sm:mr-2" : "mr-2"}`}>{icon}</span>}
215
+ <div className={`${(icon && compact) ? "hidden sm:flex" : "flex"} flex-col`}>
216
+ <span className="line-clamp-1">{label}</span>
217
+ {detail ? (
218
+ <span className="line-clamp-1 font-sans-medium text-sm text-secondary">
219
+ {detail}
220
+ </span>
221
+ ) : null}
222
+ </div>
223
+ </button>
224
+ );
225
+ }
226
+ );
227
+
228
+ Button.displayName = "Button";
@@ -0,0 +1,13 @@
1
+ 'use client';
2
+
3
+ export function Reticle({ visible }: { visible: boolean }) {
4
+ if (!visible) return null;
5
+ return (
6
+ <div className="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-10 opacity-85">
7
+ <div className="relative w-[12px] h-[12px]">
8
+ <div className="absolute left-1/2 top-0 w-[2px] h-full -translate-x-1/2 bg-zinc-200/40" />
9
+ <div className="absolute top-1/2 left-0 h-[2px] w-full -translate-y-1/2 bg-zinc-200/40" />
10
+ </div>
11
+ </div>
12
+ );
13
+ }
@@ -0,0 +1,44 @@
1
+ 'use client';
2
+
3
+ // A content artifact opens here: the page, fullscreen, over the canvas. The 3D
4
+ // world is paused (the player controller ignores input while pointer-lock is
5
+ // released), not destroyed — closing returns you exactly where you were. Closing
6
+ // (the ✕ or Esc) re-acquires pointer-lock in the same gesture, so mouse-look
7
+ // resumes immediately with no extra click.
8
+ //
9
+ // We keep a small floating ✕ rather than going fully chromeless: once you click
10
+ // into a cross-origin iframe, key events go to it, so Esc alone can't be relied on.
11
+
12
+ import { useCallback, useEffect } from 'react';
13
+ import { usePointerLock } from '@/providers/pointerLock';
14
+
15
+ export default function ContentOverlay({ url, onClose }: { url: string; onClose: () => void }) {
16
+ const { lock, unlock } = usePointerLock();
17
+
18
+ // Release the mouse so the embedded page is usable.
19
+ useEffect(() => { unlock(); }, [unlock]);
20
+
21
+ const close = useCallback(() => {
22
+ lock(); // re-grab the mouse in this gesture's call stack
23
+ onClose();
24
+ }, [lock, onClose]);
25
+
26
+ useEffect(() => {
27
+ const onKey = (e: KeyboardEvent) => { if (e.code === 'Escape') { e.preventDefault(); close(); } };
28
+ window.addEventListener('keydown', onKey);
29
+ return () => window.removeEventListener('keydown', onKey);
30
+ }, [close]);
31
+
32
+ return (
33
+ <div className="absolute inset-0 z-30 bg-black">
34
+ <iframe src={url} className="h-full w-full border-0 bg-white" title="content" />
35
+ <button
36
+ onClick={close}
37
+ aria-label="Close (Esc)"
38
+ className="absolute top-3 right-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white backdrop-blur hover:bg-black/80"
39
+ >
40
+
41
+ </button>
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,24 @@
1
+ .header {
2
+ display: flex;
3
+ flex-direction: row;
4
+ justify-content: space-between;
5
+ align-items: center;
6
+ gap: var(--space-8);
7
+ }
8
+
9
+ .title {
10
+ font-size: var(--size-md);
11
+ font-weight: 500;
12
+ color: inherit;
13
+ }
14
+
15
+ .detail {
16
+ font-size: var(--size-sm);
17
+ font-weight: 500;
18
+ color: inherit;
19
+ }
20
+
21
+ .spacer {
22
+ height: 40px;
23
+ width: 40px;
24
+ }