# zerolith Full framework. Zero guesswork. Routing, forms, rendering, and i18n, with clear patterns for you and your agent. ## Start ```sh npx @zerolith/cli@alpha new my-app cd my-app npm run dev ``` ## Features - [Fine-grained signals](/guide/signals/): an update reaches only its bindings. - [HTML templates](/guide/templates/): they run in the browser with no compiler. - [A typed router](/guide/routing/): loaders, head tags, and plain anchors. - [Native forms](/guide/forms/): fetch, pending state, and field errors. - [Server rendering and hydration](/guide/server-rendering/): on Request and Response. - [Coded diagnostics](/e/): every error names its cause and fix. - [One command line](/guide/command-line/): start, develop, bundle, and preview. - [Ready for coding agents](/guide/agents/): every new project holds an AGENTS.md and the zerolith skill. ## Packages - [zerolith](/guide/): Signals, html and css templates, rendering, hydration, server rendering, custom elements, and coded diagnostics - [@zerolith/router](/guide/routing/): Route table, loaders, head tags - [@zerolith/forms](/guide/forms/): Fetch, pending state, field errors - [@zerolith/client](/guide/client/): Typed calls from OpenAPI - [@zerolith/i18n](/guide/i18n/): Locales and compiled messages - [@zerolith/a11y](/guide/accessibility/): Focus, announcer, preferences - [@zerolith/otel](/guide/observability/): Spans for navigation and requests - [@zerolith/cli](/guide/command-line/): new, dev, build, preview, doctor, skill - [@zerolith/vite](/guide/vite/): The build passes and hot replacement - [@zerolith/test](/guide/testing/): Mount, settle, hydration checks - [@zerolith/devtools](/guide/dev-tools/): Inspector and Performance tracks ## Characteristics | Characteristic | Value | How it is checked | | --- | --- | --- | | Core size | 9,896 B gzip, against a budget of 10,240 B | `npm run size` bundles and minifies the production build and fails over budget | | Runtime dependencies | 0 | Counted from the manifests of the 9 runtime packages when this site was built | | Documented exports | 225 across 19 modules | The docs build fails on an export without a doc comment | | Diagnostic codes | 33, each with a page | Generated from the runtime's catalog | | Build step | Optional: an import map, or `zerolith build` | The test suite runs the same files unbundled and bundled | | Content Security Policy | `script-src 'self'` with Trusted Types and no `unsafe-eval` | A browser test runs both builds under that policy | | Node | 24 or newer, for the command line and the build tools | `engines` in every package | | License | Apache-2.0 | Every package and the crate | ## Learn - [Tutorial](/learn/): Build a packing list from an empty folder to a deployed app, one idea per step. - [Guide](/guide/): Every concept with its one pattern and an example that runs as a test. - [Playground](/playground/): Edit zerolith in the browser, see it run, and share a link to what you made. - [Reference](/reference/zerolith/): Every export of every package, generated from the source. - [Coding agents](/guide/agents/): What an agent reads in a zerolith app, the AGENTS.md and the zerolith skill, and when. ## Revisions - 0.1.0-alpha.7, 2026-11-01 - 0.1.0-alpha.6, 2026-11-01 - 0.1.0-alpha.5, 2026-10-31 - 0.1.0-alpha.4, 2026-10-30 - 0.1.0-alpha.3, 2026-10-30 - 0.1.0-alpha.2, 2026-10-30 - 0.1.0-alpha.1, 2026-10-29 # Build a packing list In this tutorial you build a packing list: a page where you add the things to take on a trip, check them off as they go in the bag, filter what is left, and find the list still there the next time you open the page. It is small enough to finish in an afternoon and touches every idea a zerolith application is made of. Each step adds one idea and explains why it works the way it does before showing the code. Every file a step shows is run by zerolith's own test suite, so what you type is what was tested. ## What you need - Node 24 or newer. Check with `node --version`. - A terminal and an editor. Any editor works; one that understands TypeScript helps. - No knowledge of zerolith or of signals. If you have used React, Vue, Svelte, or Solid, some ideas will look familiar, and the differences are pointed out as they come up. ## Start a project `zerolith new` writes a starter application. This tutorial uses TypeScript with `html` templates, which run in the browser without a compiler: ```sh npx @zerolith/cli@alpha new packing-list --template ts --yes cd packing-list npm run dev ``` `--template ts` picks the language and `--yes` takes the default for every other question: the zerolith command line as the dev server and build, one bundle for production, the in-page inspector, and the dependencies installed. Open the address `npm run dev` prints, http://127.0.0.1:8080/ unless that port is taken, and you see the starter's home page. Leave the server running; it reloads the page every time you save a file. ## What the starter wrote The starter is a small routed application, and its files are the layout every zerolith application uses: | File | What it owns | | --- | --- | | `index.html` | The page: the page-wide stylesheet, one module script, and one element to mount into | | `src/main.ts` | The entry: waits for the first route, renders the layout, and mounts the inspector | | `src/routes.ts` | The route table, one entry per page | | `src/router.ts` | The router, in its own module so components can import it for links | | `src/components/` | One file per component: the layout, the pages, and the counter | | `src/styles.css` | The page-wide styles and the color variables the components use | | `test/` | A browser test for the counter, run with `npm test` | | `zerolith.json` | Settings the dev server, the build, and the preview share | | `AGENTS.md` | The conventions, written for a coding agent working in the project | | `.agents/skills/`, `.claude/skills/` | The zerolith skill, which coding agents load when a task involves zerolith | Open `src/components/counter.ts`. It is the whole idea of zerolith in a dozen lines: a signal holds a number, the template binds it, and a click sets it. You will build the same way, one step further each page. ## The steps 1. [Add a page](/learn/page/): a component and a route of its own. 2. [State and lists](/learn/state/): the items in a signal, one row per item. 3. [Events and updates](/learn/events/): check items off and add new ones. 4. [Components](/learn/components/): split the page into parts that each run once. 5. [Derived state and conditions](/learn/derived/): progress, filters, and an empty state. 6. [Saving with effects](/learn/effects/): keep the list between visits. 7. [Styles](/learn/styles/): styles that belong to one component. 8. [Testing](/learn/testing/): a test that clicks through the page. 9. [Build and ship](/learn/ship/): the production build and where to put it. > [!TIP] > If you get stuck, every error zerolith reports carries a code and a link to a page that explains the cause and the fix, and the [playground](/playground/) runs small pieces of code without a project. # Add a page A zerolith page is a component, and a component is a plain function that returns a view. There is no class to extend and no special file type: a function, an `html` template, and an export. ## The component Create `src/components/packing.ts`: ```ts // The packing list page. import { type View, html } from "zerolith"; export function Packing(): View { return html`

