Topic 10.6
Build: A Themed, Responsive Dashboard
In one line
Move every chart colour into theme.css (light and dark), use a colour-blind-safe palette that passes contrast, write titles that state the finding, add a theme button, and make every chart redraw at its real width with fewer ticks when narrow.
Before you start
You will learn
- Add
theme.cssandcharts.cssand import them inmain.js - Make each chart redraw at its width with
onResize, and choose ticks and labels bysizeClass - Add the theme button and check the dashboard in light, dark, narrow and wide
You write code in
In your Vite project lumen-dashboard/ (run npm run dev), and in the editor on this page
Files you touch
- src/styles/theme.css
- src/styles/charts.css
- src/utils/resize.js
- src/parts/legend.js
- src/parts/tooltip.js
- src/charts/rides-line.js
- src/charts/stations-bar.js
- src/charts/stations-stacked.js
- src/charts/june-heatmap.js
- src/main.js
- index.html
Think of it like this
Repainting a shop. Before, each wall had its own tin of paint with a hand-written label, and changing the colour scheme meant opening every tin. Now there is one colour chart on the office wall (the theme file), every wall is painted from it, and a night-time scheme is one more page of the same chart.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- token
- A named design value in CSS, such as
--chart-ink, used by every chart instead of a typed colour. - theme
- One set of token values: light or dark.
- size class
- small, medium or large, chosen from the width by
sizeClass. - onResize
- The helper in
resize.jsthat redraws a chart once at the start and once after a resize settles.
Step by step
01Start from the Phase 9 project
You have lumen-dashboard/ with the Phase 9 files and npm run dev running. The charts have hard-coded colours such as #2a9d8f and fixed widths.
02Add the two style files and the resize helper
Create src/styles/theme.css, src/styles/charts.css and src/utils/resize.js from the files below. Nothing changes on screen yet, because no file imports them.
03Replace the chart files, the legend and main.js
Replace src/parts/legend.js, src/parts/tooltip.js, the four chart files and src/main.js. (src/utils/motion.js and src/parts/axis.js do not change.) main.js imports the two CSS files, so Vite now loads them. Replace index.html too: it has the theme button and no inline <style>.
04Look at it four ways
Press the theme button three times: auto, light, dark. Open the browser's device toolbar and try 320 pixels wide: the month labels thin out and the tick numbers shorten. Drag the window edge: the charts redraw once when you stop.
05Break it on purpose
In theme.css rename --chart-accent in the dark block to --chart-acent. Nothing turns black here, and no error appears: in dark mode the line, the dots and the bars quietly keep the light theme's dark teal, which is dim on the dark card (the Break it below measures it). Rename it back.
Try it yourself
- 1
Add a third theme
In
theme.cssadd a:root[data-theme="contrast"]block with black--chart-bg, white--chart-inkand a bright--chart-accent, and add"contrast"toMODESinmain.js. The button now cycles four ways and no chart file changed. - 2
Change the breakpoint
In
resize.jschange480to640. A 600-pixel box now draws the small layout: the rides line has four month labels (Jan, Apr, Jul, Oct) and number labels like5k, the stack has six month labels, and the heatmap has three hour labels (0, 12, 23); measured in the preview. One number moved every chart.
Code & diagrams
The preview cannot import your local files, so this example pastes resize.js, motion.js, tooltip.js, axis.js, legend.js, the four charts and main.js into one module (the loaders are one-line stand-ins for load.js and format.js), and the page body and both style files are the example's HTML and CSS. In your project they are separate files, and index.html and the CSS are loaded as files. In the picture: a header with a bike icon and a Theme button, the five key numbers, a week of bars, a line of the year with the title "Rides peaked at 4,380 on 18 Jul 2025 and fell to 439 on 14 Aug 2025" and the outage dot with its label, the June / Summer / Whole year buttons under it (hover, brush and the station bars still follow the selected dates), the station bars with a month menu, the stacked months by district with six distinct colours (blue, orange, green, pink, red, light blue) and a legend, and the June heatmap in viridis: dark purple for quiet hours, green and yellow for the weekday rush hours, and pale grey cells for hours with no rides.
Preview
Console
Nothing logged yet.Expected console output
Loaded 365 days, 0 cleaning notesRead back from the real page: with the saved choice dark, the card is dark, the text is light, and the line and the stack use the dark values. The button then cycles to auto and removes the attribute, so the system's setting decides. The preview's system setting is light, so after that click the page returns to the light tokens, and the picture (taken at the end) shows the light page.
Preview
Console
Nothing logged yet.Expected console output
Loaded 365 days, 0 cleaning notes
theme attribute: dark | button: Theme: dark
card background: rgb(22, 34, 43) | page text colour: rgb(230, 237, 242)
rides line stroke: rgb(79, 209, 192)
first stacked series fill: rgb(0, 114, 178)
rides line title: Rides peaked at 4,380 on 18 Jul 2025 and fell to 439 on 14 Aug 2025
after one click: (no attribute) | button: Theme: autoYour project files
Your lumen-dashboard 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.
/* Every colour a chart uses lives here, once, for both themes. Charts read these names and never hold a colour of their own. */
:root {
color-scheme: light;
--page-bg: #f6f7f9;
--chart-bg: #ffffff;
--chart-soft: #eef2f6;
--chart-ink: #1d2b36;
--chart-muted: #5b6b78;
--chart-grid: #dde3e8;
--chart-accent: #1f7a6f;
--chart-alert: #c2410c;
--chart-error: #7a1f16;
--chart-error-bg: #fdeceb;
/* six series colours for the six districts: at least 3:1 against both card colours, and apart under four kinds of colour vision */
--chart-s1: #0072b2;
--chart-s2: #d55e00;
--chart-s3: #009e73;
--chart-s4: #cc79a7;
--chart-s5: #f0375d;
--chart-s6: #0096ff;
}
/* Path 1: the visitor's system is set to dark and has not picked a theme on this page */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--page-bg: #0f1a21;
--chart-bg: #16222b;
--chart-soft: #1e2d38;
--chart-ink: #e6edf2;
--chart-muted: #9fb0bd;
--chart-grid: #2c3b47;
--chart-accent: #4fd1c0;
--chart-alert: #ff8a5c;
--chart-error: #ffb4a8;
--chart-error-bg: #3a1d1b;
}
}
/* Path 2: the visitor picked dark with the button (main.js sets data-theme). The same values again: CSS cannot share them between the two rules. */
:root[data-theme="dark"] {
color-scheme: dark;
--page-bg: #0f1a21;
--chart-bg: #16222b;
--chart-soft: #1e2d38;
--chart-ink: #e6edf2;
--chart-muted: #9fb0bd;
--chart-grid: #2c3b47;
--chart-accent: #4fd1c0;
--chart-alert: #ff8a5c;
--chart-error: #ffb4a8;
--chart-error-bg: #3a1d1b;
}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
Misspell a token in the dark block
In theme.css, in the :root[data-theme="dark"] block, change --chart-accent: #4fd1c0; to --chart-acent: #4fd1c0;. Press the theme button until it says dark.
Myth vs fact
Myth
Responsive means the page shrinks.
Fact
A responsive chart makes decisions at each size: which ticks to draw, which labels to keep, which text to shorten. The build does that in sizeClass.
Myth
Dark mode is a stylesheet someone adds at the end.
Fact
It works only if every colour was a token from the start. This build is where that happens for the dashboard, and each later chart will begin with tokens.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
The two dark blocks in
theme.cssrepeat the same values because CSS cannot share declarations between a media query with:not([data-theme="light"])and an attribute selector. A build step (a CSS preprocessor, or a small script that writes both) removes the repetition. Keeping them adjacent and tested is the cheaper fix. - ▸
The district colours are tokens that D3 receives as the string
var(--chart-s1). D3 never reads them: they go straight intostyle("fill", ...), which the browser resolves. A feature that needs the real colour (a canvas, an exported PNG) must read the token withgetComputedStyle(Topic 10.4).
Remember this
- 1
Priya's need is met. On the wall screen the labels are 12 pixels and real, because the charts redraw at their width instead of being stretched. In dark mode the lines and bars keep their contrast, because every colour comes from a token with a dark value that was checked (Topic 10.2). And the rider with deutan vision can tell all six districts apart: the closest pair is 18.0 apart under simulation, and the stack also has a legend with names.
- 2
What it adds.
src/styles/theme.cssholds the tokens for light, for the system's dark preference and for the button's dark choice.src/styles/charts.cssstyles the page and every chart from those tokens.src/utils/resize.jshasonResizeandsizeClass. Each chart file now returns{ update, resize }(the rides line also keepssetRangeandready), draws with CSS classes instead of hard-coded colours, and chooses ticks bysizeClass.src/parts/tooltip.jstakes its colours from the tokens too.main.jsadds the theme button, a finding title for the year's line, and oneonResizeper chart. - 3
How it plugs into what exists. It starts from the Phase 9 dashboard and keeps everything it had: the crosshair tooltip, the brush and its preset buttons, the stations chart that follows the selected dates, the keyed transitions and the reduced-motion rule, and the chart plan table. Only colours, widths and the page file change. A chart caller sees that
update(rows)still works andresize(width)is new. When the brush has a range and the width changes, the brush is put back at the same dates. - 4
What the dashboard can do now that it could not before: switch between light, dark and system themes without redrawing; keep text at a readable size at 320 to 1920 pixels wide; show a colour-blind-checked palette on both backgrounds; state the year's finding in the title; and survive a resize storm with two redraws instead of twenty. Still missing: the charts have no keyboard or screen-reader reading order beyond their summaries (Phase 15), a data table for each chart (Phase 15), and the station map (Phase 13). Tooltips, brush and motion already exist (Phases 8 and 9).
- 5
Starting point. The Phase 9 dashboard: Phase 4's loader and states, Phase 5's station bars with the month menu, Phase 6's rides line with D3 axes, Phase 7's stacked months and June heatmap with their legends, Phase 8's crosshair, brush, presets and linked bars, and Phase 9's motion. The files below were rebuilt from those versions.
- 6
The checks inside the build. Every chart reads its colours from CSS, so a typo in a token shows as a wrong colour in one theme: a black shape where no token is defined anywhere, a dim light-theme colour where only the dark block lost its copy. Test both themes. The week's bars and the low-stations picture are still fixed hand-drawn SVGs (they scale with their box and do not redraw), and the June heatmap's darkest cells sink into the dark card (Topic 10.4). Charts redraw only when the width really changes, a hidden box (width 0) is skipped, and
sizeClasstreats an unknown width as small.
Explain it without notes
How does the dashboard switch between light and dark without redrawing any chart?
How does a chart choose fewer ticks on a phone, and where is the breakpoint stored?
Why does each chart now return resize as well as update?
Practice
Add a --chart-warn token (amber in light, lighter amber in dark) and use it for the outage annotation in rides-line.js instead of --chart-alert. Which two files change?
Make main.js store the theme choice with localStorage only when storage is available. Which line already does that, and what happens in a private window?
Trade-offs
- ↔
Redrawing on every width change is simple and keeps text sharp. A chart with 100,000 marks cannot do that, and Phase 12 moves such a chart to canvas.
Done when you can
My project has
theme.css,charts.cssandresize.js, andmain.jsimports both CSS files.The theme button cycles auto, light and dark, and the charts change with it.
At 320 pixels the rides line, the station bars, the stack and the heatmap show fewer ticks and labels, and their text is still 12 pixels (the two hand-drawn pictures scale instead).
I can say what the dashboard still lacks and which phase adds it.