Skip to content

Commit cac9da3

Browse files
authored
feat(tv): per-preset FPV player scale + camera handle fix + retro/pixel polish (#64)
* feat(iframe): <poly-iframe> element + /television demo Adds a new public element across all three renderers for placing live iframes in 3D space, plus a five-TV demo page. - packages/polycss: PolyIframeElement (vanilla custom element). Mounts an iframe wrapper inside the parent <poly-scene>'s .polycss-scene so the CSS camera transform composes naturally with surrounding meshes. Same position/rotation/scale conventions as <poly-mesh> post-parity (world units, world-axis order, rotation conjugation rotateY(-rx) rotateX(-ry) rotateZ(-rz)). Width/height in world units; iframe centred at the wrapper's local origin so rotation/scale pivot at the visible centre. - packages/react: <PolyIframe> mirror with the same props. - packages/vue: <PolyIframe> mirror with the same props. - 14/8/7 tests added per package; transform math + attribute forwarding pinned. AGENTS.md updated with the new element + naming entry. Bug fixes shipped alongside: - PolyPerspectiveCameraElement / PolyOrthographicCameraElement runtime attribute updates now mutate the camera handle in place and re-apply the scene transform. Before, the elements either recreated the handle (orphaning the scene's pointer) or updated but never called applyCamera, so rot-x/rot-y/zoom changes were ignored at runtime. - DocsHeader hides the floating search dock on /television (matches /gallery, /builder, /wordart). /television demo: - Vertical-rail TV picker (5 sets, hand-drawn SVG art per tile). ?tv=id query param + popstate sync so individual TVs are linkable. - Per-TV polygon indices identify which face(s) are the screen; the page derives the iframe's world-unit position, rotation, width, and height from those polygons' vertices at load time (area-weighted normal, bbox in the in-plane right/up basis, small lift along the normal so the iframe doesn't z-fight with the screen polygon itself). Per-TV rotY + zoom remain hand-tuned because GLB authoring orientations differ. - Retro Stack uses multiple screen entries — one <poly-iframe> per CRT is mounted dynamically and each is placed independently. * feat(tv): rename /television → /tv, add header nav + floor + lighting - Rename /television route + public assets to /tv (shorter, link to it from the docs header next to WordArt). - Query param renamed to ?model=<id> (was ?tv=<id> — reads better alongside /tv). - Brighter scene lighting (directional 4.5→6.5, ambient 0.55→0.9) plus a ground <poly-plane> below each TV with cast-shadow on the mesh, so the bottom of every TV sits visibly on the floor. Element changes to support the floor: - PolyShapeElement now forwards `cast-shadow` / `receive-shadow` to scene.add (mirrors <poly-mesh>) and `exclude-from-auto-center` so ground planes don't skew the scene's auto-center calculation. - PolyShapeElement gains an attributeChangedCallback so transform attrs (position/scale/rotation/shadow) propagate after mount — the floor is re-positioned per TV from the script side once the mesh has loaded. - PolyPlaneElement: new `offset` attribute, defaulting to 0 so the plane is centered at the element's local origin. The underlying planePolygons helper defaulted to size*2 because it was authored as a transform-control drag handle. Camera-element runtime fix: - PolyPerspectiveCameraElement / PolyOrthographicCameraElement attributeChangedCallback now correctly locates the scene as a descendant (`this.querySelector('poly-scene')`) rather than an ancestor. The previous walk-up version always found null and applyCamera was never called, so runtime rot-x/rot-y/zoom changes were no-ops in vanilla. Tuning: - TV mesh gets `auto-center` so its bbox center IS mesh-local (0,0,0). - Floor's per-preset Z is derived from the mesh's minZ on load. - Per-TV cam.rotY/zoom dialed in by hand for each set (pixel TV at rotY 245 / zoom 10 for a 3/4 view that doesn't crop). * tv: add polygon 216 to retro stack's first screen + TODO for shadows - Retro stack screen 1: append poly 216 to the polyIndices array (user identified it as part of that CRT's bezel strip; was missing). - Floor shadow attempt: receiver-shadow on the floor + scene shadow config wired up cleanly, but the projected shadow SVG lands at wrong scene coords for our auto-centered TV + excludeFromAutoCenter floor combination. Vanilla dropped the legacy ground-shadow fallback for three.js parity, so a caster with no receiver draws nothing. Leaving the floor as a non-receiver for now with a TODO; lighting + floor geometry still ship. * fix(tv): bump shadow.lift to 1 world unit so TV shadows clear the floor The receiver-shadow SVG sits at the floor plane plus `shadow.lift` (default 0.05 world units = 2.5 CSS px). On a big 400-world-unit floor viewed at a normal-angle camera (rotX 70), that razor-thin gap z-fought with the floor itself and the shadow only showed from straight overhead. A 1 world unit (50 CSS px) lift puts the shadow clearly in front at every angle the camera reaches. * fix(tv): drop poly 211 from retro stack's first screen The first CRT's actual screen polygons are 212–216 — 211 is an adjacent bezel face the iframe-placement code was averaging into the plane, shifting the iframe off the screen. Restricting to 212–216 lands the iframe on the visible glass. * tv: per-screen `lift` override; recess retro-stack iframes into bezels placementFromPolygons now takes an optional liftOverride; the TvScreen config exposes it as `lift`. The retro stack's CRT screens already model the front glass surface, so a small negative lift (−0.6 world units) seats the iframe inside the bezel instead of in front of it. The default lift formula (0.2 + 0.01 * max(w,h)) still applies for every other TV — those polygons mark the screen plane itself, so the iframe sitting slightly in front of them reads as the picture surface. * tv: shift retro-stack screen polys from 206–210 to 207–211 * tv(retro-stack): each CRT plays a different YouTube video * tv(retro-stack): swap NCtzkaL2t_Y → UqyT8IEBkvY @0:35 * tv: add receive-shadow to the TV mesh for self-shadowing * tv: revert receive-shadow on TV mesh (caused atlas-poly positioning artifacts) * tv: re-enable receive-shadow on the TV mesh + bump shadow opacity The 'floating quad' I attributed to receive-shadow earlier turned out to be the monitor.glb's back panel authored at an offset position — present even without receive-shadow. Self-shadows on textured receivers are computed correctly but the texturedReceiver code reduces opacity to mimic three.js's 'darken to ambient-only' (`effOp = opacity * (1 - (ambient/total)^(1/2.4))`). With our bright light (dir 6.5, ambient 0.9) that pushed shadow alpha to ~0.32, which read as nearly-invisible 'white-ish' on textured TVs. Bumping the input opacity to 0.95 lands the effective alpha at ~0.55 and the self-shadows now read as actually dark. * tv: shadow tweaks — lower lift back to 0.1, drop ambient so shadows read dark - Lift 1 → 0.1 world units. The bigger value was detaching shadows visibly from their receivers; 0.1 (5 CSS px) still clears z-fighting but reads as flush with the floor / bezel. - Ambient intensity 0.9 → 0.4. The texture-receiver code caps shadow alpha at `opacity * (1 - (ambient/total)^(1/2.4))` to mimic three.js's 'darken to ambient-only' lighting model. With ambient=0.9 the cap pushed effective shadow alpha to ~0.32 — visibly faint / 'white-ish' on the TV's own faces. Lower ambient = darker possible shadows; 0.4 hits the polycss default and lets self-shadows read as properly dark. * tv: floor color to gallery's #4a505a * tv: Orbit/FPV camera-mode pill + wider zoom range - Bottom-centre toggle switches between <poly-orbit-controls> and <poly-first-person-controls>. FPV mounts with WASD/jump/look and a hint label appears on the right side of the pill while active. - Orbit zoom range widened to [1, 200] so users can zoom out far past each TV's default zoom. * tv: disable self-shadow + match gallery's FPV camera feel - Drop receive-shadow from the TV mesh: the texture-receiver darkening reads as 'white-ish' (capped at ambient brightness) and visible self-shadows weren't worth the cost. - FPV mode now matches the gallery's defaults: move-speed 30, jump-velocity 25, gravity 60, eye-height 6, look-sensitivity 0.15. Also switches camera perspective to 2000 while FPV is active (the same FPV_PERSPECTIVE the gallery and builder use) for a wider FOV; restores to 32000 when going back to Orbit. * tv: FPV spawns BEHIND the TV looking at it (matches gallery's useFpvSpawn) Entering FPV used to drop the camera at scene origin (where the TV sits, since the scene is auto-centered) — so the player was INSIDE the TV with a face full of bezel. Now matching gallery's spawn behaviour: - Save + drop scene's auto-center attribute on entry, restore on exit. - Compute TV bbox from the loaded mesh handle, derive a spawn target one mesh-span behind the TV along the current rotY look direction, set it on the camera so the controls' initializeOriginFromTarget uses that as the seed. - Remove target on exit so orbit returns to its scene-origin pivot. * fix(camera): keep handle identity when <poly-perspective-camera> perspective changes * tv: per-preset FPV player scale + auto-pitch + re-spawn on mesh switch
1 parent fe37afa commit cac9da3

