Command Palette

Search for a command to run...

Hectal
PHASE 17Advanced ~37 min· topic 6 of 6

Topic 17.6

Build: Ardenvale's Year on Two Wheels

In one line

Add a second page to the project: a scrolling story built from the same Lumen data, with the seasons, the August outage and the rise of e-bikes, a list version, reduced motion, and the numbers in the text.

Before you start

You will learn

  • Add story.html as a second page, next to the dashboard, with its own code and no React
  • Reuse src/data/load.js so the story and the dashboard read the data the same way
  • Tell Vite to build both pages, and check the build contains both

You write code in

In your Vite project lumen-dashboard/ (run npm run dev), and in the editor on this page

Files you touch

  • story.html
  • src/story/steps.js
  • src/story/main.js
  • vite.config.js

Think of it like this

A newspaper has a front page and a long feature. They come from the same newsroom and the same archive, but a reader opens the feature on purpose and reads it from top to bottom. The dashboard is the front page of tools; the story is the feature.

Words you'll meet

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

entry point
A page Vite starts from. Each entry (index.html, story.html) becomes its own page in the build.
multi-page build
A Vite build with more than one entry, set with build.rollupOptions.input.
progressive enhancement
The story's text works alone. The scrolling chart is added on top where the browser can do it.
deep link
story.html#outage opens the page at the outage step.
status line
A line of text that says what is happening: loading, failed, or nothing when all is well.

Step by step

01Put the text in the HTML

Create story.html in the project root, next to index.html. It holds five section elements, each with a heading that states a finding and one short paragraph with the numbers. A visitor whose JavaScript fails still reads the whole story.

The sticky figure is marked aria-hidden="true", because the paragraphs say everything the chart shows (Topic 17.5). A data table by month sits in a details element at the end, and a skip link at the top jumps to it.

02Add the chart module

Create src/story/steps.js. makeChart(svgNode, daily) draws the chart and returns show(stateName, ms). It computes its own marks from the rows: the busiest day, 14 August, the first and last week's e-bike share. If the outage day is missing from the data, that mark is skipped and the others still work.

03Add the page logic

Create src/story/main.js. It loads the rows with loadDaily(), falls back to messages on failure, and then picks a mode: the scrolling story when IntersectionObserver exists, the list when it does not or when the URL has ?list. A button switches between them.

The scrolling mode keeps the set of steps in the middle band and activates the one closest to the middle of the window. It updates the address with history.replaceState, so the link names the step, and it reads prefers-reduced-motion every time it changes the chart.

04Tell Vite about the second page

Open vite.config.js from Phase 14 and add a build.rollupOptions.input object with both pages. Keep the React plugin: the dashboard still needs it. Without this, npm run dev shows the story and npm run build quietly leaves it out.

vite.config.jswhole filejs
build: {
  rollupOptions: {
    input: {
      dashboard: fileURLToPath(new URL("./index.html", import.meta.url)),
      story: fileURLToPath(new URL("./story.html", import.meta.url)),
    },
  },
},

05Build and look

Run npm run build. The output lists both pages. This was captured in a scratch project with Vite 8.3.4 and a one-line stand-in for the dashboard, so your sizes will differ; what to check is that dist/story.html exists. Then run npm run preview and open /story.html.

terminal
$ npm run build
── expected output ──
vite v8.3.4 building client environment for production...
transforming...
✓ 587 modules transformed.
rendering chunks...
computing gzip size...
dist/index.html 0.31 kB │ gzip: 0.23 kB
dist/story.html 4.23 kB │ gzip: 1.85 kB
dist/assets/modulepreload-polyfill-P2Xu9kJm.js 0.67 kB │ gzip: 0.38 kB
dist/assets/story-DDGhBmx9.js 80.22 kB │ gzip: 28.02 kB
dist/assets/dashboard-Bzrv_QOi.js 218.86 kB │ gzip: 68.32 kB
 
✓ built in 655ms
Captured on 16 October 2026. The story's script is 80 kB because it imports all of D3; Topic 16.5 shows how to import only the modules you use.

