From c86917fff3d8cfb364d439fa11ae4ac312467960 Mon Sep 17 00:00:00 2001 From: benjaminleonard Date: Tue, 20 Oct 2026 15:53:21 +0100 Subject: [PATCH 1/5] Kiosk mode for unattended iPads Adds a kiosk mode (Options toggle or ?kiosk=1) that plays the overview tour on its own when idle, hides the header, outbound links and tracking, and locks down touch gestures. Adds a web app manifest and an offline service worker for home-screen installs, bundles detect-gpu's benchmark data, and documents iPad setup in the README. --- README.md | 69 +++++ apple-touch-icon.png | Bin 2429 -> 0 bytes bun.lock | 34 +-- index.html | 5 + package.json | 2 +- public/apple-touch-icon.png | Bin 0 -> 2972 bytes favicon-16x16.png => public/favicon-16x16.png | Bin favicon-32x32.png => public/favicon-32x32.png | Bin favicon.svg => public/favicon.svg | 0 public/manifest.webmanifest | 16 ++ public/sw.js | 113 ++++++++ src/App.tsx | 246 +++++++++++------- src/Scene.tsx | 57 +++- src/atoms.ts | 67 ++++- src/components/ContactSales.tsx | 34 +++ src/components/CookiePopup.tsx | 7 +- src/components/FreeTutorial.tsx | 30 ++- src/components/IdleTourOverlay.tsx | 35 +++ src/components/OptionsDropdown.tsx | 14 + src/main.tsx | 11 + src/styles/index.css | 12 + src/useKiosk.ts | 94 +++++++ vite.config.ts | 52 +++- 23 files changed, 752 insertions(+), 146 deletions(-) delete mode 100644 apple-touch-icon.png create mode 100644 public/apple-touch-icon.png rename favicon-16x16.png => public/favicon-16x16.png (100%) rename favicon-32x32.png => public/favicon-32x32.png (100%) rename favicon.svg => public/favicon.svg (100%) create mode 100644 public/manifest.webmanifest create mode 100644 public/sw.js create mode 100644 src/components/IdleTourOverlay.tsx create mode 100644 src/useKiosk.ts diff --git a/README.md b/README.md index c9818de..1af2ddc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,73 @@ script proxied through the `vercel.json` rewrites. Forks build with the variable ship no analytics. Setting this var also turns on the cookie popup, so to test the cookie popup locally, you can use `VITE_ANALYTICS_DOMAIN=localhost bun run dev`. +## Kiosk mode + +For running unattended on a shared device, such as an iPad at a conference booth. Turn it on +with the **Kiosk Mode** toggle in Options, or with `?kiosk=1` (`?kiosk=0` turns it off). The +setting is saved in local storage, so the app relaunches straight into it. + +In kiosk mode: + +- **Idle tour.** On launch, and after 30 seconds without input, the app resets and plays the + first guided tour on its own: steps advance every 12 seconds while the camera orbits. The + first touch hands control to the visitor at the current step. A playing video tour counts + as activity. +- **No way out.** The header and Options are hidden, outbound links are removed, and Contact + Sales becomes a QR code. +- **No tracking.** The cookie banner and trackers are skipped and Plausible ignores the + device. The free-explore tutorial is never marked as seen, so every visitor gets it. +- **Touch lockdown.** No long-press selection or callouts, page pinch-zoom or + rubber-banding. Pinching the 3D view still zooms the camera. +- **Self-refresh.** After 4 hours of uptime, the next idle reset reloads the page to free + GPU memory and pick up any new deploy. + +To leave kiosk mode, **hold the top-left corner of the screen for 3 seconds**. + +Timings are constants at the top of `src/useKiosk.ts`. + +### Offline cache + +When launched as a home-screen app (and only in production builds), a service worker +(`public/sw.js`) precaches every built file, model, video and font — about 16MB — so the app +runs without a network after its first launch. The build fills in the file list and a +content hash, so each deploy installs a fresh cache. A running app picks up a deploy the +next time it fully reloads. + +### Setting up an iPad + +Install from the production domain, not a preview URL: the saved app and its cache are tied +to one origin. + +1. **Save it as an app.** In Safari, open + [explorer.oxide.computer](https://explorer.oxide.computer) and tap **Share → Add to Home + Screen** (with **Open as Web App** on, where shown). Launch it from the home-screen icon: + it runs full screen, with no address bar. +2. **Let it cache.** Leave it open on good Wi-Fi for a minute or two. To confirm, switch on + airplane mode, force-quit and relaunch, and play the video tour. +3. **Turn on kiosk mode** from Options _inside the saved app_. Home-screen apps don't share + storage with Safari, and have no address bar for `?kiosk=1`. +4. **Lock the iPad to the app with Guided Access.** In Settings → Accessibility → Guided + Access, turn it on, set a passcode, set its Display Auto-Lock to Never and turn on the + Accessibility Shortcut. Then, in the app, triple-click the top (or Home) button. Under + Options, turn off Motion (locks orientation), Hardware Buttons and Keyboards. Rotate to + landscape, then tap **Start**. +5. **Quiet the device.** Turn on Do Not Disturb, turn off Siri, Low Power Mode and automatic + software updates, turn brightness up, and keep it on a charger. + +To get out, triple-click and enter the passcode to end Guided Access, then hold the top-left +corner for 3 seconds to leave kiosk mode. + +Things to know: + +- **Landscape only.** In portrait (under 1000px wide) the app falls back to its mobile + layout, and some of its controls live in the header that kiosk mode hides. +- **Guided Access ends on restart.** Relaunch the app and triple-click again; kiosk mode + itself is remembered. For multi-day unattended setups, Single App Mode via Apple + Configurator or MDM survives restarts but needs a supervised (wiped) iPad. +- **Updating the icon.** iOS caches home-screen icons. After changing + `public/apple-touch-icon.png`, delete the icon and add it again. + ## Stack - React 19 + Vite @@ -39,6 +106,8 @@ popup locally, you can use `VITE_ANALYTICS_DOMAIN=localhost bun run dev`. - `src/data/componentTree.ts` — hardware hierarchy (rack → sleds → components) - `src/data/guidedTours.ts` — tour definitions - `src/data/specifications.ts` — hardware specs shown in the side panel +- `src/useKiosk.ts` — kiosk idle detection and the idle tour loop +- `public/sw.js` — offline cache for the home-screen app - `models/`, `public/models/` — GLB assets (LOD0 / LOD1) ## Licensing diff --git a/apple-touch-icon.png b/apple-touch-icon.png deleted file mode 100644 index 2ae7b202109c76e30bfd4c05b8121d7afbcfd5dd..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2429 zcmd5-Yd8}O8(wR&Ii*yr*SVaswvfsxZFn8CIW=CXWRB%LQ_Z2AW6nk~M6DtsO2jC_ zoc2u)4b6mV4*O)h2;*D-zU#ZL_y70fdG7nVpXd4YobF@nEhWW{iU9xsNoy-}Ms*~N{1m`H>O_$&Qfq`nT0X66Hv+X`Uc{BWOmKdL0;#BEV z_q-e?wj*d>SQ-3#5}o39v6&z}hQ3_DfEN%-fKIUjkCD=z!lMGtFftu_WGeeUIWP?} zsivsor!`$U)za4;Hv}&=Dk0-28~LuE!~&8UtzplMR-;42Z13B#P~RB@44c#+>wd#TOhfkfS`FheP_%0vbFDF@3$bQ9srY;H0wRTx2r@F zVh--|GD<2={Xq3s#9^`Px~lto0^F+6yH}Aery6udIeasA;4#KBqeae5 z_SK4GTbW!v0-b+G<7S z%7m^$kz*Q0u^&wbtW(Md^=`Kij)S|Gn_oWY3y0(wkmAzEpb_n~!0HGC#D}_-Y(T2? zX=fF`=O`v7@llx=((G_t(g~2H3;1Ydf&v}~2WcuVKI+DjX21jgdb}6n+K6;-)}p;B zQ-;;wknL%JdDS1kVmt-I&Fq%F?|k_j5ITDw-KXgYzPp}F$4cj_nnmi_CjStCp?W1@ZbiRD2H*M8NN=riG-z5@b@r|4l?e9(lW2_26bu9OK2N zkPv|&LDpO{#eyXwz3MT)-yp3vT$x+-6U9(nBue8?YzuR?j)2%LzcFcTAN#l9J}&pV zHLLzF1p+Z{R4MF|cE_j^ehbA2aADnvSD&rAlu(WEL&c=b*FMGtV6=QRRg`DTffCJl z+YYNPf_ra>%wG$$-xm*rLBA$=G*CDA>c76?wgR`guG!@|)P0xEJb@!)IhJN_^~yA= zSw-LST9RBn+(J!Tq-u2_7W;bS@Ld0oR^Z#I6!*tv_Mc@O);*0Tc6od#%(zc`Pp5fslKAg4L;CxU?;D`#H>*gv%YXklC*f% z#OK)paUcmcJo@c%G32}Y>&!^^j+ffANq##d zaznyJdK33y9991&3U1D!piX+q@Xc2ln?V7rgv+Tr5e9_^&!`XwOUSt>$)YS;0B4*~ zCafX*EzLiUNE8w=0dsw4Sjqx>+sdT)!{tu9U(Heu*iI8=cDxQI>T~3YF|Y16bfh3D znkK858ayP`q~iL)^hITXYU|HW(^o*z1gCoGC0w{#V2><_(3yA68*M1aRNWFvIP&Seqd&TIjxy5w@ zX!`dsj}yAGx-t0UG^|<0r(Eh@gR%Qd7mAh$W!FRdY1HV7@n4gqsGU{gU6Gh`cJ3`w zoA_Fd8HcNOj6U~2b~*5wKHklbgMl{W#gVj)1Fu!OS`H1<<$KXV?#_#NUys(PaXS0D zYS8k{^=jjo`Xg%CYg9yTEx{~9G`hkuI8c*DZe5KF6{z#wC@FzAe z)jkg&@0xKjn4Cgmj(&xD5AER_YfGnfitHoLAF zqseSx5)VolepfqjIIJ@IF>g4T%@9fo=PTihgk;KfWBg2q`fL%{aHO#o^6Lz!_;3^} zAxbH()i%UaCQMOy@5Y63Rj^@Ag>WEqmAU1L5)%??ZzmYGbZ_a^C#~W}4<%`rG*OXP zw9|UL(+!ko`6P-&Ri0knv%Y#@UWD$sf*if)Q6Qc~XVHc~i?@8IZ!0dO2~yo7f1$?d z=R3!onnwb?r<-aAPd&s@??QZiv9K?Xic9}&b3PxT*eTz(klJKia3N9f^k?|9_wxo1 zQn@F%E>#+wU7Z2Ftvv zbYI^SzH$wxXX9fjw=%szO521sN#8j8ZLYUc*^WkysY`Fkh)2vUyndr$E4mAGecD_8 U1+C&M{Ney>3w!gIsB3Zm0XxflY5)KL diff --git a/bun.lock b/bun.lock index d135f59..15fec7c 100644 --- a/bun.lock +++ b/bun.lock @@ -7,7 +7,7 @@ "dependencies": { "@ctrl/tinycolor": "^4.2.0", "@oxide/design-system": "^6.4.0", - "@pmndrs/detect-gpu": "^6.0.6", + "@pmndrs/detect-gpu": "^6.0.27", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@react-three/postprocessing": "^3.0.4", @@ -47,26 +47,8 @@ "@asciidoctor/opal-runtime": ["@asciidoctor/opal-runtime@3.0.1", "", { "dependencies": { "glob": "8.1.0", "unxhr": "1.2.0" } }, "sha512-iW7ACahOG0zZft4A/4CqDcc7JX+fWRNjV5tFAVkNCzwZD+EnFolPaUOPYt8jzadc0+Bgd80cQTtRMQnaaV1kkg=="], - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], - "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@ctrl/tinycolor": ["@ctrl/tinycolor@4.2.0", "", {}, "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A=="], "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], @@ -89,8 +71,6 @@ "@headlessui/react": ["@headlessui/react@2.2.9", "", { "dependencies": { "@floating-ui/react": "^0.26.16", "@react-aria/focus": "^3.20.2", "@react-aria/interactions": "^3.25.0", "@tanstack/react-virtual": "^3.13.9", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ=="], - "@ianvs/prettier-plugin-sort-imports": ["@ianvs/prettier-plugin-sort-imports@4.7.1", "", { "dependencies": { "@babel/generator": "^7.26.2", "@babel/parser": "^7.26.2", "@babel/traverse": "^7.25.9", "@babel/types": "^7.26.0", "semver": "^7.5.2" }, "peerDependencies": { "@prettier/plugin-oxc": "^0.0.4 || ^0.1.0", "@vue/compiler-sfc": "2.7.x || 3.x", "content-tag": "^4.0.0", "prettier": "2 || 3 || ^4.0.0-0", "prettier-plugin-ember-template-tag": "^2.1.0" }, "optionalPeers": ["@prettier/plugin-oxc", "@vue/compiler-sfc", "content-tag", "prettier-plugin-ember-template-tag"] }, "sha512-jmTNYGlg95tlsoG3JLCcuC4BrFELJtLirLAkQW/71lXSyOhVt/Xj7xWbbGcuVbNq1gwWgSyMrPjJc9Z30hynVw=="], - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], @@ -189,7 +169,7 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA=="], - "@pmndrs/detect-gpu": ["@pmndrs/detect-gpu@6.0.6", "", {}, "sha512-PAYLpkukPequY2AVBLcKwdNVjjBLG4SZGGdGkNwYTQ033574IwTSidwIQihb3DMx7m6bDXDQj8eD/X4Ox6Ms4Q=="], + "@pmndrs/detect-gpu": ["@pmndrs/detect-gpu@6.0.27", "", {}, "sha512-NEXcRZDBTwFaZABSmLsdvznKqMpekQ9cLX2vds6oEG2dMOA+CmWJpMeI72SS5R4oymwy8es+KsDRDEkKhGtfww=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], @@ -411,8 +391,6 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-gpu": ["detect-gpu@5.0.70", "", { "dependencies": { "webgl-constants": "^1.1.1" } }, "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w=="], @@ -489,10 +467,6 @@ "js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -555,8 +529,6 @@ "motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="], - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "n8ao": ["n8ao@1.10.1", "", { "peerDependencies": { "postprocessing": ">=6.30.0", "three": ">=0.137" } }, "sha512-hhI1pC+BfOZBV1KMwynBrVlIm8wqLxj/abAWhF2nZ0qQKyzTSQa1QtLVS2veRiuoBQXojxobcnp0oe+PUoxf/w=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -613,8 +585,6 @@ "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], diff --git a/index.html b/index.html index 0c7adfd..2d40777 100644 --- a/index.html +++ b/index.html @@ -53,6 +53,11 @@ + + + + + diff --git a/package.json b/package.json index 31d3626..891ecd3 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "dependencies": { "@ctrl/tinycolor": "^4.2.0", "@oxide/design-system": "^6.4.0", - "@pmndrs/detect-gpu": "^6.0.6", + "@pmndrs/detect-gpu": "^6.0.27", "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.6.1", "@react-three/postprocessing": "^3.0.4", diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..164014703d92cbcd2fe10ed8e3467aba20634d33 GIT binary patch literal 2972 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 literal 0 HcmV?d00001 diff --git a/favicon-16x16.png b/public/favicon-16x16.png similarity index 100% rename from favicon-16x16.png rename to public/favicon-16x16.png diff --git a/favicon-32x32.png b/public/favicon-32x32.png similarity index 100% rename from favicon-32x32.png rename to public/favicon-32x32.png diff --git a/favicon.svg b/public/favicon.svg similarity index 100% rename from favicon.svg rename to public/favicon.svg diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..9362b84 --- /dev/null +++ b/public/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Oxide 3D Explorer", + "short_name": "3D Explorer", + "description": "Explore the Oxide Cloud Computer in 3D.", + "id": "/", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "landscape", + "background_color": "#080F11", + "theme_color": "#080F11", + "icons": [ + { "src": "/apple-touch-icon.png", "sizes": "180x180", "type": "image/png" }, + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } + ] +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..565f67a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,113 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +// Offline cache for the home-screen app, so a kiosk survives flaky conference +// Wi-Fi. Only registered in standalone display mode (see main.tsx). The build +// (precachePlugin in vite.config.ts) fills in the version and the full list of +// built files, so every model, video and chunk is cached on install — not just +// the ones a visitor happened to open. Each deploy changes the version, which +// installs a fresh cache and drops the old one. + +const VERSION = '__PRECACHE_VERSION__' +/** Same-origin build output, plus cross-origin fonts. */ +const PRECACHE = /* __PRECACHE_FILES__ */ [] +const CACHE = `rack-explorer-${VERSION}` + +self.addEventListener('install', (event) => { + event.waitUntil( + (async () => { + const cache = await caches.open(CACHE) + await Promise.all( + PRECACHE.map(async (url) => { + const sameOrigin = new URL(url, self.location.href).origin === self.location.origin + try { + const res = await fetch(url, { cache: 'reload', mode: 'cors' }) + if (!res.ok) throw new Error(`${res.status} ${url}`) + await cache.put(url, res) + } catch (err) { + // A missing build file means a broken offline copy: fail the install + // and keep the previous worker. Fonts are optional — the fallback + // stack still renders. + if (sameOrigin) throw err + } + }), + ) + await self.skipWaiting() + })(), + ) +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + for (const key of await caches.keys()) { + if (key.startsWith('rack-explorer-') && key !== CACHE) await caches.delete(key) + } + await self.clients.claim() + })(), + ) +}) + +self.addEventListener('fetch', (event) => { + const { request } = event + if (request.method !== 'GET') return + const url = new URL(request.url) + + // SPA navigations (`/`, `/?tour=…`) all get the cached shell, which matches + // the cached hashed chunks exactly. + if (request.mode === 'navigate' && url.origin === self.location.origin) { + event.respondWith(cacheFirst(request, '/index.html')) + return + } + + if (!PRECACHE.includes(url.origin === self.location.origin ? url.pathname : url.href)) { + return + } + event.respondWith(cacheFirst(request)) +}) + +async function cacheFirst(request, key = request) { + const cached = await caches.match(key, { cacheName: CACHE }) + if (!cached) return fetch(request) + const range = request.headers.get('range') + return range ? rangeResponse(cached, range) : cached +} + +/** Safari fetches video in byte ranges and won't play a full 200 response, so + * slice the cached body into the 206 it asked for. */ +async function rangeResponse(response, range) { + const body = await response.arrayBuffer() + const size = body.byteLength + const match = /^bytes=(\d*)-(\d*)$/.exec(range) + if (!match) return new Response(null, { status: 416 }) + let start + let end + if (match[1] === '') { + // Suffix range: the last N bytes. + start = Math.max(0, size - Number(match[2])) + end = size - 1 + } else { + start = Number(match[1]) + end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1) + } + if (start > end || start >= size) { + return new Response(null, { + status: 416, + headers: { 'Content-Range': `bytes */${size}` }, + }) + } + return new Response(body.slice(start, end + 1), { + status: 206, + headers: { + 'Content-Type': response.headers.get('Content-Type') ?? 'application/octet-stream', + 'Content-Range': `bytes ${start}-${end}/${size}`, + 'Content-Length': String(end - start + 1), + 'Accept-Ranges': 'bytes', + }, + }) +} diff --git a/src/App.tsx b/src/App.tsx index d8ce735..dde3e1b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,7 +16,7 @@ import { import { useValue } from '@tldraw/state-react' import clsx from 'clsx' import { AnimatePresence, motion, MotionConfig } from 'motion/react' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { activeTour, @@ -27,18 +27,23 @@ import { goToNextTour, goToTourStep, isVideoTour, + kioskMode, landingOpen, lowTierRendering, + maybeStartFreeTutorial, mobileOutlineOpen, navigationMode, restartFreeTutorial, sceneReady, seekVideo, selectedId, + setKioskMode, specificationsOpen, + startIdleTour, startTour, tourStartScreen, } from './atoms' +import { IdleTourOverlay } from './components/IdleTourOverlay' import { Card } from './components/Card' import { ContactSales } from './components/ContactSales' import { CookiePopup } from './components/CookiePopup' @@ -64,6 +69,7 @@ import { getNode, inheritInstanceIndex } from './data/componentTree' import { getFirstStandardTour, getTour } from './data/guidedTours' import { Scene } from './Scene' import { useKeyboardNavigation } from './useKeyboardNavigation' +import { useKiosk } from './useKiosk' // Deep link: `?tour=` jumps straight into the named tour, skipping the // landing modal. Runs at module load — before the first React render — so the @@ -84,6 +90,24 @@ import { useKeyboardNavigation } from './useKeyboardNavigation' } } +// `?kiosk=1` / `?kiosk=0` turns kiosk mode on or off (persisted), then the +// param is stripped. A kiosk boots straight into the idle tour rather than the +// landing modal, unless a `?tour=` deep link already chose where to start. +{ + const url = new URL(window.location.href) + const kiosk = url.searchParams.get('kiosk') + if (kiosk !== null) { + setKioskMode(kiosk !== '0') + url.searchParams.delete('kiosk') + window.history.replaceState({}, '', url.pathname + url.search + url.hash) + } + if (kioskMode.get() && landingOpen.get()) startIdleTour() +} + +/** Kiosk mode's exit hatch: the header (and Options with it) is hidden from + * visitors, so holding the top-left corner for this long turns kiosk mode off. */ +const KIOSK_EXIT_HOLD_MS = 3_000 + function findPath(targetId: string | null): { id: string; label: string }[] | null { if (!targetId) return null @@ -126,6 +150,13 @@ function App() { const isStartScreen = useValue(tourStartScreen) const isStandardTour = isGuided && !isVideo const isLowTier = useValue(lowTierRendering) + const isKiosk = useValue(kioskMode) + const exitHoldTimer = useRef(null) + + const cancelExitHold = () => { + if (exitHoldTimer.current !== null) window.clearTimeout(exitHoldTimer.current) + exitHoldTimer.current = null + } const handleVideoSkipPrev = useCallback(() => { if (!currentVideoTour) return @@ -148,6 +179,7 @@ function App() { } useKeyboardNavigation() + useKiosk() // Mobile only has guided tours. On mount and on any resize that drops // below the breakpoint, push users out of free explore into the first tour. @@ -198,108 +230,127 @@ function App() { )} -
- -
- Oxide Computer Company -
-
3D Explorer
-
- {!isGuided && ( -
- - {breadcrumbPath && - breadcrumbPath.length > 0 && - breadcrumbPath[0].label !== 'Cloud Computer' && ( - <> - {breadcrumbPath.map((item, i) => { - const isLast = i === breadcrumbPath.length - 1 - return ( - - / -
- + {breadcrumbPath && + breadcrumbPath.length > 0 && + breadcrumbPath[0].label !== 'Cloud Computer' && ( + <> + {breadcrumbPath.map((item, i) => { + const isLast = i === breadcrumbPath.length - 1 + return ( + + / +
- {item.label} - - {isLast && breadcrumbsEnabled && ( - )} -
-
- ) - })} - - )} -
- )} -
- - {!isLandingOpen && !isGuided && ( - - )} - {!isLandingOpen && !(isGuided && isStartScreen) && ( - + {isLast && breadcrumbsEnabled && ( + + )} +
+
+ ) + })} + + )} +
)} - -
+
+ + {!isLandingOpen && !isGuided && ( + + )} + {!isLandingOpen && !(isGuided && isStartScreen) && ( + + )} +
+ + )}
)} + + ) diff --git a/src/Scene.tsx b/src/Scene.tsx index d7c9e09..c78d32c 100644 --- a/src/Scene.tsx +++ b/src/Scene.tsx @@ -6,7 +6,7 @@ * Copyright Oxide Computer Company */ -import { getGPUTier, type TierResult } from '@pmndrs/detect-gpu' +import { getGPUTier, type ModelEntry, type TierResult } from '@pmndrs/detect-gpu' import { CameraControls, Environment, @@ -30,6 +30,7 @@ import { hdriRotationX, hdriRotationY, hdriRotationZ, + idleTourActive, isVideoTour, lowTierRendering, maxZoomMultiplier, @@ -169,15 +170,21 @@ function RackWireframe() { function ShowcaseRotation({ cameraControlsRef, + spinDirectionRef, }: { cameraControlsRef: React.RefObject + spinDirectionRef: React.RefObject<1 | -1> }) { const isShowcase = useValue(showcaseMode) + const isIdleTour = useValue(idleTourActive) const speed = useValue(showcaseRotationSpeed) useFrame((_state, delta) => { - if (!isShowcase || !cameraControlsRef.current) return - cameraControlsRef.current.rotate(delta * speed, 0, false) + if (!(isShowcase || isIdleTour) || !cameraControlsRef.current) return + // The idle tour keeps flying between steps; rotating with a transition + // lets the orbit ride on top of those fits instead of snapping their angle. + const direction = isIdleTour ? spinDirectionRef.current : 1 + cameraControlsRef.current.rotate(delta * speed * direction, 0, isIdleTour) }) return null @@ -270,6 +277,8 @@ const _corner = new THREE.Vector3() const _newPos = new THREE.Vector3() const _curCamPos = new THREE.Vector3() const _curCamTgt = new THREE.Vector3() +const _endSpherical = new THREE.Spherical() +const _curSpherical = new THREE.Spherical() // Zoom-in cap: minimum camera-to-target distance in world units. const MIN_DOLLY_DISTANCE_TOP = 5 @@ -368,8 +377,10 @@ function computeFitPosition( function CameraFitter({ controlsRef, + spinDirectionRef, }: { controlsRef: React.RefObject + spinDirectionRef: React.RefObject<1 | -1> }) { const sel = useValue(selectedId) const navMode = useValue(navigationMode) @@ -509,6 +520,16 @@ function CameraFitter({ controlsRef.current.normalizeRotations() } controlsRef.current.setLookAt(...position, ...waypoint.target, animate) + if (animate) { + // Each fit returns to the waypoint's own angle, undoing whatever the + // idle-tour orbit accumulated. Orbit onward in the direction the fit + // just swung, so the camera never visibly reverses on itself. With + // similar waypoint angles this alternates the spin step to step. + const swing = + controlsRef.current.getSpherical(_endSpherical, true).theta - + controlsRef.current.getSpherical(_curSpherical, false).theta + if (Math.abs(swing) > 1e-3) spinDirectionRef.current = swing > 0 ? 1 : -1 + } // camera-controls only writes camera.position inside its own update(), // which runs at priority -1 — *before* this useFrame at priority 0. So // for a non-animated snap, the new _spherical we just set won't reach @@ -634,6 +655,8 @@ function SceneContent({ multisampling: number }) { const cameraControlsRef = useRef(null) + /** Idle-tour orbit direction, set by CameraFitter after each fit. */ + const spinDirectionRef = useRef<1 | -1>(1) const currentSelectedId = useValue(selectedId) const tourStep = useValue(activeTourStep) const pointerDownPos = useRef<{ x: number; y: number } | null>(null) @@ -947,8 +970,11 @@ function SceneContent({ three: ACTION.NONE, }} /> - - + + @@ -1002,6 +1028,22 @@ const HIGH_TIER_RENDERER_RE = const isUnrecognizedHighTierGPU = (tier: TierResult | null) => tier?.type === 'FALLBACK' && !!tier.gpu && HIGH_TIER_RENDERER_RE.test(tier.gpu) +// detect-gpu fetches its benchmark data from unpkg by default. Bundle it +// instead, so tier detection (which gates Canvas mount) doesn't depend on a +// third-party CDN and works from the offline cache. Each file stays a lazy chunk. +const GPU_BENCHMARKS_DIR = '/node_modules/@pmndrs/detect-gpu/dist/benchmarks' +const gpuBenchmarks = import.meta.glob<[string, ...ModelEntry[]]>( + '/node_modules/@pmndrs/detect-gpu/dist/benchmarks/*.json', + { import: 'default' }, +) +async function loadGPUBenchmarks(file: string): Promise { + const load = gpuBenchmarks[`${GPU_BENCHMARKS_DIR}/${file}`] + if (!load) throw new Error(`Unknown GPU benchmark file: ${file}`) + // First entry is the data format version, which the default loader also strips. + const [, ...entries] = await load() + return entries +} + export const Scene = () => { // Null until getGPUTier() resolves. Canvas mount is deferred so GL context // options (powerPreference, antialias, precision) can be tier-aware — these @@ -1014,7 +1056,7 @@ export const Scene = () => { let cancelled = false if (detectSoftwareRendering()) softwareRenderingDetected.set(true) markInit('gpuTierStartMs', performance.now()) - getGPUTier().then((tier) => { + getGPUTier({ override: { loadBenchmarks: loadGPUBenchmarks } }).then((tier) => { markInit('gpuTierEndMs', performance.now()) if (cancelled) return const shouldPromote = @@ -1223,6 +1265,7 @@ const SceneCanvas = ({ const isStartScreen = useValue(tourStartScreen) const videoClickActive = isVideo && !isStartScreen const isShowcase = useValue(showcaseMode) + const isIdleTour = useValue(idleTourActive) // Perf mode forces continuous rendering and pins canvas size for deterministic runs. const canvasStyle = @@ -1256,7 +1299,7 @@ const SceneCanvas = ({ }} dpr={dpr} linear - frameloop={isShowcase || perfFlags.enabled ? 'always' : 'demand'} + frameloop={isShowcase || isIdleTour || perfFlags.enabled ? 'always' : 'demand'} onPointerMissed={() => { if (videoClickActive) { togglePlayWithFlash() diff --git a/src/atoms.ts b/src/atoms.ts index 3899e13..a015a11 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -9,7 +9,12 @@ import { computed, atom as createAtom, type Atom, type AtomOptions } from '@tldraw/state' import { findClosestModelAncestorId } from './data/componentTree' -import { getVideoTourStepAtTime, guidedTours, type VideoTour } from './data/guidedTours' +import { + getFirstStandardTour, + getVideoTourStepAtTime, + guidedTours, + type VideoTour, +} from './data/guidedTours' // Preserve atom identity across HMR re-evaluation. This module is non- // refreshable (no React components), so an edit to it — or to any non- @@ -64,6 +69,60 @@ export const mobileOutlineOpen = atom('mobileOutlineOpen', false) export const showcaseMode = atom('showcaseMode', false) export const debugMode = atom('debugMode', false) +// Kiosk mode: for unattended devices (e.g. an iPad at a conference booth). +// Hides outbound links and tracking, and plays an idle tour when nobody is using it. +// Persisted so a home-screen app relaunches into it. `?kiosk=1|0` sets it. +const KIOSK_KEY = 'rack-explorer:kiosk' + +function readKiosk(): boolean { + try { + return localStorage.getItem(KIOSK_KEY) === '1' + } catch { + return false + } +} + +export const kioskMode = atom('kioskMode', readKiosk()) + +export function setKioskMode(enabled: boolean) { + kioskMode.set(enabled) + try { + if (enabled) { + localStorage.setItem(KIOSK_KEY, '1') + // Plausible skips events when this is set. + localStorage.setItem('plausible_ignore', 'true') + } else { + localStorage.removeItem(KIOSK_KEY) + localStorage.removeItem('plausible_ignore') + } + } catch { + // localStorage unavailable — kiosk mode lasts for this session only. + } + if (enabled) startIdleTour() + else idleTourActive.set(false) +} + +/** Kiosk idle tour: the overview tour plays itself (camera orbiting, steps + * auto-advancing) until someone touches the screen. */ +export const idleTourActive = atom('idleTourActive', false) + +/** Reset everything a previous visitor may have left behind and start the + * idle tour on the first step of the overview tour, past its start screen. */ +export function startIdleTour() { + landingOpen.set(false) + freeTutorialStepIndex.set(null) + mobileOutlineOpen.set(false) + specificationsOpen.set(true) + startTour(getFirstStandardTour().id) + tourStartScreen.set(false) + idleTourActive.set(true) +} + +/** A visitor touched the screen — hand them the tour from the current step. */ +export function stopIdleTour() { + idleTourActive.set(false) +} + /** Debug-only: render the click-hitbox volumes for waypoint-only nodes * (e.g. disks, power-connector) as visible wireframes. */ export const showHitboxes = atom('showHitboxes', false) @@ -285,9 +344,10 @@ export const freeTutorialStepIndex = atom('freeTutorialStepIndex' export const FREE_TUTORIAL_STEP_COUNT = 3 -/** Start the tutorial if it hasn't been seen before. */ +/** Start the tutorial if it hasn't been seen before. In kiosk mode every + * visitor is new, so it always starts. */ export function maybeStartFreeTutorial() { - if (readTutorialSeen()) return + if (!kioskMode.get() && readTutorialSeen()) return freeTutorialStepIndex.set(0) } @@ -313,6 +373,7 @@ export function advanceFreeTutorial() { export function dismissFreeTutorial() { freeTutorialStepIndex.set(null) + if (kioskMode.get()) return try { localStorage.setItem(FREE_TUTORIAL_SEEN_KEY, '1') } catch { diff --git a/src/components/ContactSales.tsx b/src/components/ContactSales.tsx index a66fa8a..b358163 100644 --- a/src/components/ContactSales.tsx +++ b/src/components/ContactSales.tsx @@ -7,9 +7,17 @@ */ import { OpenLink12Icon } from '@oxide/design-system/icons/react' +import { useValue } from '@tldraw/state-react' +import clsx from 'clsx' +import { kioskMode } from '../atoms' import { CTASkeleton } from './Skeletons' +/** QR code for https://oxide.computer/contact (error correction M, no quiet + * zone), drawn as one horizontal stroke per run of dark modules. */ +const QR_PATH = + 'M0 0.5h7m1 0h1m8 0h2m1 0h1m1 0h7M0 1.5h1m5 0h1m9 0h2m1 0h2m1 0h1m5 0h1M0 2.5h1m1 0h3m1 0h1m2 0h3m1 0h1m1 0h5m2 0h1m1 0h3m1 0h1M0 3.5h1m1 0h3m1 0h1m1 0h5m1 0h3m1 0h1m1 0h1m1 0h1m1 0h3m1 0h1M0 4.5h1m1 0h3m1 0h1m1 0h1m1 0h2m1 0h2m1 0h1m1 0h1m3 0h1m1 0h3m1 0h1M0 5.5h1m5 0h1m1 0h1m2 0h5m1 0h3m2 0h1m5 0h1M0 6.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h1m1 0h7M8 7.5h1m5 0h1m2 0h2m1 0h1M0 8.5h1m3 0h1m1 0h4m2 0h6m1 0h7m2 0h1M0 9.5h1m1 0h1m1 0h1m4 0h1m1 0h1m5 0h2m3 0h7M1 10.5h3m1 0h3m1 0h2m2 0h3m2 0h1m3 0h2m4 0h1M2 11.5h4m2 0h1m3 0h2m1 0h4m1 0h1m1 0h1m2 0h1m1 0h2M0 12.5h4m1 0h2m4 0h3m3 0h1m1 0h1m7 0h1M1 13.5h1m2 0h2m3 0h1m1 0h1m1 0h2m1 0h3m3 0h7M3 14.5h1m1 0h2m1 0h3m5 0h1m1 0h1m1 0h1m4 0h2m1 0h1M5 15.5h1m2 0h5m1 0h1m2 0h1m2 0h2m1 0h2m2 0h2M1 16.5h1m1 0h4m4 0h7m1 0h1m1 0h1m5 0h1M0 17.5h1m1 0h1m1 0h2m1 0h2m2 0h2m9 0h4m1 0h2M2 18.5h1m1 0h3m2 0h2m2 0h3m1 0h2m2 0h3m2 0h1m1 0h1M3 19.5h2m2 0h1m1 0h2m1 0h2m1 0h3m5 0h2m2 0h2M0 20.5h2m1 0h4m2 0h1m1 0h3m3 0h1m1 0h7m2 0h1M8 21.5h7m1 0h1m1 0h1m1 0h1m3 0h1m3 0h1M0 22.5h7m1 0h2m1 0h1m4 0h2m1 0h2m1 0h1m1 0h3m1 0h1M0 23.5h1m5 0h1m3 0h3m1 0h1m2 0h4m3 0h1m2 0h1M0 24.5h1m1 0h3m1 0h1m1 0h1m3 0h4m1 0h1m1 0h7m1 0h2M0 25.5h1m1 0h3m1 0h1m2 0h1m3 0h1m4 0h1m1 0h2m1 0h1m3 0h1M0 26.5h1m1 0h3m1 0h1m4 0h1m1 0h1m1 0h2m2 0h3m3 0h4M0 27.5h1m5 0h1m4 0h1m2 0h2m1 0h2m1 0h1m3 0h2m1 0h2M0 28.5h7m1 0h1m1 0h1m1 0h1m1 0h1m1 0h2m2 0h4m1 0h1m1 0h1' + export function ContactSales({ loading, className, @@ -17,6 +25,32 @@ export function ContactSales({ loading?: boolean className?: string }) { + const isKiosk = useValue(kioskMode) + + // A kiosk can't follow links out, so hand visitors a QR code for their phone. + // The hover styles callers pass are inert on touch devices. + if (isKiosk && !loading) { + return ( +
+
+
Contact Sales
+

+ Discuss your needs and business goals with our team of experts. +

+
+ + + +
+ ) + } + return ( {loading ? ( diff --git a/src/components/CookiePopup.tsx b/src/components/CookiePopup.tsx index 32b0062..4301e6e 100644 --- a/src/components/CookiePopup.tsx +++ b/src/components/CookiePopup.tsx @@ -17,7 +17,7 @@ import { rejectTracking, } from '../util/tracking' import { useValue } from '@tldraw/state-react' -import { lowTierRendering } from '../atoms' +import { kioskMode, lowTierRendering } from '../atoms' function appendInlineScript(id: string, contents: string) { if (document.getElementById(id)) return @@ -60,8 +60,11 @@ const tinyButton = 'h-6 px-2! text-mono-xs py-1!' // Only render when analytics is configured via VITE_ANALYTICS_DOMAIN at build // time, matching the Plausible injection in vite.config.ts. Unset by default in // dev and preview, so trackers don't load locally unless you opt in. +// Kiosk mode skips the banner and trackers entirely: a shared device shouldn't +// let one visitor's choice stand for everyone after them. export function CookiePopup() { - if (!import.meta.env.VITE_ANALYTICS_DOMAIN) return null + const isKiosk = useValue(kioskMode) + if (!import.meta.env.VITE_ANALYTICS_DOMAIN || isKiosk) return null return } diff --git a/src/components/FreeTutorial.tsx b/src/components/FreeTutorial.tsx index cf40bef..fae5179 100644 --- a/src/components/FreeTutorial.tsx +++ b/src/components/FreeTutorial.tsx @@ -7,6 +7,7 @@ */ import { useValue } from '@tldraw/state-react' +import clsx from 'clsx' import { AnimatePresence, motion, useAnimationControls } from 'motion/react' import { useEffect, useRef, useState } from 'react' @@ -15,25 +16,39 @@ import { dismissFreeTutorial, FREE_TUTORIAL_STEP_COUNT, freeTutorialStepIndex, + kioskMode, landingOpen, navigationMode, } from '../atoms' +// Touch screens (e.g. an iPad, which gets this desktop layout in landscape) +// have no keyboard, scroll wheel or clicks, so they get their own wording. +const IS_TOUCH = window.matchMedia('(hover: none) and (pointer: coarse)').matches + const STEPS: { body: string position: string + /** Pinned level with the top of the sidebar cards, which sit higher in kiosk + * mode where there's no header. */ + alignToCards?: true }[] = [ { - body: 'The rack tree of components. Click any item to focus on it. Use Arrow Up and Down to step through adjacent items, Enter to drill down or Esc to go back up.', - position: 'left-72 top-20', + body: IS_TOUCH + ? 'The rack tree of components. Tap any item to focus on it.' + : 'The rack tree of components. Click any item to focus on it. Use Arrow Up and Down to step through adjacent items, Enter to drill down or Esc to go back up.', + position: 'left-72', + alignToCards: true, }, { - body: 'Drag to orbit, scroll to zoom and click components to select them. Double-click the background to zoom back out.', + body: IS_TOUCH + ? 'Drag to orbit, pinch to zoom and tap components to select them. Double-tap the background to zoom back out.' + : 'Drag to orbit, scroll to zoom and click components to select them. Double-click the background to zoom back out.', position: 'bottom-4 left-1/2 -translate-x-1/2', }, { body: 'Where available, selecting a component shows contextual specifications.', - position: 'right-72 top-20', + position: 'right-72', + alignToCards: true, }, ] @@ -57,6 +72,7 @@ export function FreeTutorial() { const stepIndex = useValue(freeTutorialStepIndex) const mode = useValue(navigationMode) const isLandingOpen = useValue(landingOpen) + const isKiosk = useValue(kioskMode) const [isDirty, setIsDirty] = useState(false) const visible = stepIndex !== null && mode === 'free' && !isLandingOpen @@ -98,7 +114,11 @@ export function FreeTutorial() { {visible && step && ( { diff --git a/src/components/IdleTourOverlay.tsx b/src/components/IdleTourOverlay.tsx new file mode 100644 index 0000000..7cc4c6c --- /dev/null +++ b/src/components/IdleTourOverlay.tsx @@ -0,0 +1,35 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { useValue } from '@tldraw/state-react' +import { AnimatePresence, motion } from 'motion/react' + +import { idleTourActive, stopIdleTour } from '../atoms' + +/** Invisible full-screen catcher for the kiosk idle tour. Swallows the first + * touch so it only hands the tour over, rather than also pressing whichever + * tour control was under the finger. Stays mounted through a brief exit so the + * matching pointerup/click land here too, not on the UI underneath. */ +export function IdleTourOverlay() { + const isIdleTour = useValue(idleTourActive) + + return ( + + {isIdleTour && ( + { + if (idleTourActive.get()) stopIdleTour() + }} + /> + )} + + ) +} diff --git a/src/components/OptionsDropdown.tsx b/src/components/OptionsDropdown.tsx index 029decd..34141d5 100644 --- a/src/components/OptionsDropdown.tsx +++ b/src/components/OptionsDropdown.tsx @@ -15,11 +15,13 @@ import { useState } from 'react' import { debugMode, + kioskMode, landingOpen, navigationMode, postProcessingSetting, qualitySetting, resolutionSetting, + setKioskMode, showcaseMode, type QualityLevel, } from '../atoms' @@ -115,9 +117,13 @@ export function OptionsDropdown() { const currentNavigationMode = useValue(navigationMode) const isGuided = currentNavigationMode === 'guided' const isLandingOpen = useValue(landingOpen) + const isKiosk = useValue(kioskMode) // While landing modal is open, the dropdown is force-hidden. const open = openState && !isLandingOpen + // Kept away from kiosk visitors; holding the top-left corner exits kiosk mode. + if (isKiosk) return null + return (
- {breadcrumbPath && - breadcrumbPath.length > 0 && - breadcrumbPath[0].label !== 'Cloud Computer' && ( - <> - {breadcrumbPath.map((item, i) => { - const isLast = i === breadcrumbPath.length - 1 - return ( - - / -
+ {/* No outbound link in kiosk mode */} + +
+ Oxide Computer Company +
+
3D Explorer
+
+ {!isGuided && ( +
+ + {breadcrumbPath && + breadcrumbPath.length > 0 && + breadcrumbPath[0].label !== 'Cloud Computer' && ( + <> + {breadcrumbPath.map((item, i) => { + const isLast = i === breadcrumbPath.length - 1 + return ( + + / +
+ + {isLast && breadcrumbsEnabled && ( - {isLast && breadcrumbsEnabled && ( - - )} -
-
- ) - })} - - )} -
- )} -
- - {!isLandingOpen && !isGuided && ( - - )} - {!isLandingOpen && !(isGuided && isStartScreen) && ( - - )} + )} +
+ + ) + })} + + )}
- - )} + )} +
+ + {!isLandingOpen && !isGuided && ( + + )} + {!isLandingOpen && !(isGuided && isStartScreen) && ( + + )} +
+