Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 19.6Advanced ~23 min · shot 6 of 6

Build: The Finished Launch Site and Portfolio README

In one line

Assemble every phase into one project, build it with Vite, run the launch audit in Chromium and write the README you present in an interview.

0/6 · 0%

Before you start

You will learn

  • Merge the shared files so every earlier piece is kept
  • Build the site and read the bundle sizes
  • Run the launch audit on the real site
  • Write the portfolio README with measured numbers

You write code in

In your lumen-motion project (npm run dev, then npm run build and npx playwright test audit)

Files you touch

  • index.html
  • src/js/main.js
  • src/styles/components.css
  • README.md
  • tests/audit.spec.js
  • tests/launch-audit.js

Think of it like this

Before opening night the company does a dress rehearsal. Each actor knew their scene, but only the rehearsal shows that two people use the same door. This build is the dress rehearsal for nineteen phases.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

merge
Combining edits made by different people to one file. Marked blocks make it a copy-and-paste job.
README
The file a reviewer reads first.
bundle
The files Vite builds.
lazy chunk
Code the page downloads only when needed, such as the mascot's player.

Step by step

01Merge the shared files

Replace index.html, src/js/main.js and src/styles/components.css with the files below. Keep mascot.js and src/assets/mascot/headlight.lottie from Phase 16. Keep every Phase N block.

02Install and build

Run npm install, then npm run build.

03Run the audit

Run npx playwright install chromium once, then the audit. It starts the dev server and runs the site in Chromium, twice (normal, reduced).

terminal
$ npx playwright test audit
── expected output ──
Running 2 tests using 1 worker
 
ok 1 tests/audit.spec.js:22:1 › the launch site passes the motion audit (12.7s)
ok 2 tests/audit.spec.js:34:3 › reduced motion › nothing moves and nothing loops (8.4s)
 
2 passed (23.0s)

04Write the README

Say what moves and why, give the numbers you measured, say how to run it, and say what it does not cover. Then practise the five-step walk-through at its end.

Try it yourself

  1. 1

    Make the audit fail

    Append .volt { animation: d 3s infinite alternate; position: relative; } @keyframes d { to { left: 12px; } } to components.css and run the audit: layout: FAIL #volt-hero:left (it animates left on the hero SVG).

Code & diagrams

The audit on a finished part

The whole site cannot run in this preview (it needs Vite and many files), so this is the fixed panel with the same audit. On the real site the audit declares the drawer's gesture and three leak tests.

Press Run to see the page.
00:00.000
animations
0.00s0.10s0.20s0.30s0.40s0.50s0.60s0.70s0.80s0.90s

Nothing is moving yet. Hover, click or scroll the page: every animation it starts shows up here as a track.

frames

Console

Nothing logged yet.

Expected console output

layout: PASS
reduced-motion: skipped (run again with reduced motion on)
autoplay: PASS
interrupts: skipped (none declared)
leaks: PASS
AUDIT PASSED

Your project files

