Course outline
Build: Production-ready
In one line
The showroom stops assuming the best. It starts when seen, shows a picture first and a reason when it cannot draw, recovers from a lost GPU, describes itself to a screen reader, and gains the bell and the bottle cage.
Before you start
You will learn
- Start the showroom only when it is about to be seen
- Show a picture and a reason when 3D is not possible
- Recover from a lost context and describe the showroom to a screen reader
You write code in
In your lumen-3d project: add src/boot.js, src/systems/context.js and src/systems/lazy.js, and change the six other files
Files you touch
- index.html
- src/boot.js
- src/systems/context.js
- src/systems/lazy.js
- src/ui/a11y.js
- src/systems/rear-glow.js
- src/objects/bike.js
- src/ui/panel.js
- src/main.js
Think of it like this
Opening day. Before the doors open you check the fire exits, put a sign at the door, plan for a power cut, and train the staff to help a customer who cannot see the shelves. The bike is built; this phase is that check.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- boot file
- A tiny entry script that decides whether and when to load the real app.
- poster
- The still picture shown until the first real frame is drawn.
- announcer
- A helper that writes a message into a live region so a screen reader says it.
- text alternative
- Words that describe what the canvas shows.
Step by step
01Create the new files
Add src/boot.js, src/systems/context.js and src/systems/lazy.js from the file list. In index.html the script tag now points at /src/boot.js.
02Edit the existing files
a11y.js, bike.js, panel.js, rear-glow.js, main.js and index.html are given in full below; search each for Phase 18.
03Run it and read the console
The example below prints the backend and the part list: eighteen parts, the bell and the bottle cage among them, listed in the panel as buttons with prices.
04Drill the failures
In the devtools console run $0.getContext("webgl2").getExtension("WEBGL_lose_context").loseContext() with the canvas selected (the "Restoring it…" message appears and the picture returns after restoreContext()). Then turn off hardware acceleration in the browser settings and reload to see the picture fallback.
Try it yourself
- 1
Read what a screen reader reads
After the bike loads, select
#altin devtools. Its text is generated from the parts list, and ends with the key help; the live region#liveholds "The Lumen bike is ready. N parts can be chosen." Add a part toPARTSand the numbers change by themselves. - 2
Force the fallback
In
supportsWebGL2()make the function returnfalse. The page shows the still picture and the sentence "Your browser can't draw 3D here. Here is a picture of the Lumen bike instead." andconsole.warnrecords why.
Code & diagrams
Every project file, joined into one (local imports can't run in the preview); boot.js is left out. Look at the Bell and Bottle cage buttons, and the blue bottle on the down tube.
Console
Run the scene to see its console.Expected console output
backend: WebGL 2
parts: down-tube, top-tube, seat-tube, chain-stays, seat-stays, fork, front-wheel, rear-wheel, handlebar, crankset, chain, pedals, brakes, bell, bottle-cage, mudguards, rear-light, headlightYour project files
Your lumen-3d project after this topic. Click a file to see it in full.
The whole file after this topic. If yours differs, you can copy this one and carry on.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Lumen Bikes · 3D showroom</title>
<style>
html, body { margin: 0; height: 100%; background: #0b1118; color: #e6edf6; font-family: system-ui, sans-serif; }
#app { position: fixed; inset: 0; }
canvas { display: block; }
#views { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; gap: 8px; }
#views[hidden], #pause[hidden] { display: none; }
#views button, #pause { min-height: 44px; padding: 0 18px; border: 1px solid #3b4757; border-radius: 22px; background: #121b26; color: inherit; font: inherit; cursor: pointer; }
#views button:hover, #pause:hover { border-color: #ff6b2c; }
#views button:focus-visible, #pause:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
#pause { position: fixed; left: 16px; bottom: 16px; }
@media (max-width: 760px) { #pause { bottom: 70px; } } /* on a phone the camera buttons fill the bottom row */
#app:focus-visible { outline: 3px solid #2a9df4; outline-offset: -3px; }
#panel { position: fixed; top: 12px; right: 12px; width: min(240px, calc(100% - 24px)); padding: 12px; border-radius: 12px; background: #151d29ee; }
#panel[hidden] { display: none; }
#panel h2 { margin: 0 0 8px; font-size: 14px; }
#panel ul { display: grid; gap: 4px; margin: 0; padding: 0; list-style: none; }
#panel button { width: 100%; padding: 6px 10px; border: 2px solid #3b4658; border-radius: 8px; background: transparent; color: inherit; font: inherit; font-size: 13px; text-align: left; cursor: pointer; }
#panel button[aria-pressed="true"] { border-color: #ff6b2c; background: #ff6b2c; color: #0b1118; font-weight: 600; }
#panel button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
#panel .info { min-height: 2.6em; margin: 10px 0 0; font-size: 13px; }
@media (max-width: 760px) {
#panel { top: 8px; left: 8px; right: 8px; width: auto; padding: 8px; }
#panel h2 { display: none; }
#panel ul { display: flex; flex-wrap: wrap; }
#panel button { width: auto; padding: 4px 8px; }
#panel .info { min-height: 0; margin-top: 6px; }
}
#fx { position: fixed; right: 16px; bottom: 72px; min-height: 44px; padding: 0 18px; border: 1px solid #3b4757; border-radius: 22px; background: #121b26; color: inherit; font: inherit; cursor: pointer; }
#fx[hidden] { display: none; }
#fx[aria-pressed="false"] { border-style: dashed; color: #a9b4c2; }
#fx:hover { border-color: #ff6b2c; }
#fx:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
#fallback { position: fixed; inset: 0; display: grid; place-content: center; justify-items: center; gap: 12px; padding: 16px; text-align: center; background: #0b1118; }
#fallback[hidden] { display: none; }
#fallback img { max-width: min(100%, 420px); max-height: 60%; border-radius: 8px; }
#status { position: fixed; left: 50%; top: 16px; transform: translateX(-50%); display: flex; align-items: center; gap: 12px; max-width: calc(100% - 32px); padding: 10px 16px; border: 1px solid #3b4757; border-radius: 10px; background: #121b26; }
#status[hidden] { display: none; }
#status[data-state="error"] { border-color: #e5484d; }
#status p { margin: 0; }
#status button { min-height: 44px; padding: 0 16px; border: 1px solid #3b4757; border-radius: 22px; background: #1b2633; color: inherit; font: inherit; cursor: pointer; }
#status button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
.glow { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } /* Phase 14: clicks go through to the 3D canvas */
#ride { position: fixed; right: 16px; bottom: 16px; }
#ride-controls { position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%); display: flex; gap: 8px; }
#ride[hidden], #ride-controls[hidden] { display: none; }
#ride, #ride-controls button { min-height: 44px; padding: 0 18px; border: 1px solid #3b4757; border-radius: 22px; background: #121b26; color: inherit; font: inherit; cursor: pointer; touch-action: none; user-select: none; }
#ride:focus-visible, #ride-controls button:focus-visible { outline: 3px solid #2a9df4; outline-offset: 2px; }
#ride-controls button:active { border-color: #ff6b2c; }
@media (max-width: 760px) { #ride-controls { bottom: 72px; } }
#panel .paints { display: flex; gap: 10px; margin-top: 10px; }
#panel .paints button { width: 28px; height: 28px; padding: 0; border-radius: 50%; }
#panel .paints button[aria-pressed="true"] { border-color: #e6edf6; background-clip: padding-box; }
.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
#poster { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
</style>
</head>
<body>
<div id="app" tabindex="0" role="group" aria-label="Lumen bike viewer" aria-describedby="alt"><img id="poster" src="/data/3d/showroom-poster.png" alt="" /></div>
<p id="alt" class="sr-only">An orange Lumen bike on a round display stand. Arrow keys turn the view, plus and minus zoom, Escape clears the choice.</p>
<p id="live" class="sr-only" role="status" aria-live="polite"></p>
<aside id="panel" aria-label="Bike parts" hidden></aside>
<nav id="views" aria-label="Camera views" hidden>
<button type="button" data-view="side">Side</button>
<button type="button" data-view="front">Front</button>
<button type="button" data-view="detail">Rear wheel</button>
</nav>
<button id="pause" type="button" hidden>Pause animation</button>
<button id="ride" type="button" hidden>Test ride</button>
<div id="ride-controls" role="group" aria-label="Test ride. Hold W or the up arrow to pedal; S, the down arrow or Space to brake; R resets." hidden>
<button id="pedal" type="button">Pedal</button>
<button id="brake" type="button">Brake</button>
<button id="reset" type="button">Reset</button>
</div>
<button id="fx" type="button" aria-pressed="true" hidden>Glow and outline</button>
<div id="status" role="status" aria-live="polite" hidden><p></p><button type="button" hidden>Try again</button></div>
<div id="fallback" hidden>
<p id="fallback-reason">Your browser can't draw 3D here, so here is a picture of the Lumen bike instead.</p>
<img src="/data/3d/showroom-poster.png" alt="The Lumen Bikes showroom: an orange e-bike on a round display stand" />
</div>
<script type="module" src="/src/boot.js"></script>
</body>
</html>
Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Leave the event listener out
In main.js delete the watchContext(...) call, then lose the context from devtools.
Myth vs fact
Myth
Production-ready means fast.
Fact
It also means every failure has an answer: a picture, a message, an announcement or a retry.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
poster?.remove()runs one animation frame after the loop starts, so the first real frame is on screen; removing it earlier flashes the dark background. - ▸
runWhileVisiblemay callloop.start()again on re-entry: safe, becausesetAnimationLoopreplaces its callback. One gap: after a restore while the canvas is off-screen, the loop runs until the next scroll in and out.
Remember this
- 1
The problem this solves: the story's three reports. Ravi heard only "group" because the canvas had no description and nothing announced the bike. Lena's phone left a dead black rectangle after a context loss. The office laptops with no graphics acceleration got a blank page. Each is now handled, and each is something you can test.
- 2
What it adds.
src/boot.jsis the new entry: it loads onlylazy.jsand waits until the showroom is near the screen beforeimport("./main.js").lazy.jshaswhenVisibleandrunWhileVisible,context.jshaswatchContext,a11y.jsgrowscreateAnnounceranddescribeShowroom.bike.jsgainscreateBell()andcreateBottleCage();panel.jsprices them at €12 and €18. - 3
How it plugs in.
main.jskeeps its Phase 1 check but callsshowFallback(reason), and a.catchonstartShowroom()sends a later failure to the picture too. After the first frame the poster is removed. On a lost context the loop stops and#statusand the live region say so; on restore the environment is made again and the loop restarts.rear-glow.jsrecovers its own overlay context. - 4
Measured with the assembled showroom (animation paused, eight pixels read before the loss and after the restore). With the HDR environment all eight matched, even without the
loadEnvironmentline: three.js rebuilds an HDR environment from its own data. With the HDR file missing, so the built-in room environment (PMREM from a scene) is used, they matched with the line and five of eight differed without it (darker, no reflections). That is the line's job. The glow overlay's own context also came back as a newcanvas.glowthat works. - 5
What the showroom can do now that it could not before: start only when seen, show a picture first, explain a failure, survive a lost GPU, be understood by a screen reader, and sell a bell and a bottle cage.
- 6
What is still missing: a recovery path for a lost WebGPU device (the
?webgpupath does not handle it), real screen-reader testing (we checked structure and text, not a live reader), a CDN and cache headers (Topic 18.6 names them), and the interview round and capstone in Phase 19, which adds the Lumen badge.
Explain it without notes
List the new ways the showroom can fail and what the visitor sees for each after this phase.
Why is boot.js a separate file and not just a condition in main.js?
Practice
Add a third part, a kickstand priced at 15, to the panel and the text alternative.
Make showFallback also announce the reason through the live region.
Done when you can
You can start a 3D scene only when it is about to be seen.
You show a picture and a reason when 3D cannot run.
You recover from a lost context, including your own WebGL canvases.
You describe the scene to a screen reader and announce changes.