Packing list

Nothing to pack yet.

`; } ``` `html` is a tagged template. It reads like a string of HTML, but nothing is parsed from strings at run time and nothing can inject markup into it: the browser parses the template once, and the values you put in `${}` slots are bound to exact places in it. The function returns a `View`, which is what `render`, the router, and other templates accept. ## The route Pages appear through the route table in `src/routes.ts`. Import the component next to the other imports: ```ts import { Packing } from "./components/packing.ts"; ``` Then add an entry to the table, next to `home`, `counter`, and `packages`: ```ts packing: route({ path: "/packing", view: () => Packing(), head: { title: "Packing list" } }) ``` The key, `packing`, is the route's name. The path is matched against the URL, the view renders when it matches, and `head` sets the document title while the page is open. ## The link Links are plain anchors. The router handles clicks on links to its own routes, so the page changes without a reload, and every other link works as a link always does. The starter's layout in `src/components/layout.ts` already builds its navigation from the route names, so add one more line to the `nav` there: `${Link({ href: router.href("packing"), label: "Packing", exact: false })}`. `router.href("packing")` builds the URL from the route's name. Since the route table is typed, a misspelled name or a missing parameter is a type error in your editor rather than a broken link in production. Save, click Packing in the header, and the new page appears with its title in the browser tab. ## What you learned - A component is a function that returns a view, written with the `html` tag. - A page is a route: a path, a view, and its head. - Links are anchors, and `router.href` builds them from route names. # State and lists The list needs items, and the items will change: you will add some, check some off, and remove some. In zerolith, a value that changes over time lives in a signal. ## A signal for the items Create `src/store.ts`, a module for the list's state: ```ts // The packing list's state: one signal holding every item. import { signal } from "zerolith"; export interface Item { id: number; name: string; packed: boolean; } export const items = signal([ { id: 1, name: "Passport", packed: true }, { id: 2, name: "Phone charger", packed: false }, { id: 3, name: "Rain jacket", packed: false } ], { name: "items" }); ``` `signal` takes the starting value and returns an object with two methods: `get()` reads the value, and `set()` replaces it. State that more than one component reads lives in `src/store.ts`, the place every zerolith project keeps it, so any component can import it and it outlives a page that is closed and opened again. The second argument names the signal. A named signal keeps its value when you save a file: `zerolith dev` reloads the page and restores every named signal, so the list you were testing with is still there after an edit. ## One row per item Now render the items. Replace `src/components/packing.ts`: ```ts // The packing list page: a row for every item, kept in step with the list. import { type View, computed, each, html } from "zerolith"; import { items } from "../store.ts"; export function Packing(): View { const count = computed(() => items.get().length); return html`

