diff --git a/apps/video/.gitignore b/apps/video/.gitignore new file mode 100644 index 000000000..aaf2f409e --- /dev/null +++ b/apps/video/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +output +.turbo diff --git a/apps/video/index.html b/apps/video/index.html new file mode 100644 index 000000000..4beb5ff48 --- /dev/null +++ b/apps/video/index.html @@ -0,0 +1,16 @@ + + + + + + + React Doctor for Three.js + + +
+ + + diff --git a/apps/video/package.json b/apps/video/package.json new file mode 100644 index 000000000..585d13c0f --- /dev/null +++ b/apps/video/package.json @@ -0,0 +1,29 @@ +{ + "name": "video-workspace", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc --noEmit && vite build", + "dev": "vite", + "render": "tsc --noEmit && vite build && node scripts/render-video.mjs", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fontsource/ibm-plex-mono": "^5.2.7", + "@react-three/fiber": "^9.7.0", + "framer-motion": "^12.23.24", + "react": "19.2.5", + "react-dom": "19.2.5", + "three": "^0.185.1" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@types/three": "^0.185.3", + "@vitejs/plugin-react": "^5.1.1", + "playwright-core": "^1.62.1", + "typescript": "^6.0.3", + "vite": "^7.3.1" + } +} diff --git a/apps/video/scripts/render-video.mjs b/apps/video/scripts/render-video.mjs new file mode 100644 index 000000000..3441e56be --- /dev/null +++ b/apps/video/scripts/render-video.mjs @@ -0,0 +1,115 @@ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { chromium } from "playwright-core"; +import { preview } from "vite"; + +const VIDEO_WIDTH_PX = 1920; +const VIDEO_HEIGHT_PX = 1080; +const VIDEO_FPS = 30; +const VIDEO_FRAME_COUNT = 390; +const PREVIEW_PORT = 4173; +const FRAME_EVENT_NAME = "react-doctor:set-frame"; +const CHROMIUM_EXECUTABLE_PATH = process.env.CHROMIUM_PATH ?? "/usr/bin/chromium"; +const appDirectory = process.cwd(); +const outputDirectory = path.join(appDirectory, "output"); +const outputPath = path.join(outputDirectory, "react-doctor-three.mp4"); + +const runCommand = (command, argumentsList) => + new Promise((resolve, reject) => { + const processInstance = spawn(command, argumentsList, { stdio: "inherit" }); + processInstance.once("error", reject); + processInstance.once("exit", (exitCode) => { + if (exitCode === 0) { + resolve(); + return; + } + reject(new Error(`${command} exited with code ${exitCode ?? "unknown"}`)); + }); + }); + +const closeServer = (previewServer) => + new Promise((resolve, reject) => { + previewServer.httpServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + +await mkdir(outputDirectory, { recursive: true }); +const frameDirectory = await mkdtemp(path.join(tmpdir(), "react-doctor-three-frames-")); +const previewServer = await preview({ + root: appDirectory, + preview: { host: "127.0.0.1", port: PREVIEW_PORT, strictPort: true }, +}); +const browser = await chromium.launch({ + executablePath: CHROMIUM_EXECUTABLE_PATH, + headless: true, + args: ["--disable-gpu-sandbox", "--no-sandbox"], +}); + +try { + const page = await browser.newPage({ + deviceScaleFactor: 1, + viewport: { width: VIDEO_WIDTH_PX, height: VIDEO_HEIGHT_PX }, + }); + await page.goto(`http://127.0.0.1:${PREVIEW_PORT}/?real=true&manual=true`, { + waitUntil: "networkidle", + }); + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + }); + + for (let frame = 0; frame < VIDEO_FRAME_COUNT; frame += 1) { + await page.evaluate( + async ({ eventName, nextFrame }) => { + window.dispatchEvent(new CustomEvent(eventName, { detail: nextFrame })); + await new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => requestAnimationFrame(resolve))), + ); + }, + { eventName: FRAME_EVENT_NAME, nextFrame: frame }, + ); + await page.screenshot({ + path: path.join(frameDirectory, `frame-${String(frame).padStart(4, "0")}.png`), + type: "png", + }); + if ((frame + 1) % VIDEO_FPS === 0) { + process.stdout.write( + `Rendered ${(frame + 1) / VIDEO_FPS}s / ${VIDEO_FRAME_COUNT / VIDEO_FPS}s\n`, + ); + } + } + + await browser.close(); + await runCommand("ffmpeg", [ + "-y", + "-framerate", + String(VIDEO_FPS), + "-i", + path.join(frameDirectory, "frame-%04d.png"), + "-c:v", + "libx264", + "-preset", + "medium", + "-crf", + "18", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + outputPath, + ]); + process.stdout.write(`${outputPath}\n`); +} finally { + if (browser.isConnected()) await browser.close(); + await closeServer(previewServer); + await rm(frameDirectory, { recursive: true, force: true }); +} diff --git a/apps/video/src/components/doctor-face.tsx b/apps/video/src/components/doctor-face.tsx new file mode 100644 index 000000000..aaf162db7 --- /dev/null +++ b/apps/video/src/components/doctor-face.tsx @@ -0,0 +1,115 @@ +export interface DoctorFaceProps { + color: string; + mood: "happy" | "neutral" | "sad"; + size: number; +} + +export const DoctorFace = ({ color, mood, size }: DoctorFaceProps) => { + const borderWidth = Math.max(2, size * 0.04); + const eyeSize = size * 0.12; + const eyeGap = size * 0.22; + const mouthWidth = size * 0.25; + const mouthHeight = size * 0.12; + + return ( +
+
+ {mood === "happy" ? ( + <> +
+
+ + ) : ( + <> +
+
+ + )} +
+ +
+ {mood === "happy" && ( +
+ )} + {mood === "neutral" && ( +
+ )} + {mood === "sad" && ( +
+ )} +
+
+ ); +}; diff --git a/apps/video/src/components/playback-controls.tsx b/apps/video/src/components/playback-controls.tsx new file mode 100644 index 000000000..25bd23612 --- /dev/null +++ b/apps/video/src/components/playback-controls.tsx @@ -0,0 +1,53 @@ +import type { RefObject } from "react"; +import { formatPlaybackTime } from "../utils/format-playback-time"; + +export interface TimelinePlaybackControls { + pause: () => void; + play: () => void; + stop: () => void; + time: number; +} + +export interface PlaybackControlsProps { + canAutoplay: boolean; + currentSeconds: number; + durationSeconds: number; + playbackRef: RefObject; +} + +export const PlaybackControls = ({ + canAutoplay, + currentSeconds, + durationSeconds, + playbackRef, +}: PlaybackControlsProps) => { + const resumePlayback = () => { + const playback = playbackRef.current; + if (!playback || !canAutoplay) return; + if (playback.time >= durationSeconds) playback.time = 0; + playback.play(); + }; + + return ( +
+ {formatPlaybackTime(currentSeconds)} + playbackRef.current?.pause()} + onChange={(event) => { + if (playbackRef.current) { + playbackRef.current.time = Number(event.currentTarget.value); + } + }} + onPointerUp={resumePlayback} + onPointerCancel={resumePlayback} + onBlur={resumePlayback} + /> +
+ ); +}; diff --git a/apps/video/src/components/three-code-card.tsx b/apps/video/src/components/three-code-card.tsx new file mode 100644 index 000000000..dc4e9079f --- /dev/null +++ b/apps/video/src/components/three-code-card.tsx @@ -0,0 +1,35 @@ +import { GREEN_COLOR, RED_COLOR } from "../constants"; +import type { ThreeCodeLine } from "../three-constants"; + +export interface ThreeCodeCardProps { + isOptimized: boolean; + lines: ThreeCodeLine[]; + opacity: number; + title: string; +} + +export const ThreeCodeCard = ({ isOptimized, lines, opacity, title }: ThreeCodeCardProps) => ( +
+
+ {title} + + {isOptimized ? "STABLE" : "HOT PATH"} + +
+
+      {lines.map((line, lineIndex) => (
+        
+          {String(lineIndex + 1).padStart(2, "0")}
+          {line.text || " "}
+        
+      ))}
+    
+
+); diff --git a/apps/video/src/components/three-donut-stage.tsx b/apps/video/src/components/three-donut-stage.tsx new file mode 100644 index 000000000..0e6c544e8 --- /dev/null +++ b/apps/video/src/components/three-donut-stage.tsx @@ -0,0 +1,89 @@ +import { Canvas } from "@react-three/fiber"; +import { GREEN_COLOR, RED_COLOR, WHITE_COLOR, YELLOW_COLOR } from "../constants"; +import { + THREE_BAD_FRAME_STEP, + THREE_CAMERA_FOV_DEGREES, + THREE_CAMERA_Z, + THREE_DONUT_RADIAL_SEGMENTS, + THREE_DONUT_RADIUS, + THREE_DONUT_TUBE_RADIUS, + THREE_DONUT_TUBULAR_SEGMENTS, +} from "../three-constants"; + +export interface ThreeDonutStageProps { + frame: number; + optimizationProgress: number; + problemProgress: number; +} + +const HeroDonut = ({ frame, optimizationProgress, problemProgress }: ThreeDonutStageProps) => { + const steppedFrame = Math.floor(frame / THREE_BAD_FRAME_STEP) * THREE_BAD_FRAME_STEP; + const rotationFrame = steppedFrame + (frame - steppedFrame) * optimizationProgress; + const materialColor = optimizationProgress > 0.5 ? GREEN_COLOR : WHITE_COLOR; + const warningRingOpacity = problemProgress * (1 - optimizationProgress); + + return ( + + + + 0.5 ? GREEN_COLOR : YELLOW_COLOR} + emissiveIntensity={0.06 + optimizationProgress * 0.12} + metalness={0.58} + roughness={0.27} + /> + + + + + + + ); +}; + +export const ThreeDonutStage = ({ + frame, + optimizationProgress, + problemProgress, +}: ThreeDonutStageProps) => ( + +); diff --git a/apps/video/src/components/three-finding-row.tsx b/apps/video/src/components/three-finding-row.tsx new file mode 100644 index 000000000..166ea5d49 --- /dev/null +++ b/apps/video/src/components/three-finding-row.tsx @@ -0,0 +1,39 @@ +import { GREEN_COLOR, RED_COLOR } from "../constants"; +import type { ThreeFinding } from "../three-constants"; + +export interface ThreeFindingRowProps { + finding: ThreeFinding; + fixProgress: number; + opacity: number; +} + +export const ThreeFindingRow = ({ finding, fixProgress, opacity }: ThreeFindingRowProps) => ( +
0.5 ? "rgba(74, 222, 128, 0.28)" : "rgba(248, 113, 113, 0.24)", + opacity, + transform: `translateY(${(1 - opacity) * 18}px)`, + }} + > + 0.5 ? GREEN_COLOR : RED_COLOR, + color: fixProgress > 0.5 ? "#052e16" : "#450a0a", + }} + > + {fixProgress > 0.5 ? "✓" : "!"} + +
+
0.5 ? GREEN_COLOR : undefined }} + > + {finding.title} +
+
{finding.detail}
+
+ {finding.file} +
+); diff --git a/apps/video/src/components/three-performance-meter.tsx b/apps/video/src/components/three-performance-meter.tsx new file mode 100644 index 000000000..d510b7115 --- /dev/null +++ b/apps/video/src/components/three-performance-meter.tsx @@ -0,0 +1,57 @@ +import { GREEN_COLOR, RED_COLOR } from "../constants"; +import { + THREE_BAD_FPS, + THREE_GRAPH_BAR_HEIGHT_PX, + THREE_GRAPH_BAR_IDS, + THREE_INITIAL_ALLOCATIONS_PER_SECOND, + THREE_TARGET_FPS, +} from "../three-constants"; + +export interface ThreePerformanceMeterProps { + optimizationProgress: number; + problemProgress: number; +} + +export const ThreePerformanceMeter = ({ + optimizationProgress, + problemProgress, +}: ThreePerformanceMeterProps) => { + const framesPerSecond = Math.round( + THREE_BAD_FPS + (THREE_TARGET_FPS - THREE_BAD_FPS) * optimizationProgress, + ); + const allocationsPerSecond = Math.round( + THREE_INITIAL_ALLOCATIONS_PER_SECOND * (1 - optimizationProgress), + ); + const meterColor = optimizationProgress > 0.5 ? GREEN_COLOR : RED_COLOR; + const bars = THREE_GRAPH_BAR_IDS.map((id, barIndex) => { + const wave = (Math.sin(barIndex * 1.7) + 1) / 2; + const unstableHeight = 0.3 + wave * 0.7; + const stableHeight = 0.84 + wave * 0.08; + return { height: unstableHeight + (stableHeight - unstableHeight) * optimizationProgress, id }; + }); + + return ( +
+
+ FRAME HEALTH + {framesPerSecond} FPS +
+
+ {bars.map((bar) => ( + + ))} +
+
+ {allocationsPerSecond} frame allocations / sec + {optimizationProgress > 0.5 ? "DPR ≤ 1.5" : "DPR = device"} +
+
+ ); +}; diff --git a/apps/video/src/components/three-timeline.tsx b/apps/video/src/components/three-timeline.tsx new file mode 100644 index 000000000..1f672b059 --- /dev/null +++ b/apps/video/src/components/three-timeline.tsx @@ -0,0 +1,236 @@ +import { DoctorFace } from "./doctor-face"; +import { ThreeCodeCard } from "./three-code-card"; +import { ThreeDonutStage } from "./three-donut-stage"; +import { ThreeFindingRow } from "./three-finding-row"; +import { ThreePerformanceMeter } from "./three-performance-meter"; +import { easeInOutCubic } from "../utils/ease-in-out-cubic"; +import { easeOutCubic } from "../utils/ease-out-cubic"; +import { interpolateNumber } from "../utils/interpolate-number"; +import { FONT_FAMILY, GREEN_COLOR, RED_COLOR, WHITE_COLOR, YELLOW_COLOR } from "../constants"; +import { + THREE_BAD_CODE_LINES, + THREE_FINDING_FADE_DURATION_FRAMES, + THREE_FINDING_INTERVAL_FRAMES, + THREE_FINDINGS, + THREE_FIX_INTERVAL_FRAMES, + THREE_GOOD_CODE_LINES, + THREE_INTRO_END_FRAME, + THREE_PROBLEM_END_FRAME, + THREE_PROBLEM_REVEAL_FRAME, + THREE_SCAN_END_FRAME, + THREE_TOTAL_DURATION_FRAMES, + THREE_TRANSITION_DURATION_FRAMES, +} from "../three-constants"; + +export interface ThreeTimelineProps { + frame: number; +} + +export const ThreeTimeline = ({ frame }: ThreeTimelineProps) => { + const introProgress = interpolateNumber({ + value: frame, + inputStart: 0, + inputEnd: 28, + outputStart: 0, + outputEnd: 1, + easing: easeOutCubic, + }); + const problemProgress = interpolateNumber({ + value: frame, + inputStart: THREE_INTRO_END_FRAME - THREE_TRANSITION_DURATION_FRAMES, + inputEnd: THREE_INTRO_END_FRAME, + outputStart: 0, + outputEnd: 1, + easing: easeInOutCubic, + }); + const scanProgress = interpolateNumber({ + value: frame, + inputStart: THREE_PROBLEM_END_FRAME - THREE_TRANSITION_DURATION_FRAMES, + inputEnd: THREE_PROBLEM_END_FRAME, + outputStart: 0, + outputEnd: 1, + easing: easeInOutCubic, + }); + const optimizationProgress = interpolateNumber({ + value: frame, + inputStart: THREE_SCAN_END_FRAME - THREE_TRANSITION_DURATION_FRAMES, + inputEnd: THREE_SCAN_END_FRAME + 34, + outputStart: 0, + outputEnd: 1, + easing: easeInOutCubic, + }); + const finalProgress = interpolateNumber({ + value: frame, + inputStart: THREE_TOTAL_DURATION_FRAMES - 58, + inputEnd: THREE_TOTAL_DURATION_FRAMES - 34, + outputStart: 0, + outputEnd: 1, + easing: easeOutCubic, + }); + const scanExitProgress = interpolateNumber({ + value: optimizationProgress, + inputStart: 0, + inputEnd: 0.7, + outputStart: 0, + outputEnd: 1, + easing: easeOutCubic, + }); + const introOpacity = 1 - problemProgress; + const problemOpacity = problemProgress * (1 - scanProgress); + const scanOpacity = scanProgress * (1 - scanExitProgress); + const optimizedOpacity = optimizationProgress * (1 - finalProgress); + const warningOpacity = interpolateNumber({ + value: frame, + inputStart: THREE_PROBLEM_REVEAL_FRAME, + inputEnd: THREE_PROBLEM_REVEAL_FRAME + 12, + outputStart: 0, + outputEnd: 1, + easing: easeOutCubic, + }); + + return ( +
+
+
0.5 ? GREEN_COLOR : YELLOW_COLOR, + opacity: 0.08 + optimizationProgress * 0.05, + }} + /> + +
+ +
+ +
+
REACT DOCTOR  ×  3D
+

