# Gyral examples

> Gyral examples with their source, from a counter to a server-rendered app, plus gyral-shop.

Source: https://gyral.dev/examples/

## Basics

### Hello world

Two fields and a checkbox, and a greeting derived from state: input intents, reducers and ?checked, with valid and invalid input as plain values.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/hello-world
- Explained in: [Intent](https://gyral.dev/docs/intent/), [Views](https://gyral.dev/docs/views/#form-state)

`src/hello.ts`:

```ts
import { css, define, html } from '@gyral/core';

export interface State {
  readonly first: string;
  readonly last: string;
  readonly formal: boolean;
}

export type Msg =
  | { readonly _tag: 'First'; readonly value: string }
  | { readonly _tag: 'Last'; readonly value: string }
  | { readonly _tag: 'Formal'; readonly on: boolean };

/** Minimum last-name length before the formal greeting uses it. */
export const MIN_LAST = 3;

/**
 * Derived from state, never stored: the first name, or "LAST, First" when the formal greeting
 * is on and the last name is long enough. Raw, valid and invalid input are plain values in the
 * model, so no extra state is needed.
 */
export const greeting = ({ first, last, formal }: State): string =>
  formal && first.length > 0 && last.length >= MIN_LAST
    ? `${last.toUpperCase()}, ${first}`
    : first.trim();

export const Hello = define<State, Msg>()('gy-hello', {
  init: () => ({ first: '', last: '', formal: false }),
  intent: {
    // Text inputs fire on `input` and give `value`; checkboxes fire on `change` and give
    // `checked` (ADR 0001).
    First: ({ value }) => ({ _tag: 'First', value: value ?? '' }),
    Last: ({ value }) => ({ _tag: 'Last', value: value ?? '' }),
    Formal: ({ checked }) => ({ _tag: 'Formal', on: checked === true }),
  },
  update: {
    First: (s, m) => ({ ...s, first: m.value }),
    Last: (s, m) => ({ ...s, last: m.value }),
    Formal: (s, m) => ({ ...s, formal: m.on }),
  },
  view: (s, i) => html`
    <p>
      <label for="first">First name</label>
      <input id="first" type="text" autocomplete="given-name" data-intent=${i.First} />
    </p>
    <p>
      <label for="last">Last name</label>
      <input
        id="last"
        type="text"
        autocomplete="family-name"
        minlength=${MIN_LAST}
        aria-describedby="last-hint"
        data-intent=${i.Last}
      />
      <small id="last-hint">At least ${MIN_LAST} letters for the formal greeting.</small>
    </p>
    <p>
      <input id="formal" type="checkbox" ?checked=${s.formal} data-intent=${i.Formal} />
      <label for="formal">Formal greeting</label>
    </p>
    <hr />
    <output for="first last formal" aria-live="polite">Hello ${greeting(s)}</output>
  `,
  styles: css`…`,
});
```

## Effects, time and HTTP

### Seconds elapsed

A clock as a subscription: every() sends the time each second for as long as the component is on the page.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/seconds-elapsed
- Explained in: [Subscriptions](https://gyral.dev/docs/update/#subscriptions)

`src/seconds-elapsed.ts`:

```ts
import { css, define, html } from '@gyral/core';
import { every } from '@gyral/time';

export interface State {
  /** The wall-clock time of the first tick (ms). */
  readonly since: number | undefined;
  readonly seconds: number;
}

export type Msg = { readonly _tag: 'Tick'; readonly now: number };

/** ISO 8601 duration for `<time datetime>`, e.g. 75 → "PT75S". */
export const duration = (seconds: number): string => `PT${String(seconds)}S`;

// A clock subscription: `every` sends the time at once and then once a second, for as long as
// the component is in the page (ADR 0025). Seconds are counted from the first tick.
export const SecondsElapsed = define<State, Msg>()('gy-seconds-elapsed', {
  init: () => ({ since: undefined, seconds: 0 }),
  subscriptions: () => [every(1000, (now) => ({ _tag: 'Tick', now }))],
  update: {
    Tick: (s, m) => {
      const since = s.since ?? m.now;
      return { since, seconds: Math.round((m.now - since) / 1000) };
    },
  },
  view: (s) => html`
    <p>
      Seconds elapsed
      <output aria-live="off"><time datetime=${duration(s.seconds)}>${s.seconds}</time></output>
    </p>
  `,
  styles: css`…`,
});
```

### Random user

Randomness and HTTP as commands: a six-line random driver picks an id, then the user is fetched and decoded with a schema.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/http-random-user
- Explained in: [Effects and drivers](https://gyral.dev/docs/effects/)

`src/random-user.ts`:

```ts
import { css, define, html } from '@gyral/core';
import type { HttpError } from '@gyral/http';
import { randomInt } from './random.js';
import { fetchUser, USER_COUNT, type User } from './users.js';

export type State =
  | { readonly _tag: 'Idle' }
  | { readonly _tag: 'Loading'; readonly id: number }
  | { readonly _tag: 'Loaded'; readonly user: User }
  | { readonly _tag: 'Failed'; readonly message: string };

export type Msg =
  | { readonly _tag: 'GetRandom' }
  | { readonly _tag: 'Picked'; readonly id: number }
  | { readonly _tag: 'UserLoaded'; readonly user: User }
  | { readonly _tag: 'UserFailed'; readonly error: HttpError };

const describeError = (error: HttpError): string => {
  switch (error._tag) {
    case 'HttpStatusError':
      return `The server answered ${String(error.status)} ${error.statusText}.`;
    case 'HttpNetworkError':
      return 'Could not reach the server. Check your connection.';
    case 'HttpTimeoutError':
      return 'The server took too long to answer.';
    case 'HttpDecodeError':
      return 'The server sent a user we could not read.';
  }
};

export const RandomUser = define<State, Msg>()('gy-random-user', {
  init: () => ({ _tag: 'Idle' }),
  intent: {
    GetRandom: true,
  },
  update: {
    // Randomness is an effect: ask the random driver (src/random.ts), so tests can fix it.
    // Clicks while loading are ignored: any in-flight answer is "a random user".
    GetRandom: (s) =>
      s._tag === 'Loading'
        ? s
        : [s, [randomInt<Msg>(1, USER_COUNT, (id) => ({ _tag: 'Picked', id }))]],
    Picked: (_s, m) => [
      { _tag: 'Loading', id: m.id },
      [
        fetchUser<Msg>(
          m.id,
          (user) => ({ _tag: 'UserLoaded', user }),
          (error) => ({ _tag: 'UserFailed', error }),
        ),
      ],
    ],
    UserLoaded: (_s, m) => ({ _tag: 'Loaded', user: m.user }),
    UserFailed: (_s, m) => ({ _tag: 'Failed', message: describeError(m.error) }),
  },
  // Exposed to CSS as :state(loading) / :state(failed), on this host and for page styles.
  states: (s) => ({ loading: s._tag === 'Loading', failed: s._tag === 'Failed' }),
  view: (s, i) => html`
    <button type="button" data-intent=${i.GetRandom} aria-busy=${s._tag === 'Loading'}>
      Get random user
    </button>
    ${body(s)}
  `,
  styles: css`…`,
});

function body(s: State) {
  switch (s._tag) {
    case 'Idle':
      return html`<p role="status">Press the button to meet someone.</p>`;
    case 'Loading':
      return html`<p role="status">Loading user ${s.id}…</p>`;
    case 'Failed':
      return html`<p role="alert">${s.message}</p>`;
    case 'Loaded':
      return userCard(s.user);
  }
}

function userCard(user: User) {
  return html`<article aria-labelledby="user-name">
    <h2 id="user-name">${user.name}</h2>
    <dl>
      <dt>Email</dt>
      <dd><a href=${`mailto:${user.email}`}>${user.email}</a></dd>
      <dt>Phone</dt>
      <dd><a href=${`tel:${user.phone}`}>${user.phone}</a></dd>
      <dt>Website</dt>
      <dd><a href=${`https://${user.website}`} rel="noopener">${user.website}</a></dd>
      <dt>Company</dt>
      <dd>${user.company.name}</dd>
    </dl>
  </article>`;
}
```

### Search GitHub

Search as you type: debounce() and a switch lane cancel stale requests, and late answers for old queries are ignored.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/http-search-github
- Explained in: [Concurrency lanes](https://gyral.dev/docs/effects/#concurrency-lanes)

`src/search.ts`:

```ts
import { css, define, each, html, nothing } from '@gyral/core';
import type { HttpError } from '@gyral/http';
import { debounce } from '@gyral/time';
import { searchRepos, type Repo } from './github.js';

export type Results =
  | { readonly _tag: 'Idle' }
  | { readonly _tag: 'Searching' }
  | { readonly _tag: 'Found'; readonly repos: readonly Repo[] }
  | { readonly _tag: 'Failed'; readonly message: string };

export interface State {
  readonly query: string;
  readonly results: Results;
}

export type Msg =
  | { readonly _tag: 'Typed'; readonly query: string }
  | { readonly _tag: 'Search'; readonly query: string }
  | { readonly _tag: 'Found'; readonly query: string; readonly repos: readonly Repo[] }
  | { readonly _tag: 'SearchFailed'; readonly query: string; readonly error: HttpError };

export const DEBOUNCE_MS = 500;

const describeError = (error: HttpError): string =>
  error._tag === 'HttpStatusError' && error.status === 403
    ? 'GitHub rate limit reached. Try again in a minute.'
    : `Search failed (${error._tag}).`;

export const GithubSearch = define<State, Msg>()('gy-github-search', {
  init: () => ({ query: '', results: { _tag: 'Idle' } }),
  intent: {
    Typed: ({ value }) => ({ _tag: 'Typed', query: value ?? '' }),
    Search: ({ formData }) => {
      const query = formData?.get('q');
      return typeof query === 'string' ? { _tag: 'Search', query } : undefined;
    },
  },
  update: {
    Typed: (s, m) => [
      { ...s, query: m.query },
      // A 500 ms debounce: each keystroke cancels the pending timer.
      [debounce<Msg>(DEBOUNCE_MS, { _tag: 'Search', query: m.query })],
    ],
    Search: (s, m) => {
      const query = m.query.trim();
      if (query === '') return { query: m.query, results: { _tag: 'Idle' } };
      return [
        { query: m.query, results: { _tag: 'Searching' } },
        [
          searchRepos<Msg>(
            query,
            (repos) => ({ _tag: 'Found', query: m.query, repos }),
            (error) => ({ _tag: 'SearchFailed', query: m.query, error }),
          ),
        ],
      ];
    },
    Found: (s, m) =>
      m.query === s.query ? { ...s, results: { _tag: 'Found', repos: m.repos } } : s,
    SearchFailed: (s, m) =>
      m.query === s.query
        ? { ...s, results: { _tag: 'Failed', message: describeError(m.error) } }
        : s,
  },
  view: (s, i) => html`
    <search>
      <form data-intent=${i.Search}>
        <label for="q">Repository name</label>
        <input
          id="q"
          name="q"
          type="search"
          autocomplete="off"
          spellcheck="false"
          value=${s.query}
          data-intent=${i.Typed}
        />
      </form>
    </search>
    ${status(s.results)}
    ${
      s.results._tag === 'Found'
        ? html`<ol>
            ${each(s.results.repos, (r) => r.id, repoItem)}
          </ol>`
        : nothing
    }
  `,
  styles: css`…`,
});

function status(results: Results) {
  switch (results._tag) {
    case 'Idle':
      return html`<output for="q">Type to search.</output>`;
    case 'Searching':
      return html`<output for="q" aria-busy="true">Searching…</output>`;
    case 'Found':
      return html`<output for="q">${String(results.repos.length)} repositories</output>`;
    case 'Failed':
      return html`<p role="alert">${results.message}</p>`;
  }
}

function repoItem(repo: Repo) {
  return html`<li>
    <article>
      <h2><a href=${repo.html_url}>${repo.full_name}</a></h2>
      ${repo.description === null ? nothing : html`<p>${repo.description}</p>`}
      <p><data value=${repo.stargazers_count}>★ ${repo.stargazers_count.toLocaleString()}</data></p>
    </article>
  </li>`;
}
```

### Autocomplete

Wikipedia suggestions in a popover, with keyboard navigation through keydown intents and debounced requests.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/autocomplete-search
- Explained in: [Intent](https://gyral.dev/docs/intent/#trigger-events)

`src/autocomplete.ts`:

```ts
import { define, each, html, nothing, type Next } from '@gyral/core';
import { debounce, delay } from '@gyral/time';
import { popoverOpen } from './popover.js';
import { styles } from './styles.js';
import { suggest } from './wikipedia.js';

export const DEBOUNCE_MS = 300;

export interface State {
  readonly query: string;
  readonly suggestions: readonly string[];
  /** Index of the highlighted option, if any (aria-activedescendant). */
  readonly highlighted: number | undefined;
  readonly open: boolean;
  readonly status: 'idle' | 'loading' | 'error';
}

export type NavKey = 'ArrowDown' | 'ArrowUp' | 'Enter' | 'Escape';

export type Msg =
  | { readonly _tag: 'Typed'; readonly query: string }
  | { readonly _tag: 'Search'; readonly query: string }
  | { readonly _tag: 'Found'; readonly query: string; readonly titles: readonly string[] }
  | { readonly _tag: 'Failed'; readonly query: string }
  | { readonly _tag: 'Key'; readonly key: NavKey }
  | { readonly _tag: 'Pick'; readonly index: number }
  | { readonly _tag: 'Blurred' }
  | { readonly _tag: 'Dismiss' };

/** Long enough for a click on an option to land after the field loses focus. */
const BLUR_GRACE_MS = 150;

const NAV_KEYS: readonly string[] = ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'];
const isNavKey = (key: string): key is NavKey => NAV_KEYS.includes(key);

const closed = (s: State): State => ({ ...s, open: false, highlighted: undefined });

/** Puts the chosen title in the field and closes the list (no new search). */
const select = (s: State, index: number): State => {
  const title = s.suggestions[index];
  return title === undefined ? s : { ...closed(s), query: title, suggestions: [] };
};

/** Arrow keys wrap around; the first press from "nothing highlighted" picks an end. */
export function move(s: State, delta: 1 | -1): State {
  const n = s.suggestions.length;
  if (n === 0) return s;
  const from = s.highlighted ?? (delta === 1 ? -1 : 0);
  return { ...s, open: true, highlighted: (from + delta + n) % n };
}

function onKey(s: State, key: NavKey): State {
  switch (key) {
    case 'ArrowDown':
      return move(s, 1);
    case 'ArrowUp':
      return move(s, -1);
    case 'Enter':
      return s.open && s.highlighted !== undefined ? select(s, s.highlighted) : s;
    case 'Escape':
      return closed(s);
  }
}

function onTyped(s: State, query: string): Next<State, Msg> {
  const next: State = { ...closed(s), query };
  if (query.trim() === '') return { ...next, suggestions: [], status: 'idle' };
  // Each keystroke restarts the timer: one search once typing pauses.
  return [next, [debounce<Msg>(DEBOUNCE_MS, { _tag: 'Search', query }, 'autocomplete')]];
}

function statusText(s: State): string {
  if (s.status === 'loading') return 'Searching…';
  if (s.status === 'error') return 'Suggestions are unavailable right now.';
  if (!s.open) return '';
  const n = s.suggestions.length;
  return `${String(n)} suggestion${n === 1 ? '' : 's'}. Use the up and down arrows to choose.`;
}

/** One suggestion; pure: its index, highlight and intent come through `pick`. */
const option = (title: string, [index, highlighted, pick]: readonly [number, boolean, string]) =>
  html`<li
    id="option-${String(index)}"
    role="option"
    aria-selected=${highlighted ? 'true' : 'false'}
    data-index=${index}
    data-intent=${pick}
  >
    ${title}
  </li>`;

export const Autocomplete = define<State, Msg>()('gy-autocomplete', {
  init: () => ({ query: '', suggestions: [], highlighted: undefined, open: false, status: 'idle' }),
  intent: {
    Typed: ({ value }) => ({ _tag: 'Typed', query: value ?? '' }),
    Key: ({ key, event }) => {
      if (key === undefined || !isNavKey(key) || (event as KeyboardEvent).isComposing) return;
      if (key === 'ArrowDown' || key === 'ArrowUp') event.preventDefault(); // keep the caret
      return { _tag: 'Key', key };
    },
    Blurred: true,
    Pick: ({ target }) => {
      const index = Number(target.getAttribute('data-index'));
      return Number.isInteger(index) ? { _tag: 'Pick', index } : undefined;
    },
  },
  update: {
    Typed: (s, m) => onTyped(s, m.query),
    Search: (s, m) =>
      m.query !== s.query
        ? s
        : [
            { ...s, status: 'loading' },
            [
              suggest<Msg>(
                m.query,
                (titles) => ({ _tag: 'Found', query: m.query, titles }),
                () => ({ _tag: 'Failed', query: m.query }),
              ),
            ],
          ],
    // Answers for an older query are ignored (the switch lane usually cancels them anyway).
    Found: (s, m) =>
      m.query !== s.query
        ? s
        : {
            ...s,
            suggestions: m.titles,
            open: m.titles.length > 0,
            highlighted: undefined,
            status: 'idle',
          },
    Failed: (s, m) => (m.query !== s.query ? s : { ...closed(s), status: 'error' }),
    Key: (s, m) => onKey(s, m.key),
    Pick: (s, m) => select(s, m.index),
    // Close when focus leaves the combobox, after a grace period so option clicks still land.
    Blurred: (s) => (s.open ? [s, [delay<Msg>(BLUR_GRACE_MS, { _tag: 'Dismiss' })]] : s),
    Dismiss: (s) => closed(s),
  },
  view: (s, i) => html`
    <div class="field">
      <label for="query">Wikipedia article</label>
      <span class="combo" data-intent=${i.Blurred} data-intent-on="focusout">
        <span class="keys" data-intent=${i.Key} data-intent-on="keydown">
          <input
            id="query"
            type="text"
            role="combobox"
            autocomplete="off"
            spellcheck="false"
            aria-autocomplete="list"
            aria-controls="suggestions"
            aria-expanded=${s.open ? 'true' : 'false'}
            aria-activedescendant=${
              s.highlighted === undefined ? nothing : `option-${String(s.highlighted)}`
            }
            value=${s.query}
            data-intent=${i.Typed}
          />
        </span>
        <ul
          id="suggestions"
          role="listbox"
          aria-label="Suggestions"
          ?hidden=${!s.open}
          ${popoverOpen(s.open)}
        >
          ${each(
            s.suggestions,
            (title) => title,
            option,
            (title) => {
              const index = s.suggestions.indexOf(title);
              return [index, index === s.highlighted, i.Pick] as const;
            },
          )}
        </ul>
      </span>
    </div>
    <p class="status" role="status">${statusText(s)}</p>
    <div class="field">
      <label for="other">Some field</label>
      <input id="other" type="text" />
    </div>
  `,
  styles,
});
```

### Custom driver

A canvas chart as a driver: one command draws, another streams the bars the user clicks.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/custom-driver
- Explained in: [Writing a driver](https://gyral.dev/docs/effects/#writing-a-driver)

`src/chart-driver.ts`:

```ts
// A custom driver (ADR 0006): a plain object that owns an imperative resource, here a
// <canvas> outside any component, and talks to components only through commands.
// It draws a bar chart with the 2D canvas API, so the example needs no charting dependency.
import { command, type Command, type Driver } from '@gyral/core';

export interface ChartData {
  readonly label: string;
  readonly values: readonly number[];
}

export type ChartInput =
  | { readonly _tag: 'Draw'; readonly data: ChartData }
  /** Streams the index of every bar the user clicks, until aborted. */
  | { readonly _tag: 'Clicks' };

export type ChartDriver = Driver<ChartInput, number | undefined>;

export interface ChartOptions {
  /** Driver name used for substitution (`el.drivers`). Default `'chart'`. */
  readonly name?: string;
  /** Where to look the canvas up. Default: `document`, resolved when a command runs. */
  readonly root?: () => ParentNode;
}

const PAD = 24;

/** Horizontal geometry of the bars, shared by drawing and hit-testing. */
export function layout(width: number, count: number): { readonly barWidth: number } {
  return { barWidth: (width - 2 * PAD) / Math.max(count, 1) };
}

/** The bar under canvas x-coordinate `x`, if any. */
export function barAt(x: number, width: number, count: number): number | undefined {
  const index = Math.floor((x - PAD) / layout(width, count).barWidth);
  return x >= PAD && index >= 0 && index < count ? index : undefined;
}

function draw(canvas: HTMLCanvasElement, data: ChartData): void {
  const ctx = canvas.getContext('2d');
  if (ctx === null) return;
  const { width, height } = canvas;
  const style = getComputedStyle(canvas);
  const bar = style.getPropertyValue('--chart-bar').trim() || 'steelblue';
  const ink = style.getPropertyValue('--chart-ink').trim() || style.color;
  const { barWidth } = layout(width, data.values.length);
  const max = Math.max(1, ...data.values);
  const plot = height - 2 * PAD;

  ctx.clearRect(0, 0, width, height);
  ctx.font = '12px system-ui, sans-serif';
  ctx.fillStyle = ink;
  ctx.fillText(data.label, PAD, PAD - 8);
  data.values.forEach((value, i) => {
    const h = (value / max) * plot;
    const x = PAD + i * barWidth;
    ctx.fillStyle = bar;
    ctx.fillRect(x + 2, PAD + plot - h, Math.max(barWidth - 4, 1), h);
    ctx.fillStyle = ink;
    ctx.fillText(String(i), x + barWidth / 2 - 3, height - 6);
  });
}

/** Creates a chart driver for the canvas matching `selector`. Nothing runs at import time. */
export function makeChartDriver(selector: string, options: ChartOptions = {}): ChartDriver {
  let last: ChartData = { label: '', values: [] };
  const find = (): HTMLCanvasElement => {
    const el = (options.root?.() ?? document).querySelector(selector);
    if (!(el instanceof HTMLCanvasElement)) throw new Error(`No canvas '${selector}' found`);
    return el;
  };

  return {
    name: options.name ?? 'chart',
    run: (input, { signal, emit }) => {
      const canvas = find();
      if (input._tag === 'Draw') {
        last = input.data;
        draw(canvas, input.data);
        return undefined;
      }
      // Streaming source (DriverContext.emit): one message per clicked bar.
      return new Promise<never>((_resolve, reject) => {
        const onClick = (event: MouseEvent): void => {
          const rect = canvas.getBoundingClientRect();
          const x = (event.clientX - rect.left) * (canvas.width / rect.width);
          const index = barAt(x, canvas.width, last.values.length);
          if (index !== undefined) emit(index);
        };
        const stop = (): void => {
          canvas.removeEventListener('click', onClick);
          reject(new DOMException('Aborted', 'AbortError'));
        };
        if (signal.aborted) {
          stop();
          return;
        }
        canvas.addEventListener('click', onClick);
        signal.addEventListener('abort', stop, { once: true });
      });
    },
  };
}

/** The page's chart: `<canvas id="clicks-chart">`. */
export const chart: ChartDriver = makeChartDriver('#clicks-chart');

/** Redraws the chart. Only the latest drawing matters, so the lane switches. */
export function drawChart(data: ChartData): Command<never> {
  return command(chart, { _tag: 'Draw', data }, { key: 'chart:draw', concurrency: 'switch' });
}

/** Streams clicked bar indexes as messages until the component disconnects. */
export function chartClicks<M>(toMsg: (bar: number) => M | undefined): Command<M> {
  return command(
    chart,
    { _tag: 'Clicks' },
    {
      onSuccess: (bar) => (bar === undefined ? undefined : toMsg(bar)),
      key: 'chart:clicks',
      concurrency: 'switch',
    },
  );
}
```

### Workload lab

Heavier work under the same rules: a module worker behind a driver that a subscription starts and cancels, timed steps, a booking form with retries, and a canvas grid with pointer capture.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/workload-lab
- Explained in: [Subscriptions](https://gyral.dev/docs/update/#subscriptions), [Writing a driver](https://gyral.dev/docs/effects/#writing-a-driver)

`src/solver.ts`:

```ts
import { command, define, html } from '@gyral/core';
import { solver } from './solver-driver.js';
import { controls } from './styles.js';

interface State {
  readonly job: number;
  readonly status: 'idle' | 'running' | 'done' | 'failed';
  readonly report: string;
}
type Msg =
  | { readonly _tag: 'Generate' }
  | { readonly _tag: 'Cancel' }
  | { readonly _tag: 'Solved'; readonly job: number; readonly checksum: number }
  | { readonly _tag: 'Failed'; readonly job: number; readonly error: string };
export const Solver = define<State, Msg>()('gy-solver', {
  init: () => ({ job: 0, status: 'idle', report: 'Worker idle' }),
  intent: { Generate: true, Cancel: true },
  subscriptions: (s) =>
    s.status === 'running'
      ? [
          command(
            solver,
            { id: s.job, iterations: 300_000 },
            {
              key: 'solver:job',
              concurrency: 'switch',
              onSuccess: (result) => ({
                _tag: 'Solved',
                job: result.id,
                checksum: result.checksum,
              }),
              onFailure: (error) => ({ _tag: 'Failed', job: s.job, error }),
            },
          ),
        ]
      : [],
  update: {
    Generate: (s) => ({ ...s, job: s.job + 1, status: 'running', report: 'Worker running' }),
    Cancel: (s) => ({ ...s, status: 'idle', report: 'Worker cancelled' }),
    Solved: (s, m) =>
      s.status === 'running' && m.job === s.job
        ? { ...s, status: 'done', report: `Worker result: ${String(m.checksum)}` }
        : s,
    Failed: (s, m) =>
      s.status === 'running' && m.job === s.job ? { ...s, status: 'failed', report: m.error } : s,
  },
  view: (s, i) => html`
    <div class="actions">
      <button type="button" data-intent=${i.Generate}>Generate in worker</button>
      <button type="button" data-intent=${i.Cancel} ?disabled=${s.status !== 'running'}>
        Cancel worker
      </button>
    </div>
    <output aria-live="polite">${s.report}</output>
  `,
  styles: controls,
});
```

## Components and shared state

### Nested BMI

Two instances of one slider component. Props go down, outputs come up, and the parent owns the values.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/bmi-nested
- Explained in: [Child components](https://gyral.dev/docs/components/#child-components-and-outputs)

`src/bmi-nested.ts`:

```ts
import { child, css, define, html } from '@gyral/core';
import { LabeledSlider } from './labeled-slider.js';

export interface State {
  readonly weight: number;
  readonly height: number;
}

export type Msg =
  | { readonly _tag: 'Weight'; readonly kg: number }
  | { readonly _tag: 'Height'; readonly cm: number };

export const bmiOf = ({ weight, height }: State): number =>
  Math.round(weight / (height / 100) ** 2);

/**
 * The parent owns both values and derives the BMI. Each slider is the same child component;
 * the two `data-intent` names tell their outputs apart, with no extra scoping step.
 */
export const BmiNested = define<State, Msg>()('gy-bmi-nested', {
  init: () => ({ weight: 70, height: 170 }),
  intent: {
    Weight: child(LabeledSlider, (out) => ({ _tag: 'Weight', kg: out.value })),
    Height: child(LabeledSlider, (out) => ({ _tag: 'Height', cm: out.value })),
  },
  update: {
    Weight: (s, m) => ({ ...s, weight: m.kg }),
    Height: (s, m) => ({ ...s, height: m.cm }),
  },
  view: (s, i) => html`
    <gy-labeled-slider
      id="weight"
      label="Weight"
      unit="kg"
      min="40"
      max="140"
      .value=${s.weight}
      data-intent=${i.Weight}
    ></gy-labeled-slider>
    <gy-labeled-slider
      id="height"
      label="Height"
      unit="cm"
      min="140"
      max="210"
      .value=${s.height}
      data-intent=${i.Height}
    ></gy-labeled-slider>
    <h2>BMI is <output>${bmiOf(s)}</output></h2>
  `,
  styles: css`…`,
});
```

### Nested folders

A folder that contains folders: a recursive component through child(() => Folder), each level owning its own children.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/nested-folders
- Explained in: [Child components](https://gyral.dev/docs/components/#child-components-and-outputs)

`src/folder.ts`:

```ts
import {
  child,
  css,
  define,
  each,
  html,
  nothing,
  outputs,
  prop,
  type GyralElementClass,
} from '@gyral/core';

export interface State {
  /** Ids of this folder's direct children. Each child owns its own subtree. */
  readonly children: readonly string[];
  readonly next: number;
}

export interface Props {
  readonly folderId: string;
  readonly removable: boolean;
}

export type FolderOutput = { readonly _tag: 'Removed' };

/** Typed `emit` for this component's outputs. */
const emit = outputs<FolderOutput>();

export type Msg =
  | { readonly _tag: 'Add' }
  | { readonly _tag: 'Remove' }
  | { readonly _tag: 'Child'; readonly id: string; readonly out: FolderOutput };

/** Stable pastel hue per id, so a folder keeps its color across renders. */
export function hueOf(id: string): number {
  let hue = 7;
  for (let n = 0; n < id.length; n += 1) hue = (hue * 31 + id.charCodeAt(n)) % 360;
  return hue;
}

/**
 * A folder renders folders of its own kind: recursion through the custom-element tag.
 * Each folder owns the list of its direct children (no single state tree with lenses per
 * level); a child removes itself by emitting `Removed` up one level.
 * Ids are paths (`1.2.1`), so making a new one is pure.
 */
/** One child folder; its intent name comes through `pick`, so the row stays pure. */
const subfolder = (id: string, intent: string) =>
  html`<li>
    <gy-folder folder-id=${id} removable data-intent=${intent}></gy-folder>
  </li>`;

// The explicit type lets `child(() => Folder, …)` refer to the constant being defined.
export const Folder: GyralElementClass<State, Msg, Props, FolderOutput> = define<
  State,
  Msg,
  Props,
  FolderOutput
>()('gy-folder', {
  props: {
    folderId: prop.string({ required: true }), // attribute "folder-id"
    // A boolean attribute: absent on the root folder means "not removable" (found by ui:check).
    removable: prop.boolean(),
  },
  init: () => ({ children: [], next: 1 }),
  intent: {
    Add: true,
    Remove: true,
    // Lazy source: `Folder` is still being defined here (recursion), so pass a function.
    Child: child(
      () => Folder,
      (out, el) => ({ _tag: 'Child', id: el.folderId, out }),
    ),
  },
  update: {
    Add: (s, _m, { props }) => ({
      children: [...s.children, `${props.folderId}.${String(s.next)}`],
      next: s.next + 1,
    }),
    Remove: (s) => [s, [emit({ _tag: 'Removed' })]],
    // FolderOutput has one variant (Removed); switch on m.out._tag when it grows.
    Child: (s, m) => ({ ...s, children: s.children.filter((id) => id !== m.id) }),
  },
  view: (s, i, { props }) => html`
    <details open style="--hue: ${hueOf(props.folderId)}">
      <summary>Folder ${props.folderId}</summary>
      <menu>
        <li><button type="button" data-intent=${i.Add}>Add folder</button></li>
        ${
          props.removable
            ? html`<li><button type="button" data-intent=${i.Remove}>Remove me</button></li>`
            : nothing
        }
      </menu>
      ${
        s.children.length === 0
          ? nothing
          : html`<ul>
              ${each(
                s.children,
                (id) => id,
                subfolder,
                () => i.Child,
              )}
            </ul>`
      }
    </details>
  `,
  styles: css`…`,
});
```

### Shared cart

A header badge, a product list and a cart panel, far apart on the page, sharing one store.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/shared-cart
- Explained in: [Shared state](https://gyral.dev/docs/stores/)

`src/cart.ts`:

```ts
import { defineStore } from '@gyral/core';

export interface Product {
  readonly sku: string;
  readonly name: string;
  readonly priceCents: number;
}

export interface Line extends Product {
  readonly qty: number;
}

export interface Cart {
  readonly lines: readonly Line[];
}

export type CartMsg =
  | { readonly _tag: 'Add'; readonly product: Product }
  | { readonly _tag: 'Remove'; readonly sku: string }
  | { readonly _tag: 'Clear' };

export const count = (cart: Cart): number => cart.lines.reduce((n, l) => n + l.qty, 0);
export const totalCents = (cart: Cart): number =>
  cart.lines.reduce((sum, l) => sum + l.priceCents * l.qty, 0);
export const money = (cents: number): string =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(cents / 100);

/** The shared cart: MVI without a view (ADR 0013). Components read it and send it messages. */
export const cart = defineStore<Cart, CartMsg>('cart', {
  init: () => ({ lines: [] }),
  update: {
    Add: (s, { product }) =>
      s.lines.some((l) => l.sku === product.sku)
        ? { lines: s.lines.map((l) => (l.sku === product.sku ? { ...l, qty: l.qty + 1 } : l)) }
        : { lines: [...s.lines, { ...product, qty: 1 }] },
    Remove: (s, { sku }) => ({ lines: s.lines.filter((l) => l.sku !== sku) }),
    Clear: () => ({ lines: [] }),
  },
});
```

## Routing, forms and the platform

### Routing

A typed route table, listen() streaming every location, setHead(), and a View Transition between pages.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/routing-view
- Explained in: [Routing](https://gyral.dev/docs/routing/)

`src/app.ts`:

```ts
import { command, css, define, html, nothing, type Head } from '@gyral/core';
import {
  listen,
  router,
  routes,
  setHead,
  type RouteLocation,
  type RouteMatch,
} from '@gyral/router';

/** The route table: pure, typed, and reusable on a server (ADR 0009). */
export const app = routes({ home: '/', about: '/about', contacts: '/contacts' });

type Route = RouteMatch<typeof app.table>;
type Name = Route['name'];

export interface State {
  /** `undefined` until the router delivers the current location. */
  readonly location: RouteLocation | undefined;
  readonly route: Route | undefined;
}

export type Msg =
  { readonly _tag: 'Routed'; readonly location: RouteLocation } | { readonly _tag: 'Back' };

interface Page {
  readonly title: string;
  readonly nav: string;
  readonly body: string;
}

const pages: Readonly<Record<Name, Page>> = {
  home: {
    nav: 'Home',
    title: 'Welcome to the routing example',
    body: 'Links below are plain anchors. The router captures same-origin clicks, updates history, and streams every location to this component as a message.',
  },
  about: {
    nav: 'About',
    title: 'About me',
    body: 'The current route is model state, derived from the location by matching it against a typed route table. The view is a pure function of that state.',
  },
  contacts: {
    nav: 'Contacts',
    title: 'Contact me',
    body: 'Try the browser back button, or the Back button below: both arrive here as the same Routed message.',
  },
};

const order: readonly Name[] = ['home', 'about', 'contacts'];

/**
 * The document head for a route: one pure function, so a server could reuse it (ADR 0019).
 * An unknown route is `noindex`; a known one names its canonical URL.
 */
export const pageHead = (route: Route | undefined, origin: string): Head => ({
  title: `${route === undefined ? 'Page not found' : pages[route.name].nav} — Gyral routing`,
  ...(route === undefined
    ? { robots: 'noindex' }
    : { canonical: new URL(route.path, origin).href }),
});

export const RoutingView = define<State, Msg>()('gy-routing-view', {
  init: () => ({ location: undefined, route: undefined }),
  // One subscription: the current location now, then every change (ADR 0009, ADR 0025).
  subscriptions: () => [listen((location) => ({ _tag: 'Routed', location }))],
  intent: {
    Back: true,
  },
  update: {
    Routed: (_s, { location }) => {
      const route = app.match(location.href);
      // A client-only app: the page's own origin is the canonical one.
      return [{ location, route }, [setHead(pageHead(route, new URL(location.href).origin))]];
    },
    // History back is a router input (the "History back and forward" recipe).
    Back: (s) => [s, [command(router, { _tag: 'Traverse', delta: -1 })]],
  },
  // Cross-fade between pages (an enhancement: skipped without support or with reduced motion).
  // The first location is not a page change, so it renders directly.
  viewTransition: (prev, next) =>
    prev.location !== undefined && prev.route?.name !== next.route?.name,
  view: (s, i) => {
    const page = s.route === undefined ? undefined : pages[s.route.name];
    return html`
      <header>
        <nav aria-label="Main">
          <ul>
            ${order.map(
              (name) =>
                html`<li>
                  <a
                    href=${app.href(name, {})}
                    aria-current=${s.route?.name === name ? 'page' : nothing}
                    >${pages[name].nav}</a
                  >
                </li>`,
            )}
          </ul>
        </nav>
      </header>
      <main>
        ${
          s.location === undefined
            ? nothing
            : page === undefined
              ? html`<h1>404 — page not found</h1>
                  <p>No route matches <code>${s.location.pathname}</code>.</p>`
              : html`<h1>${page.title}</h1>
                  <p>${page.body}</p>`
        }
        <p><button type="button" data-intent=${i.Back}>Back</button></p>
        <details>
          <summary>Location object</summary>
          <pre><code>${JSON.stringify(s.location, null, 2)}</code></pre>
        </details>
      </main>
    `;
  },
  styles: css`…`,
});
```

### Isomorphic app

Rendered on the server with Declarative Shadow DOM, hydrated in place, with one route prerendered and one rendered per request.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/isomorphic
- Explained in: [Server rendering](https://gyral.dev/docs/server-rendering/)

`src/app.ts`:

```ts
import { css, define, html, prop } from '@gyral/core';
import { listen, setHead, type RouteLocation } from '@gyral/router';
import { pageHead, site, titles } from './routes.js';

export interface Props {
  /** The request path, set by the server. On the client the router takes over. */
  readonly path?: string;
}

export interface State {
  readonly path: string;
}

export type Msg = { readonly _tag: 'Routed'; readonly location: RouteLocation };

const menu = (path: string) => html`
  <nav aria-label="Site">
    <ul>
      ${(['home', 'about'] as const).map((name) => {
        const href = site.href(name, {});
        return html`<li>
          <a href=${href} aria-current=${href === path ? 'page' : 'false'}>${titles[name]}</a>
        </li>`;
      })}
    </ul>
  </nav>
`;

const content = (path: string) => {
  switch (site.match(path)?.name) {
    case 'home':
      return html`<h1>${titles.home}</h1>
        <p>Welcome to our spectacular web page with nothing special here.</p>`;
    case 'about':
      return html`<h1>${titles.about}</h1>
        <p>This is the page where we describe ourselves.</p>
        <gy-iso-contact></gy-iso-contact>`;
    case undefined:
      return html`<h1>Page not found</h1>
        <p>Unknown page <code>${path}</code>.</p>`;
  }
};

export const App = define<State, Msg, Props>()('gy-iso-app', {
  props: { path: prop.string() },
  // Same on server and client.
  init: (props) => ({ path: props.path ?? '/' }),
  // The server never runs commands; after hydration the client subscribes to the location,
  // which streams the current one and every navigation (ADR 0012, ADR 0025).
  subscriptions: () => [listen((location) => ({ _tag: 'Routed', location }))],
  update: {
    // pageHead() also renders the server's head: one source for both (ADR 0019). The first
    // Routed after hydration finds the server's head already matching and writes nothing.
    Routed: (_s, m) => [{ path: m.location.pathname }, [setHead(pageHead(m.location.pathname))]],
  },
  view: (s) => html`
    <header>${menu(s.path)}</header>
    <main>${content(s.path)}</main>
  `,
  styles: css`…`,
});
```

### Register

A sign-up form that validates the same way with and without JavaScript, including a server-only check.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/register
- Explained in: [Forms](https://gyral.dev/docs/forms/#the-server-half)

`src/register.ts`:

```ts
import '../../shared/devtools.js'; // ?devtools opens the panel (dev only)
import {
  css,
  define,
  fieldErrors,
  form,
  html,
  invalid,
  prop,
  type FormFields,
  type IntentRejected,
} from '@gyral/core';
import { submitForm } from '@gyral/http';
import { RegisterForm } from './schema.js';

export interface State {
  /** Text to re-fill after a rejection. Never passwords: state is serialized into the page. */
  readonly values: FormFields;
  readonly errors: Readonly<Record<string, readonly string[]>>;
  readonly welcome: string | undefined;
  /** The name being registered while the JS-path request is in flight. */
  readonly pending: string | undefined;
}

export type Msg =
  // Passed client-side validation; `form` is the raw submission, posted to the server as is.
  | { readonly _tag: 'Register'; readonly name: string; readonly form: FormData }
  | { readonly _tag: 'Registered' }
  | { readonly _tag: 'Failed' };

export interface Props {
  /** Set by the server after a successful no-JS submission (Post/Redirect/Get). */
  readonly welcome: string | undefined;
}

const text = (values: FormFields, key: string): string => {
  const value = values[key];
  return typeof value === 'string' ? value : '';
};

// Client-side (form()) and server-side (submitForm → 422) rejections arrive the same way.
const rejected = (s: State, m: IntentRejected): State => ({
  ...s,
  values: m.values ?? s.values, // secret fields are never in m.values
  errors: fieldErrors(m.issues),
  pending: undefined,
});

interface FieldSpec {
  readonly name: string;
  readonly label: string;
  readonly type: string;
  readonly autocomplete: string;
  readonly minlength?: number;
}

const FIELDS: readonly FieldSpec[] = [
  { name: 'name', label: 'Name', type: 'text', autocomplete: 'username' },
  { name: 'email', label: 'Email', type: 'email', autocomplete: 'email' },
  {
    name: 'password',
    label: 'Password',
    type: 'password',
    autocomplete: 'new-password',
    minlength: 8,
  },
  { name: 'confirm', label: 'Repeat password', type: 'password', autocomplete: 'new-password' },
];

// invalid() writes aria-invalid on the server too (its server half), so no-JS users still get
// the error announced (ADR 0008 server half, view/02-bindings.md "Element hooks").
const fieldView = (s: State, f: FieldSpec) => {
  const errors = s.errors[f.name];
  return html`<p>
    <label for=${f.name}>${f.label}</label>
    <input
      id=${f.name}
      name=${f.name}
      type=${f.type}
      autocomplete=${f.autocomplete}
      minlength=${f.minlength}
      required
      value=${text(s.values, f.name)}
      aria-describedby=${`${f.name}-error`}
      ${invalid(errors)}
    />
    <span id=${`${f.name}-error`} class="error">${errors?.join(' ') ?? ''}</span>
  </p>`;
};

export const Register = define<State, Msg, Props>()('gy-register', {
  props: { welcome: prop.string() },
  init: (props) => ({ values: {}, errors: {}, welcome: props.welcome, pending: undefined }),
  intent: {
    Register: form(RegisterForm, (data, raw) => ({ _tag: 'Register', name: data.name, form: raw })),
  },
  update: {
    // Valid in the browser; the server still decides (it also stores the account).
    Register: (s, m) => [
      { ...s, errors: {}, pending: m.name },
      [
        submitForm('/', m.form, {
          onSuccess: () => ({ _tag: 'Registered' }),
          onFailure: () => ({ _tag: 'Failed' }),
        }),
      ],
    ],
    Registered: (s) => ({ values: {}, errors: {}, welcome: s.pending, pending: undefined }),
    Failed: (s) => ({
      ...s,
      errors: { '': ['Something went wrong. Please try again.'] },
      pending: undefined,
    }),
    IntentRejected: rejected,
  },
  view: (s, i) =>
    s.welcome === undefined
      ? html`<form data-intent=${i.Register} action="/" method="post">
          ${FIELDS.map((f) => fieldView(s, f))}
          <p class="error" role="alert">${s.errors['']?.join(' ') ?? ''}</p>
          <button ?disabled=${s.pending !== undefined}>Create account</button>
        </form>`
      : html`<p role="status">Welcome, <strong>${s.welcome}</strong>! Your account is ready.</p>`,
  styles: css`…`,
});
```

### Invoker commands

Buttons that send command and commandfor invokers to a list, with a fallback where browsers lack them.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/invoker-commands
- Explained in: [Intent](https://gyral.dev/docs/intent/#invoker-commands)

`src/shopping-list.ts`:

```ts
import { css, define, each, html } from '@gyral/core';

export interface State {
  readonly items: readonly string[];
  readonly next: number;
}

export type ListCommand = '--add' | '--clear';

export type Msg = { readonly _tag: 'Command'; readonly command: ListCommand };

const isListCommand = (c: string | undefined): c is ListCommand => c === '--add' || c === '--clear';

/**
 * Buttons send invoker commands to the list (gyral-czi.6): its `command` intent parses which
 * one. `show-modal` is a built-in command the browser handles with no JS at all.
 */
export const ShoppingList = define<State, Msg>()('gy-shopping-list', {
  init: () => ({ items: ['Milk', 'Bread'], next: 1 }),
  intent: {
    Command: ({ command }) =>
      isListCommand(command?.command) ? { _tag: 'Command', command: command.command } : undefined,
  },
  update: {
    Command: (s, m) => {
      switch (m.command) {
        case '--add':
          return { items: [...s.items, `Item ${String(s.next)}`], next: s.next + 1 };
        case '--clear':
          return { ...s, items: [] };
      }
    },
  },
  view: (s, i) => html`
    <menu>
      <li><button type="button" commandfor="list" command="--add">Add item</button></li>
      <li><button type="button" commandfor="list" command="--clear">Clear</button></li>
      <li><button type="button" commandfor="help" command="show-modal">Help</button></li>
    </menu>
    <section id="list" data-intent=${i.Command} data-intent-on="command" aria-labelledby="list-h">
      <h2 id="list-h">List (${s.items.length})</h2>
      <ul>
        ${each(
          s.items,
          (item) => item,
          (item) => html`<li>${item}</li>`,
        )}
      </ul>
    </section>
    <dialog id="help">
      <p>Each button sends a command to the element named by its <code>commandfor</code>.</p>
      <form method="dialog"><button>Close</button></form>
    </dialog>
  `,
  styles: css`…`,
});
```

### Animation

The model counts runs and CSS does the motion: no per-frame state, each run restarted with keyed().

- Source: https://github.com/gyraljs/gyral/tree/main/examples/animation
- Explained in: [Views](https://gyral.dev/docs/views/#lists)

`src/animation.ts`:

```ts
import { css, define, html, keyed } from '@gyral/core';

// The model only counts runs; the motion is a CSS animation the browser runs on its own
// (no per-frame state, no JS timer, no coordinates tweened in JS).

/** A keyframe: time (0..1), position in units of the travel distance, easing to the next stop. */
export interface Stop {
  readonly at: number;
  readonly x: number;
  readonly y: number;
  readonly ease: string;
}

/** The three legs of the original, in milliseconds: right, down, then a curve back home. */
const LEGS = [500, 500, 1600] as const;
export const DURATION_MS = LEGS[0] + LEGS[1] + LEGS[2];

const EASE_IN_CUBIC = 'cubic-bezier(0.32, 0, 0.67, 0)'; // tween.power3.easeIn
const EASE_OUT_CUBIC = 'cubic-bezier(0.33, 1, 0.68, 1)'; // tween.power3.easeOut
const ARC_STEPS = 12;

/** Inverse of easeInOutCubic: the fraction of the arc's time at which it reaches `progress`. */
const arcTime = (progress: number): number =>
  progress < 0.5 ? Math.cbrt(progress / 4) : 1 - Math.cbrt(2 * (1 - progress)) / 2;

const round = (n: number): number => Math.round(n * 1e4) / 1e4 || 0; // `|| 0` drops -0

/**
 * The original path as keyframe stops. The quarter circle (centre at (1, 0), from (1, 1) back to
 * (0, 0)) is sampled so its angle follows easeInOutCubic, like `tween.power3.easeInOut`.
 */
export function path(): readonly Stop[] {
  const right = LEGS[0] / DURATION_MS;
  const down = (LEGS[0] + LEGS[1]) / DURATION_MS;
  const stops: Stop[] = [
    { at: 0, x: 0, y: 0, ease: EASE_IN_CUBIC },
    { at: right, x: 1, y: 0, ease: EASE_OUT_CUBIC },
    { at: down, x: 1, y: 1, ease: 'linear' },
  ];
  for (let k = 1; k <= ARC_STEPS; k++) {
    const angle = (Math.PI / 2) * (1 + k / ARC_STEPS);
    stops.push({
      at: round(down + (1 - down) * arcTime(k / ARC_STEPS)),
      x: round(1 + Math.cos(angle)),
      y: round(Math.sin(angle)),
      ease: 'linear',
    });
  }
  return stops;
}

const offset = (n: number): string => `calc(var(--travel) * ${String(n)})`;

/** `@keyframes travel` for the stops, using the individual `translate` property. */
export function keyframes(stops: readonly Stop[]): string {
  const frames = stops.map(
    (s) =>
      `${(s.at * 100).toFixed(2)}% { translate: ${offset(s.x)} ${offset(s.y)}; ` +
      `animation-timing-function: ${s.ease}; }`,
  );
  return `@keyframes travel { ${frames.join(' ')} }`;
}

export interface State {
  /** How many times the animation was started. Each run gets a fresh element. */
  readonly runs: number;
}

export type Msg = { readonly _tag: 'Animate' };

/** One run's element; keyed by the run number, so each run gets a fresh one. */
const target = (run: number) => html`<div class=${run === 0 ? 'target' : 'target moving'}></div>`;

export const Animation = define<State, Msg>()('gy-animation', {
  init: () => ({ runs: 0 }),
  intent: { Animate: true },
  update: { Animate: (s) => ({ runs: s.runs + 1 }) },
  view: (s, i) => html`
    <button type="button" data-intent=${i.Animate}>Animate it!</button>
    <div class="stage" aria-hidden="true">
      ${
        // A new key per run replaces the element, which restarts the CSS animation.
        keyed(s.runs, target(s.runs))
      }
    </div>
  `,
  styles: css`…`,
});
```

### Animated letters

Letters you type grow in and shrink out. The model tracks which are present or leaving; CSS transitions animate.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/animated-letters
- Explained in: [Model and update](https://gyral.dev/docs/update/)

`src/letters.ts`:

```ts
import { css, define, each, html, type Next } from '@gyral/core';
import { delay } from '@gyral/time';

import { letterKeys } from './keyboard.js';

// The model only knows which letters are present or leaving; CSS transitions do the growing
// and shrinking, so there is no per-frame easing loop over the letters.

/** How long a letter takes to grow in or shrink out. The model waits this long to drop it. */
export const TRANSITION_MS = 600;

export interface Letter {
  readonly key: string;
  /** Shrinking out; removed from the model once the transition has had time to finish. */
  readonly leaving: boolean;
}

export interface State {
  readonly letters: readonly Letter[];
}

export type Msg =
  | { readonly _tag: 'Key'; readonly letter: string }
  | { readonly _tag: 'Gone'; readonly letter: string };

const byKey = (a: Letter, b: Letter): number => a.key.localeCompare(b.key);

const setLeaving = (s: State, letter: string, leaving: boolean): State => ({
  letters: s.letters.map((l) => (l.key === letter ? { ...l, leaving } : l)),
});

/** Toggles a letter: absent → added, present → leaving (dropped later), leaving → back. */
export function toggle(s: State, letter: string): Next<State, Msg> {
  const existing = s.letters.find((l) => l.key === letter);
  if (existing === undefined) {
    return { letters: [...s.letters, { key: letter, leaving: false }].toSorted(byKey) };
  }
  if (existing.leaving) return setLeaving(s, letter, false);
  return [
    setLeaving(s, letter, true),
    // One lane per letter: toggling it again restarts its own timer, nobody else's.
    [delay<Msg>(TRANSITION_MS, { _tag: 'Gone', letter }, { key: `leave:${letter}` })],
  ];
}

export const AnimatedLetters = define<State, Msg>()('gy-animated-letters', {
  // The original starts with just "A"; "GYRAL" shows the staggered entrance.
  init: () => ({ letters: ['A', 'G', 'L', 'R', 'Y'].map((key) => ({ key, leaving: false })) }),
  // Key presses stream in for as long as the component is in the page (ADR 0025).
  subscriptions: () => [letterKeys<Msg>((letter) => ({ _tag: 'Key', letter }))],
  update: {
    Key: (s, m) => toggle(s, m.letter),
    // Ignore a stale timer if the letter came back while it was shrinking.
    Gone: (s, m) => ({ letters: s.letters.filter((l) => l.key !== m.letter || !l.leaving) }),
  },
  view: (s) => html`
    <p id="hint">Press a letter key (A–Z) to add it, or press it again to remove it.</p>
    <ul aria-label="Letters" aria-describedby="hint">
      ${each(
        s.letters,
        (l) => l.key,
        (l) =>
          html`<li class=${l.leaving ? 'out' : 'in'} aria-hidden=${l.leaving ? 'true' : 'false'}>
            ${l.key}
          </li>`,
      )}
    </ul>
  `,
  styles: css`…`,
});
```

### Devtools demo

A small app that exercises the devtools panel: updates, a store and a command lane.

- Source: https://github.com/gyraljs/gyral/tree/main/examples/devtools
- Explained in: [Devtools](https://gyral.dev/docs/devtools/)

`src/demo.ts`:

```ts
import { css, define, defineStore, html, send } from '@gyral/core';
import { delay } from '@gyral/time';

/** A shared store, so the timeline shows store messages too. */
export const tally = defineStore<{ readonly total: number }, { readonly _tag: 'Add' }>('tally', {
  init: () => ({ total: 0 }),
  update: { Add: (s) => ({ total: s.total + 1 }) },
});

export interface State {
  readonly count: number;
  readonly saving: boolean;
}

export type Msg =
  { readonly _tag: 'Increment' } | { readonly _tag: 'Save' } | { readonly _tag: 'Saved' };

/** Increment updates state and the store; Save runs a delayed command (a command lane). */
export const Demo = define<State, Msg>()('gy-devtools-demo', {
  init: () => ({ count: 0, saving: false }),
  stores: [tally],
  intent: {
    Increment: true,
    Save: true,
  },
  update: {
    Increment: (s) => [{ ...s, count: s.count + 1 }, [send(tally, { _tag: 'Add' })]],
    Save: (s) => [
      { ...s, saving: true },
      [delay<Msg>(400, { _tag: 'Saved' }, { key: 'save', concurrency: 'switch' })],
    ],
    Saved: (s) => ({ ...s, saving: false }),
  },
  view: (s, i, { read }) => html`
    <p>Count: <output>${s.count}</output> · Store total: <output>${read(tally).total}</output></p>
    <p>
      <button type="button" data-intent=${i.Increment}>Increment</button>
      <button type="button" data-intent=${i.Save}>${s.saving ? 'Saving…' : 'Save'}</button>
    </p>
  `,
  styles: css`…`,
});
```

## A whole application

[gyral-shop](https://github.com/gyraljs/gyral-shop) is a department store built with Gyral: catalog, search, cart, checkout, accounts and an admin area, server-rendered with forms that work without JavaScript, and four switchable themes. It runs at https://demo-shop.gyral.dev, reset every night.