06Check it works for everyone

Open the page in a window 390 px wide: the chart sits at the top and the text slides under it. Turn on your system's reduced motion setting and scroll: the chart changes without moving. Press the "Show as a list" button: five charts, one per step. Open /story.html?list to start in that mode. Delete the data file for a minute: the status line explains and the text is still there.

Try it yourself

  1. 1

    Start in the list

    Open http://localhost:5173/story.html?list (Vite's default port). The page starts in list mode, with five charts, and the button reads "Show as a scrolling story". The step text is the same either way.

  2. 2

    Open a step directly

    Open story.html#outage. The browser jumps to the outage step, main.js finds that hash, and the first chart it draws is already the outage state, so there is no blank frame and no morph from the year. This was run in a built page: the status line stays empty and the active step is outage.

Code & diagrams

Without the input setting, the build forgets the storybash
# vite.config.js has no build.rollupOptions.input: only index.html is built
$ npm run build
dist/index.html                  0.22 kB
dist/assets/index-BqZBrFiv.js  219.49 kB

$ ls dist
assets  data  index.html          <- no story.html: /story.html is a 404 once deployed
The story page, all modules in one script New tab

The project's files import each other, and the preview cannot run local imports, so this example puts `steps.js` and `main.js` into one script. `loadDaily` is a short stand-in that reads the same CSV (the real one in `src/data/load.js` also cleans the rows). The last lines drive the page: they scroll to each step and report the active one.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

status text after loading: ""
scrolled to year -> active step: year
scrolled to seasons -> active step: seasons
scrolled to peak -> active step: peak
scrolled to outage -> active step: outage
scrolled to ebikes -> active step: ebikes
scrolled to seasons -> active step: seasons
table rows: 12
list mode: charts drawn: 5 | sticky chart hidden: true

Your project files

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

