From 71be29afc387538ab8de8f51a561dbf97559331d Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 27 May 2026 16:29:07 +0100 Subject: [PATCH 1/5] Add CI workflow (lint, fmt:check, tsc) --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d0e9e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: true + +jobs: + ci: + name: ci + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + - run: bun install --frozen-lockfile + - run: bun run lint + - run: bun run fmt:check + - run: bunx tsc -b From 2ba6f783d9df918108af6b58e04015a34ce6a99c Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 27 May 2026 16:35:36 +0100 Subject: [PATCH 2/5] Suppress prefer-tag-over-role on custom video scrubber MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The track is an intentional custom slider with hover preview, step segments, and keyboard handling — not replaceable with input type=range. --- src/components/VideoTourTimeline.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/VideoTourTimeline.tsx b/src/components/VideoTourTimeline.tsx index 649c63c..567f4b8 100644 --- a/src/components/VideoTourTimeline.tsx +++ b/src/components/VideoTourTimeline.tsx @@ -177,6 +177,7 @@ export function VideoTourTimeline({ onSeek }: { onSeek: (time: number) => void } {/* Track background with step segments */}
Date: Wed, 27 May 2026 16:36:35 +0100 Subject: [PATCH 3/5] Add eslint disable comment --- src/components/VideoTourTimeline.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/VideoTourTimeline.tsx b/src/components/VideoTourTimeline.tsx index 567f4b8..e8104a0 100644 --- a/src/components/VideoTourTimeline.tsx +++ b/src/components/VideoTourTimeline.tsx @@ -177,6 +177,8 @@ export function VideoTourTimeline({ onSeek }: { onSeek: (time: number) => void } {/* Track background with step segments */}
Date: Wed, 27 May 2026 16:40:32 +0100 Subject: [PATCH 4/5] Set up oxfmt, drop prettier Migrated .prettierrc to .oxfmtrc.json. oxlint and oxfmt are now the canonical lint/format toolchain. Adds 'fmt' and 'fmt:check' scripts. --- .prettierrc => .oxfmtrc.json | 6 +++-- CLAUDE.md | 37 ++++++++++++++++++++--------- bun.lock | 45 ++++++++++++++++++++++++++++++++++-- package.json | 5 ++-- 4 files changed, 76 insertions(+), 17 deletions(-) rename .prettierrc => .oxfmtrc.json (55%) diff --git a/.prettierrc b/.oxfmtrc.json similarity index 55% rename from .prettierrc rename to .oxfmtrc.json index 874a571..6e24ddf 100644 --- a/.prettierrc +++ b/.oxfmtrc.json @@ -4,7 +4,9 @@ "semi": false, "singleQuote": true, "trailingComma": "all", - "plugins": ["@ianvs/prettier-plugin-sort-imports", "prettier-plugin-tailwindcss"], + "sortTailwindcss": {}, "importOrder": ["", "", "^~/(.*)$", "", "^[./]"], - "importOrderTypeScriptVersion": "5.2.2" + "importOrderTypeScriptVersion": "5.2.2", + "sortPackageJson": false, + "ignorePatterns": ["public/", "dist/", "bun.lock"] } diff --git a/CLAUDE.md b/CLAUDE.md index ea209cc..66998c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,10 +1,13 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +This file provides guidance to Claude Code (claude.ai/code) when working with code in this +repository. ## Project Overview -Rack Explorer is an interactive 3D web application for visualizing Oxide Rack hardware architecture. Users navigate a hierarchical component tree (rack → sleds → CPUs/disks/etc.) with smooth camera animations, and can take guided tours with step-by-step walkthroughs. +Rack Explorer is an interactive 3D web application for visualizing Oxide Rack hardware +architecture. Users navigate a hierarchical component tree (rack → sleds → CPUs/disks/etc.) +with smooth camera animations, and can take guided tours with step-by-step walkthroughs. ## Commands @@ -16,13 +19,15 @@ Rack Explorer is an interactive 3D web application for visualizing Oxide Rack ha Package manager is **Bun**. -**Do not start the dev server (`bun run dev`) — the user always verifies UI changes themselves.** Run `bunx tsc -b` to verify changes compile. +**Do not start the dev server (`bun run dev`) — the user always verifies UI changes +themselves.** Run `bunx tsc -b` to verify changes compile. ## Architecture ### State Management Uses `@tldraw/state` reactive atoms (not Redux/Zustand). Core state lives in `src/atoms.ts`: + - `selectedId` / `hoveredId` — current selection and hover - `navigationMode` — `'free'` (exploration) or `'guided'` (tour mode) - `activeTourId` / `activeTourStepIndex` — guided tour state @@ -33,16 +38,20 @@ Read atoms in React with `useValue()` from `@tldraw/state-react`. ### Component Tree (`src/data/componentTree.ts`) Central data structure defining the hardware hierarchy. Each `ComponentNode` has: + - ID, label, model path (GLB), children, camera waypoint, specs reference -- Optional `instances: Vec3[]` for repeated components (e.g., 32 compute sleds in a 2×16 grid) +- Optional `instances: Vec3[]` for repeated components (e.g., 32 compute sleds in a 2×16 + grid) -**ID convention:** `"component-id"` for single items, `"component-id:0"`, `"component-id:1"` for instances. +**ID convention:** `"component-id"` for single items, `"component-id:0"`, `"component-id:1"` +for instances. ### 3D Rendering (`src/Scene.tsx`) - React Three Fiber canvas with `CameraControls` for orbit/pan - `SelectableGLBModel` — renders a single GLB model -- `InstancedGLBModel` — efficient instanced rendering for repeated components (single draw call for ~32 instances) +- `InstancedGLBModel` — efficient instanced rendering for repeated components (single draw + call for ~32 instances) - GPU tier detection (`@pmndrs/detect-gpu`) adjusts DPR and post-processing - DRACO-compressed GLB models loaded via `src/loaders.ts` - Post-processing (selection outlines, AO) lazy-loaded via `React.lazy` @@ -58,16 +67,22 @@ Central data structure defining the hardware hierarchy. Each `ComponentNode` has ### Guided Tours (`src/data/guidedTours.ts`) -Data-driven tour definitions with steps containing title, description, selectedId, and optional custom waypoints. In guided mode, direct interaction (clicks, keyboard nav) is disabled — navigation is through tour controls only. +Data-driven tour definitions with steps containing title, description, selectedId, and +optional custom waypoints. In guided mode, direct interaction (clicks, keyboard nav) is +disabled — navigation is through tour controls only. ### UI Layer -- **Tailwind CSS 4** with Oxide Design System (`@oxide/design-system`) for colors, typography, and icons -- **Motion (Framer Motion)** for animations — consistent spring config: `type: 'spring', duration: 0.5, bounce: 0` -- Key UI components: `Outline.tsx` (tree browser), `Specifications.tsx` (specs panel), `GuidedTourPanel.tsx`, `LandingModal.tsx` +- **Tailwind CSS 4** with Oxide Design System (`@oxide/design-system`) for colors, + typography, and icons +- **Motion (Framer Motion)** for animations — consistent spring config: + `type: 'spring', duration: 0.5, bounce: 0` +- Key UI components: `Outline.tsx` (tree browser), `Specifications.tsx` (specs panel), + `GuidedTourPanel.tsx`, `LandingModal.tsx` ## Conventions - Prettier with 92 print width, import sorting (third-party → local `~/` → relative `./`) - Strict TypeScript (`tsconfig.app.json`) -- Instance disposal: `InstancedGLBModel` manually disposes Three.js geometries/materials on unmount to prevent VRAM leaks +- Instance disposal: `InstancedGLBModel` manually disposes Three.js geometries/materials on + unmount to prevent VRAM leaks diff --git a/bun.lock b/bun.lock index 62134d6..060ac84 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,6 @@ "name": "rack-explorer", "dependencies": { "@ctrl/tinycolor": "^4.2.0", - "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@oxide/design-system": "^6.2.1", "@pmndrs/detect-gpu": "^6.0.4", "@react-three/drei": "^10.7.7", @@ -34,8 +33,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "baseline-browser-mapping": "^2.10.27", + "oxfmt": "^0.52.0", "oxlint": "^1.63.0", - "prettier": "^3.8.3", "typescript": "~6.0.3", "vite": "^8.0.11", }, @@ -108,6 +107,44 @@ "@oxc-project/types": ["@oxc-project/types@0.128.0", "", {}, "sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.52.0", "", { "os": "android", "cpu": "arm" }, "sha512-17EMSJnQ9g+upVHrAUYDMfH5lvRKQ9Nvg8WtEoH72oDr1VpWz+7/o3tD97U1EToen2YAQ/68JmtDYkQUi20dfQ=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.52.0", "", { "os": "android", "cpu": "arm64" }, "sha512-A2G1IdwGEW2lLJkIxcvuirRH1CzSl/e0NX11zTlW1gvxJThfwbI/BEoaKrTNpm7M2FchvIf6guvIQU7d5iz+OQ=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.52.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-f9+bLvOYxy7NttCLFTvQ7afmqDOWY4wIP9xdvfj5trQ1qj6f2UFAGwZESlfsMjvJNTyRpXfIlOanCI9FOvoeQA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.52.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YSTB9sJ5nnQd/Q0ddHkgof0ZCHPAnWZT1IW2SJ8omz7CP7KluJhO1fNHrpqdxCtpztJwSs4hY1uAee35wKxxaw=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.52.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-NIrRNTTPCs4UbmVs0bxLSCDlLCtIRMJIXklNKaXa5Oj2/K1UIMBvgE8+uPVo01Io3N9HF0+GAX+aAHjUgZS7vA=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-JXUCde8mn3GpgQouz2PXUokgy/uT1QrRJBL2s983VWcSQp62wTFYiNXgTKdeo1Jgbr0IgUnKKvzIk/YBlj/nVQ=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.52.0", "", { "os": "linux", "cpu": "arm" }, "sha512-psbUXaRZ+V8DaXz10Qf7LSHtdtdKAmC8fxXgeU608jjzrmWK4quamZMOpl6sf+dikoFHA85uE93Q0BqxrCdQrQ=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Jw7MgWUU9lcLCcy82updISP3EthTlfvAwR6gWNxPzqly7+fLvOi2gHQE9xXQjpqaVLm/8P+gOzlv9ODuoVlaaw=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.52.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.52.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.52.0", "", { "os": "linux", "cpu": "none" }, "sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.52.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.52.0", "", { "os": "linux", "cpu": "x64" }, "sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.52.0", "", { "os": "none", "cpu": "arm64" }, "sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.52.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-k2sz6gWQdMfh5HPpIS+Bw/0UEV/kaK2xuqJRrWL233sEHx9WLlsmvlPFM4HUNThkYbSN0U0vPW7LVKZWDS8hPQ=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.52.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-rhke69GTcArodLHpjMTfNnvjTEBryDeZcUCKK/VjXDMtfTULl6QRh0ymX5/hbCUv2WjYm9h/QbW++q2vE15gWQ=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.52.0", "", { "os": "win32", "cpu": "x64" }, "sha512-q5xL7oeXkZdEtNZWBdvehJcmt+GRu9l2bK40yJs1jJXlqq+r0Hygb1rTjq+FM2o/2xyt4cufH6KRplHp3Jjsvw=="], + "@oxide/design-system": ["@oxide/design-system@6.2.1", "", { "dependencies": { "@floating-ui/react": "^0.27.16", "@headlessui/react": "^2.2.8", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-tabs": "^1.1.13", "classnames": "^2.5.1", "prettier-plugin-tailwindcss": "^0.6.14", "shiki": "^3.13.0" }, "peerDependencies": { "@asciidoctor/core": "^3.0.0", "@oxide/react-asciidoc": "^1.3.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-1QBu1CVLx20AI9QHOo/m7yFslghpYfHhHxa7r/74NV6Ff/RawkUQjl7X+NgFxLyf3jJehWgWBhcycxM+Bw1q3A=="], "@oxide/react-asciidoc": ["@oxide/react-asciidoc@1.3.0", "", { "dependencies": { "html-react-parser": "^5.2.6" }, "peerDependencies": { "@asciidoctor/core": "^3.0.0", "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-0QNKE03z3nh82AjmJD7Gx7uxpEa0Io4bb/3Zlqh7b87c6iyQ99nV1bdiIel/Ja7FfSdC/qqIfmcedxutvKuD7Q=="], @@ -524,6 +561,8 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.3", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg=="], + "oxfmt": ["oxfmt@0.52.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.52.0", "@oxfmt/binding-android-arm64": "0.52.0", "@oxfmt/binding-darwin-arm64": "0.52.0", "@oxfmt/binding-darwin-x64": "0.52.0", "@oxfmt/binding-freebsd-x64": "0.52.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.52.0", "@oxfmt/binding-linux-arm-musleabihf": "0.52.0", "@oxfmt/binding-linux-arm64-gnu": "0.52.0", "@oxfmt/binding-linux-arm64-musl": "0.52.0", "@oxfmt/binding-linux-ppc64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-gnu": "0.52.0", "@oxfmt/binding-linux-riscv64-musl": "0.52.0", "@oxfmt/binding-linux-s390x-gnu": "0.52.0", "@oxfmt/binding-linux-x64-gnu": "0.52.0", "@oxfmt/binding-linux-x64-musl": "0.52.0", "@oxfmt/binding-openharmony-arm64": "0.52.0", "@oxfmt/binding-win32-arm64-msvc": "0.52.0", "@oxfmt/binding-win32-ia32-msvc": "0.52.0", "@oxfmt/binding-win32-x64-msvc": "0.52.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-nJlYM35F64zTDMecCNhoHNkf+D/eHv7xcjj9XDSj+bFAVtN93m7v8DQMdHd6nDG6Akf/kEYYHmDUBs2Dz27Sug=="], + "oxlint": ["oxlint@1.63.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.63.0", "@oxlint/binding-android-arm64": "1.63.0", "@oxlint/binding-darwin-arm64": "1.63.0", "@oxlint/binding-darwin-x64": "1.63.0", "@oxlint/binding-freebsd-x64": "1.63.0", "@oxlint/binding-linux-arm-gnueabihf": "1.63.0", "@oxlint/binding-linux-arm-musleabihf": "1.63.0", "@oxlint/binding-linux-arm64-gnu": "1.63.0", "@oxlint/binding-linux-arm64-musl": "1.63.0", "@oxlint/binding-linux-ppc64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-gnu": "1.63.0", "@oxlint/binding-linux-riscv64-musl": "1.63.0", "@oxlint/binding-linux-s390x-gnu": "1.63.0", "@oxlint/binding-linux-x64-gnu": "1.63.0", "@oxlint/binding-linux-x64-musl": "1.63.0", "@oxlint/binding-openharmony-arm64": "1.63.0", "@oxlint/binding-win32-arm64-msvc": "1.63.0", "@oxlint/binding-win32-ia32-msvc": "1.63.0", "@oxlint/binding-win32-x64-msvc": "1.63.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.22.1" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-9TGXetdjgIHOJ9OiReomP7nnrMkV9HxC1xM2ramJSLQpzxjsAJtQwa4wqkJN2f/uCrqZuJseFuSlWDdvcruveg=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -606,6 +645,8 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], "troika-three-text": ["troika-three-text@0.52.4", "", { "dependencies": { "bidi-js": "^1.0.2", "troika-three-utils": "^0.52.4", "troika-worker-utils": "^0.52.0", "webgl-sdf-generator": "1.1.1" }, "peerDependencies": { "three": ">=0.125.0" } }, "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg=="], diff --git a/package.json b/package.json index 697a97f..06f46de 100644 --- a/package.json +++ b/package.json @@ -7,11 +7,12 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "oxlint", + "fmt": "oxfmt", + "fmt:check": "oxfmt --check", "preview": "vite preview" }, "dependencies": { "@ctrl/tinycolor": "^4.2.0", - "@ianvs/prettier-plugin-sort-imports": "^4.7.1", "@oxide/design-system": "^6.2.1", "@pmndrs/detect-gpu": "^6.0.4", "@react-three/drei": "^10.7.7", @@ -39,8 +40,8 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", "baseline-browser-mapping": "^2.10.27", + "oxfmt": "^0.52.0", "oxlint": "^1.63.0", - "prettier": "^3.8.3", "typescript": "~6.0.3", "vite": "^8.0.11" } From 3142eb077936ef3d820ddd2cd5251ba00f52d86d Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Wed, 27 May 2026 16:40:54 +0100 Subject: [PATCH 5/5] Format codebase with oxfmt Applied 'bun run fmt' across the repo (trailing whitespace, prose wrap, TS formatting). Also updates CLAUDE.md to reference oxfmt. --- .github/dependabot.yml | 6 +- .oxlintrc.json | 5 +- README.md | 22 +- src/components/PostProcessing.tsx | 6 +- src/components/SelectableGLBModel.tsx | 5 +- src/components/Selection.tsx | 2 +- src/components/TourAnnotations.tsx | 5 +- src/components/WireframeCube.tsx | 33 +- src/perf/PERF.md | 453 +++++++++++++------------- src/perf/PerfHarness.tsx | 13 +- src/perf/gpuTimer.ts | 7 +- src/perf/harness.ts | 14 +- src/perf/raycasting.ts | 20 +- src/useKeyboardNavigation.ts | 5 +- tsconfig.json | 5 +- vite.config.ts | 22 +- 16 files changed, 323 insertions(+), 300 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c7be077..eeb870a 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -5,7 +5,7 @@ version: 2 updates: - - package-ecosystem: "bun" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: 'bun' # See documentation for possible values + directory: '/' # Location of package manifests schedule: - interval: "weekly" + interval: 'weekly' diff --git a/.oxlintrc.json b/.oxlintrc.json index d20cd70..ccbbbe9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -19,10 +19,7 @@ "caughtErrorsIgnorePattern": "^_" } ], - "react/only-export-components": [ - "error", - { "allowConstantExport": true } - ] + "react/only-export-components": ["error", { "allowConstantExport": true }] }, "ignorePatterns": ["dist", "node_modules", "public"] } diff --git a/README.md b/README.md index 4efc1b2..10707e2 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ # Rack Explorer -An interactive 3D view of the [Oxide Cloud Computer](https://oxide.computer), live at [explorer.oxide.computer](https://explorer.oxide.computer). +An interactive 3D view of the [Oxide Cloud Computer](https://oxide.computer), live at +[explorer.oxide.computer](https://explorer.oxide.computer). -Navigate the rack hierarchy — from the chassis down to sleds, CPUs, DIMMs, and disks — or take a guided tour through how the system fits together. +Navigate the rack hierarchy — from the chassis down to sleds, CPUs, DIMMs, and disks — or +take a guided tour through how the system fits together. ## Development @@ -17,7 +19,10 @@ bun run lint # oxlint ## Analytics -Analytics are off by default. The canonical deploy at `explorer.oxide.computer` sets `VITE_ANALYTICS_DOMAIN` at build time, which injects a [Plausible](https://plausible.io) script proxied through the `vercel.json` rewrites. Forks build with the variable unset and ship no analytics. +Analytics are off by default. The canonical deploy at `explorer.oxide.computer` sets +`VITE_ANALYTICS_DOMAIN` at build time, which injects a [Plausible](https://plausible.io) +script proxied through the `vercel.json` rewrites. Forks build with the variable unset and +ship no analytics. ## Stack @@ -39,6 +44,13 @@ Analytics are off by default. The canonical deploy at `explorer.oxide.computer` Source code is licensed under the [Mozilla Public License 2.0](LICENSE). -The 3D models, textures, images, and other binary assets are licensed under [CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/). They may not be used for commercial purposes or in derivative works. See [LICENSE-ASSETS](LICENSE-ASSETS) for full terms and covered paths. +The 3D models, textures, images, and other binary assets are licensed under +[CC BY-NC-ND 4.0](https://creativecommons.org/licenses/by-nc-nd/4.0/). They may not be used +for commercial purposes or in derivative works. See [LICENSE-ASSETS](LICENSE-ASSETS) for +full terms and covered paths. -**Trademark notice:** The Oxide name, logo, and hardware designs are trademarks or trade dress of Oxide Computer Company. Nothing in these licenses grants the right to use them in any way that suggests endorsement or affiliation with Oxide Computer Company. Some models depict third-party components; all third-party trademarks remain the property of their respective owners and their inclusion does not imply endorsement by those manufacturers. +**Trademark notice:** The Oxide name, logo, and hardware designs are trademarks or trade +dress of Oxide Computer Company. Nothing in these licenses grants the right to use them in +any way that suggests endorsement or affiliation with Oxide Computer Company. Some models +depict third-party components; all third-party trademarks remain the property of their +respective owners and their inclusion does not imply endorsement by those manufacturers. diff --git a/src/components/PostProcessing.tsx b/src/components/PostProcessing.tsx index dc705eb..4778844 100644 --- a/src/components/PostProcessing.tsx +++ b/src/components/PostProcessing.tsx @@ -82,11 +82,7 @@ export const PostProcessing = ({ } return ( - + {effects} ) diff --git a/src/components/SelectableGLBModel.tsx b/src/components/SelectableGLBModel.tsx index 3401671..14f05fa 100644 --- a/src/components/SelectableGLBModel.tsx +++ b/src/components/SelectableGLBModel.tsx @@ -50,10 +50,7 @@ export const SelectableGLBModel = memo(function SelectableGLBModel({ // Swap any perforation material on the loaded scene to the shared module- // level material before sceneInfo clones it (clone shares material refs). // Idempotent per gltf.scene. - useMemo( - () => rewritePerforations(gltf.scene, textures, gl), - [gltf.scene, textures, gl], - ) + useMemo(() => rewritePerforations(gltf.scene, textures, gl), [gltf.scene, textures, gl]) // Re-clone whenever the source GLTF or low-tier setting changes. The clone // owns any new lambert materials we create during downgrade — they get diff --git a/src/components/Selection.tsx b/src/components/Selection.tsx index 7584b77..00ea6ad 100644 --- a/src/components/Selection.tsx +++ b/src/components/Selection.tsx @@ -40,7 +40,7 @@ export function ModifiedSelect({ enabled = false, children, ...props }: SelectAp api.select((state) => state.filter((selected) => !toRemove.has(selected))) } } - }, [enabled, api]) + }, [enabled, api]) return ( {children} diff --git a/src/components/TourAnnotations.tsx b/src/components/TourAnnotations.tsx index 652f39c..4859c12 100644 --- a/src/components/TourAnnotations.tsx +++ b/src/components/TourAnnotations.tsx @@ -58,10 +58,7 @@ export function TourAnnotations() { const step = useValue(activeTourStep) const currentSelectedId = useValue(selectedId) - const offset = useMemo( - () => getElementPosition(currentSelectedId), - [currentSelectedId], - ) + const offset = useMemo(() => getElementPosition(currentSelectedId), [currentSelectedId]) if (!step?.annotations?.length) return null diff --git a/src/components/WireframeCube.tsx b/src/components/WireframeCube.tsx index 904f38c..79dfdef 100644 --- a/src/components/WireframeCube.tsx +++ b/src/components/WireframeCube.tsx @@ -15,11 +15,7 @@ interface WireframeCubeProps { color?: string } -export function WireframeCube({ - size, - position, - color = '#5D5E61', -}: WireframeCubeProps) { +export function WireframeCube({ size, position, color = '#5D5E61' }: WireframeCubeProps) { const [w, h, d] = size const geometry = useMemo(() => { const x = w / 2 @@ -38,9 +34,30 @@ export function WireframeCube({ ] const edgeIndices = [ - 0, 1, 1, 2, 2, 3, 3, 0, // back face - 4, 5, 5, 6, 6, 7, 7, 4, // front face - 0, 4, 1, 5, 2, 6, 3, 7, // connecting edges + 0, + 1, + 1, + 2, + 2, + 3, + 3, + 0, // back face + 4, + 5, + 5, + 6, + 6, + 7, + 7, + 4, // front face + 0, + 4, + 1, + 5, + 2, + 6, + 3, + 7, // connecting edges ] const positions = new Float32Array(edgeIndices.length * 3) diff --git a/src/perf/PERF.md b/src/perf/PERF.md index 8de0e3e..c06590a 100644 --- a/src/perf/PERF.md +++ b/src/perf/PERF.md @@ -1,8 +1,7 @@ # Perf Harness -URL-flag-driven performance harness that runs scripted scenarios inside the R3F -canvas and emits a JSON report. Inert unless `?perf=` is set — no runtime cost -in production. +URL-flag-driven performance harness that runs scripted scenarios inside the R3F canvas and +emits a JSON report. Inert unless `?perf=` is set — no runtime cost in production. ## Running @@ -14,60 +13,69 @@ Append flags to any URL: ?perf=all&download=1 auto-download JSON when finished ``` -Results are logged via `console.table(...)` and stashed on `window.__perfReport` -for DevTools poking. `download=1` also saves `perf-.json`. +Results are logged via `console.table(...)` and stashed on `window.__perfReport` for +DevTools poking. `download=1` also saves `perf-.json`. ### Scenarios -| Name | What it stresses | -| --- | --- | -| `idle-rack` | Full rack, no interaction. Baseline. | -| `showcase-rack` | Full rack + continuous rotation. Hits `CameraControls.rotate`. | -| `drilled-sled` | Camera inside a compute sled. Many small meshes, close camera. | +| Name | What it stresses | +| ----------------- | -------------------------------------------------------------------------------------- | +| `idle-rack` | Full rack, no interaction. Baseline. | +| `showcase-rack` | Full rack + continuous rotation. Hits `CameraControls.rotate`. | +| `drilled-sled` | Camera inside a compute sled. Many small meshes, close camera. | | `rapid-selection` | Toggles selection between two sleds every 6 frames. Exercises the outline render path. | -| `orbit-stress` | Full rack, camera rotating 0.02 rad/frame. CPU-light orbit baseline. | +| `orbit-stress` | Full rack, camera rotating 0.02 rad/frame. CPU-light orbit baseline. | ### Ablation flags -| Flag | Purpose | -| --- | --- | -| `frames=` | Measurement frames per scenario (default 300). | -| `warmup=` | Discarded warmup frames (default 60). | -| `dpr=` | Override devicePixelRatio (e.g. `1`, `2`, `4`). Bypasses GPU-tier config. | -| `canvas=` | Pin canvas to a fixed square size. | -| `post=` | `none`, `outline`, `ao`, `outline+ao`. Isolates post-processing passes. | -| `instancing=` | `instanced` (default) or `cloned` — unrolls instances into individual meshes. | +| Flag | Purpose | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| `frames=` | Measurement frames per scenario (default 300). | +| `warmup=` | Discarded warmup frames (default 60). | +| `dpr=` | Override devicePixelRatio (e.g. `1`, `2`, `4`). Bypasses GPU-tier config. | +| `canvas=` | Pin canvas to a fixed square size. | +| `post=` | `none`, `outline`, `ao`, `outline+ao`. Isolates post-processing passes. | +| `instancing=` | `instanced` (default) or `cloned` — unrolls instances into individual meshes. | | `perforations=` | `on` (default) or `off` — strips alpha-tested perforation GLBs (sled / switch / power-shelf) to isolate their fillrate cost. | ## How it measures -- **GPU time:** `EXT_disjoint_timer_query_webgl2` wrapped in `GPUTimer`. Chrome/Edge only; Firefox/Safari report `gpuMs: null`. -- **CPU time:** `performance.now()` delta between a `useFrame(-Infinity)` (before R3F render) and a `useFrame(+Infinity)` (after EffectComposer's priority-1 pass). Covers the full R3F render including post. -- **Frame time:** Wall-clock delta between successive priority-`+Infinity` callbacks. V-syncs to 60Hz in most browsers — use `gpuMs`/`cpuMs` for real signal. -- **Memory:** `gl.info.{memory,programs}` for geometry/texture/shader counts; `performance.memory.usedJSHeapSize` for JS heap (Chrome-only). Snapshots taken at start, end, and every 60 frames — flat lines mean no leak. -- **Draw calls / triangles:** `gl.info.render.{calls,triangles}` with `gl.info.autoReset = false`. +- **GPU time:** `EXT_disjoint_timer_query_webgl2` wrapped in `GPUTimer`. Chrome/Edge only; + Firefox/Safari report `gpuMs: null`. +- **CPU time:** `performance.now()` delta between a `useFrame(-Infinity)` (before R3F + render) and a `useFrame(+Infinity)` (after EffectComposer's priority-1 pass). Covers the + full R3F render including post. +- **Frame time:** Wall-clock delta between successive priority-`+Infinity` callbacks. + V-syncs to 60Hz in most browsers — use `gpuMs`/`cpuMs` for real signal. +- **Memory:** `gl.info.{memory,programs}` for geometry/texture/shader counts; + `performance.memory.usedJSHeapSize` for JS heap (Chrome-only). Snapshots taken at start, + end, and every 60 frames — flat lines mean no leak. +- **Draw calls / triangles:** `gl.info.render.{calls,triangles}` with + `gl.info.autoReset = false`. ### R3F gotchas baked into the harness -The `useFrame(-Infinity)` + `useFrame(+Infinity)` pattern disables R3F's auto-render -(any non-zero-priority frame subscriber does). This is fine in normal operation -because `EffectComposer` runs at priority 1 and does its own `gl.render`. But -with `post=none` there's no composer — so the harness mounts a `ManualRenderer` -that explicitly calls `gl.render(scene, camera)` at priority 0 to keep things -rendering. +The `useFrame(-Infinity)` + `useFrame(+Infinity)` pattern disables R3F's auto-render (any +non-zero-priority frame subscriber does). This is fine in normal operation because +`EffectComposer` runs at priority 1 and does its own `gl.render`. But with `post=none` +there's no composer — so the harness mounts a `ManualRenderer` that explicitly calls +`gl.render(scene, camera)` at priority 0 to keep things rendering. -The `FirstRenderMarker` is gated behind `perfFlags.enabled` — without it, the -production build pays zero per-frame cost for the harness. +The `FirstRenderMarker` is gated behind `perfFlags.enabled` — without it, the production +build pays zero per-frame cost for the harness. ## Production cost when disabled - `parsePerfFlags()` runs once at module load (reads `location.search`). -- `markInit(...)` fires three times at startup (gpuTier start/end, canvas created). Each writes a single field. +- `markInit(...)` fires three times at startup (gpuTier start/end, canvas created). Each + writes a single field. - `PerfHarness`, `ManualRenderer`, `FirstRenderMarker` — none mounted. - `frameloop` stays `demand`; no forced continuous rendering. -- `PerfHarness.tsx` and `gpuTimer.ts` code-split into a ~5kB chunk that never loads without `?perf=`. +- `PerfHarness.tsx` and `gpuTimer.ts` code-split into a ~5kB chunk that never loads without + `?perf=`. -Bundle footprint when `?perf=` is absent: ~3kB for `harness.ts` (flag parser + types + metric scaffolding). The `PerfHarness` chunk does not load. +Bundle footprint when `?perf=` is absent: ~3kB for `harness.ts` (flag parser + types + +metric scaffolding). The `PerfHarness` chunk does not load. --- @@ -77,19 +85,19 @@ GPU vendor: `Google Inc. (Apple)` / renderer: `ANGLE Metal Renderer: Apple M4 Ma ## TL;DR -The app is **GPU-bound**, specifically **fragment/fillrate-bound at high DPR**. -CPU is comfortable (<3ms p99 everywhere). Post-processing is the dominant GPU -cost and the most actionable lever. Instancing is earning its keep. +The app is **GPU-bound**, specifically **fragment/fillrate-bound at high DPR**. CPU is +comfortable (<3ms p99 everywhere). Post-processing is the dominant GPU cost and the most +actionable lever. Instancing is earning its keep. ## Cost breakdown Baseline scenarios at the default config (tier-3 GPU, `dpr=[1,2]`, outline+AO): -| Scenario | gpuMs p50 | cpuMs p50 | calls | tris | programs | -| --- | --- | --- | --- | --- | --- | -| idle-rack | ~3–5 | 2.5 | 239 | 1.69M | 24 | -| drilled-sled | ~3–5 | 2.7 | 225 | 355k | 30 | -| rapid-selection | ~4–5 | 2.1 | 179 | 882k | 24 | +| Scenario | gpuMs p50 | cpuMs p50 | calls | tris | programs | +| --------------- | --------- | --------- | ----- | ----- | -------- | +| idle-rack | ~3–5 | 2.5 | 239 | 1.69M | 24 | +| drilled-sled | ~3–5 | 2.7 | 225 | 355k | 30 | +| rapid-selection | ~4–5 | 2.1 | 179 | 882k | 24 | ## Ablations run (historical — before halfRes AO) @@ -97,12 +105,12 @@ Baseline scenarios at the default config (tier-3 GPU, `dpr=[1,2]`, outline+AO): `post=none` vs default, at `dpr=1`: -| | post=none | post=default | -| --- | --- | --- | -| calls | 93 | 239 (+146) | -| tris | 717k | 1.69M (+977k fullscreen quads) | -| programs | 9 | 24 (+15) | -| gpuMs p50 (idle) | 1.66 | 4.54 (+2.9ms) | +| | post=none | post=default | +| ---------------- | --------- | ------------------------------ | +| calls | 93 | 239 (+146) | +| tris | 717k | 1.69M (+977k fullscreen quads) | +| programs | 9 | 24 (+15) | +| gpuMs p50 (idle) | 1.66 | 4.54 (+2.9ms) | Post-processing adds **~3ms GPU at dpr=1** and 15 extra shader programs. @@ -110,49 +118,47 @@ Post-processing adds **~3ms GPU at dpr=1** and 15 extra shader programs. Going from `dpr=1` to `dpr=4` (16× pixels): -| Scenario | gpuMs @ dpr=1 | gpuMs @ dpr=4 | ratio | -| --- | --- | --- | --- | -| idle-rack | 4.54 | 42.76 | 9.4× | -| drilled-sled | 4.05 | 52.35 | **12.9×** | -| rapid-selection | 5.34 | 66.99 | **12.5×** | +| Scenario | gpuMs @ dpr=1 | gpuMs @ dpr=4 | ratio | +| --------------- | ------------- | ------------- | --------- | +| idle-rack | 4.54 | 42.76 | 9.4× | +| drilled-sled | 4.05 | 52.35 | **12.9×** | +| rapid-selection | 5.34 | 66.99 | **12.5×** | -Modeling: ~2ms fixed cost (vertex/program/draw-call) + fillrate. Most scenarios -show ~10× scaling for 16× pixels — sub-linear, fillrate dominates the delta. -Drilled-sled and rapid-selection scale *faster* than linear on pixels, meaning -they pick up per-pixel cost on top of normal fillrate. +Modeling: ~2ms fixed cost (vertex/program/draw-call) + fillrate. Most scenarios show ~10× +scaling for 16× pixels — sub-linear, fillrate dominates the delta. Drilled-sled and +rapid-selection scale _faster_ than linear on pixels, meaning they pick up per-pixel cost on +top of normal fillrate. ### rapid-selection @ dpr=4 is the danger zone -frameMs p95 = 83ms, p99 = 87ms — drops to ~12fps. Outline pass re-rendering the -selected object's silhouette + blur is the prime suspect. +frameMs p95 = 83ms, p99 = 87ms — drops to ~12fps. Outline pass re-rendering the selected +object's silhouette + blur is the prime suspect. ### instancing=cloned -Unrolling 32 compute-sled instances into individual meshes costs **+868 draw -calls**, **+1.8ms CPU**, **+1.5ms GPU** on M4 Max. Invisible to a desktop user. -On a low-tier GPU where per-draw-call overhead is 10–50µs, those extra 868 -calls would cost 9–43ms — so `InstancedMesh2` is explicitly protecting the -low-tier path. Don't remove it. +Unrolling 32 compute-sled instances into individual meshes costs **+868 draw calls**, +**+1.8ms CPU**, **+1.5ms GPU** on M4 Max. Invisible to a desktop user. On a low-tier GPU +where per-draw-call overhead is 10–50µs, those extra 868 calls would cost 9–43ms — so +`InstancedMesh2` is explicitly protecting the low-tier path. Don't remove it. ### No memory leak -Rapid-selection run over 1800 frames: geometries=120, textures=67, programs=24 -completely flat. JS heap sawtoothed 29–56MB with normal GC drops. The manual -disposal logic in `InstancedGLBModel.tsx` and `SelectableGLBModel.tsx` works. +Rapid-selection run over 1800 frames: geometries=120, textures=67, programs=24 completely +flat. JS heap sawtoothed 29–56MB with normal GC drops. The manual disposal logic in +`InstancedGLBModel.tsx` and `SelectableGLBModel.tsx` works. ## Open diagnostic -Drilled-sled at `dpr=4` with post costs **+10ms vs idle-rack** despite fewer -triangles and fewer draw calls. Root cause hypotheses: +Drilled-sled at `dpr=4` with post costs **+10ms vs idle-rack** despite fewer triangles and +fewer draw calls. Root cause hypotheses: -1. **Outline blur:** silhouette of `compute-inner` covers a larger screen - fraction than the full rack, so the Gaussian blur kernel hits more pixels. -2. **N8AO depth complexity:** stacked PCB + heatsink + shroud geometry means - more occlusion samples hit real geometry per fragment. +1. **Outline blur:** silhouette of `compute-inner` covers a larger screen fraction than the + full rack, so the Gaussian blur kernel hits more pixels. +2. **N8AO depth complexity:** stacked PCB + heatsink + shroud geometry means more occlusion + samples hit real geometry per fragment. The `post=ao` flag (added to isolate AO-only) lets us disambiguate: compare -`post=outline&dpr=4` vs `post=ao&dpr=4` for drilled-sled and read off each -pass's fillrate. +`post=outline&dpr=4` vs `post=ao&dpr=4` for drilled-sled and read off each pass's fillrate. --- @@ -160,64 +166,61 @@ pass's fillrate. ## `halfRes` on N8AO -`src/components/PostProcessing.tsx` — AO now samples at half resolution and -upsamples. Typical savings: 3–4× on the AO-specific GPU portion with slight -edge softening. Safe default because AO is already a blurred, low-frequency -effect. +`src/components/PostProcessing.tsx` — AO now samples at half resolution and upsamples. +Typical savings: 3–4× on the AO-specific GPU portion with slight edge softening. Safe +default because AO is already a blurred, low-frequency effect. ## Tier-2 DPR cap lowered 2.0 → 1.75 `src/Scene.tsx` `getGPUConfig`: -| Tier | Old | New | -| --- | --- | --- | -| 3 (high) | `[1, 2]` | `[1, 2]` (unchanged) | -| 2 (mid) | `[1, 2]` | `[1, 1.75]` | -| 0–1 (low) | `1` | `1` (unchanged) | +| Tier | Old | New | +| --------- | -------- | -------------------- | +| 3 (high) | `[1, 2]` | `[1, 2]` (unchanged) | +| 2 (mid) | `[1, 2]` | `[1, 1.75]` | +| 0–1 (low) | `1` | `1` (unchanged) | -Tier-2 devices (mid-range integrated / older discrete) now render ~23% fewer -fragments. Visually nearly imperceptible, materially cheaper. +Tier-2 devices (mid-range integrated / older discrete) now render ~23% fewer fragments. +Visually nearly imperceptible, materially cheaper. ## `enableOutline` / `post=ao` for diagnostics -`PostProcessing` gained an `enableOutline` prop so we can mount AO without the -outline pass. The harness exposes it via `post=ao`, completing the 2×2 matrix -of post-processing ablations. +`PostProcessing` gained an `enableOutline` prop so we can mount AO without the outline pass. +The harness exposes it via `post=ao`, completing the 2×2 matrix of post-processing +ablations. ## Reverted: temporal AO amortization -A patch monkey-patched N8AO's `effectShaderQuad` / `poissonBlurQuad` / -`accumulationQuad` to noop on alternate frames, leaving the composite running -every frame against the cached accumulation target. Theory: halve the AO -compute cost; only the AO mask is one frame stale. +A patch monkey-patched N8AO's `effectShaderQuad` / `poissonBlurQuad` / `accumulationQuad` to +noop on alternate frames, leaving the composite running every frame against the cached +accumulation target. Theory: halve the AO compute cost; only the AO mask is one frame stale. Measured at dpr=4 on M4 Max (post=ao, 300 frames, mean gpuMs): -| Scenario | temporal on | temporal off | Δ | -| --- | --- | --- | --- | -| idle-rack | 17.94 | 17.62 | −0.32 | -| showcase-rack | 16.25 | 17.10 | +0.85 | -| drilled-sled | 16.17 | 16.99 | +0.81 | -| rapid-selection | 22.10 | 22.07 | −0.03 | -| orbit-stress | 15.86 | 14.57 | −1.30 | +| Scenario | temporal on | temporal off | Δ | +| --------------- | ----------- | ------------ | ----- | +| idle-rack | 17.94 | 17.62 | −0.32 | +| showcase-rack | 16.25 | 17.10 | +0.85 | +| drilled-sled | 16.17 | 16.99 | +0.81 | +| rapid-selection | 22.10 | 22.07 | −0.03 | +| orbit-stress | 15.86 | 14.57 | −1.30 | Deltas are within noise floor and split in sign — no real signal. The reason: -post-`halfRes`, the AO compute is small relative to the **normal pass** (full- -res geometry repass, owned by `EffectComposer` via `enableNormalPass`) and the -**composite quad** — neither of which the temporal patch touches. Halving an -already-small slice yielded ~nothing. AO is also disabled on tier 0–1 -(`enableAO: tierLevel >= 2`), so the only candidate tiers are 2 and 3, both of -which carry the same shape of cost breakdown. +post-`halfRes`, the AO compute is small relative to the **normal pass** (full- res geometry +repass, owned by `EffectComposer` via `enableNormalPass`) and the **composite quad** — +neither of which the temporal patch touches. Halving an already-small slice yielded +~nothing. AO is also disabled on tier 0–1 (`enableAO: tierLevel >= 2`), so the only +candidate tiers are 2 and 3, both of which carry the same shape of cost breakdown. -Lesson: stack ablations carefully. `halfRes` collapsed the budget that -temporal was sized to amortize. Once `halfRes` shipped, temporal's premise no -longer held, but we hadn't re-measured. +Lesson: stack ablations carefully. `halfRes` collapsed the budget that temporal was sized to +amortize. Once `halfRes` shipped, temporal's premise no longer held, but we hadn't +re-measured. ## Perforation ablation (May 2026) -Question: do the alpha-tested perforation GLBs (sled / switch / power-shelf, -`Perforations` material with `alphaMap` + `alphaTest=0.5` + `DoubleSide`) cost -meaningful GPU time, given they're essentially everywhere in the rack views? +Question: do the alpha-tested perforation GLBs (sled / switch / power-shelf, `Perforations` +material with `alphaMap` + `alphaTest=0.5` + `DoubleSide`) cost meaningful GPU time, given +they're essentially everywhere in the rack views? Added `?perforations=off` to strip them at render time, ran `?perf=all` and `?perf=drilled-sled&dpr=4` both ways on M4 Max / Chrome 147. @@ -226,155 +229,145 @@ Added `?perforations=off` to strip them at render time, ran `?perf=all` and Default tier-3 config (DPR `[1,2]`, post=`outline+ao`): -| Scenario | gpu p50 on | gpu p50 off | Δ | -| --- | --- | --- | --- | -| idle-rack | 9.19 | 9.11 | -0.08 | -| showcase-rack | 9.18 | 9.13 | -0.05 | -| drilled-sled | 8.91 | 8.88 | -0.03 | -| rapid-selection | 10.07 | 9.66 | -0.41 | -| orbit-stress | 9.10 | 9.11 | +0.01 | +| Scenario | gpu p50 on | gpu p50 off | Δ | +| --------------- | ---------- | ----------- | ----- | +| idle-rack | 9.19 | 9.11 | -0.08 | +| showcase-rack | 9.18 | 9.13 | -0.05 | +| drilled-sled | 8.91 | 8.88 | -0.03 | +| rapid-selection | 10.07 | 9.66 | -0.41 | +| orbit-stress | 9.10 | 9.11 | +0.01 | -Confirmed at `dpr=4` for drilled-sled across two independent runs: medians -clustered 41–42ms regardless of perforations. The perforation pass adds -no measurable median GPU cost on this GPU. +Confirmed at `dpr=4` for drilled-sled across two independent runs: medians clustered 41–42ms +regardless of perforations. The perforation pass adds no measurable median GPU cost on this +GPU. ### Tail: ~40ms p99 spikes, reproducible. -| Scenario | gpu p99 on | gpu p99 off | Δ | -| --- | --- | --- | --- | -| idle-rack (dpr default) | 17.60 | 10.85 | -6.75 | -| drilled-sled (dpr default) | 17.51 | 10.36 | -7.15 | -| rapid-selection (dpr default) | 21.33 | 18.49 | -2.84 | -| drilled-sled (dpr=4, run 1) | 90.20 | 49.96 | -40.24 | -| drilled-sled (dpr=4, run 2) | 90.28 | 49.81 | -40.47 | +| Scenario | gpu p99 on | gpu p99 off | Δ | +| ----------------------------- | ---------- | ----------- | ------ | +| idle-rack (dpr default) | 17.60 | 10.85 | -6.75 | +| drilled-sled (dpr default) | 17.51 | 10.36 | -7.15 | +| rapid-selection (dpr default) | 21.33 | 18.49 | -2.84 | +| drilled-sled (dpr=4, run 1) | 90.20 | 49.96 | -40.24 | +| drilled-sled (dpr=4, run 2) | 90.28 | 49.81 | -40.47 | -The two `dpr=4` p99 measurements landing at `90.20` / `90.28` confirm this -is a deterministic stall, not measurement noise. Likely cause: Metal -pipeline state change for the alpha-test pass, or a composite swap that -re-touches alpha-test fragments. Manifests as occasional dropped frames -during interaction — invisible at p50 dashboards, visible to a user. +The two `dpr=4` p99 measurements landing at `90.20` / `90.28` confirm this is a +deterministic stall, not measurement noise. Likely cause: Metal pipeline state change for +the alpha-test pass, or a composite swap that re-touches alpha-test fragments. Manifests as +occasional dropped frames during interaction — invisible at p50 dashboards, visible to a +user. ### Resource diff (rack-level scenarios, perforations on → off) - draw calls: 257 → 217 (**-40, -16%**) - programs: 27 → 23 (-4) - geometries: 107 → 99 (-8) -- textures: 76 → 67 (-9; ~3 baked PBR maps per perforation GLB plus the - shared `perforations.jpg` from the texture cache) +- textures: 76 → 67 (-9; ~3 baked PBR maps per perforation GLB plus the shared + `perforations.jpg` from the texture cache) - triangles: 1.32M → 1.29M (-25k, -1.9%) -The 40-draw-call delta is invisible on M4 Max but would matter on a -tier-1 mobile/integrated GPU at 10–50µs per call (0.4–2ms total). +The 40-draw-call delta is invisible on M4 Max but would matter on a tier-1 mobile/integrated +GPU at 10–50µs per call (0.4–2ms total). ### Verdict: keep them on by default, watch the tail. -Perforations are visually load-bearing for hardware fidelity and cost -nothing at the median. The p99 spike is the only real cost, and it's -worth attention only if low-tier hardware reports stutter. Mitigations -in priority order if it ever bites: - -1. Bake the perforation pattern into the cosmo-ext shell as a single - alphaMap'd material — eliminates the separate mesh + draw call + - pipeline state change without changing the visual. -2. On detected tier 0–1 GPUs, swap the perforation mesh for a darker - non-perforated panel (visual fidelity loss, eliminates the alpha-test - path entirely). -3. Move perforations to a dedicated render pass with explicit depth - pre-pass to stabilize alpha-test fragment cost. Most invasive; only - warranted if 1–2 don't suffice. - -**Update (May 2026):** the p99 stall was closed by a much simpler fix — -switching `SHARED_PERF_MATERIAL` to Lambert. See *Perforation material → -Lambert* below. None of the three mitigations above were needed. +Perforations are visually load-bearing for hardware fidelity and cost nothing at the median. +The p99 spike is the only real cost, and it's worth attention only if low-tier hardware +reports stutter. Mitigations in priority order if it ever bites: + +1. Bake the perforation pattern into the cosmo-ext shell as a single alphaMap'd material — + eliminates the separate mesh + draw call + pipeline state change without changing the + visual. +2. On detected tier 0–1 GPUs, swap the perforation mesh for a darker non-perforated panel + (visual fidelity loss, eliminates the alpha-test path entirely). +3. Move perforations to a dedicated render pass with explicit depth pre-pass to stabilize + alpha-test fragment cost. Most invasive; only warranted if 1–2 don't suffice. + +**Update (May 2026):** the p99 stall was closed by a much simpler fix — switching +`SHARED_PERF_MATERIAL` to Lambert. See _Perforation material → Lambert_ below. None of the +three mitigations above were needed. ## Perforation material → Lambert (May 2026) -`SHARED_PERF_MATERIAL` switched from `MeshStandardMaterial` to -`MeshLambertMaterial`. The surface is fully diffuse black -(`color=0x000000, metalness=0, roughness=1`), so PBR's IBL/specular -contribution to it is negligible — Lambert just skips the GGX path -on every alpha-test fragment and renders identically. +`SHARED_PERF_MATERIAL` switched from `MeshStandardMaterial` to `MeshLambertMaterial`. The +surface is fully diffuse black (`color=0x000000, metalness=0, roughness=1`), so PBR's +IBL/specular contribution to it is negligible — Lambert just skips the GGX path on every +alpha-test fragment and renders identically. Re-ran `?perf=drilled-sled&dpr=4` on M4 Max, perforations on: -| | Standard | Lambert | Δ | -| --- | --- | --- | --- | -| gpuMs p50 | 42.12 | 35.77 | **-6.35 (-15%)** | -| gpuMs p95 | 48.60 | 37.76 | -10.83 (-22%) | -| gpuMs p99 | **90.28** | **38.08** | **-52.20 (-58%)** | -| frameMs p99 | 55.90 | 24.40 | -31.50 (-56%) | -| draw calls | 234 | 222 | -12 | -| programs | 28 | 26 | -2 | - -The 90ms p99 spike documented in the perforation ablation section is -gone — p99 now sits ~0.3ms above p95, indistinguishable from frame noise. -The original hypothesis was a Metal pipeline state change for the -alpha-test pass; removing PBR's uniform set, env-map binding, and unused -texture slots apparently collapsed enough of that state diff to -eliminate the stall. Median GPU also down ~15%, which suggests the -StandardMaterial GGX evaluation was non-trivial even on a tier-3 GPU -once you multiply it by the alpha-test fragment count. - -Net: closes the *watch the tail* item from the perforation ablation. +| | Standard | Lambert | Δ | +| ----------- | --------- | --------- | ----------------- | +| gpuMs p50 | 42.12 | 35.77 | **-6.35 (-15%)** | +| gpuMs p95 | 48.60 | 37.76 | -10.83 (-22%) | +| gpuMs p99 | **90.28** | **38.08** | **-52.20 (-58%)** | +| frameMs p99 | 55.90 | 24.40 | -31.50 (-56%) | +| draw calls | 234 | 222 | -12 | +| programs | 28 | 26 | -2 | + +The 90ms p99 spike documented in the perforation ablation section is gone — p99 now sits +~0.3ms above p95, indistinguishable from frame noise. The original hypothesis was a Metal +pipeline state change for the alpha-test pass; removing PBR's uniform set, env-map binding, +and unused texture slots apparently collapsed enough of that state diff to eliminate the +stall. Median GPU also down ~15%, which suggests the StandardMaterial GGX evaluation was +non-trivial even on a tier-3 GPU once you multiply it by the alpha-test fragment count. + +Net: closes the _watch the tail_ item from the perforation ablation. ## Per-instance frustum culling re-enabled (May 2026) `InstancedMesh2.perObjectFrustumCulled` flipped from `false` to `true` in -`src/components/InstancedGLBModel.tsx`. The original `false` setting -short-circuited per-instance BVH culling entirely; the bet was that at -rack-overview the whole batch is in frustum so culling never engages -and would only add traversal cost. Worth re-testing because during -close camera work (drilled-sled, rapid-selection) 31 of 32 sleds are -outside the frustum and could be skipped. - -Re-ran `?perf=all` on M4 Max / Chrome 148, default tier-3 config -(DPR `[1,2]`, post=`outline+ao`): - -| Scenario | gpu p50 off→on | gpu p95 | gpu p99 | mean | -| --- | --- | --- | --- | --- | -| idle-rack | 9.11 → 9.13 | 10.08 → 9.53 | 12.78 → 15.21 | 9.31 → 9.31 | -| showcase-rack | 9.06 → 9.01 | 9.55 → 9.54 | 9.84 → 9.70 | 9.10 → 8.96 | -| drilled-sled | 8.93 → 8.94 | 9.28 → 9.30 | 13.98 → **10.29** | 8.86 → 8.84 | -| rapid-selection | 9.70 → 9.53 | 18.34 → **16.64** | 19.38 → **17.10** | 11.49 → **10.38** | -| orbit-stress | 9.05 → 9.01 | 9.63 → 9.64 | 10.27 → 10.02 | 9.00 → 8.95 | - -Median is a wash on M4 Max, as expected: whole-rack scenarios have -everything in frustum so there's nothing to cull, and M4 Max has fillrate -to spare for close-up scenarios. Tail improves where it should: -drilled-sled p99 −3.7ms, rapid-selection p95 −1.7ms / p99 −2.3ms / mean -−1.1ms. Triangle count during rapid-selection drops 28% (1.08M → 771k) — -that's the actual signal. The lone p99 regression (idle-rack +2.4ms) is -one frame in 300 with p50 and p95 flat; tail noise, not real cost. - -CPU unchanged across all scenarios (BVH-traversal overhead invisible). -Draw calls unchanged (still one per InstancedMesh2 batch) — the saving -is in skipped vertex shading on culled instances, not in fewer calls. - -Net: free win on M4 Max, more meaningful on tier-1 GPUs where vertex -shading isn't free. The 28% triangle reduction during selection should -translate proportionally on weaker hardware. +`src/components/InstancedGLBModel.tsx`. The original `false` setting short-circuited +per-instance BVH culling entirely; the bet was that at rack-overview the whole batch is in +frustum so culling never engages and would only add traversal cost. Worth re-testing because +during close camera work (drilled-sled, rapid-selection) 31 of 32 sleds are outside the +frustum and could be skipped. + +Re-ran `?perf=all` on M4 Max / Chrome 148, default tier-3 config (DPR `[1,2]`, +post=`outline+ao`): + +| Scenario | gpu p50 off→on | gpu p95 | gpu p99 | mean | +| --------------- | -------------- | ----------------- | ----------------- | ----------------- | +| idle-rack | 9.11 → 9.13 | 10.08 → 9.53 | 12.78 → 15.21 | 9.31 → 9.31 | +| showcase-rack | 9.06 → 9.01 | 9.55 → 9.54 | 9.84 → 9.70 | 9.10 → 8.96 | +| drilled-sled | 8.93 → 8.94 | 9.28 → 9.30 | 13.98 → **10.29** | 8.86 → 8.84 | +| rapid-selection | 9.70 → 9.53 | 18.34 → **16.64** | 19.38 → **17.10** | 11.49 → **10.38** | +| orbit-stress | 9.05 → 9.01 | 9.63 → 9.64 | 10.27 → 10.02 | 9.00 → 8.95 | + +Median is a wash on M4 Max, as expected: whole-rack scenarios have everything in frustum so +there's nothing to cull, and M4 Max has fillrate to spare for close-up scenarios. Tail +improves where it should: drilled-sled p99 −3.7ms, rapid-selection p95 −1.7ms / p99 −2.3ms / +mean −1.1ms. Triangle count during rapid-selection drops 28% (1.08M → 771k) — that's the +actual signal. The lone p99 regression (idle-rack +2.4ms) is one frame in 300 with p50 and +p95 flat; tail noise, not real cost. + +CPU unchanged across all scenarios (BVH-traversal overhead invisible). Draw calls unchanged +(still one per InstancedMesh2 batch) — the saving is in skipped vertex shading on culled +instances, not in fewer calls. + +Net: free win on M4 Max, more meaningful on tier-1 GPUs where vertex shading isn't free. The +28% triangle reduction during selection should translate proportionally on weaker hardware. ## Rejected: adaptive AO during user interaction -Prototype that toggled AO off during `controlstart` and back on 200ms after -`controlend` was visually distracting — the AO popping in on release was too -noticeable to justify the fillrate savings. Reverted. +Prototype that toggled AO off during `controlstart` and back on 200ms after `controlend` was +visually distracting — the AO popping in on release was too noticeable to justify the +fillrate savings. Reverted. -Lesson: for a static hardware visualizer, the eye tracks the shading -continuity even during motion. Adaptive DPR is the same shape of idea and -would likely have the same problem. If we revisit, cross-fade via tweened -`N8AO.intensity` rather than a hard toggle. +Lesson: for a static hardware visualizer, the eye tracks the shading continuity even during +motion. Adaptive DPR is the same shape of idea and would likely have the same problem. If we +revisit, cross-fade via tweened `N8AO.intensity` rather than a hard toggle. --- # Priority of follow-ups -1. **Confirm outline-blur hypothesis** with `post=outline&dpr=4` and - `post=ao&dpr=4` comparisons. If outline dominates drilled-sled's +10ms, - consider reducing `edgeStrength` or gating `blur` on high-tier GPUs. -2. **Consider tier-3 DPR cap at 1.75 too** if halfRes AO isn't enough on - Retina. The quality loss is small and fillrate scales with pixel count. -3. **Do not** spend time on draw-call count or scene graph traversal — CPU is - not the bottleneck on any measured configuration. -4. **Do not** merge instanced geometry back into monolithic meshes — the - cloned ablation proves instancing saves ~3ms on the low-tier path. +1. **Confirm outline-blur hypothesis** with `post=outline&dpr=4` and `post=ao&dpr=4` + comparisons. If outline dominates drilled-sled's +10ms, consider reducing `edgeStrength` + or gating `blur` on high-tier GPUs. +2. **Consider tier-3 DPR cap at 1.75 too** if halfRes AO isn't enough on Retina. The quality + loss is small and fillrate scales with pixel count. +3. **Do not** spend time on draw-call count or scene graph traversal — CPU is not the + bottleneck on any measured configuration. +4. **Do not** merge instanced geometry back into monolithic meshes — the cloned ablation + proves instancing saves ~3ms on the low-tier path. diff --git a/src/perf/PerfHarness.tsx b/src/perf/PerfHarness.tsx index 897f2b0..d6a2903 100644 --- a/src/perf/PerfHarness.tsx +++ b/src/perf/PerfHarness.tsx @@ -206,7 +206,11 @@ export function PerfHarness({ // Run BEFORE the R3F render so we can start CPU+GPU timers. useFrame(() => { - if (!flags.enabled || runRef.current.phase === 'pending' || runRef.current.phase === 'done') { + if ( + !flags.enabled || + runRef.current.phase === 'pending' || + runRef.current.phase === 'done' + ) { return } const now = performance.now() @@ -233,7 +237,11 @@ export function PerfHarness({ // Run AFTER the R3F render (and EffectComposer pass, which sits at priority 1 // in react-three/postprocessing) to capture the full frame cost. useFrame(() => { - if (!flags.enabled || runRef.current.phase === 'pending' || runRef.current.phase === 'done') { + if ( + !flags.enabled || + runRef.current.phase === 'pending' || + runRef.current.phase === 'done' + ) { return } timerRef.current?.end() @@ -322,4 +330,3 @@ function readGpuInfo(gl: WebGL2RenderingContext | WebGLRenderingContext): { renderer: gl.getParameter(ext.UNMASKED_RENDERER_WEBGL) as string, } } - diff --git a/src/perf/gpuTimer.ts b/src/perf/gpuTimer.ts index f9e74e5..33ca1d6 100644 --- a/src/perf/gpuTimer.ts +++ b/src/perf/gpuTimer.ts @@ -32,7 +32,12 @@ export class GPUTimer { constructor(rawGl: AnyGL) { // Only WebGL2 path — this project uses a WebGL2 context. - if (!(typeof WebGL2RenderingContext !== 'undefined' && rawGl instanceof WebGL2RenderingContext)) { + if ( + !( + typeof WebGL2RenderingContext !== 'undefined' && + rawGl instanceof WebGL2RenderingContext + ) + ) { return } this.gl = rawGl diff --git a/src/perf/harness.ts b/src/perf/harness.ts index 87e7476..d62ced7 100644 --- a/src/perf/harness.ts +++ b/src/perf/harness.ts @@ -192,7 +192,12 @@ export function percentile(sorted: number[], p: number): number { return sorted[idx] } -export function summarize(values: number[]): { median: number; p95: number; p99: number; mean: number } { +export function summarize(values: number[]): { + median: number + p95: number + p99: number + mean: number +} { if (values.length === 0) return { median: NaN, p95: NaN, p99: NaN, mean: NaN } const sorted = [...values].sort((a, b) => a - b) const mean = values.reduce((s, v) => s + v, 0) / values.length @@ -210,7 +215,9 @@ export function summarize(values: number[]): { median: number; p95: number; p99: export const initMetrics: InitMetrics = { navigationStart: - typeof performance !== 'undefined' && performance.timeOrigin ? performance.timeOrigin : 0, + typeof performance !== 'undefined' && performance.timeOrigin + ? performance.timeOrigin + : 0, gpuTierStartMs: null, gpuTierEndMs: null, canvasCreatedMs: null, @@ -241,7 +248,6 @@ export function downloadJson(filename: string, data: unknown) { } export function logReport(report: PerfReport) { - console.log('[perf] report', report) const rows = report.scenarios.map((s) => ({ scenario: s.name, @@ -256,6 +262,6 @@ export function logReport(report: PerfReport) { tex: s.memory.textures, heapMB: s.memory.heapUsedMB?.toFixed(1) ?? 'n/a', })) - + console.table(rows) } diff --git a/src/perf/raycasting.ts b/src/perf/raycasting.ts index 87bfc0e..d981577 100644 --- a/src/perf/raycasting.ts +++ b/src/perf/raycasting.ts @@ -7,20 +7,22 @@ */ import * as THREE from 'three' -import { - acceleratedRaycast, - computeBoundsTree, - disposeBoundsTree, -} from 'three-mesh-bvh' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' // Patch Three.js prototypes once at module load. Geometries with a `boundsTree` // will use BVH-accelerated raycasting (O(log N) vs O(N)). Meshes whose // geometries have no `boundsTree` fall back to default behavior — so this is // safe to apply globally. -;(THREE.BufferGeometry.prototype as unknown as { computeBoundsTree: typeof computeBoundsTree }).computeBoundsTree = - computeBoundsTree -;(THREE.BufferGeometry.prototype as unknown as { disposeBoundsTree: typeof disposeBoundsTree }).disposeBoundsTree = - disposeBoundsTree +;( + THREE.BufferGeometry.prototype as unknown as { + computeBoundsTree: typeof computeBoundsTree + } +).computeBoundsTree = computeBoundsTree +;( + THREE.BufferGeometry.prototype as unknown as { + disposeBoundsTree: typeof disposeBoundsTree + } +).disposeBoundsTree = disposeBoundsTree ;(THREE.Mesh.prototype as unknown as { raycast: typeof acceleratedRaycast }).raycast = acceleratedRaycast diff --git a/src/useKeyboardNavigation.ts b/src/useKeyboardNavigation.ts index 7ec90dc..85c9c96 100644 --- a/src/useKeyboardNavigation.ts +++ b/src/useKeyboardNavigation.ts @@ -70,10 +70,7 @@ export function useKeyboardNavigation() { function handleKeyDown(e: KeyboardEvent) { const target = e.target as HTMLElement | null if (target?.matches('input, textarea, select, [contenteditable]')) return - if ( - target?.matches('button, a') && - (e.key === 'Enter' || e.key === ' ') - ) { + if (target?.matches('button, a') && (e.key === 'Enter' || e.key === ' ')) { return } diff --git a/tsconfig.json b/tsconfig.json index 1ffef60..d32ff68 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,4 @@ { "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] } diff --git a/vite.config.ts b/vite.config.ts index 0c2beb6..f3e2efa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,25 +6,25 @@ * Copyright Oxide Computer Company */ -import { defineConfig, loadEnv, type Plugin } from "vite"; -import react from "@vitejs/plugin-react"; -import tailwindcss from "@tailwindcss/vite"; +import { defineConfig, loadEnv, type Plugin } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' function analyticsPlugin(domain: string | undefined): Plugin { return { - name: "inject-analytics", + name: 'inject-analytics', transformIndexHtml(html) { - if (!domain) return html.replace("", ""); - const tag = ``; - return html.replace("", tag); + if (!domain) return html.replace('', '') + const tag = `` + return html.replace('', tag) }, - }; + } } // https://vite.dev/config/ export default defineConfig(({ mode }) => { - const env = loadEnv(mode, process.cwd(), ""); + const env = loadEnv(mode, process.cwd(), '') return { plugins: [react(), tailwindcss(), analyticsPlugin(env.VITE_ANALYTICS_DOMAIN)], - }; -}); + } +})