3 files changed

Lines changed: 269 additions & 7 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* Tests for <poly-perspective-camera>.
3+
*
4+
* Regression coverage: updating the `perspective` attribute at runtime must
5+
* NOT recreate the camera handle, because <poly-scene> captures the handle
6+
* by identity at mount time and would be orphaned by a swap — leaving every
7+
* later rot-x/rot-y/zoom/target update silently no-op'd. The fix mutates the
8+
* wrapper's CSS perspective in place instead.
9+
*/
10+
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
11+
import { PolyPerspectiveCameraElement } from "./PolyPerspectiveCameraElement";
12+
13+
beforeAll(() => {
14+
if (!customElements.get("poly-perspective-camera")) {
15+
customElements.define("poly-perspective-camera", PolyPerspectiveCameraElement);
16+
}
17+
});
18+
19+
describe("<poly-perspective-camera>", () => {
20+
let host: HTMLElement;
21+
22+
beforeEach(() => {
23+
host = document.createElement("div");
24+
document.body.appendChild(host);
25+
});
26+
27+
afterEach(() => {
28+
if (host.parentNode) host.parentNode.removeChild(host);
29+
});
30+
31+
it("creates a camera handle on connect", () => {
32+
const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
33+
host.appendChild(el);
34+
expect(el.getCamera()).not.toBeNull();
35+
});
36+
37+
it("preserves the same camera handle when `perspective` attribute changes", () => {
38+
const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
39+
el.setAttribute("perspective", "32000");
40+
host.appendChild(el);
41+
const handleBefore = el.getCamera();
42+
el.setAttribute("perspective", "2000");
43+
const handleAfter = el.getCamera();
44+
expect(handleAfter).toBe(handleBefore);
45+
});
46+
47+
it("updates the wrapper's CSS perspective when `perspective` changes", () => {
48+
const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
49+
el.setAttribute("perspective", "32000");
50+
host.appendChild(el);
51+
const wrapper = el.querySelector(".polycss-camera") as HTMLElement;
52+
expect(wrapper.style.perspective).toBe("32000px");
53+
el.setAttribute("perspective", "2000");
54+
expect(wrapper.style.perspective).toBe("2000px");
55+
});
56+
57+
it("forwards rot-x updates to the live camera handle", () => {
58+
const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
59+
el.setAttribute("rot-x", "65");
60+
host.appendChild(el);
61+
const handle = el.getCamera()!;
62+
el.setAttribute("rot-x", "90");
63+
expect(handle.state.rotX).toBe(90);
64+
});
65+
66+
it("forwards rot-x updates even after a `perspective` change", () => {
67+
const el = document.createElement("poly-perspective-camera") as PolyPerspectiveCameraElement;
68+
el.setAttribute("rot-x", "65");
69+
host.appendChild(el);
70+
const handle = el.getCamera()!;
71+
el.setAttribute("perspective", "2000");
72+
el.setAttribute("rot-x", "90");
73+
expect(handle.state.rotX).toBe(90);
74+
});
75+
});