Your 3D scene deserves a performance review.

+

THREE.JS  /  REACT THREE FIBER

+
+ +
+
+
01 / FRAME LOOP
+

Pretty can still be expensive.

+ +
+
+ +
+ ! + NEW OBJECT, EVERY FRAME +
+
+
+ +
+
+
02 / REACT DOCTOR
+

It reads the scene graph.

+

React Three Fiber detected  ·  performance rules enabled

+
+
+ {THREE_FINDINGS.map((finding, findingIndex) => ( + + ))} +
+
+ +
+
+
+ 03 / OPTIMIZED +
+

Reuse. Batch. Move by delta.

+ +
+
+ +
+ {THREE_FINDINGS.slice(0, 3).map((finding, findingIndex) => ( + + ))} +
+
+
+ +
+ +
+
+ REACT DOCTOR FOR 3D +
+

Ship smoother 3D.

+

npx react-doctor@latest

+
+
+ +
+ RD  /  3D +
+
+ ); +}; diff --git a/apps/video/src/constants.ts b/apps/video/src/constants.ts new file mode 100644 index 000000000..df1ce0f01 --- /dev/null +++ b/apps/video/src/constants.ts @@ -0,0 +1,8 @@ +export const VIDEO_WIDTH_PX = 1920; +export const VIDEO_HEIGHT_PX = 1080; +export const VIDEO_FPS = 30; +export const GREEN_COLOR = "#4ade80"; +export const RED_COLOR = "#f87171"; +export const WHITE_COLOR = "#ffffff"; +export const YELLOW_COLOR = "#eab308"; +export const FONT_FAMILY = '"IBM Plex Mono", monospace'; diff --git a/apps/video/src/main.tsx b/apps/video/src/main.tsx new file mode 100644 index 000000000..58af42a8c --- /dev/null +++ b/apps/video/src/main.tsx @@ -0,0 +1,19 @@ +import "@fontsource/ibm-plex-mono/400.css"; +import "@fontsource/ibm-plex-mono/500.css"; +import "@fontsource/ibm-plex-mono/700.css"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { VideoWorkspace } from "./video-workspace"; +import "./style.css"; + +const rootElement = document.getElementById("root"); + +if (!rootElement) { + throw new Error("The video workspace root element is missing."); +} + +createRoot(rootElement).render( + + + , +); diff --git a/apps/video/src/style.css b/apps/video/src/style.css new file mode 100644 index 000000000..7ab988094 --- /dev/null +++ b/apps/video/src/style.css @@ -0,0 +1,464 @@ +:root { + color: #d4d4d8; + background: #000; + font-family: "IBM Plex Mono", monospace; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; +} + +body { + min-width: 320px; + overflow: hidden; + background: #000; +} + +button, +input { + font: inherit; +} + +.video-workspace { + display: flex; + width: 100%; + height: 100%; + align-items: center; + justify-content: center; + overflow: hidden; + background: #000; +} + +.video-frame { + position: relative; + flex: none; +} + +.video-stage { + position: absolute; + top: 0; + left: 0; + overflow: hidden; + transform-origin: top left; + background: #0a0a0a; +} + +.scene { + position: absolute; + inset: 0; + overflow: hidden; + background: #0a0a0a; +} + +.three-scene { + color: #d4d4d8; + background: + radial-gradient(circle at 70% 45%, rgba(255, 255, 255, 0.035), transparent 34%), #0a0a0a; +} + +.three-grid { + position: absolute; + inset: 0; + opacity: 0.16; + background-image: + linear-gradient(rgba(255, 255, 255, 0.06) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.06) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient(to right, rgba(0, 0, 0, 0.5), transparent 80%); +} + +.three-ambient-glow { + position: absolute; + width: 720px; + height: 720px; + border-radius: 999px; + filter: blur(180px); + right: 70px; + top: 150px; +} + +.three-donut-wrap { + position: absolute; + z-index: 2; + top: 105px; + right: 20px; + width: 940px; + height: 850px; + transform-origin: center; +} + +.three-donut-stage, +.three-donut-stage canvas { + width: 100% !important; + height: 100% !important; +} + +.three-copy { + position: absolute; + z-index: 4; + left: 112px; + width: 820px; +} + +.three-intro-copy { + top: 262px; +} + +.three-kicker, +.three-step-label { + color: #eab308; + font-size: 25px; + font-weight: 700; + letter-spacing: 0.18em; +} + +.three-copy h2, +.three-scan-heading h2, +.three-final-layout h2 { + margin: 26px 0 24px; + color: #fff; + font-size: 78px; + font-weight: 500; + letter-spacing: -0.055em; + line-height: 1.06; +} + +.three-intro-copy h2 { + width: 840px; + font-size: 92px; +} + +.three-copy > p, +.three-scan-heading > p, +.three-final-layout p { + margin: 0; + color: #737373; + font-size: 27px; + letter-spacing: 0.08em; +} + +.three-problem-layout, +.three-scan-layout, +.three-optimized-layout, +.three-final-layout { + position: absolute; + z-index: 5; + inset: 0; +} + +.three-problem-copy, +.three-optimized-copy { + top: 92px; +} + +.three-problem-copy h2, +.three-optimized-copy h2 { + width: 730px; + margin-top: 18px; + font-size: 64px; +} + +.three-code-card { + width: 780px; + margin-top: 38px; + overflow: hidden; + border: 1px solid; + border-radius: 18px; + background: rgba(10, 10, 10, 0.82); + box-shadow: 0 30px 90px rgba(0, 0, 0, 0.45); +} + +.three-code-card-header, +.three-meter-heading, +.three-meter-stats { + display: flex; + align-items: center; + justify-content: space-between; +} + +.three-code-card-header { + padding: 18px 24px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); + color: #a3a3a3; + font-size: 20px; + letter-spacing: 0.08em; +} + +.three-code-card pre { + display: flex; + padding: 22px 0 26px; + margin: 0; + flex-direction: column; + color: #e4e4e7; + font-family: inherit; + font-size: 24px; + line-height: 1.72; +} + +.three-code-card pre span { + display: block; + padding: 0 26px; + white-space: pre; +} + +.three-code-card pre i { + display: inline-block; + width: 58px; + color: #525252; + font-style: normal; + user-select: none; +} + +.three-problem-hud, +.three-optimized-hud { + position: absolute; + z-index: 5; + right: 98px; + bottom: 88px; + width: 710px; +} + +.three-performance-meter { + padding: 24px 26px 22px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 16px; + background: rgba(10, 10, 10, 0.84); + backdrop-filter: blur(18px); +} + +.three-meter-heading { + color: #a3a3a3; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.1em; +} + +.three-meter-graph { + display: flex; + height: 84px; + margin: 24px 0 16px; + align-items: flex-end; + gap: 8px; +} + +.three-meter-graph span { + flex: 1; + border-radius: 2px 2px 0 0; +} + +.three-meter-stats { + color: #737373; + font-size: 17px; +} + +.three-warning-pill { + display: flex; + width: max-content; + padding: 13px 18px; + margin: 18px 0 0 auto; + align-items: center; + gap: 14px; + border: 1px solid rgba(248, 113, 113, 0.3); + border-radius: 999px; + color: #fca5a5; + background: rgba(69, 10, 10, 0.72); + font-size: 18px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.three-scan-heading { + position: absolute; + top: 70px; + left: 100px; + width: 920px; +} + +.three-scan-heading h2 { + margin: 16px 0 12px; + font-size: 62px; +} + +.three-findings { + position: absolute; + top: 315px; + left: 100px; + display: flex; + width: 960px; + flex-direction: column; + gap: 14px; +} + +.three-finding-row { + display: flex; + min-height: 112px; + padding: 18px 20px; + align-items: center; + gap: 20px; + border: 1px solid; + border-radius: 14px; + background: rgba(10, 10, 10, 0.82); + backdrop-filter: blur(18px); +} + +.three-finding-status { + display: grid; + width: 42px; + height: 42px; + flex: none; + place-items: center; + border-radius: 8px; + font-size: 24px; + font-weight: 700; +} + +.three-finding-copy { + min-width: 0; + flex: 1; +} + +.three-finding-title { + color: #fff; + font-size: 23px; + font-weight: 500; +} + +.three-finding-detail, +.three-finding-file { + color: #737373; + font-size: 17px; +} + +.three-finding-detail { + margin-top: 6px; +} + +.three-finding-file { + flex: none; +} + +.three-optimized-hud { + bottom: 68px; +} + +.three-fixed-list { + display: flex; + margin-top: 14px; + flex-direction: column; + gap: 9px; +} + +.three-fixed-list .three-finding-row { + min-height: 78px; + padding: 12px 15px; +} + +.three-fixed-list .three-finding-detail { + display: none; +} + +.three-fixed-list .three-finding-file { + font-size: 14px; +} + +.three-final-layout { + display: flex; + align-items: center; + justify-content: center; + gap: 52px; + background: rgba(10, 10, 10, 0.8); + backdrop-filter: blur(26px); + transform-origin: center; +} + +.three-final-layout h2 { + margin: 10px 0 16px; + font-size: 92px; +} + +.three-final-layout p { + color: #fff; + font-size: 31px; + letter-spacing: 0; +} + +.three-corner-mark { + position: absolute; + z-index: 8; + right: 54px; + bottom: 40px; + opacity: 0.34; + font-size: 18px; + font-weight: 700; + letter-spacing: 0.12em; +} + +.playback-controls { + position: fixed; + z-index: 50; + top: 24px; + left: 24px; + display: flex; + align-items: center; + gap: 12px; + color: rgba(255, 255, 255, 0.62); + font-size: 20px; + line-height: 1; +} + +.playback-controls button { + display: grid; + width: 34px; + height: 34px; + padding: 0; + place-items: center; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + color: white; + background: rgba(10, 10, 10, 0.8); + cursor: pointer; +} + +.playback-controls button:hover { + border-color: rgba(255, 255, 255, 0.42); +} + +.playback-controls button:focus-visible, +.playback-controls input:focus-visible { + outline: 2px solid white; + outline-offset: 3px; +} + +.playback-controls input { + width: 192px; + height: 20px; + cursor: ew-resize; + accent-color: white; +} + +.playback-time { + min-width: 86px; + font-variant-numeric: tabular-nums; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/apps/video/src/three-constants.ts b/apps/video/src/three-constants.ts new file mode 100644 index 000000000..afd81ac91 --- /dev/null +++ b/apps/video/src/three-constants.ts @@ -0,0 +1,96 @@ +export interface ThreeFinding { + detail: string; + file: string; + title: string; +} + +export interface ThreeCodeLine { + id: string; + text: string; +} + +export const THREE_INTRO_END_FRAME = 78; +export const THREE_PROBLEM_END_FRAME = 190; +export const THREE_SCAN_END_FRAME = 300; +export const THREE_TOTAL_DURATION_FRAMES = 390; +export const THREE_TRANSITION_DURATION_FRAMES = 18; +export const THREE_FINDING_INTERVAL_FRAMES = 13; +export const THREE_FINDING_FADE_DURATION_FRAMES = 8; +export const THREE_FIX_INTERVAL_FRAMES = 9; +export const THREE_DONUT_RADIUS = 1.52; +export const THREE_DONUT_TUBE_RADIUS = 0.48; +export const THREE_DONUT_RADIAL_SEGMENTS = 48; +export const THREE_DONUT_TUBULAR_SEGMENTS = 144; +export const THREE_CAMERA_Z = 6.6; +export const THREE_CAMERA_FOV_DEGREES = 34; +export const THREE_BAD_FRAME_STEP = 3; +export const THREE_TARGET_FPS = 60; +export const THREE_BAD_FPS = 24; +export const THREE_INITIAL_ALLOCATIONS_PER_SECOND = 60; +export const THREE_GRAPH_BAR_HEIGHT_PX = 74; +export const THREE_PROBLEM_REVEAL_FRAME = 118; + +export const THREE_FINDINGS: ThreeFinding[] = [ + { + title: "Allocation inside useFrame", + detail: "Reuse vectors instead of allocating every frame", + file: "Donut.tsx:18", + }, + { + title: "Unbounded device pixel ratio", + detail: "Cap DPR before dense displays multiply the work", + file: "Scene.tsx:9", + }, + { + title: "Repeated meshes use separate draw calls", + detail: "Batch shared geometry with instancedMesh", + file: "Sprinkles.tsx:31", + }, + { + title: "Frame update ignores delta", + detail: "Make motion independent of refresh rate", + file: "Donut.tsx:17", + }, +]; + +export const THREE_GRAPH_BAR_IDS = [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10", + "11", + "12", + "13", + "14", + "15", + "16", + "17", + "18", + "19", + "20", + "21", + "22", +]; + +export const THREE_BAD_CODE_LINES: ThreeCodeLine[] = [ + { id: "bad-frame-start", text: "useFrame(() => {" }, + { id: "bad-frame-allocation", text: " const target = new THREE.Vector3()" }, + { id: "bad-frame-lerp", text: " mesh.current.position.lerp(target, 0.1)" }, + { id: "bad-frame-rotation", text: " mesh.current.rotation.y += 0.02" }, + { id: "bad-frame-end", text: "})" }, +]; + +export const THREE_GOOD_CODE_LINES: ThreeCodeLine[] = [ + { id: "good-stable-target", text: "const target = useMemo(() => new THREE.Vector3(), [])" }, + { id: "good-spacer", text: "" }, + { id: "good-frame-start", text: "useFrame((_, delta) => {" }, + { id: "good-frame-lerp", text: " mesh.current.position.lerp(target, delta * 6)" }, + { id: "good-frame-rotation", text: " mesh.current.rotation.y += delta" }, + { id: "good-frame-end", text: "})" }, +]; diff --git a/apps/video/src/utils/ease-in-out-cubic.ts b/apps/video/src/utils/ease-in-out-cubic.ts new file mode 100644 index 000000000..bf931b0de --- /dev/null +++ b/apps/video/src/utils/ease-in-out-cubic.ts @@ -0,0 +1,2 @@ +export const easeInOutCubic = (progress: number) => + progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2; diff --git a/apps/video/src/utils/ease-out-cubic.ts b/apps/video/src/utils/ease-out-cubic.ts new file mode 100644 index 000000000..9cd1144a4 --- /dev/null +++ b/apps/video/src/utils/ease-out-cubic.ts @@ -0,0 +1 @@ +export const easeOutCubic = (progress: number) => 1 - (1 - progress) ** 3; diff --git a/apps/video/src/utils/format-playback-time.ts b/apps/video/src/utils/format-playback-time.ts new file mode 100644 index 000000000..1b86c67b6 --- /dev/null +++ b/apps/video/src/utils/format-playback-time.ts @@ -0,0 +1,6 @@ +export const formatPlaybackTime = (seconds: number) => { + const tenths = Math.floor(seconds * 10); + const minutes = Math.floor(tenths / 600); + const wholeSeconds = Math.floor((tenths % 600) / 10); + return `${String(minutes).padStart(2, "0")}:${String(wholeSeconds).padStart(2, "0")}.${tenths % 10}`; +}; diff --git a/apps/video/src/utils/interpolate-number.ts b/apps/video/src/utils/interpolate-number.ts new file mode 100644 index 000000000..d95163dc4 --- /dev/null +++ b/apps/video/src/utils/interpolate-number.ts @@ -0,0 +1,22 @@ +export interface InterpolateNumberInput { + easing?: (progress: number) => number; + inputEnd: number; + inputStart: number; + outputEnd: number; + outputStart: number; + value: number; +} + +export const interpolateNumber = ({ + easing = (progress) => progress, + inputEnd, + inputStart, + outputEnd, + outputStart, + value, +}: InterpolateNumberInput) => { + const duration = inputEnd - inputStart; + const rawProgress = duration === 0 ? 1 : (value - inputStart) / duration; + const progress = Math.min(1, Math.max(0, rawProgress)); + return outputStart + (outputEnd - outputStart) * easing(progress); +}; diff --git a/apps/video/src/video-workspace.tsx b/apps/video/src/video-workspace.tsx new file mode 100644 index 000000000..ebfbaf101 --- /dev/null +++ b/apps/video/src/video-workspace.tsx @@ -0,0 +1,123 @@ +import { + animate, + domAnimation, + LazyMotion, + useMotionValue, + useMotionValueEvent, + useReducedMotion, +} from "framer-motion"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { PlaybackControls, type TimelinePlaybackControls } from "./components/playback-controls"; +import { ThreeTimeline } from "./components/three-timeline"; +import { VIDEO_FPS, VIDEO_HEIGHT_PX, VIDEO_WIDTH_PX } from "./constants"; +import { THREE_TOTAL_DURATION_FRAMES } from "./three-constants"; + +const subscribeViewport = (onChange: () => void) => { + window.addEventListener("resize", onChange); + return () => window.removeEventListener("resize", onChange); +}; + +const getViewportWidth = () => window.innerWidth; +const getViewportHeight = () => window.innerHeight; +const getServerViewportWidth = () => VIDEO_WIDTH_PX; +const getServerViewportHeight = () => VIDEO_HEIGHT_PX; + +export const VideoWorkspace = () => { + const searchParameters = new URLSearchParams(window.location.search); + const isCaptureMode = searchParameters.get("real") === "true"; + const totalDurationFrames = THREE_TOTAL_DURATION_FRAMES; + const requestedFrameParameter = searchParameters.get("frame"); + const requestedFrame = Number(requestedFrameParameter); + const hasRequestedFrame = + requestedFrameParameter !== null && Number.isFinite(requestedFrame) && requestedFrame >= 0; + const isManualFrame = searchParameters.get("manual") === "true"; + const isStillFrame = isManualFrame || hasRequestedFrame; + const initialFrame = isStillFrame ? Math.min(requestedFrame, totalDurationFrames) : 0; + const playhead = useMotionValue(initialFrame / VIDEO_FPS); + const [currentFrame, setCurrentFrame] = useState(initialFrame); + const playbackRef = useRef(null); + const shouldReduceMotion = Boolean(useReducedMotion()); + const totalDurationSeconds = totalDurationFrames / VIDEO_FPS; + const canAutoplay = isCaptureMode || !shouldReduceMotion; + const viewportWidth = useSyncExternalStore( + subscribeViewport, + getViewportWidth, + getServerViewportWidth, + ); + const viewportHeight = useSyncExternalStore( + subscribeViewport, + getViewportHeight, + getServerViewportHeight, + ); + const canvasScale = Math.min(viewportWidth / VIDEO_WIDTH_PX, viewportHeight / VIDEO_HEIGHT_PX); + + useMotionValueEvent(playhead, "change", (seconds) => { + setCurrentFrame(Math.min(totalDurationFrames, seconds * VIDEO_FPS)); + }); + + useEffect(() => { + if (!isManualFrame) return; + const setManualFrame = (event: Event) => { + if (!(event instanceof CustomEvent) || typeof event.detail !== "number") return; + setCurrentFrame(Math.max(0, Math.min(totalDurationFrames, event.detail))); + }; + window.addEventListener("react-doctor:set-frame", setManualFrame); + return () => window.removeEventListener("react-doctor:set-frame", setManualFrame); + }, [isManualFrame, totalDurationFrames]); + + useEffect(() => { + if (isStillFrame) return; + + const playback = animate(playhead, totalDurationSeconds, { + duration: totalDurationSeconds, + ease: "linear", + }); + playbackRef.current = playback; + + if (!canAutoplay) playback.pause(); + + return () => { + playbackRef.current = null; + playback.stop(); + }; + }, [canAutoplay, isStillFrame, playhead, totalDurationSeconds]); + + const currentSeconds = currentFrame / VIDEO_FPS; + + return ( + +
+

React Doctor for Three.js performance animation

+ + {!isCaptureMode && ( + + )} + +
+
+ +
+
+
+
+ ); +}; diff --git a/apps/video/tsconfig.json b/apps/video/tsconfig.json new file mode 100644 index 000000000..d3396793a --- /dev/null +++ b/apps/video/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "types": ["vite/client"], + "jsx": "react-jsx" + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/video/vite.config.ts b/apps/video/vite.config.ts new file mode 100644 index 000000000..58676f788 --- /dev/null +++ b/apps/video/vite.config.ts @@ -0,0 +1,6 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a5f8e459..d8b841604 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,49 @@ importers: specifier: ^0.1.15 version: 0.1.20(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.0)(tsx@4.22.4)(typescript@6.0.3)(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))(yaml@2.9.0) + apps/video: + dependencies: + '@fontsource/ibm-plex-mono': + specifier: ^5.2.7 + version: 5.3.0 + '@react-three/fiber': + specifier: ^9.7.0 + version: 9.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.185.1) + framer-motion: + specifier: ^12.23.24 + version: 12.43.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: 19.2.5 + version: 19.2.5 + react-dom: + specifier: 19.2.5 + version: 19.2.5(react@19.2.5) + three: + specifier: ^0.185.1 + version: 0.185.1 + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.4(@types/react@19.2.14) + '@types/three': + specifier: ^0.185.3 + version: 0.185.3 + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.2.0(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0)) + playwright-core: + specifier: ^1.62.1 + version: 1.62.1 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + packages/api: dependencies: '@react-doctor/core': @@ -511,6 +554,10 @@ packages: peerDependencies: '@babel/core': ^7.0.0 + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -545,6 +592,18 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -635,6 +694,9 @@ packages: '@daytona/toolbox-api-client@0.196.0': resolution: {integrity: sha512-QjLGLr7NzD8+3SLwUGpIhroi8rdhP6VlUHYUD+mlR09xHHWlZA9QidbaoDyGqhLlH04PthXNB3nuX5UjXNAf4g==} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@effect/platform-node-shared@4.0.0-beta.70': resolution: {integrity: sha512-3VXuL63IDmq13We+ApRKn2JW3Rb9g5gj1YEmfb8u2b73norur1VsIJ/pRE4qjShevg19dQYi2JsLawSZ6gApug==} engines: {node: '>=18.0.0'} @@ -1174,6 +1236,9 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + '@grpc/grpc-js@1.14.4': resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} engines: {node: '>=12.10.0'} @@ -2196,6 +2261,34 @@ packages: engines: {bun: '>=1.3.0', node: ^20.19.0 || >=22.12.0} hasBin: true + '@react-three/fiber@9.7.0': + resolution: {integrity: sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -2491,12 +2584,27 @@ packages: cpu: [arm64] os: [win32] + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} '@types/babel__code-frame@7.27.0': resolution: {integrity: sha512-Dwlo+LrxDx/0SpfmJ/BKveHf7QXWvLBLc+x03l5sbzykj3oB9nHygCpSECF1a+s+QIxbghe+KHqC90vGtxLRAA==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2527,15 +2635,34 @@ packages: '@types/prompts@2.4.9': resolution: {integrity: sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.3': + resolution: {integrity: sha512-8TqTn1+fjPWuJ4mR6Igtg56DCf9b5EeAlwhb5xaa6WlBrsg7SvG0NQbyctGRkHCKwg0uftfCspOEsTXelgktMA==} + '@types/vscode@1.120.0': resolution: {integrity: sha512-feaT4Rst+FkTch5zz/ZbNCxoIvo55YU80Be2kiL7OJcod4+CUYf2lUBPdIJzozNnSEMq1VRTGrWEcCGFB3fBmA==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -2543,6 +2670,12 @@ packages: resolution: {integrity: sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/expect@4.1.7': resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} @@ -2858,6 +2991,9 @@ packages: buffer@5.6.0: resolution: {integrity: sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -3209,6 +3345,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -3255,6 +3394,20 @@ packages: forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + framer-motion@12.43.0: + resolution: {integrity: sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -3464,6 +3617,11 @@ packages: peerDependencies: ws: '*' + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -3713,6 +3871,9 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -3755,6 +3916,12 @@ packages: module-details-from-path@1.0.4: resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + motion-dom@12.43.0: + resolution: {integrity: sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3932,6 +4099,11 @@ packages: resolution: {integrity: sha512-xhcb4yHu9sM/G7foGzoLtXYcC0zHEaOXXjRKhGup0fw78Nf2Tkiapv4EQyMzrbcmQPsllAI7DbFY2UT7PlI9Pg==} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + pngjs@7.0.0: resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} engines: {node: '>=14.19.0'} @@ -3984,12 +4156,30 @@ packages: react-devtools-core@7.0.1: resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} peerDependencies: react: ^19.2.0 + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -4183,6 +4373,11 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -4204,6 +4399,9 @@ packages: engines: {node: '>=10'} hasBin: true + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4296,6 +4494,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -4516,6 +4719,24 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alcalzone/ansi-tokenize@0.3.0': @@ -4768,6 +4989,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/helper-plugin-utils@7.29.7': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.29.7': {} @@ -4791,6 +5014,16 @@ snapshots: dependencies: '@babel/types': 7.29.7 + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/runtime@7.29.2': {} '@babel/template@7.28.6': @@ -5023,6 +5256,8 @@ snapshots: - debug - supports-color + '@dimforge/rapier3d-compat@0.12.0': {} + '@effect/platform-node-shared@4.0.0-beta.70(effect@4.0.0-beta.70)': dependencies: '@types/ws': 8.18.1 @@ -5351,6 +5586,8 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fontsource/ibm-plex-mono@5.3.0': {} + '@grpc/grpc-js@1.14.4': dependencies: '@grpc/proto-loader': 0.8.1 @@ -6137,6 +6374,28 @@ snapshots: oxc-parser: 0.132.0 sade: 1.8.1 + '@react-three/fiber@9.7.0(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(three@0.185.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.5) + react: 19.2.5 + react-use-measure: 2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.5) + three: 0.185.1 + use-sync-external-store: 1.6.0(react@19.2.5) + zustand: 5.0.14(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + transitivePeerDependencies: + - '@types/react' + - immer + + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -6347,6 +6606,8 @@ snapshots: '@turbo/windows-arm64@2.9.7': optional: true + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -6354,6 +6615,27 @@ snapshots: '@types/babel__code-frame@7.27.0': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -6382,20 +6664,53 @@ snapshots: '@types/node': 25.6.0 kleur: 3.0.3 + '@types/react-dom@19.2.4(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 '@types/semver@7.7.1': {} + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.3': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/vscode@1.120.0': {} + '@types/webxr@0.5.24': {} + '@types/ws@8.18.1': dependencies: '@types/node': 25.6.0 '@typescript-eslint/types@8.59.3': {} + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.46.0)(tsx@4.22.4)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@4.1.7': dependencies: '@standard-schema/spec': 1.1.0 @@ -6722,6 +7037,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -7135,6 +7455,8 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -7178,6 +7500,15 @@ snapshots: forwarded-parse@2.1.2: {} + framer-motion@12.43.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + motion-dom: 12.43.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + fs-extra@7.0.1: dependencies: graceful-fs: 4.2.11 @@ -7380,6 +7711,13 @@ snapshots: dependencies: ws: 8.20.0 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.5): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.5 + transitivePeerDependencies: + - '@types/react' + jiti@2.7.0: {} js-tokens@4.0.0: {} @@ -7563,6 +7901,8 @@ snapshots: merge2@1.4.1: {} + meshoptimizer@1.1.1: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -7598,6 +7938,12 @@ snapshots: module-details-from-path@1.0.4: {} + motion-dom@12.43.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + mri@1.2.0: {} mrmime@2.0.1: {} @@ -7878,6 +8224,8 @@ snapshots: dependencies: pngjs: 7.0.0 + playwright-core@1.62.1: {} + pngjs@7.0.0: {} postcss@8.5.6: @@ -7931,11 +8279,24 @@ snapshots: - bufferutil - utf-8-validate + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.5): dependencies: react: 19.2.5 scheduler: 0.27.0 + react-refresh@0.18.0: {} + + react-use-measure@2.1.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + react@19.2.5: {} read-yaml-file@1.1.0: @@ -8135,6 +8496,10 @@ snapshots: dependencies: has-flag: 4.0.0 + suspend-react@0.1.3(react@19.2.5): + dependencies: + react: 19.2.5 + tagged-tag@1.0.0: {} tar@7.5.20: @@ -8157,6 +8522,8 @@ snapshots: source-map-support: 0.5.21 optional: true + three@0.185.1: {} + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -8232,6 +8599,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.5): + dependencies: + react: 19.2.5 + util-deprecate@1.0.2: {} uuid@14.0.0: {} @@ -8470,3 +8841,9 @@ snapshots: zod: 4.3.6 zod@4.3.6: {} + + zustand@5.0.14(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)): + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 959ff85c4..5b7a6dfc3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,5 @@ packages: + - "apps/*" - "packages/*" minimumReleaseAge: 7200