Command Palette

Search for a command to run...

Hectal
Shots in this scene
SHOT 18.6Advanced ~24 min · shot 6 of 6

Build: The Tokens File and the Test Suite

In one line

Finish tokens.css with purpose and spring tokens and a JavaScript mirror, then add a Vitest suite (fake timers) and a Playwright suite (seeking, reduced motion, a token change, a snapshot) that check the toast, the drawer and reduced motion. The real output of both runs is below.

0/6 · 0%

Before you start

You will learn

  • Add purpose and spring tokens to tokens.css and the mirror tokens.js
  • Add tests/motion.test.js (Vitest) and tests/launch.spec.js (Playwright) and the two configs
  • Run both suites, accept the first snapshot, and break each check on purpose to see it fail

You write code in

In your lumen-motion project (npm run dev, npm test, npm run test:e2e) and in the editor on this page: add five files, change two

Files you touch

  • src/styles/tokens.css
  • src/js/motion/tokens.js
  • vitest.config.js
  • playwright.config.js
  • package.json
  • tests/motion.test.js
  • tests/launch.spec.js

Think of it like this

The shopkeeper who writes the opening checklist on the shop door: lights on, till counted, sign turned. Anyone can run it, on any morning, and a missed step shows. Tests are the opening checklist for the site's motion.

Words you'll meet

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

test suite
A set of automatic checks you run with one command.
baseline
The saved picture a snapshot test compares against. The first run writes it.
webServer
A Playwright setting that starts your dev server before the tests and stops it after.
pinned version
An exact version written in package.json (5.0.5, not ^5), so every machine installs the same code.

Step by step

01Install the test tools

Run the install of Topic 18.4 in your project. Then add the two scripts to package.json (the file below has them): test runs vitest run, test:e2e runs playwright test.

02Finish the tokens

Replace tokens.css with the file below (every earlier token stays, the new block is marked Phase 18) and add src/js/motion/tokens.js. Your index.html already links tokens.css, so no other file changes.

03Add the configs and the tests

Create vitest.config.js (happy-dom, tests under tests/), playwright.config.js (the dev server and baseURL), tests/motion.test.js and tests/launch.spec.js.

04Run the logic tests

npm test runs the eight Vitest tests.

terminal
$ npm test
── expected output ──
RUN v5.0.5
✓ tests/motion.test.js > tokens > the JavaScript mirror reads the CSS 15ms
✓ tests/motion.test.js > toast timing > it leaves after its duration, not before 3ms
✓ tests/motion.test.js > drawer > it starts closed, opens with a spring, and closes with Escape 10ms
... (5 more passing lines)
Test Files 1 passed (1)
Tests 8 passed (8)
Duration 850ms
Run with the verbose reporter on 2026-10-23 (Windows, Node 24.14.1). Times vary; `Start at` and five passing lines are left out.

05Run the browser tests, twice

The first run has no baseline for the snapshot, so that one test fails and writes the picture. Open tests/launch.spec.js-snapshots/toast-win32.png: it is a strip of the #toasts element (1280 px wide and 41 px tall in our run) with the dark "Lock added to cart" toast in the middle and a little of the page behind it. Commit it and run again.

terminal
$ npm run test:e2e
npm run test:e2e
── expected output ──
Running 6 tests using 1 worker
 
ok 1 tests\launch.spec.js:7:1 › the toast arrives: seek its animation instead of waiting (381ms)
...
ok 5 tests\launch.spec.js:52:1 › a token change reaches the toast (221ms)
x 6 tests\launch.spec.js:60:1 › the toast looks right once it has arrived (503ms)
Error: A snapshot doesn't exist at ...\tests\launch.spec.js-snapshots\toast-win32.png, writing actual.
1 failed
5 passed (5.5s)
 