story.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">
  <title>Ardenvale's year on two wheels</title>
  <style>
    :root { --ink: #1d2b32; --muted: #5b6a72; --bg: #ffffff; --card: #f3f6f7; --accent: #e76f51; --error: #b3361c; }
    @media (prefers-color-scheme: dark) { :root { --ink: #e8eef0; --muted: #a3b1b8; --bg: #12191d; --card: #1b252a; --error: #e76f51; } }
    body { margin: 0; font: 18px/1.5 system-ui, sans-serif; color: var(--ink); background: var(--bg); }
    .skip { position: absolute; left: 8px; top: -48px; background: var(--card); padding: 8px 12px; }
    .skip:focus { top: 8px; }
    header, main, #data { max-width: 62rem; margin: 0 auto; padding: 0 16px; }
    h1 { font-size: 2rem; margin: 1.5rem 0 .25rem; }
    .lede { color: var(--muted); margin: 0 0 1rem; }
    #status { color: var(--muted); min-height: 1.5em; }
    #status.error { color: var(--error); }
    button { font: inherit; padding: 6px 12px; }

    /* Scrolling story: steps on the left, the chart sticks on the right. */
    .layout { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; }
    .figure { position: sticky; top: 16px; align-self: start; margin: 0; color: var(--ink); }
    .figure svg, .list-figure svg { width: 100%; height: auto; display: block; font: 11px system-ui; }
    .step { min-height: 80svh; display: flex; flex-direction: column; justify-content: center; padding: 8px 12px; border-left: 4px solid transparent; opacity: .7; transition: opacity .3s; }
    .step.is-active { opacity: 1; border-left-color: var(--accent); }
    .step h2 { margin: 0 0 .25rem; font-size: 1.4rem; }
    .step p { margin: 0 0 .5rem; }

    /* Phones: the chart takes the top of the screen, the text slides under it. */
    @media (max-width: 700px) {
      .layout { grid-template-columns: 1fr; gap: 0; }
      .figure { order: -1; top: 0; height: 42svh; background: var(--bg); border-bottom: 1px solid var(--card); z-index: 1; }
      .figure svg { height: 100%; }
      .step { min-height: 60svh; }
    }

    /* List mode (no scrolling effect): every step carries its own chart. */
    .list .layout { display: block; }
    .list .figure { display: none; }
    .list .step { min-height: 0; display: block; opacity: 1; border-left-color: transparent; margin-bottom: 1.5rem; }
    .list-figure { margin: .5rem 0 0; color: var(--ink); max-width: 36rem; }

    /* Less motion: no fade between steps either. */
    @media (prefers-reduced-motion: reduce) { .step { transition: none; } }
    #data { margin-top: 2rem; margin-bottom: 3rem; }
    table { border-collapse: collapse; } td, th { padding: 2px 12px; text-align: right; border-bottom: 1px solid var(--card); }
  </style>
</head>
<body>
  <a class="skip" href="#data">Skip the story, go to the data table</a>
  <header>
    <h1>Ardenvale's year on two wheels</h1>
    <p class="lede">What 933,651 bike rides in 2025 tell us about the island.</p>
    <p id="status" role="status"></p>
    <button id="mode" type="button" hidden>Show as a list</button>
  </header>

  <main id="story">
    <div class="layout">
      <div class="steps">
        <section class="step" id="year" data-state="year">
          <h2>One line, 365 days</h2>
          <p>In 2025, Lumen Bikes recorded 933,651 rides from 25 stations. Every wiggle in the line is one day.</p>
        </section>
        <section class="step" id="seasons" data-state="seasons">
          <h2>Summer carries the year</h2>
          <p>A summer day averaged 3,634 rides. A winter day averaged 1,480. Summer days carry about 2.5 times as many rides.</p>
        </section>
        <section class="step" id="peak" data-state="peak">
          <h2>The busiest day</h2>
          <p>Friday 18 July: 4,380 rides, the most of the year.</p>
        </section>
        <section class="step" id="outage" data-state="outage">
          <h2>The day the app went down</h2>
          <p>On Thursday 14 August only 439 people rode. A normal August day had about 3,612. It also rained that day (8.6 mm), but other rainy August days were only about 15% quieter, so rain explains only part of it.</p>
        </section>
        <section class="step" id="ebikes" data-state="ebikes">
          <h2>E-bikes keep climbing</h2>
          <p>In the first week of January, 28% of rides were on e-bikes. In the last week of December, 38%.</p>
        </section>
      </div>
      <figure class="figure" aria-hidden="true">
        <svg id="chart" role="presentation"></svg>
      </figure>
    </div>
  </main>

  <details id="data">
    <summary>Data table: rides and e-bike share by month</summary>
    <table id="months"><thead><tr><th>Month</th><th>Rides</th><th>E-bike share</th></tr></thead><tbody></tbody></table>
  </details>

  <script type="module" src="/src/story/main.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 out the input setting

In vite.config.js, delete the whole build: { ... } block and run npm run build.

terminal
$ ls dist
── what you'll see ──
assets data index.html

Break #2

Let the data fail

In the browser's developer tools, block /data/lumen/daily.csv, or rename the file, and reload story.html.

terminal
$ Page
── what you'll see ──
The chart could not be loaded. The story below still reads on its own. (Could not load /data/lumen/daily.csv (the server answered 404).)

Myth vs fact

Myth

A story page should be another route of the dashboard's React app.

Fact

It can be, and the shared data code is the part worth sharing. A separate page with plain JavaScript loads less, has no framework between the observer and the chart, and keeps the public page working if the dashboard breaks.

Myth

If npm run dev shows the page, the build has it.

Fact

The dev server serves any file in the project. The build only includes the pages in rollupOptions.input.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The page keeps the text in HTML and the behaviour in JavaScript, so search engines, link previews, readers with scripting off and screen readers all see the story. A story that renders its text from JavaScript loses all of them.

  • ▸

    Inactive steps are dimmed with opacity: .7, not lower. Measured: dark text on white at 55 % opacity has a contrast of 3.5 to 1, below the 4.5 to 1 that WCAG 2.2 level AA asks of body text; at 70 % it is 5.5 to 1. The error message uses its own darker red in light mode (6.1 to 1 on white; the bright accent red is 3.1 to 1). Dimming is a design choice that quietly becomes an accessibility failure, so measure it.

  • ▸

    history.replaceState runs on every step change. In a sandboxed frame it can throw a SecurityError, which is why the call sits in a try. Using pushState instead would put every step in the back button's history, and a reader would have to press back five times to leave.

  • ▸

    The story's chunk is 80 kB because import * as d3 from "d3" pulls in all of D3. The page needs d3-array, d3-scale, d3-shape, d3-axis, d3-selection, d3-transition and d3-format: importing those alone (Topic 16.5) cuts the script, which matters for a page aimed at phones.

Remember this

  1. 1

    The problem this solves: Lina's request is met here. The city's website needs one page a visitor can scroll through in a minute and understand: summer is about 2.5 times winter, the app went down on 14 August, and e-bikes went from 28 % to 38 % of rides. The dashboard cannot do that. This page can, and it does not touch the dashboard's code.

  2. 2

    What it adds. Three files, plus one changed. story.html holds the page and, importantly, the text of every step, so the story reads with no JavaScript at all. src/story/steps.js draws the chart and can show five named states, the code of Topic 17.4. src/story/main.js loads the data with loadDaily() from src/data/load.js, then runs either the scrolling story or the list. vite.config.js lists both pages.

  3. 3

    How it plugs in. The story page is a second entry point. It is plain JavaScript and D3, so it does not load React, and the dashboard (React since Phase 14) does not load the story. They share src/data/load.js and its cleaning in src/data/clean.js and src/utils/config.js, so a change of DATA_URL (the S3 line of Phase 4.6) moves both pages. In development, Vite serves /story.html at once. In a production build, Vite only builds pages it is told about, which is the one change to vite.config.js.

  4. 4

    What every earlier topic contributes. The finding comes first (17.1): each step's heading states it. Labels sit beside their marks and flip at the edge (17.2). A sticky chart and a band observer find the active step (17.3). Each step is a full state, back-scroll and links work, and the closest step to the middle wins (17.4). Reduced motion sets durations to 0, the page works on a 390 px phone, a list version exists, and the steps' text carries every number while the sticky chart is hidden from screen readers (17.5).

  5. 5

    Failure states are on screen. While the data loads, the page says so. If the file is missing, the status line says The chart could not be loaded. The story below still reads on its own. with the loader's own message, and the text is still there. If the file has no usable rows, it says so. A day with a missing number is left out, and a mark whose day is missing (the outage day) is not drawn.

  6. 6

    What the dashboard can do now that it could not before: the team can send a public visitor a link that makes three points in a minute, and share a step (story.html#outage). What is still missing: Phase 18 gathers every part into the finished dashboard, publishes it, and prepares you for interviews about all of this. The story page is not linked from the dashboard yet, and it is not yet on a real host (Phase 18.5 does that).

Explain it without notes

01

Why does the story page keep each step's text in the HTML instead of writing it from JavaScript?

02

Why can a page work under npm run dev and be missing from npm run build?

03

How does the page behave when the data cannot be loaded, and why?

Practice

01

Add a sixth step to the story: "Rain" with a state that shows the 108 rainy days. Say which file you change for the text, which for the state and what else must change for the page's accessibility.

02

Write a check script for deployment, check-dist.mjs, that fails when dist/story.html or dist/index.html is missing.

Trade-offs

  • ↔

    A separate vanilla page is light and independent, and it duplicates the data-loading path unless both pages import the same load.js, as here.

  • ↔

    Putting the text in HTML means a copy edit is an HTML edit, while the numbers in it can drift from the data. Check them against the data in a test (Topic 16.7).

Done when you can

  • I added story.html with the step text in the HTML.

  • I added the chart module and the page logic, and reused loadDaily().

  • I listed both pages in vite.config.js and found story.html in dist.

  • I checked the page on a 390 px window, with reduced motion, in list mode, and with the data blocked.

  • I can say what the dashboard can do now and what Phase 18 adds.