The diff for this lesson is mostly deletions. Every document.getElementById, the manual render pass, the repaint watcher, and the handlers that call actions — gone. What replaces them is one component and an entry file with nothing in it but imports. The page will briefly show less than it did, which is what rebuilding on a real foundation looks like. Checkpoint: ui-03.

Registration is an import

import { startQueryRunner } from './state/query-runner.js';

import './components/db-status.js';

startQueryRunner();

That is the whole entry file. The import is not for a value — it is for the side effect of @customElement('db-status') running, which calls customElements.define. Import a component module and its tag works; the browser upgrades every matching element already in the document.

This is why the page paints before any data exists. The HTML is static, the elements are inert until their definitions arrive, and DuckDB’s 34 MB download is happening on a different thread the whole time. There is no render-blocking JavaScript bundle deciding whether a header may appear.

A task renders its own states

db-status owns the bootstrap, and it does it with @lit/task:

@customElement('db-status')
export class DbStatus extends LitElement {
    private _init = new Task(this, {
        args: () => [] as const,
        task: bootstrapDb,
    });

    render(): TemplateResult {
        return this._init.render({
            pending: () => html`<p role="status">Loading DuckDB…</p>`,
            complete: (result) => html`<p role="status">${this._summary(result)}</p>`,
            error: (e) => html`<p class="error" role="alert">Database failed to initialize: ${String(e)}</p>`,
        }) as TemplateResult;
    }
}

Task runs an async function and gives you three render branches. What it removes is the state machine everyone writes by hand — loading, error, data, three fields to keep consistent, and the race where a fast failure leaves a spinner up forever.

The two role attributes are not decoration. role="status" announces politely when the text changes; role="alert" interrupts. A sighted user sees the status line change; without these, a screen reader user gets nothing at all.

The task is also where dbReady gets flipped, which starts the query runner working. The chain is: component upgrades → task runs → bootstrap completes → dbReady becomes true → runner’s watcher fires → queries run → queryResult lands. Nothing polls, and nothing is ordered by hand.

Reading a signal is subscribing

db-status does not read the store, but every component after it does, and the mechanism is worth stating before it appears:

export class StatCard extends SignalWatcher(LitElement) {
    render() {
        const { totalRecords } = stats.get();   // ← this is the subscription
        return html`<dd>${totalRecords.toLocaleString()}</dd>`;
    }
}

SignalWatcher is a mixin that wraps the component’s render in the same watcher dance from lesson 2 — read a signal during render and the component re-renders when it changes. No subscribe, no useSelector, no cleanup. Which is exactly why the repaint watcher you wrote by hand last lesson is deleted here: twelve components are about to need that behaviour, and none of them will implement it.

Styles that cannot leak

Each component ships CSS in its shadow root, so a rule in one cannot affect another. That is a guarantee, not a convention — which raises the obvious question of how anything gets themed.

Custom properties are the answer: they inherit through shadow boundaries. Tokens defined on :root in the global stylesheet reach every component, while selectors do not. So the global sheet keeps exactly three jobs:

@layer reset, tokens, base, layout, components, interactions;

Reset, tokens, page layout. Everything about how a table or a chart looks moves inside its component. shared-styles.ts covers what genuinely repeats —

export const baseStyles = css` /* focus rings, reduced motion, scrollbars */ `;
export const skeletonStyles = css` /* the shimmer placeholder */ `;
export const visuallyHiddenStyles = css` /* screen-reader-only text */ `;

— composed into a component’s static styles array. These exist because of encapsulation: a global :focus-visible rule cannot reach inside a shadow root, so cross-cutting concerns must be shipped rather than inherited. That is the tax, and it is smaller than the bugs it prevents.

What you lose, briefly

Deleting the scaffolding deletes the tables it rendered. After this lesson the page is a header, a status line, and an intro paragraph — the transactions table, the totals, the pivot, and the console are all gone.

They come back as components, one lesson at a time: stat cards next, then filters, charts, the table in lesson 9, the console in lesson 10. The queries never stopped running; the runner is still filling queryResult on every change, with nothing reading it yet. Watching the data reappear piece by piece is the clearest possible demonstration that the store and the interface are genuinely separate.

Build it

File Action What goes in it
package.json modify Add lit and @lit/task
src/components/shared-styles.ts write The composable style fragments
src/components/db-status.ts write The @lit/task bootstrap component with its three render branches
index.html modify Replace the scaffolding with the app shell: header, header-tools, main, intro
src/index.ts modify Delete everything except the runner call and the component import
src/style.css modify Cascade layers, design tokens, page layout only

Done when: the header and intro paragraph render immediately on load, and the status line moves from Loading DuckDB… to Seeded 20,000 rows → saved to OPFS (or Restored… on a reload) without any other JavaScript running on the page.

Answer key: ui-02...ui-03.

Challenge: break the task on purpose

Make bootstrapDb throw on its first line and reload. The error branch renders, the shell stays up, and nothing else on the page is affected — compare that with what an exception during the old imperative bootstrap did. Then throttle your network to Slow 3G in DevTools and reload to watch the pending branch for several seconds, which is the state most users will actually see once.

Next lesson: three numbers, one component, and the skeletons that stand in for them.