--- second run ---
ok 6 tests\launch.spec.js:60:1 › the toast looks right once it has arrived (335ms)
6 passed (5.5s)
Two real runs joined, with the other lines of the second run left out. Paths use `\` because this ran on Windows; yours may differ. The snapshot's file name has your platform in it.

Try it yourself

  1. 1

    Slow the toast with a token

    Press --dur-base: 440ms and then Show a toast. The toast's WAAPI track is 440 ms long. Press Reset: the next toast is 220 ms again. The Playwright test "a token change reaches the toast" does the same with 900 ms.

  2. 2

    Run one test

    In your project run npx vitest run -t "fourth toast" to run a single test by name, and npx playwright test -g "reduced motion" to run the two reduced-motion tests only.

Code & diagrams

The tokens and their checks, in the page

`tokens.css` and `tokens.js` inlined (the preview cannot import project files), four of the unit checks, and a toast whose duration and curve come from the tokens. **--dur-base: 440ms** changes the next toast. Vitest and Playwright cannot run here; their real output is above.

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

ok   the mirror reads the CSS: --dur-base is 220 ms
ok   a purpose token resolves through var()
ok   a missing token throws, it does not return NaN
ok   a spring token becomes spring() options: damping 33.92
toast lasts 220 ms
after --dur-base: 440ms, the next toast lasts 440 ms

Your project files

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

src/styles/tokens.csscss

The whole file after this topic. If yours differs, you can copy this one and carry on.

/* tokens.css: Lumen's motion tokens. One place for durations and easings, so every component speaks the same language.
   Phase 2 started this file and Phase 18 finishes it. Never rename a token: components depend on the names. */
:root {
  /* durations (the Nielsen Norman Group ladder, Topic 2.4) */
  --dur-fast: 120ms; /* feedback: a press, a hover colour */
  --dur-base: 220ms; /* most things: toasts, panels, dialogs entering */
  --dur-slow: 400ms; /* large movement: a drawer across the screen */

  /* easings (Topic 5.3 explains the choices) */
  --ease-out: cubic-bezier(0.22, 1, 0.36, 1); /* things arriving */
  --ease-in: cubic-bezier(0.55, 0, 1, 0.45); /* things leaving */
  --ease-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* things moving between two places on screen */
  /* ── Phase 5: choreography ── */
  --stagger-step: 60ms; /* gap between neighbours in a reveal; main.js caps how many steps count */
  /* ── end Phase 5 ── */

  /* ── Phase 18: purpose names (components ask for the purpose, not for a number) ── */
  --motion-enter-duration: var(--dur-base); /* something appears */
  --motion-enter-ease: var(--ease-out);
  --motion-exit-duration: var(--dur-fast); /* something leaves: quicker than it came */
  --motion-exit-ease: var(--ease-in);
  --motion-emphasis-duration: var(--dur-slow); /* a big, rare move: the drawer, a page change */
  --motion-emphasis-ease: var(--ease-in-out);
  /* springs: "stiffness damping-ratio" (mass is always 1). CSS cannot run a spring: tokens.js reads these for spring.js.
     The shape follows Material 3's spring tokens (stiffness plus a damping ratio, spatial and effects); the numbers are Lumen's own. */
  --spring-spatial-fast: 700 0.9; /* small things that move: a switch, a chip */
  --spring-spatial: 380 0.87; /* the drawer (Phase 9): a calm, tiny overshoot */
  --spring-spatial-slow: 200 0.9; /* large things that move */
  --spring-effects: 1600 1; /* opacity and colour: never overshoot (a ratio of 1 does not) */
  /* ── end Phase 18 ── */
}

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

Write a token without its unit

In tokens.css change --dur-base: 220ms; to --dur-base: 220; and run both suites.

terminal
$ npm test
npm run test:e2e
── what you'll see ──
× the JavaScript mirror reads the CSS
AssertionError: expected '220' to be '220ms'
 
x 1 the toast arrives: seek its animation instead of waiting
Expected: 0
Received: 1
x 6 the toast looks right once it has arrived
79 pixels (ratio 0.01 of all image pixels) are different.
2 failed
4 passed

Break #2

Lose the reduced-motion rule

In components.css change the toast's @media (prefers-reduced-motion: reduce) to no-preference (the rule under "reduced motion: a fade instead of a slide").

terminal
$ npm run test:e2e -- -g "toast fades"
── what you'll see ──
x 1 reduced motion › the toast fades and does not travel
- Expected - 1
+ Received + 1
Array [
- "toast-fade-in",
+ "toast-in",
]

Myth vs fact

Myth

Visual snapshots make animation tests unnecessary.

Fact

A picture of the final state says nothing about the animation, reduced motion or cleanup. Use snapshots for how it looks, and seeks and counts for how it behaves.

Pro corner

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

  • ▸

    The unit suite stubs nothing about animation: it relies on toast.js removing the toast at once when getAnimations() is empty, as it is in happy-dom. If that fallback were removed, the test would need a stub for getAnimations.

  • ▸

    playwright.config.js has reuseExistingServer: true, so a dev server you already started is used. In CI, set it to false so a stale server never answers.

Remember this

  1. 1

    Why the site needs this now: this closes Kofi's chapter. The contractors will edit the toast and the drawer, Lena wants a failing check instead of a complaint, and Tomás wants to know before customers do. Nothing yet tells the team when motion changes by accident.

  2. 2

    What it adds. tokens.css keeps every earlier token and gains the purpose and spring tokens; src/js/motion/tokens.js exports token(), ms() and springOf(). index.html already links tokens.css (Phase 2), so nothing else loads it. The two configs are new and package.json gains the scripts and pinned dev dependencies (it also lists the packages of Phases 14 to 16). One honest gap: the purpose tokens are in place but Lumen's components still ask for primitives, such as var(--dur-base, 220ms). Moving each rule over to --motion-enter-* is the first pull request after this phase; until then a change to --dur-base still reaches the toast, but a change to --motion-enter-duration does not.

  3. 3

    The Vitest suite (tests/motion.test.js, 8 tests, happy-dom, fake timers) checks the tokens (the mirror reads the CSS, a wrong token throws, every var() names a token that exists), the toast (there at 2,599 ms, gone at 2,600 ms; a fourth pushes out the oldest), the drawer (starts closed, opens with the spring, ends at translateX(0px), Escape closes it) and reduced motion (the device setting is followed, the visitor's choice beats it). It checks our logic; it cannot see an animation (Topic 18.4).

  4. 4

    The Playwright suite (tests/launch.spec.js, 6 tests, Chromium, with its own dev server) seeks the toast's real animation to 0, 110 and 220 ms; opens the drawer and polls until the spring settles (a spring is JavaScript, so there is nothing to seek); under reducedMotion: "reduce" checks the toast uses the fade and the drawer needs no spring; changes --dur-base to 900 ms and checks the toast's real duration; and takes a snapshot of the toast.

  5. 5

    What we measured. npm test: 8 passed. npm run test:e2e: the first run passed 5 and failed the snapshot (A snapshot doesn't exist ... writing actual.); the second passed all 6. Then we broke things on purpose (see Break it) and the right check failed.

  6. 6

    What the launch site can do now that it could not before: be edited by someone who was not in the room, with a safety net for the toast, the drawer and reduced motion; read its motion numbers from one place in CSS and JavaScript; and fail a check, not a customer. What is still missing: the interview rounds, the jank hunt and the launch audit (Phase 19). The suites skip the gallery, the page transition and the scroll story; we ran them in Chromium on Windows only (snapshot toast-win32.png), and on no real phone.

Explain it without notes

01

Why does the browser suite poll for the drawer instead of seeking it?

02

Why does the toast's timing test advance 2,599 ms and then 1 ms?

Practice

01

Add a test to tests/motion.test.js that checks every --motion-*-duration token resolves to a number with ms().

02

Say why the Playwright test that changes --dur-base still works, although the toast does not use the purpose tokens.

Done when you can

  • tokens.css has every earlier token plus the purpose and spring tokens, and tokens.js reads them

  • npm test passes 8 tests and npm run test:e2e passes 6, after the first snapshot is committed

  • You broke a token, a media query and a timer on purpose and saw the right check fail

  • You can say which problems the unit suite can catch and which only the browser suite can