Packing list

${count} items

`; } ``` Three things are new here. `computed` makes a value that follows other signals. `count` reads `items`, so whenever `items` changes, `count` recomputes, and only then. `each` renders a list. It takes the signal holding the array, a key function that says which item is which, and a function that renders one row. When the array changes, `each` compares keys: a row whose item is still there keeps its element, a new key gets a new row, and a missing key loses its row. Nothing else is touched, so a focused checkbox or a half-typed field in a row survives the update. The row function receives the item as a signal, not as a plain object, so that `computed(() => item.get().name)` can follow the item if its name changes later while the row stays in place. > [!NOTE] > If you know React's `items.map()` with a `key`, or Angular's `@for` with `track`, `each` is the same idea, kept current as the list changes. The [templates guide](/guide/templates/#coming-from-react-or-angular) lines up the rest. ## The one rule Look at how the template uses `count`: it puts the signal itself in the slot, `${count}`, not `${count.get()}`. This is the rule to learn first, and it is the only one: > [!IMPORTANT] > **Bind the signal, not its value.** `${count}` hands the template the signal, and the template keeps the text up to date for as long as the page is open. `${count.get()}` reads the number once, while the template is being made, and the page never changes again. Both are valid code, so the runtime cannot warn you; `zerolith doctor` and the Vite plugin report a frozen read like that before the code runs, under the code `template_frozen_read`. There is no re-rendering in zerolith. `Packing()` runs once, when the page opens. After that, a change to `items` updates exactly the text nodes and rows that depend on it. > [!NOTE] > If you come from React, this is the biggest difference: the function is not called again, so it is a fine place to create signals and computed values. ## What you learned - A signal holds a value that changes: `get()` reads it, `set()` replaces it. - `computed` derives a value from other signals and follows them. - `each` renders keyed rows and keeps each row's element while its key stays. - Bind the signal, not its value, and the page stays live. # Events and updates A packing list you cannot check off is a poster. This step adds the two things you do with the list: mark an item packed, and add a new one. ## Changing the state Add two functions to `src/store.ts`: ```ts // The packing list's state and the two ways it changes. import { signal } from "zerolith"; export interface Item { id: number; name: string; packed: boolean; } export const items = signal([ { id: 1, name: "Passport", packed: true }, { id: 2, name: "Phone charger", packed: false }, { id: 3, name: "Rain jacket", packed: false } ], { name: "items" }); export function toggle(id: number): void { items.set(items.get().map((item) => (item.id === id ? { ...item, packed: !item.packed } : item))); } export function add(name: string): void { const id = Math.max(0, ...items.get().map((item) => item.id)) + 1; items.set([...items.get(), { id, name, packed: false }]); } ``` Both functions build a new array and set it, rather than changing the old array in place. A signal notices `set()`, and it compares the new value with the old one: the same array, even with a changed item inside, looks like no change at all. Setting a new array is what tells the page something happened, and `each` then works out which rows actually differ by their keys. `toggle` copies only the item that changes; every other item keeps its object, so its row sees no change. `add` picks the next free id, one more than the largest so far. > [!WARNING] > Changing the array in place does nothing on the page. `items.get().push(item)` never calls `set`, and setting the same array again is ignored as no change, so always set a new array. ## Listening to events Update `src/components/packing.ts`: ```ts // The packing list page: check items off, and add new ones. import { type View, computed, each, html } from "zerolith"; import { add, items, toggle } from "../store.ts"; export function Packing(): View { const count = computed(() => items.get().length); const submitted = (event: SubmitEvent): void => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const name = String(new FormData(form).get("name") ?? "").trim(); if (name !== "") { add(name); form.reset(); } }; return html`

Packing list

${count} items

`; } ``` An attribute that starts with `@` listens to an event: `@change` on the checkbox, `@submit` on the form. The value is the function to call. Two more binding forms appear here: - `.checked=${...}` with a leading dot sets a property on the element instead of an attribute, which is what a checkbox's checked state needs. - A plain attribute, like `name="name"`, is static markup. The form is a real form: it has a label, a required field, and a submit button, so it works with the keyboard and with assistive technology, and pressing Enter submits it. The handler stops the browser's own submission, reads the field through `FormData`, and clears the form after adding. ## What you learned - `@event=${handler}` listens to an event; `.property=${value}` sets a property. - State changes by setting a new value; a signal ignores a value that is the same as the old one. - Native forms and labels give you keyboard and screen reader support for free. # Components The page now does two jobs in one function: it renders rows and it handles a form. As it grows, splitting it keeps each part readable. A component is a function, so splitting is moving code into another function. First add a `remove` function next to `toggle` and `add`, so a row can delete its item: ```ts // The packing list's state and every way it changes. import { signal } from "zerolith"; export interface Item { id: number; name: string; packed: boolean; } export const items = signal([ { id: 1, name: "Passport", packed: true }, { id: 2, name: "Phone charger", packed: false }, { id: 3, name: "Rain jacket", packed: false } ], { name: "items" }); export function toggle(id: number): void { items.set(items.get().map((item) => (item.id === id ? { ...item, packed: !item.packed } : item))); } export function add(name: string): void { const id = Math.max(0, ...items.get().map((item) => item.id)) + 1; items.set([...items.get(), { id, name, packed: false }]); } export function remove(id: number): void { items.set(items.get().filter((item) => item.id !== id)); } ``` ## A row Create `src/components/item.ts`: ```ts // One row of the list. It runs once per item; its bindings follow the item. import { type ReadableSignal, type View, computed, html } from "zerolith"; import { type Item, remove, toggle } from "../store.ts"; export function ItemRow({ item }: { item: ReadableSignal }): View { const name = computed(() => item.get().name); const packed = computed(() => item.get().packed); return html`
  • `; } ``` Props are an object, and TypeScript checks them like any function's arguments. The row receives `item` as a signal, the same signal `each` hands to its row function, so the row's bindings follow the item. The remove button has visible text and an `aria-label` that names the item, because "Remove" on its own does not say which item a screen reader user is about to remove. An attribute can mix text and bindings: `aria-label="Remove ${name}"` stays current if the name changes. ## The form Create `src/components/add-item.ts`, with the form from the last step: ```ts // The form that adds an item, with the field it clears after each one. import { type View, html } from "zerolith"; import { add } from "../store.ts"; export function AddItem(): View { const submitted = (event: SubmitEvent): void => { event.preventDefault(); const form = event.currentTarget as HTMLFormElement; const name = String(new FormData(form).get("name") ?? "").trim(); if (name !== "") { add(name); form.reset(); } }; return html`
    `; } ``` ## The page, from its parts Now `src/components/packing.ts` puts the parts together: ```ts // The packing list page, put together from its parts. import { type View, computed, each, html } from "zerolith"; import { items } from "../store.ts"; import { AddItem } from "./add-item.ts"; import { ItemRow } from "./item.ts"; export function Packing(): View { const count = computed(() => items.get().length); return html`

    Packing list

    ${count} items

      ${each(items, (item) => item.id, (item) => ItemRow({ item }))}
    ${AddItem()}`; } ``` A component call goes straight into a slot: `${AddItem()}`. Call the function; passing `AddItem` without calling it is reported as `unrenderable_value`, since a function is not something a page can show. ## Why components run once Each of these functions runs one time, when its part of the page is created. `ItemRow` runs once per item, not once per change: checking an item updates the row's checkbox and nothing else. This is why it is safe to create signals and computed values at the top of a component, and why there are no rules about the order of hooks or dependency arrays to remember: there is nothing to re-run. ## What you learned - Splitting a component is moving code into another function and calling it. - Props are an object; a row gets its item as a signal. - Components run once, and their bindings keep them current. # Derived state and conditions Two questions come up while packing: how much is left, and what exactly is left. Both answers are derived from the items, so they are computed values rather than more state to keep in sync. ## Derived values Update `src/store.ts`: ```ts // The packing list's state, what follows from it, and every way it changes. import { computed, signal } from "zerolith"; export interface Item { id: number; name: string; packed: boolean; } export type Filter = "all" | "left" | "packed"; export const items = signal([ { id: 1, name: "Passport", packed: true }, { id: 2, name: "Phone charger", packed: false }, { id: 3, name: "Rain jacket", packed: false } ], { name: "items" }); export const filter = signal("all", { name: "filter" }); export const packedCount = computed(() => items.get().filter((item) => item.packed).length); export const shown = computed(() => { const which = filter.get(); return which === "all" ? items.get() : items.get().filter((item) => item.packed === (which === "packed")); }); export function toggle(id: number): void { items.set(items.get().map((item) => (item.id === id ? { ...item, packed: !item.packed } : item))); } export function add(name: string): void { const id = Math.max(0, ...items.get().map((item) => item.id)) + 1; items.set([...items.get(), { id, name, packed: false }]); } export function remove(id: number): void { items.set(items.get().filter((item) => item.id !== id)); } ``` `filter` is new state: which items to show. `packedCount` and `shown` are derived: they read `items` and `filter` and recompute when either changes. Nothing ever sets them directly, so they can never disagree with the items. A computed value is lazy and cached. It recomputes only when something it read has changed and something reads it again, so `shown` costs nothing while no page shows it. ## The filter buttons Create `src/components/filters.ts`: ```ts // The buttons that choose which items the list shows. import { type View, computed, html } from "zerolith"; import { type Filter, filter } from "../store.ts"; const choices: Array<{ value: Filter; label: string }> = [ { value: "all", label: "All" }, { value: "left", label: "Left to pack" }, { value: "packed", label: "Packed" } ]; export function Filters(): View { return html`
    ${choices.map(({ value, label }) => html` `)}
    `; } ``` The three buttons come from an array with `map`, since the choices are fixed; `each` is for lists that change. `aria-pressed` tells assistive technology which button is on, and the computed value keeps it current: `true` and `false` become the attribute's text. ## Conditions Update `src/components/packing.ts`: ```ts // The packing list page: progress, the filters, the rows, and a note when // the filter leaves nothing to show. import { type View, computed, each, html, when } from "zerolith"; import { items, packedCount, shown } from "../store.ts"; import { AddItem } from "./add-item.ts"; import { Filters } from "./filters.ts"; import { ItemRow } from "./item.ts"; export function Packing(): View { const total = computed(() => items.get().length); const empty = computed(() => shown.get().length === 0); return html`

    Packing list

    ${packedCount} of ${total} packed

    ${Filters()}
      ${each(shown, (item) => item.id, (item) => ItemRow({ item }))}
    ${when(empty, () => html`

    Nothing to show here.

    `)} ${AddItem()}`; } ``` `each` now renders `shown` instead of `items`, so the rows follow the filter. `when` renders its view while the condition is true and removes it when the condition turns false; an optional third argument renders while it is false. Here it shows a note when the filter leaves no rows, such as choosing Left to pack once everything is packed. > [!NOTE] > `when` is zerolith's if and else, the place of a `? :` in React or `@if` and `@else` in Angular. Its condition is a signal, so `empty` is a computed value; a function there fails with `when_condition_function`, since it would always count as true. ## What you learned - Derived values are computed, never set, so they cannot drift from their sources. - Computed values are lazy and cached. - `when` shows a view while a condition holds; `each` is for lists that change, `map` for fixed ones. # Saving with effects Reload the page and the list resets, because it lives only in memory. The fix is to read the list from the browser's storage when the app starts and write it back whenever it changes. Writing to storage is a side effect: it reaches outside the page's own state. In zerolith, code that should run whenever some signals change is an `effect`. ## Load, then save Update `src/store.ts`: ```ts // The packing list's state, kept in the browser's storage between visits. import { computed, effect, signal } from "zerolith"; export interface Item { id: number; name: string; packed: boolean; } export type Filter = "all" | "left" | "packed"; const storageKey = "packing-list"; const firstItems: Item[] = [ { id: 1, name: "Passport", packed: true }, { id: 2, name: "Phone charger", packed: false }, { id: 3, name: "Rain jacket", packed: false } ]; function stored(): Item[] { try { const text = localStorage.getItem(storageKey); return text === null ? firstItems : (JSON.parse(text) as Item[]); } catch { return firstItems; } } export const items = signal(stored(), { name: "items" }); export const filter = signal("all", { name: "filter" }); effect(() => { const text = JSON.stringify(items.get()); try { localStorage.setItem(storageKey, text); } catch { // Storage can be full or turned off; the list still works // for this visit. } }); export const packedCount = computed(() => items.get().filter((item) => item.packed).length); export const shown = computed(() => { const which = filter.get(); return which === "all" ? items.get() : items.get().filter((item) => item.packed === (which === "packed")); }); export function toggle(id: number): void { items.set(items.get().map((item) => (item.id === id ? { ...item, packed: !item.packed } : item))); } export function add(name: string): void { const id = Math.max(0, ...items.get().map((item) => item.id)) + 1; items.set([...items.get(), { id, name, packed: false }]); } export function remove(id: number): void { items.set(items.get().filter((item) => item.id !== id)); } ``` `stored()` runs once, when the module loads, and gives `items` its starting value: the saved list if there is one, the three starting items if not. Reading storage can fail, in a private window with storage turned off or with damaged data, so a failure falls back to the starting items instead of breaking the page. The `effect` runs once right away and then again each time a signal it read changes. It reads `items`, so every `set()` saves the new list. It never reads `filter`, so changing the filter saves nothing. You never list the dependencies by hand: an effect depends on exactly what it read the last time it ran. ## Where effects belong An effect at the top of a module, like this one, lasts as long as the page. An effect created inside a component belongs to that component and stops when the component is removed, so a page that closes takes its effects with it. > [!TIP] > Keep effects for work outside the page, such as storage, timers, or logging. Anything the page shows should be a computed value bound into a template, not an effect that writes to the DOM. Open the page, check a few items off, and reload: the list is still there. ## What you learned - `effect` runs code now and again whenever a signal it read changes. - Dependencies are tracked from what the code read; there is no list to keep up to date. - Effects are for side effects; what the page shows comes from bindings. # Styles The page works; now it should look like a packing list. The starter's `src/styles.css` sets the page-wide basics and the color variables, `--text`, `--muted`, `--line`, and `--accent`. Styles that belong to one component live with that component, written with the `css` tag. ## A styled row Update `src/components/item.ts`: ```ts // One row of the list, with styles that apply to this row and nothing else. import { type ReadableSignal, type View, computed, css, html } from "zerolith"; import { type Item, remove, toggle } from "../store.ts"; const row = css` :scope { display: flex; align-items: center; gap: 0.75rem; padding: 0.5rem 0; border-bottom: 2px solid var(--line); } label { display: flex; align-items: center; gap: 0.75rem; flex: 1; } :scope[data-packed="true"] label { color: var(--muted); text-decoration: line-through; } `; export function ItemRow({ item }: { item: ReadableSignal }): View { const name = computed(() => item.get().name); const packed = computed(() => item.get().packed); return html`
  • `; } ``` `css` returns a style sheet scoped to the elements that use it: `class=${row}` applies it to the row. Inside it, `:scope` is the row itself, and the other rules match only inside the row, so `label` here never styles a label anywhere else on the page. The styles are added to the document once, however many rows there are, with the browser's `@scope` rule and no shadow DOM, so the page's own styles and your design tokens still reach in. `data-packed=${packed}` turns the item's state into an attribute the style sheet can select, which keeps the rule in CSS rather than in a class name built in JavaScript. The [styles guide](/guide/styles/) shows where a scope stops when one styled component sits inside another. ## A styled page The page's styles can live in a stylesheet of their own, beside the component. Create `src/components/packing.css`: ```css :scope { display: grid; gap: 1.5rem; } h1, p { margin: 0; } ul { margin: 0; padding: 0; list-style: none; border-top: 2px solid var(--line); } ``` Then import it in `src/components/packing.ts`: ```ts // The packing list page: progress, the filters, the rows, and a note when // the filter leaves nothing to show. Its styles are in packing.css. import { type View, computed, each, html, when } from "zerolith"; import { items, packedCount, shown } from "../store.ts"; import { AddItem } from "./add-item.ts"; import { Filters } from "./filters.ts"; import { ItemRow } from "./item.ts"; import page from "./packing.css?scoped"; export function Packing(): View { const total = computed(() => items.get().length); const empty = computed(() => shown.get().length === 0); return html`

    Packing list

    ${packedCount} of ${total} packed

    ${Filters()}
      ${each(shown, (item) => item.id, (item) => ItemRow({ item }))}
    ${when(empty, () => html`

    Nothing to show here.

    `)} ${AddItem()}
    `; } ``` `?scoped` on the import turns the file into the same scoped style sheet a `css` literal makes, and `class=${page}` applies it the same way: `:scope` is the `section`, and the rules stop at the rows, which have styles of their own. A literal suits a few rules beside the markup, as in the row; a file suits a longer stylesheet, which your editor checks and completes as CSS. `zerolith dev` applies an edit to the file in place, without reloading the page. The page is now a `section` with its own scoped styles, and the list is the finished one. `src/components/add-item.ts` and `src/components/filters.ts` stay as they were. ## What you learned - `css` writes a style sheet scoped to one component, applied with `class=${styles}`. - `:scope` is the element the styles apply to; other rules match only inside it. - A stylesheet imported with `?scoped`, as `import page from "./packing.css?scoped"`, is scoped the same way, for styles that read better in a file of their own. - Attributes bound to state, such as `data-packed`, let CSS follow the state. # Testing The starter came with a test for its counter, in `test/counter.browser.test.ts`, and `npm test` runs it in a real browser with Vitest. A test for the packing list works the same way: mount the page, act on it the way a person would, and check what changed. Create `test/packing.browser.test.ts`: ```ts import { afterEach, beforeEach, expect, it } from "vitest"; import { accessibilityIssues, cleanup, mount } from "@zerolith/test"; import { Packing } from "../src/components/packing.ts"; import { items } from "../src/store.ts"; beforeEach(() => { items.set([ { id: 1, name: "Passport", packed: true }, { id: 2, name: "Phone charger", packed: false }, { id: 3, name: "Rain jacket", packed: false } ]); }); afterEach(cleanup); it("checks an item off and counts it as packed", () => { const { container } = mount(Packing()); expect(container.querySelector("p")?.textContent).toBe("1 of 3 packed"); const boxes = container.querySelectorAll("li input"); boxes[1]?.click(); expect(container.querySelector("p")?.textContent).toBe("2 of 3 packed"); }); it("adds an item from the form", () => { const { container } = mount(Packing()); const form = container.querySelector("form"); const field = container.querySelector("input[name=name]"); if (form === null || field === null) { throw new Error("the page has no form"); } field.value = "Sunscreen"; form.requestSubmit(); expect(container.querySelectorAll("li")).toHaveLength(4); expect(field.value).toBe(""); }); it("names every control", () => { const { container } = mount(Packing()); expect(accessibilityIssues(container)).toEqual([]); }); ``` `mount` from `@zerolith/test` renders a view into a container attached to the document and returns it; `cleanup`, registered after each test, removes every mounted view. Since a click updates the page at once, the test checks the result on the next line, with no waiting. `beforeEach` resets the items, because the list is module state shared by every test in the file and kept in storage between runs. Setting it explicitly at the start of each test makes the tests independent of each other and of what you clicked in the browser. `accessibilityIssues` finds the defects a test can see without an accessibility tree: images without alternative text, form controls without a label, and buttons or links without a name. The remove buttons pass because they have text; their `aria-label` goes further and says which item each one removes, which no test can judge. Delete the `