Your lumen-motion 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" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Lumen Volt · Lumen Bikes</title>
    <!-- Phase 7: apply the saved motion choice before the first paint (a normal script, not a module: modules run later) -->
    <script>
      try {
        var m = localStorage.getItem("lumen-motion");
        if (m === "reduced" || m === "full") document.documentElement.dataset.motion = m;
      } catch (e) {} // storage blocked: follow the device
    </script>
    <!-- end Phase 7 -->
    <!-- Phase 11: opt in, inline on purpose (a rule in a linked file can arrive too late) -->
    <style>
      @view-transition { navigation: auto; }
    </style>
    <!-- end Phase 11 -->
    <!-- Phase 2: motion tokens (load first, so later files can use them) -->
    <link rel="stylesheet" href="/src/styles/tokens.css" />
    <!-- end Phase 2 -->
    <link rel="stylesheet" href="/src/styles/base.css" />
    <link rel="stylesheet" href="/src/styles/components.css" />
    <!-- Phase 12: scroll-driven effects -->
    <link rel="stylesheet" href="/src/styles/scroll.css" />
    <!-- end Phase 12 -->
    <link rel="stylesheet" href="/src/styles/motion-safe.css" />
  </head>
  <body>
    <!-- Phase 12: the reading bar (decoration: hidden from screen readers) -->
    <div class="read-progress" aria-hidden="true"><div class="read-progress-bar"></div></div>
    <!-- end Phase 12 -->
    <header class="site-header">
      <a class="logo" id="logo" href="/" aria-label="Lumen Bikes, home">
        <!-- Phase 13: the logo drawing (the same markup as src/assets/svg/logo.svg; inline, so the page's CSS can reach it) -->
        <svg class="logo-mark" viewBox="0 0 98 28" height="28" aria-hidden="true" focusable="false"
          fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
          <circle class="draw ring" cx="14" cy="14" r="11" pathLength="1" stroke-dasharray="1" />
          <path class="draw bolt" d="M16 6 L10 15 H15 L13 22 L19 12 H14 Z" pathLength="1" stroke-dasharray="1" />
          <text class="logo-word" x="32" y="21" fill="currentColor" stroke="none" font-family="system-ui, sans-serif" font-size="19" font-weight="800">lumen</text>
        </svg>
        <!-- end Phase 13 -->
      </a>
      <nav aria-label="Main"><a href="#accessories">Accessories</a> <a href="#gallery">Gallery</a> <a href="#specs">Specs</a> <a href="#story">Story</a></nav>
      <button id="cart-button" class="cart-button" aria-label="Cart, 0 items" aria-haspopup="dialog" aria-controls="cart-drawer" aria-expanded="false">Cart <span id="cart-count">0</span></button>
    </header>
    <main>
      <section id="hero" class="hero">
        <div>
          <h1>The Lumen Volt</h1>
          <p class="hero-copy">Our first e-bike. 80 km on one charge, built for Ardenvale's hills.</p>
          <p class="price">€2,490</p>
          <button id="reserve" class="button primary">Reserve yours</button>
          <!-- Phase 11: a normal link; the browser animates the navigation -->
          <a id="ride-link" class="button" href="/ride.html">See the ride</a>
          <!-- end Phase 11 -->
          <!-- Phase 17: the reservation message (announced to screen readers; the confetti is decoration) -->
          <p id="reserve-note" class="reserve-note" role="status"></p>
          <!-- end Phase 17 -->
          <!-- Phase 16: the headlight mascot (a button, so keyboard users can reach it) -->
          <button id="mascot-button" class="mascot" type="button" aria-label="Lumen headlight: press to say hello">
            <canvas id="mascot" width="120" height="120" aria-hidden="true"></canvas>
          </button>
          <!-- end Phase 16 -->
          <!-- Phase 1: loading the Volt -->
          <div class="loading-wrap">
            <div class="loading" id="volt-loading" role="progressbar" aria-label="Loading the Volt" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0"><div class="loading-fill"></div></div>
            <p class="loading-label" id="volt-loading-label">Loading the Volt…</p>
          </div>
          <!-- end Phase 1 -->
        </div>
        <!-- Phase 6: a wrapper, so the glow can be a pseudo-element behind the drawing -->
        <div class="hero-art">
        <svg id="volt-hero" class="volt" viewBox="0 0 240 140" role="img" aria-label="The Lumen Volt e-bike">
          <g fill="none" stroke="currentColor" stroke-width="6" stroke-linecap="round" stroke-linejoin="round">
            <circle cx="52" cy="98" r="34" />
            <circle cx="188" cy="98" r="34" />
            <path d="M52 98 L96 98 L132 50 L84 50 Z M96 98 L72 38 M132 50 L188 98 M126 34 L140 34 M132 50 L130 34" />
            <path d="M64 38 L82 38" />
          </g>
          <rect x="98" y="58" width="26" height="14" rx="4" fill="#2a9df4" />
        </svg>
        </div>
        <!-- end Phase 6 -->
      </section>
      <!-- Phase 14: the battery story (scroll-story-gsap.js pins it while the meter fills) -->
      <section id="battery" class="battery" aria-labelledby="battery-title">
        <h2 id="battery-title">All-day battery</h2>
        <p>500 Wh, removable. 80 km on one charge, full in 4.5 hours.</p>
        <div class="battery-meter" aria-hidden="true"><div class="battery-fill"></div></div>
      </section>
      <!-- end Phase 14 -->
      <section id="accessories">
        <h2>Accessories</h2>
        <div class="grid">
          <article class="card"><h3>Lock</h3><p>€39</p><button class="add" data-product="lock">Add</button></article>
          <article class="card"><h3>Lights</h3><p>€29</p><button class="add" data-product="lights">Add</button></article>
          <article class="card"><h3>Rack</h3><p>€45</p><button class="add" data-product="rack">Add</button></article>
          <article class="card"><h3>Bell</h3><p>€9</p><button class="add" data-product="bell">Add</button></article>
          <article class="card"><h3>Basket</h3><p>€35</p><button class="add" data-product="basket">Add</button></article>
          <article class="card"><h3>Helmet</h3><p>€59</p><button class="add" data-product="helmet">Add</button></article>
        </div>
      </section>
      <section id="gallery">
        <h2>Gallery</h2>
        <div class="filters" role="group" aria-label="Show colour">
          <button class="filter" data-color="all" aria-pressed="true">All</button>
          <button class="filter" data-color="orange" aria-pressed="false">Orange</button>
          <button class="filter" data-color="blue" aria-pressed="false">Blue</button>
          <button class="filter" data-color="ink" aria-pressed="false">Ink</button>
        </div>
        <!-- Phase 10: says what the filter did (a live region) -->
        <p class="gallery-status" id="gallery-status" role="status">Showing 6 of 6 photos</p>
        <!-- end Phase 10 -->
        <div class="photos">
          <figure class="photo" data-color="orange"><div class="swatch"></div><figcaption>Volt in orange, side</figcaption></figure>
          <figure class="photo" data-color="blue"><div class="swatch"></div><figcaption>Volt in blue, side</figcaption></figure>
          <figure class="photo" data-color="ink"><div class="swatch"></div><figcaption>Volt in ink, side</figcaption></figure>
          <figure class="photo" data-color="orange"><div class="swatch"></div><figcaption>Orange, at the harbour</figcaption></figure>
          <figure class="photo" data-color="blue"><div class="swatch"></div><figcaption>Blue, in the old town</figcaption></figure>
          <figure class="photo" data-color="ink"><div class="swatch"></div><figcaption>Ink, at night</figcaption></figure>
        </div>
      </section>
      <section id="specs">
        <h2>Specs</h2>
        <details class="spec"><summary>Battery</summary><p>500 Wh, removable, 80 km range, full in 4.5 hours.</p></details>
        <details class="spec"><summary>Motor</summary><p>250 W rear-hub motor, assists up to 25 km/h.</p></details>
        <details class="spec"><summary>Frame</summary><p>Aluminium, three sizes, 21 kg with the battery.</p></details>
      </section>
      <!-- Phase 12: the Volt story (each feature fades in as it enters the screen) -->
      <section id="story" class="story" aria-labelledby="story-title">
        <h2 id="story-title">Why the Volt</h2>
        <div class="features">
          <article class="feature reveal"><h3>80 km on one charge</h3><p>A 500 Wh battery, full in 4.5 hours.</p></article>
          <article class="feature reveal"><h3>Built for Ardenvale's hills</h3><p>A 250 W rear-hub motor helps you up to 25 km/h.</p></article>
          <article class="feature reveal"><h3>Light enough to carry</h3><p>An aluminium frame, 21 kg with the battery in.</p></article>
          <article class="feature reveal"><h3>Charge it anywhere</h3><p>The battery lifts out, so it charges at your desk.</p></article>
        </div>
      </section>
      <!-- end Phase 12 -->
      <!-- Phase 13: the Ardenvale test route -->
      <section id="route">
        <h2>The Ardenvale test route</h2>
        <p>Twelve kilometres from the harbour to the hill, with a stop in the old town. Every Volt rides it before it ships.</p>
    <svg class="route-map" viewBox="0 0 320 124" role="img"
      aria-label="Map line of the Ardenvale test route: Harbour Gate, Old Town, Hill Park" fill="none">
      <path class="route-line" d="M16 92 C60 92 70 40 118 52 S190 100 230 62 S284 22 304 30" pathLength="1"
        stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-dasharray="1" stroke-dashoffset="0" />
      <g class="route-stops" fill="#f6f3ee" stroke="#ff6b2c" stroke-width="3">
        <circle cx="16" cy="92" r="6" /><circle cx="118" cy="52" r="6" /><circle cx="304" cy="30" r="6" />
      </g>
      <g font-family="system-ui, sans-serif" font-size="11" font-weight="600" fill="#1b1a22" stroke="none">
        <text x="4" y="116">Harbour Gate</text><text x="92" y="38">Old Town</text><text x="256" y="18">Hill Park</text>
      </g>
    </svg>
      </section>
      <!-- end Phase 13 -->
      <!-- Phase 15: the configurator (React mounts into #configurator-root) -->
      <section id="configurator">
        <h2>Build your Volt</h2>
        <div id="configurator-root"></div>
      </section>
      <!-- end Phase 15 -->
    </main>
    <footer class="site-footer">
      <p>Lumen Bikes · Ardenvale</p>
      <!-- Phase 7: the motion setting -->
      <fieldset id="motion-toggle" class="motion-toggle">
        <legend>Motion</legend>
        <label><input type="radio" name="motion" value="system" checked /> Follow my device</label>
        <label><input type="radio" name="motion" value="reduced" /> Reduced</label>
        <label><input type="radio" name="motion" value="full" /> Full</label>
      </fieldset>
      <!-- end Phase 7 -->
    </footer>
    <!-- Phase 4: where toasts appear (a live region: screen readers read what is added to it) -->
    <div id="toasts" role="status" aria-live="polite"></div>
    <!-- end Phase 4 -->
    <!-- Phase 9: the cart drawer (the scrim is the dimmed page behind it) -->
    <div id="cart-scrim" class="drawer-scrim"></div>
    <aside id="cart-drawer" class="drawer" role="dialog" aria-modal="true" aria-labelledby="cart-title" data-state="closed">
      <div class="drawer-head">
        <h2 id="cart-title">Your cart</h2>
        <button class="drawer-close" type="button" aria-label="Close cart">×</button>
      </div>
      <p id="cart-summary" aria-live="polite">Your cart is empty.</p>
      <ul class="drawer-list" id="cart-list"></ul>
      <button class="button primary" type="button">Check out</button>
    </aside>
    <!-- end Phase 9 -->
    <script type="module" src="/src/js/main.js"></script>
    <!-- Phase 15: the React configurator -->
    <script type="module" src="/src/react/main.jsx"></script>
    <!-- end Phase 15 -->
  </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

Put dist/ back in the wasm import

In mascot.js change the import to @lottiefiles/dotlottie-web/dist/dotlottie-player.wasm?url and run npm run build.

terminal
$ npm run build
── what you'll see ──
"./dist/dotlottie-player.wasm" is not exported under the conditions ["module", "browser", "production", "import"] from package @lottiefiles/dotlottie-web
Real text from Vite 8.3.5 (the full path of the package follows).

Myth vs fact

Myth

If every phase works alone, the whole works.

Fact

Phases built on old copies of shared files overwrite each other. Only assembling and running the whole shows it.

Remember this

  1. 1

    The problem this solves: Tomás's yes or no needs one project, not nineteen good pieces. Each phase edited its own copy of index.html, main.js and components.css, so a later copy had lost what an earlier one added. The marked blocks (Phase N) are what let you merge them: keep every block, the newest version of each.

  2. 2

    What the assembly found, by building it. Phase 17's shared files already carry Phases 1 to 13 and 17. Phases 14, 15 and 16 were built on older copies, so the battery story, launch timeline, configurator and mascot (markup, CSS, wiring) were missing: the build adds them. Three clashes are fixed in the phases: Phase 12 scopes its reveal to .feature.reveal (Phase 5 uses .grid.reveal), Phase 13's keyframe is logo-fade, and the hero timeline moves .hero-art, because Phase 6's drift moves the SVG inside it and would cancel the timeline. mascot.js needs src/assets/mascot/headlight.lottie, not among the files below: you saved it in Topic 16.6 (https://hectal.in/data/anim/headlight.lottie).

  3. 3

    What it adds. The three merged shared files, tests/launch-audit.js and tests/audit.spec.js (Topic 19.5) and the README. npm test gives 8 passed.

  4. 4

    What the launch site can do now that it could not before: it exists as one project that builds, answers yes or no with an audit, and comes with its numbers. What is still missing: real phones and screen readers (the checks used Chromium and the accessibility tree), and field data. Topic 18.5 shows how to collect it after launch.

Explain it without notes

01

How would you present this project in an interview in two minutes?

02

What would you add before real users arrive?

Practice

01

Run the audit with the CPU throttled and say what could change.

02

Add one more declared gesture to tests/audit.spec.js.

Done when you can

  • Your project builds and every marked block is present

  • You ran the audit on the built site, normally and with reduced motion

  • Your README has measured numbers and its limits

  • You can present the project in two minutes