packages/polycss/src/elements/PolyPerspectiveCameraElement.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -103,11 +103,11 @@ export class PolyPerspectiveCameraElement extends ELEMENT_BASE {
103103
if (!this._camera || !this._wrapper) return;
104104
const opts = this._readOptions();
105105
if (name === "perspective") {
106-
// Re-creating the handle is required for perspective — it's baked
107-
// into `perspectiveStyle` on the wrapper. Everything else uses
108-
// update() so the SCENE keeps its reference to the same handle.
109-
this._camera = createPolyPerspectiveCamera(opts);
110-
this._wrapper.style.perspective = this._camera.perspectiveStyle;
106+
// Update the wrapper's CSS perspective in place. Recreating the camera
107+
// handle here would orphan the scene's captured reference (it would
108+
// keep pointing at the old handle and ignore every later update).
109+
const px = opts.perspective !== undefined ? `${opts.perspective}px` : "32000px";
110+
this._wrapper.style.perspective = px;
111111
return;
112112
}
113113
// Mutate the existing handle in place — the scene captured this object

website/src/pages/tv.astro

Lines changed: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ const TVS = [
8080
},
8181
],
8282
cam: { rotX: 70, rotY: 215, zoom: 8 },
83+
// FPV player shrinks for the smaller presets so the TVs read as larger
84+
// without actually scaling the DOM — large CSS scales cause compositor
85+
// flicker on these many-polygon meshes.
86+
fpvPlayerScale: 1 / 2,
8387
},
8488
{
8589
id: 'tv-obj',
@@ -88,6 +92,7 @@ const TVS = [
8892
mtl: '/tv/materials.mtl',
8993
screens: [{ polyIndices: [70] }],
9094
cam: { rotX: 70, rotY: 245, zoom: 10 },
95+
fpvPlayerScale: 1 / 3,
9196
},
9297
] as const;
9398
---
@@ -187,8 +192,8 @@ const TVS = [
187192
directional-intensity="6.5"
188193
ambient-intensity="0.4"
189194
>
190-
<poly-orbit-controls drag wheel></poly-orbit-controls>
191-
<poly-mesh id="tv-mesh" src={TVS[0].src} mtl={('mtl' in TVS[0] ? TVS[0].mtl : undefined)} auto-center cast-shadow receive-shadow></poly-mesh>
195+
<poly-orbit-controls id="tv-orbit-controls" drag wheel zoom-min="1" zoom-max="200"></poly-orbit-controls>
196+
<poly-mesh id="tv-mesh" src={TVS[0].src} mtl={('mtl' in TVS[0] ? TVS[0].mtl : undefined)} auto-center cast-shadow></poly-mesh>
192197
{/* Ground plane for shadow reception. Z position is set
193198
per-preset by the script (= TV's bbox minZ) so the floor
194199
always sits flush with the TV's bottom regardless of how
@@ -208,6 +213,11 @@ const TVS = [
208213
Retro Stack can light up every CRT independently. */}
209214
</poly-scene>
210215
</poly-perspective-camera>
216+
<div class="tv-camera-mode" role="group" aria-label="Camera mode">
217+
<button type="button" class="tv-camera-mode__btn is-active" data-mode="orbit">Orbit</button>
218+
<button type="button" class="tv-camera-mode__btn" data-mode="fpv">FPV</button>
219+
<span class="tv-camera-mode__hint" data-fpv-hint hidden>WASD · mouse · space</span>
220+
</div>
211221
</div>
212222
</div>
213223
</div>
@@ -231,6 +241,7 @@ const TVS = [
231241
mtl?: string;
232242
screens: readonly TvScreen[];
233243
cam: { rotX: number; rotY: number; zoom: number };
244+
fpvPlayerScale?: number;
234245
}
235246

236247
interface PolygonLike { vertices: Array<[number, number, number]>; }
@@ -461,6 +472,138 @@ const TVS = [
461472
const sceneHandle = (sceneEl as unknown as { getScene?: () => { setOptions: (o: Record<string, unknown>) => void } | null }).getScene?.();
462473
sceneHandle?.setOptions({ shadow: { color: "#000000", opacity: 0.95, lift: 0.1 } });
463474

475+
// ── Camera-mode toggle (Orbit ⇄ FPV) ───────────────────────────────
476+
// Bottom-centre pill swaps the orbit controls for first-person
477+
// controls. FPV gives WASD movement + mouse look; click anywhere
478+
// in the stage to lock the pointer.
479+
//
480+
// On the way INTO FPV: disable scene auto-center, switch the
481+
// camera to a wider FOV (perspective 2000, matching the gallery's
482+
// FPV_PERSPECTIVE), and spawn the player a few mesh-spans behind
483+
// the TV along the current rotY look direction so W walks toward
484+
// it (matches useFpvSpawn from the gallery).
485+
const orbitControls = document.getElementById("tv-orbit-controls");
486+
const fpvHint = document.querySelector<HTMLElement>("[data-fpv-hint]");
487+
let fpvControls: HTMLElement | null = null;
488+
let savedAutoCenter = true;
489+
let savedRotX = "70";
490+
let savedZoom = "8";
491+
let currentMode: "orbit" | "fpv" = "orbit";
492+
493+
// Derive FPV camera + controls config from the current mesh's bbox
494+
// and apply it. Used both on initial mode switch and on every mesh
495+
// swap while FPV is active (so changing TVs in FPV doesn't snap back
496+
// to the orbit-tuned zoom/rotation from applyPreset).
497+
function applyFpvSpawn(): void {
498+
if (!camera || !mesh) return;
499+
const handle = mesh.getMeshHandle?.();
500+
let eyeHeight = 6;
501+
let groundZ = 0;
502+
if (handle) {
503+
// Per-preset player scale: smaller = TV reads as bigger from the
504+
// player's POV, without actually scaling the mesh DOM (which
505+
// causes compositor flicker on large CSS transforms).
506+
const playerScale = (activePreset as { fpvPlayerScale?: number }).fpvPlayerScale ?? 1;
507+
let minX = Infinity, maxX = -Infinity;
508+
let minY = Infinity, maxY = -Infinity;
509+
let minZ = Infinity, maxZ = -Infinity;
510+
for (const p of handle.polygons) {
511+
for (const v of p.vertices) {
512+
if (v[0] < minX) minX = v[0]; if (v[0] > maxX) maxX = v[0];
513+
if (v[1] < minY) minY = v[1]; if (v[1] > maxY) maxY = v[1];
514+
if (v[2] < minZ) minZ = v[2]; if (v[2] > maxZ) maxZ = v[2];
515+
}
516+
}
517+
if (Number.isFinite(minZ)) {
518+
const cx = (minX + maxX) / 2;
519+
const cy = (minY + maxY) / 2;
520+
const midZ = (minZ + maxZ) / 2;
521+
const meshHeight = Math.max(maxZ - minZ, 1);
522+
const horizSpan = Math.max(maxX - minX, maxY - minY, 1);
523+
const rotY = parseFloat(camera.getAttribute("rot-y") ?? "215");
524+
const r = (rotY * Math.PI) / 180;
525+
// Same back-distance regardless of player size. A smaller
526+
// player at the same distance sees the TV loom larger in FOV
527+
// — that's the whole point of shrinking the player.
528+
const back = horizSpan * 3;
529+
const ox = cx + Math.cos(r) * back;
530+
const oy = cy + Math.sin(r) * back;
531+
camera.setAttribute("target", `${ox},${oy},0`);
532+
groundZ = minZ;
533+
// Cabinet-anchored eye height (96), shrunk by playerScale.
534+
eyeHeight = Math.max(meshHeight * 1.6, meshHeight + 6) * playerScale;
535+
// Aim the camera at the TV's vertical center. Computing pitch
536+
// from look geometry instead of hard-coding 75° means small
537+
// players (low camera) look horizontally and tall players
538+
// tilt down sharply to see the TV below them.
539+
const cameraZ = groundZ + eyeHeight;
540+
const dz = midZ - cameraZ;
541+
const rotXDeg = 90 + (Math.atan2(dz, back) * 180) / Math.PI;
542+
camera.setAttribute("rot-x", String(rotXDeg));
543+
// FPV zoom inversely tracks player size.
544+
const fpvZoom = Math.max(0.6, 60 / meshHeight) / playerScale;
545+
camera.setAttribute("zoom", String(fpvZoom));
546+
}
547+
}
548+
// Detach any prior FPV controls element. The internal handle seeds
549+
// `cameraOrigin` from the scene's target once, on attach — so to
550+
// teleport between model switches we throw it away and recreate it
551+
// *after* the new target attribute is in place.
552+
if (fpvControls && fpvControls.isConnected) fpvControls.remove();
553+
fpvControls = document.createElement("poly-first-person-controls");
554+
fpvControls.setAttribute("jump-velocity", "25");
555+
fpvControls.setAttribute("gravity", "60");
556+
fpvControls.setAttribute("look-sensitivity", "0.15");
557+
// Constant player-scale tuning — move-speed and crouch-height are
558+
// anchored to the cabinet-TV-sized eye height (96) so traversal
559+
// and ducking feel identical across all presets.
560+
fpvControls.setAttribute("move-speed", String(eyeHeight * 2));
561+
fpvControls.setAttribute("eye-height", String(eyeHeight));
562+
// Crouch at 70% of eye height — duck-walk, not turn-into-mouse.
563+
fpvControls.setAttribute("crouch-height", String(eyeHeight * 0.7));
564+
fpvControls.setAttribute("ground-z", String(groundZ));
565+
sceneEl!.appendChild(fpvControls);
566+
}
567+
568+
function setCameraMode(mode: "orbit" | "fpv"): void {
569+
if (!sceneEl || !camera || !mesh) return;
570+
currentMode = mode;
571+
root!.querySelectorAll<HTMLButtonElement>(".tv-camera-mode__btn").forEach((b) => {
572+
b.classList.toggle("is-active", b.dataset.mode === mode);
573+
});
574+
if (fpvHint) fpvHint.hidden = mode !== "fpv";
575+
camera.setAttribute("perspective", mode === "fpv" ? "2000" : "32000");
576+
if (mode === "fpv") {
577+
savedAutoCenter = sceneEl.hasAttribute("auto-center");
578+
savedRotX = camera.getAttribute("rot-x") ?? "70";
579+
savedZoom = camera.getAttribute("zoom") ?? "8";
580+
sceneEl.removeAttribute("auto-center");
581+
if (orbitControls) orbitControls.remove();
582+
applyFpvSpawn();
583+
} else {
584+
if (savedAutoCenter) sceneEl.setAttribute("auto-center", "");
585+
camera.setAttribute("rot-x", savedRotX);
586+
camera.setAttribute("zoom", savedZoom);
587+
// Removing the attribute doesn't clear the camera handle's
588+
// internal target — parseVec3(null) returns undefined and the
589+
// handle ignores undefined updates. Force the orbit recenter
590+
// by writing target back to world origin explicitly. Combined
591+
// with the restored auto-center this puts the TV back in the
592+
// middle of the view.
593+
camera.setAttribute("target", "0,0,0");
594+
if (fpvControls) fpvControls.remove();
595+
if (orbitControls && !orbitControls.isConnected) {
596+
sceneEl.appendChild(orbitControls);
597+
}
598+
}
599+
}
600+
root.querySelectorAll<HTMLButtonElement>(".tv-camera-mode__btn").forEach((btn) => {
601+
btn.addEventListener("click", () => {
602+
const mode = btn.dataset.mode;
603+
if (mode === "orbit" || mode === "fpv") setCameraMode(mode);
604+
});
605+
});
606+
464607
let activePreset: TvPreset = presets[0];
465608
let activeIframes: HTMLElement[] = [];
466609

@@ -545,6 +688,11 @@ const TVS = [
545688
// That's our cue to read polygons and derive every screen's placement.
546689
mesh.addEventListener("polycss:loaded", () => {
547690
applyPlacementsFromMesh();
691+
// While in FPV, applyPreset (which fires on every model switch)
692+
// writes the orbit-tuned rot-x/rot-y/zoom from TvPreset.cam into
693+
// the camera. Re-spawn from the freshly-loaded mesh's bbox so the
694+
// FPV view stays player-scale instead of snapping to orbit zoom.
695+
if (currentMode === "fpv") applyFpvSpawn();
548696
});
549697

550698
function applyPreset(p: TvPreset, opts: { pushUrl?: boolean } = {}): void {
@@ -717,6 +865,45 @@ const TVS = [
717865
position: relative;
718866
overflow: hidden;
719867
}
868+
/* Floating Orbit/FPV pill — bottom-centre of the viewport. Same shape
869+
as the builder's camera-mode pill. */
870+
.tv-camera-mode {
871+
position: absolute;
872+
left: 50%;
873+
bottom: 16px;
874+
transform: translateX(-50%);
875+
display: inline-flex;
876+
align-items: center;
877+
gap: 4px;
878+
padding: 4px;
879+
background: #111316;
880+
border: 1px solid #252b36;
881+
border-radius: 999px;
882+
user-select: none;
883+
z-index: 5;
884+
}
885+
.tv-camera-mode__btn {
886+
appearance: none;
887+
border: 0;
888+
background: #111316;
889+
color: #8c98a6;
890+
padding: 4px 12px;
891+
font: inherit;
892+
font-size: 12px;
893+
border-radius: 999px;
894+
cursor: pointer;
895+
}
896+
.tv-camera-mode__btn.is-active {
897+
color: #0a0b0d;
898+
background: #22d3ee;
899+
}
900+
.tv-camera-mode__hint {
901+
color: #707b87;
902+
font-size: 11px;
903+
padding: 0 10px 0 6px;
904+
border-left: 1px solid #2a3039;
905+
margin-left: 2px;
906+
}
720907
@media (max-width: 38rem) {
721908
.tv-page { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
722909
.tv-rail {

0 commit comments

Comments
 (0)