Command Palette

Search for a command to run...

Hectal
Course outline
Topic 18.7Advanced ~107 min · step 7 of 7

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.

0/7 · 0%

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. 1

    Read what a screen reader reads

    After the bike loads, select #alt in devtools. Its text is generated from the parts list, and ends with the key help; the live region #live holds "The Lumen bike is ready. N parts can be chosen." Add a part to PARTS and the numbers change by themselves.

  2. 2

    Force the fallback

    In supportsWebGL2() make the function return false. 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." and console.warn records why.

Code & diagrams

The showroom after Phase 18 New tab

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.

idle

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, headlight

Your project files

Your lumen-3d project after this topic. Click a file to see it in full.

index.htmlhtml

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.

terminal
$ $0.getContext("webgl2").getExtension("WEBGL_lose_context").loseContext()
── what you'll see ──
THREE.WebGLRenderer: Context Lost.
(the canvas freezes; no message, nothing announced)

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.

  • ▸

    runWhileVisible may call loop.start() again on re-entry: safe, because setAnimationLoop replaces 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. 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. 2

    What it adds. src/boot.js is the new entry: it loads only lazy.js and waits until the showroom is near the screen before import("./main.js"). lazy.js has whenVisible and runWhileVisible, context.js has watchContext, a11y.js grows createAnnouncer and describeShowroom. bike.js gains createBell() and createBottleCage(); panel.js prices them at €12 and €18.

  3. 3

    How it plugs in. main.js keeps its Phase 1 check but calls showFallback(reason), and a .catch on startShowroom() sends a later failure to the picture too. After the first frame the poster is removed. On a lost context the loop stops and #status and the live region say so; on restore the environment is made again and the loop restarts. rear-glow.js recovers its own overlay context.

  4. 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 loadEnvironment line: 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 new canvas.glow that works.

  5. 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. 6

    What is still missing: a recovery path for a lost WebGPU device (the ?webgpu path 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

01

List the new ways the showroom can fail and what the visitor sees for each after this phase.

02

Why is boot.js a separate file and not just a condition in main.js?

Practice

01

Add a third part, a kickstand priced at 15, to the panel and the text alternative.

02

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.