# Gyral 0.3.6: complete documentation > Gyral is a small framework for web components: intents in, a pure update, a pure view out. Server rendering, forms, routing and testing built in. Generated from https://gyral.dev. Each section below is one page; its source URL follows its title. --- # Gyral documentation > Learn Gyral, the Model-View-Intent framework for web components: guides, concepts and reference. Source: https://gyral.dev/docs/ Gyral builds web components from three pure parts. **Intent** turns platform events into typed messages, **update** turns messages into new state, and **view** turns state into HTML. Side effects such as HTTP, routing and timers are returned from update as data, and drivers carry them out at the edges of your app. New here? Start with [Getting started](https://gyral.dev/docs/getting-started/). It takes about ten minutes and ends with a working, tested component. Writing code with an agent? Connect it to Gyral's MCP server first: [Use Gyral with coding agents](https://gyral.dev/docs/coding-agents/). The guides build on each other, but each one stands alone. The [API reference](https://gyral.dev/docs/api/) is generated from the published type declarations, so it always matches the code. --- # Getting started > Install Gyral, write your first Model-View-Intent component, use it in a page, and test it without a browser. Source: https://gyral.dev/docs/getting-started/ In this guide you'll create a Gyral app, write a counter component, put it on a page, and test it. You need [Node.js](https://nodejs.org) 24 or later and a package manager. The examples use npm; pnpm and yarn work the same way. ## Create a project The quickest start is `create-gyral`. It sets up a Vite project with Gyral, TypeScript and a first test: ```sh npm create gyral@latest my-app -- --template ssr cd my-app npm install npm run dev ``` There are two templates: - **`basic`**: a client-rendered app. The page loads and your components render in the browser. It builds [client-only](https://gyral.dev/docs/rendering-modes/#client-only-builds), without hydration code; remove `clientOnly: true` from `vite.config.ts` before you render any page on a server. - **`ssr`**: the same component rendered on the server first, so the page works before any JavaScript loads, then [hydrated](https://gyral.dev/docs/server-rendering/) in place. Leave out `--template` and it asks you which one you want. ### Or set it up by hand Gyral works with any bundler. To add it to a [Vite](https://vite.dev) project yourself: ```sh npm create vite@latest my-app -- --template vanilla-ts cd my-app npm install @gyral/core ``` `@gyral/core` is the whole runtime: Gyral renders with its own view layer and has no runtime dependencies. Then add Gyral's Vite preset. It adds the template compiler to `vite build`: every template is checked and precompiled, so a mistake fails the build with a code frame and the bundle doesn't carry the code that prepares templates at runtime: ```ts // vite.config.ts import { defineConfig } from 'vite'; import { gyralVitePreset } from '@gyral/core/vite'; export default defineConfig({ ...gyralVitePreset(), }); ``` To see the same checks in your editor, add the ESLint config from `@gyral/core/eslint` (see [Views](https://gyral.dev/docs/views/#checked-before-it-runs)). Optionally, `npm install -D parse5` lets the compiler check markup against a full HTML parser too. ## Write a component A component is a custom element defined from four parts: its initial state, the intents it understands, how each message changes the state, and how the state looks. ```ts // src/counter.ts import { define, html } from '@gyral/core'; export interface State { readonly count: number; } export type Msg = { readonly _tag: 'Increment' } | { readonly _tag: 'Decrement' }; export const Counter = define()('my-counter', { init: () => ({ count: 0 }), intent: { Increment: () => ({ _tag: 'Increment' }), Decrement: () => ({ _tag: 'Decrement' }), }, update: { Increment: (s) => ({ count: s.count + 1 }), Decrement: (s) => ({ count: s.count - 1 }), }, view: (s, i) => html` ${s.count} `, }); ``` Read it from the bottom up: - **The view** is an `html` template. It doesn't attach event handlers. Instead it _names_ an intent on each button with `data-intent`. `i` is typed from your messages, so a misspelt `i.Incremnt` is a compile error. - **Intent** says how a platform event becomes a message. A click on a button carrying `data-intent=${i.Increment}` runs the `Increment` parser, which returns `{ _tag: 'Increment' }`. A parser can also read the event's value, form data or key, and return `undefined` to decline it. - **Update** has one reducer per message, and TypeScript checks you have one for every message. A reducer returns the next state and never changes the old one. - **Init** gives the state the component starts with. ## Use it in a page `define()` registers the element, so importing the module is enough. Replace the contents of `index.html`'s `` and import your component: ```html

My first Gyral app

``` Run `npm run dev` and open the address it prints. The counter is a standard custom element with its own Shadow DOM, so it works in any page and alongside any framework. When the page loads an entry module instead (the `basic` template's `src/main.ts`), that module must import every component module, for its side effect. Each new component in its own file gets a line here, such as the stepper below: ```ts // src/main.ts import './counter.js'; import './stepper.js'; ``` A module that nothing imports never runs, so its tag is never defined: `` stays an empty unknown element. In development, Gyral warns in the console (G0014) when a tag on the page is still undefined 3 seconds after it loaded; production builds stay silent. A component that renders another component in its view imports that module too. ## Read input Intents get more than the event. Here's a step size the user can type, read from the input's `value` and parsed before it reaches the model: ```ts // src/stepper.ts import { define, html } from '@gyral/core'; export interface State { readonly count: number; readonly step: number; } export type Msg = { readonly _tag: 'Increment' } | { readonly _tag: 'StepChanged'; readonly step: number }; export const Stepper = define()('my-stepper', { init: () => ({ count: 0, step: 1 }), intent: { Increment: () => ({ _tag: 'Increment' }), StepChanged: ({ value }) => { const step = Number(value); // Not a whole number: ignore the keystroke, keep the last good step. return Number.isInteger(step) ? { _tag: 'StepChanged', step } : undefined; }, }, update: { Increment: (s) => ({ ...s, count: s.count + s.step }), StepChanged: (s, m) => ({ ...s, step: m.step }), }, view: (s, i) => html` ${s.count} `, }); ``` The model only ever sees a whole number. Parsing happens once, at the boundary, which is where Gyral wants it. ## Test it `update` is a plain function, so you can test behaviour without a browser. `@gyral/testing` gives you `step` (one message) and `run` (a sequence): ```sh npm install -D @gyral/testing vitest ``` ```ts // src/counter.test.ts import { expect, it } from 'vitest'; import { run, step } from '@gyral/testing'; import { Counter } from './counter.js'; it('counts', () => { expect(step(Counter.spec, { count: 1 }, { _tag: 'Increment' }).state).toEqual({ count: 2 }); const { state } = run(Counter.spec, [ { _tag: 'Increment' }, { _tag: 'Increment' }, { _tag: 'Decrement' }, ]); expect(state.count).toBe(1); }); ``` `Counter.spec` is the object you passed to `define()`. Tests that need the real element (clicks, focus, rendering) run in a browser with [Vitest browser mode](https://vitest.dev/guide/browser/) and `await settled()` before they look at the DOM; `@gyral/testing` also has helpers for server rendering, hydration and virtual time. See [Testing](https://gyral.dev/docs/testing/). ## Next steps - Build something real from a [recipe](https://gyral.dev/docs/recipes/): a [todo list with filters](https://gyral.dev/docs/recipes/#a-todo-list-with-filters) and a [signup form with validation](https://gyral.dev/docs/recipes/#a-signup-form-with-native-validation), each a complete component with tests that needs nothing beyond a `basic` app. - Learn the three parts in depth: [Intent](https://gyral.dev/docs/intent/), [Model and update](https://gyral.dev/docs/update/) and [Views](https://gyral.dev/docs/views/). - Browse the [examples](https://gyral.dev/examples/): forms, HTTP, routing, shared state and server rendering. - See a whole application in [gyral-shop](https://github.com/gyraljs/gyral-shop). - Before you ship, read [Deploying](https://gyral.dev/docs/deploying/) and the [known issues](https://gyral.dev/docs/packages/#known-issues). - Working with a coding agent? Connect it to Gyral's MCP server and skill: [Use Gyral with coding agents](https://gyral.dev/docs/coding-agents/). - Upgrading an existing app? [Versioning and stability](https://gyral.dev/docs/versioning/) explains how to move to 0.3.6 safely. --- # Recipes > Complete components to copy, with their tests. A todo list with filters, a signup form with native validation, and fixes for common bugs. Source: https://gyral.dev/docs/recipes/ Each recipe is a whole component in one file, with its tests. They need only `@gyral/core`, plus `@gyral/testing` and Vitest for the tests, so they work in a new `basic` app from [create-gyral](https://gyral.dev/docs/getting-started/#create-a-project). Every recipe compiles against Gyral 0.3.6, and the site tests each one in a browser when it builds. ## A todo list with filters Add todos, tick them off, delete them, and show all, active or done ones: ```ts // src/todo-app.ts import { define, each, focus, html, intentsOf, type TemplateResult } from '@gyral/core'; export interface Todo { readonly id: number; readonly text: string; readonly done: boolean; } export type Filter = 'all' | 'active' | 'done'; export interface State { readonly todos: readonly Todo[]; readonly nextId: number; readonly draft: string; readonly filter: Filter; } export type Msg = | { readonly _tag: 'Typed'; readonly text: string } | { readonly _tag: 'Add'; readonly text: string } | { readonly _tag: 'Toggle'; readonly id: number } | { readonly _tag: 'Delete'; readonly id: number } | { readonly _tag: 'Show'; readonly filter: Filter }; const FILTERS: readonly Filter[] = ['all', 'active', 'done']; const LABELS: Readonly> = { all: 'All', active: 'Active', done: 'Done' }; const isFilter = (value: string | undefined): value is Filter => FILTERS.some((f) => f === value); /** A row's id from its control's `value`, or `undefined` for anything else. */ const idOf = (value: string | undefined): number | undefined => value !== undefined && /^\d+$/.test(value) ? Number(value) : undefined; /** The todos a filter shows. State keeps the whole list; the view shows this. */ export const visible = (todos: readonly Todo[], filter: Filter): readonly Todo[] => filter === 'all' ? todos : todos.filter((t) => t.done === (filter === 'done')); // The intent names as a module constant, so rows can use them and stay pure. const i = intentsOf(); // A row reads only its todo and module constants. Its controls carry the id, never a position. const Row = (todo: Todo): TemplateResult => html`
  • `; export const TodoApp = define()('my-todo-app', { init: () => ({ todos: [], nextId: 1, draft: '', filter: 'all' }), intent: { Typed: ({ value }) => ({ _tag: 'Typed', text: value ?? '' }), Add: ({ formData }) => { const text = formData?.get('text'); return typeof text === 'string' && text.trim() !== '' ? { _tag: 'Add', text: text.trim() } : undefined; }, Toggle: ({ value }) => { const id = idOf(value); return id === undefined ? undefined : { _tag: 'Toggle', id }; }, Delete: ({ value }) => { const id = idOf(value); return id === undefined ? undefined : { _tag: 'Delete', id }; }, Show: ({ value }) => (isFilter(value) ? { _tag: 'Show', filter: value } : undefined), }, update: { Typed: (s, m) => ({ ...s, draft: m.text }), // Clearing `draft` empties the input; focus goes back to it for the next todo. Add: (s, m) => [ { ...s, todos: [...s.todos, { id: s.nextId, text: m.text, done: false }], nextId: s.nextId + 1, draft: '', }, [focus('#new-todo')], ], Toggle: (s, m) => ({ ...s, todos: s.todos.map((t) => (t.id === m.id ? { ...t, done: !t.done } : t)), }), // The deleted row had focus: give it to the input, not to the page body. Delete: (s, m) => [{ ...s, todos: s.todos.filter((t) => t.id !== m.id) }, [focus('#new-todo')]], Show: (s, m) => ({ ...s, filter: m.filter }), }, view: (s, i) => { const shown = visible(s.todos, s.filter); const left = s.todos.filter((t) => !t.done).length; return html`
    ${FILTERS.map( (f) => html``, )}
      ${each(shown, (t) => t.id, Row)}

    ${shown.length === 0 ? 'Nothing to show.' : ''}

    ${left} left

    `; }, }); ``` How it works: - **State keeps the whole list. The view filters it.** `visible(todos, filter)` is a plain function that the view and the tests call. The filter is state; the filtered list isn't, so the two can't get out of step. - **Controls carry the id**: `value=${todo.id}`, and the parser reads `value`. A message says which todo, never which row. See [the wrong item changes](#toggling-an-item-in-a-filtered-list-changes-the-wrong-item). - **Rows are keyed by id**: `each(shown, (t) => t.id, Row)`. A todo keeps its element when the filter changes or another row goes away. - **Rows are pure.** `Row` reads only its todo and module constants, such as `i = intentsOf()`. Its return type is written out, so `Row` and `TodoApp` don't infer each other's types. The view uses its own `i`. - **The input is form state.** `value=${s.draft}` follows `Typed`, and `Add` sets `draft` back to `''`, which empties the input. Without `draft` in state, the text would stay after Add. - **Focus is a command.** `Add` and `Delete` return `focus('#new-todo')`, so focus never falls to the page body when its row is deleted. - The filter buttons use `.map`: three buttons that never move don't need `each`. Test the model without a browser. `run` folds messages from `init` (or from `state`), and `parse` runs one parser without a DOM: ```ts // src/todo-app.test.ts import { expect, it } from 'vitest'; import { focusTargetsIn, parse, run, step } from '@gyral/testing'; import { TodoApp, visible } from './todo-app.js'; const added = run(TodoApp.spec, [ { _tag: 'Add', text: 'Write docs' }, { _tag: 'Add', text: 'Ship it' }, { _tag: 'Add', text: 'Celebrate' }, ]).state; it('adds todos with their own ids and clears the input', () => { expect(added.todos.map((t) => [t.id, t.text])).toEqual([ [1, 'Write docs'], [2, 'Ship it'], [3, 'Celebrate'], ]); expect(added.draft).toBe(''); const { commands } = step(TodoApp.spec, added, { _tag: 'Add', text: 'More' }); expect(focusTargetsIn(commands)).toEqual([{ selector: '#new-todo' }]); }); it('toggles and deletes by id, whatever the filter shows', () => { const { state } = run( TodoApp.spec, [ { _tag: 'Toggle', id: 1 }, { _tag: 'Show', filter: 'active' }, // "Celebrate" is the second row on screen now, but the message names it by id. { _tag: 'Toggle', id: 3 }, { _tag: 'Delete', id: 2 }, ], { state: added }, ); expect(state.todos).toEqual([ { id: 1, text: 'Write docs', done: true }, { id: 3, text: 'Celebrate', done: true }, ]); expect(visible(state.todos, 'active')).toEqual([]); expect(visible(state.todos, 'done').map((t) => t.id)).toEqual([1, 3]); }); it('reads ids and filters from the markup, and ignores anything else', () => { expect(parse(TodoApp, 'Toggle', { value: '3' })).toEqual({ _tag: 'Toggle', id: 3 }); expect(parse(TodoApp, 'Toggle', { value: 'three' })).toBeUndefined(); expect(parse(TodoApp, 'Show', { value: 'done' })).toEqual({ _tag: 'Show', filter: 'done' }); expect(parse(TodoApp, 'Show', { value: 'archived' })).toBeUndefined(); }); ``` ## A signup form with native validation No extra packages. The browser's constraint checks find what is wrong, and your own messages say it. The [Forms](https://gyral.dev/docs/forms/#complete-example-a-validated-form) page starts with this same component and explains each part; here it comes with its tests: ```ts // src/account-form.ts import { define, focus, html } from '@gyral/core'; /** The fields, in the order they appear: the first one with an error gets focus. */ export const FIELDS = ['name', 'email', 'password', 'consent'] as const; export type Field = (typeof FIELDS)[number]; /** One message per field; `''` means no error. */ export type Errors = Readonly>; export const NO_ERRORS: Errors = { name: '', email: '', password: '', consent: '' }; export interface State { readonly errors: Errors; /** What the live region announces after a rejected submit; `''` otherwise. */ readonly summary: string; /** The clean values after a successful signup. */ readonly account: { readonly name: string; readonly email: string } | undefined; } export type Msg = | { readonly _tag: 'Rejected'; readonly errors: Errors } | { readonly _tag: 'SignedUp'; readonly name: string; readonly email: string } | { readonly _tag: 'Checked'; readonly field: Field; readonly error: string }; const isField = (name: string): name is Field => FIELDS.some((f) => f === name); /** * Your message for a field's first problem, from the browser's own checks (`validity`), or * `''` when the field is fine. The constraints in the markup (`required`, `type="email"`, * `minlength`) still decide what is wrong; `novalidate` only stops the browser from * blocking the submit and showing its own bubble. */ export function problemWith(input: HTMLInputElement): string { const { validity } = input; // A checkbox is missing when unchecked. `required` accepts " ", so blank text is missing too. const missing = input.type === 'checkbox' ? !input.checked : validity.valueMissing || input.value.trim() === ''; switch (input.name) { case 'name': return missing ? 'Enter your name.' : ''; case 'email': if (missing) return 'Enter your email address.'; return validity.typeMismatch ? 'Enter an email address like ada@example.com.' : ''; case 'password': if (missing) return 'Enter a password.'; // `tooShort` is set only for typed values; the length check also covers a pre-filled one. return validity.tooShort || input.value.length < input.minLength ? 'Use at least 8 characters.' : ''; case 'consent': return missing ? 'Accept the privacy policy to continue.' : ''; default: return ''; } } /** What the status region announces after a successful signup. */ const welcome = (account: State['account']): string => account === undefined ? '' : `Welcome, ${account.name}! We’ll write to ${account.email}.`; /** Reads a submitted form: every field's error, or the clean values. */ function submission(form: Element, data: FormData | undefined): Msg | undefined { if (!(form instanceof HTMLFormElement) || data === undefined) return undefined; const errors: Record = { ...NO_ERRORS }; for (const field of FIELDS) { const input = form.elements.namedItem(field); if (input instanceof HTMLInputElement) errors[field] = problemWith(input); } if (FIELDS.some((f) => errors[f] !== '')) return { _tag: 'Rejected', errors }; const text = (field: Field): string => { const value = data.get(field); return typeof value === 'string' ? value.trim() : ''; }; // The password stays in the form data: never keep it in state. return { _tag: 'SignedUp', name: text('name'), email: text('email') }; } export const AccountForm = define()('my-account-form', { init: () => ({ errors: NO_ERRORS, summary: '', account: undefined }), intent: { // Not a message tag, so it may return either message. Submit: ({ target, formData }) => submission(target, formData), // Text fields send it on `input`, the checkbox on `change`. Checked: ({ target }) => target instanceof HTMLInputElement && isField(target.name) ? { _tag: 'Checked', field: target.name, error: problemWith(target) } : undefined, }, update: { // Show every error at once, announce how many, and focus the first field that has one. Rejected: (s, m) => { const count = FIELDS.filter((f) => m.errors[f] !== '').length; const first = FIELDS.find((f) => m.errors[f] !== ''); const summary = `Fix ${String(count)} ${count === 1 ? 'field' : 'fields'} to continue.`; const focusFirst = first === undefined ? [] : [focus(`#${first}`)]; return [{ ...s, errors: m.errors, summary }, focusFirst]; }, // In an app, this also returns the command that posts the form. SignedUp: (_s, m) => ({ errors: NO_ERRORS, summary: '', account: { name: m.name, email: m.email }, }), // While the user types, only a field that already shows an error is checked again, // so a message changes or disappears as soon as the value is fixed. Checked: (s, m) => s.errors[m.field] === '' ? s : { ...s, errors: { ...s.errors, [m.field]: m.error } }, }, // No `value` bindings: what the user typed stays in the fields after a rejected submit. view: (s, i) => html`

    ${s.summary}

    ${s.errors.name}

    ${s.errors.email}

    ${s.errors.password}

    ${welcome(s.account)}

    `, }); ``` How it works: - **`novalidate` on the form, constraints on the fields.** `required`, `type="email"` and `minlength` still set each field's `validity`. `novalidate` stops the browser from blocking the submit. Without it, a failing constraint shows the browser's bubble and no `submit` event fires, so your messages never appear. - **The submit parser reads `validity`.** `problemWith(input)` turns `valueMissing`, `typeMismatch`, `tooShort` and an unchecked checkbox into your words. `Submit` checks every field and returns `Rejected` with all the errors, or `SignedUp` with clean values. Reading the form is input parsing, the intent layer's job; reducers see only the result. - **Check what the browser doesn't.** `required` accepts a name made of spaces, so the parser trims first. `tooShort` is set only after the user types, not for a value set by script or rendered by a server, so the parser checks the length too. - **Errors are state**: one string per field, `''` for none. The view writes `aria-invalid` and the message, and `aria-describedby` links the message to its field. - **Focus goes to the first invalid field**, through a `focus()` command from `Rejected`, and the `role="alert"` paragraph announces how many fields to fix. - **An error clears as soon as the field is fixed.** Each field sends `Checked` on input (the checkbox on change). The reducer updates only a field that already shows an error, so nothing turns red while someone is still typing. - **Enter submits.** The form has a submit button, so Enter in any field submits it, like a click. - **No password in state.** `SignedUp` carries the name and the email. To send the form to a server, keep the raw `FormData` in the message and post it with a command, as in [Submitting to the server](https://gyral.dev/docs/forms/#submitting-to-the-server). The reducers are tested without a browser: ```ts // src/account-form.test.ts import { expect, it } from 'vitest'; import { focusTargetsIn, run, step } from '@gyral/testing'; import { AccountForm, NO_ERRORS } from './account-form.js'; const rejected = { name: '', email: 'Enter an email address like ada@example.com.', password: 'Use at least 8 characters.', consent: '', }; it('shows every error, announces how many and focuses the first invalid field', () => { const { state, commands } = step( AccountForm.spec, { errors: NO_ERRORS, summary: '', account: undefined }, { _tag: 'Rejected', errors: rejected }, ); expect(state.errors).toEqual(rejected); expect(state.summary).toBe('Fix 2 fields to continue.'); expect(focusTargetsIn(commands)).toEqual([{ selector: '#email' }]); }); it('clears an error as soon as the field is fixed, and leaves untouched fields alone', () => { const { state } = run(AccountForm.spec, [ { _tag: 'Rejected', errors: rejected }, { _tag: 'Checked', field: 'email', error: '' }, { _tag: 'Checked', field: 'name', error: 'Enter your name.' }, ]); expect(state.errors).toEqual({ ...rejected, email: '' }); }); it('welcomes the new user and forgets old errors', () => { const { state } = run(AccountForm.spec, [ { _tag: 'Rejected', errors: rejected }, { _tag: 'SignedUp', name: 'Ada', email: 'ada@example.com' }, ]); expect(state).toEqual({ errors: NO_ERRORS, summary: '', account: { name: 'Ada', email: 'ada@example.com' }, }); }); ``` The submit parser reads the form's fields, so test it in a browser too. In a `basic` app, `npm test` runs both files in Chromium. `mount()` sets values with script, which is why the parser's own length check matters: ```ts // src/account-form.browser.test.ts import { expect, it } from 'vitest'; import { mount } from '@gyral/testing'; import { AccountForm } from './account-form.js'; it('reads the browser’s checks on submit and focuses the first invalid field', async () => { const form = await mount(AccountForm); await form.type('#email', 'ada'); await form.type('#password', 'short'); await form.click('button'); expect(form.$('[role="alert"]')?.textContent).toBe('Fix 4 fields to continue.'); expect(form.$('#name-error')?.textContent).toBe('Enter your name.'); expect(form.$('#email-error')?.textContent).toBe('Enter an email address like ada@example.com.'); expect(form.$('#password-error')?.textContent).toBe('Use at least 8 characters.'); expect(form.$('#consent-error')?.textContent).toBe('Accept the privacy policy to continue.'); expect(form.el.shadowRoot?.activeElement?.id).toBe('name'); await form.type('#name', ' Ada '); expect(form.$('#name-error')?.textContent).toBe(''); await form.type('#email', 'ada@example.com'); await form.type('#password', 'correct horse'); await form.click('#consent'); expect(form.$('#consent-error')?.textContent).toBe(''); await form.click('button'); expect(form.$('[role="status"]')?.textContent).toBe( 'Welcome, Ada! We’ll write to ada@example.com.', ); form.dispose(); }); ``` To validate on the server with the same rules, use one schema for both sides: [Forms](https://gyral.dev/docs/forms/#one-schema-for-both-sides). ## Troubleshooting ### Toggling an item in a filtered list changes the wrong item **What you see.** With a filter on, ticking a row ticks a different item, often one that isn't shown. **Why.** The control carries the row's position, and the reducer uses it as an index into the whole list: ```ts // Wrong: in the view, `n` is the row's place in the filtered list… ${visible(s.todos, s.filter).map( (t, n) => html``, )} // …and the reducer reads it as a place in the whole list. Toggle: (s, m) => ({ ...s, todos: s.todos.map((t, n) => (n === m.index ? { ...t, done: !t.done } : t)), }), ``` With "Active" showing, the second row on screen may be the fourth todo, so the reducer changes `s.todos[1]` instead. A sort or a search breaks it the same way. **Fix.** Carry the id, and find the item by id: - Put `value=${todo.id}` on the control, and parse `value` as an id. - Match by id in the reducer: `t.id === m.id`. - Key the list by id: `each(shown, (t) => t.id, Row)`. A key alone doesn't fix it. The key keeps each element with its item; the message still has to say which item. The [todo list](#a-todo-list-with-filters) recipe does all three. ### Custom error messages never show **What you see.** Submitting an empty form shows the browser's bubble ("Please fill out this field"), and your parser never runs. **Why.** Without `novalidate`, the browser checks `required`, `type="email"`, `minlength` and `pattern` before it fires `submit`. When one fails, there is no `submit` event, so there is no intent. **Fix.** Add `novalidate` to the `
    ` and keep the constraints on the fields. Read each field's `validity` in the submit parser, as the [signup form](#a-signup-form-with-native-validation) does. --- # Use Gyral with coding agents > Connect Claude Code, VS Code or any MCP client to the Gyral MCP server, hosted or local, install the agent skill, and give agents llms-small.txt. Source: https://gyral.dev/docs/coding-agents/ A coding agent writes better Gyral code when it can look things up instead of guessing. Gyral gives agents four things, and they work together: - **The MCP server**, `@gyral/mcp`: searches these docs, returns exact API signatures and working examples, scaffolds components, and typechecks code against your project. It runs locally or at `https://mcp.gyral.dev/mcp`. - **The agent skill**: the rules and the API in a form agents load on demand. - **[llms-small.txt](https://gyral.dev/llms-small.txt)**: the rules, one complete component and tested recipes in one small file. - **An `AGENTS.md`** in every app `create-gyral` makes. ## The hosted MCP server `https://mcp.gyral.dev/mcp` is a public MCP server over Streamable HTTP. It needs no token and no install, and it only reads: it answers from Gyral's docs, API and examples. In Claude Code: ```sh claude mcp add --transport http gyral https://mcp.gyral.dev/mcp ``` In VS Code, add it to `.vscode/mcp.json`: ```json { "servers": { "gyral": { "type": "http", "url": "https://mcp.gyral.dev/mcp" } } } ``` Any other client that speaks Streamable HTTP: add a server with the URL `https://mcp.gyral.dev/mcp` and no authentication. The hosted server has every tool except `check_snippet`, which needs your project's TypeScript and packages. It can run an older `@gyral/mcp` than the latest release; its `initialize` response and `https://mcp.gyral.dev/healthz` report the version it serves. This page describes the tools as `@gyral/mcp` 0.3.6 provides them. ## The local MCP server Run the server on your machine when the agent writes code. It adds `check_snippet`, which typechecks against the TypeScript and `@gyral/*` versions installed in your project (TypeScript 5 or 6; TypeScript 7 has no compiler API, so there it asks you to run your own typecheck), and `scaffold_component` reads your `package.json`. Start it from the project directory: ```sh claude mcp add gyral -- npx -y @gyral/mcp ``` That's Claude Code. Other clients: - **Codex**: `codex mcp add gyral -- npx -y @gyral/mcp` - **Cursor**, in `.cursor/mcp.json`: ```json { "mcpServers": { "gyral": { "command": "npx", "args": ["-y", "@gyral/mcp"] } } } ``` - **VS Code**, in `.vscode/mcp.json`: ```json { "servers": { "gyral": { "type": "stdio", "command": "npx", "args": ["-y", "@gyral/mcp"] } } } ``` - **Any other client**: run `npx -y @gyral/mcp` as a stdio server. npx fetches the latest `@gyral/mcp` release unless your project has another version installed. The local server answers from the docs bundled with its own version, so it works offline and matches the Gyral you install. Set `GYRAL_DOCS_URL=https://gyral.dev/llms-full.txt` to load the latest docs at startup instead; it falls back to the bundled copy after 3 seconds. `GYRAL_PROJECT_DIR` points `check_snippet` at another directory than the working one. ## The tools | Tool | What it returns | | -------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `start_here` | The mental model, the rules, the workflow, and which example or guide covers which task | | `search_docs` | Ranked doc sections for a query: title, URL and snippet | | `get_doc` | One docs page, or one section with `#anchor`, such as `forms#the-server-half` | | `get_api` | The declaration, doc comment and import line of an export, with usage code and the next call; on a miss, the nearest names | | `list_examples` | Every example and what it shows | | `get_example` | One example's component source | | `scaffold_component` | Starting code: `basic`, `form`, `form-native` or `ssr-page`, importing only what you have | | `check_snippet` | Type errors for a module against your project, with a suggested fix for each (local only) | The workflow the server teaches, first in its instructions and in every tool description: call `start_here` once, start from `get_example` or `scaffold_component`, write the module right away, then run `check_snippet` and apply its suggested fixes until it passes. Use `get_api` only for a name you already saw in an example or an error, not to read the API type by type. `get_api` steers back to that loop. On a function with a matching example, such as `define`, `each`, `fieldErrors` or `every`, it adds a short usage module and the call that gives working code for the task, such as `scaffold_component({ tag: "signup-form", kind: "form-native" })`. On a type, it adds a note that TypeScript infers it and that the real type errors come from `check_snippet` (on the local server) or your typecheck. `get_api("*")` groups the functions by task, with the example or scaffold to start from, and collapses the types into one line. The local stdio server also counts reads (every docs, API and example lookup) since the agent last called `scaffold_component` or `check_snippet`: from the third `get_example`, the fourth `get_api` or the eighth read of any kind, its answers end with a nudge that names the examples read and suggests adapting the closest one, then `check_snippet`. The HTTP server, hosted or your own, keeps no state between requests, so it never nudges. `scaffold_component` imports only packages your project has installed, and writes a model test only when `@gyral/testing` and `vitest` are there. `form-native` needs no dependencies: it turns the browser's own constraint checks into your messages. `check_snippet` also flags Gyral mistakes TypeScript can't see, such as a one-call `define` or side effects in `update`. The server also offers the resources `gyral://llms.txt`, `gyral://skill` and `gyral://skill/references/{name}`, and a `build-gyral-component` prompt. ### Host your own `gyral-mcp http` runs the server behind the hosted one: read-only, anonymous Streamable HTTP at `/mcp`, without `check_snippet`. It listens on `127.0.0.1:3000` by default, and needs `GYRAL_MCP_PUBLIC_ORIGIN` set to the origin it's served from: ```sh GYRAL_MCP_PUBLIC_ORIGIN=http://127.0.0.1:3000 npx -y @gyral/mcp http ``` It is configured by environment variables only; `gyral-mcp http --help` lists them. Put it behind TLS, and pin the package version you deploy. The [package README](https://github.com/gyraljs/gyral/blob/v0.3.6/packages/mcp/README.md#public-hosting) has the details. ## The agent skill The skill teaches an agent the rules and the API, with a reference per topic that it loads when the task needs it. Every code block in it typechecks against the packages. `SKILL.md` opens with where to start from working code (the recipes, llms-small.txt and the MCP loop) and the rule that the entry must import every component module, and its forms reference starts with native validation, as the [Forms guide](https://gyral.dev/docs/forms/) does. In Claude Code, add Gyral's repository as a plugin marketplace and install the plugin: ```text /plugin marketplace add gyraljs/gyral /plugin install gyral@gyral ``` From a shell: `claude plugin marketplace add gyraljs/gyral && claude plugin install gyral@gyral`. Other agents: copy [`skills/gyral/`](https://github.com/gyraljs/gyral/tree/v0.3.6/skills/gyral) into the agent's skills or rules folder, or point it at `SKILL.md`. ## Docs for agents - **[llms-small.txt](https://gyral.dev/llms-small.txt)**: read first. The mental model, MUST and DO NOT rules, a complete component with tests, the recipes, and where each export comes from, in under 40 KB. Every code block in it compiles against the released packages. - **[llms.txt](https://gyral.dev/llms.txt)**: the index of every page, each with a line on when to read it. - **[llms-full.txt](https://gyral.dev/llms-full.txt)**: all the docs, the API reference and the examples in one file. - Every docs page has a Markdown copy: add `index.md` to its URL, as in `/docs/testing/index.md`. ## New apps `npm create gyral@latest` writes an `AGENTS.md` into every project, and a `CLAUDE.md` that imports it, so an agent that opens the project learns the rules and the commands before it writes anything. See [Getting started](https://gyral.dev/docs/getting-started/#create-a-project). --- # Components > How define() turns a spec into a custom element, and how components pass data to each other with props and outputs. Source: https://gyral.dev/docs/components/ A Gyral component is a standard custom element. You don't write a class: you describe the component as data, and `define()` builds the element, a plain `HTMLElement` subclass. This page covers the parts of that description, then how components talk to each other: **props down, outputs up**. ## Anatomy of define() ```ts // src/greeter.ts import { define, html } from '@gyral/core'; export interface State { readonly name: string; } export type Msg = { readonly _tag: 'NameChanged'; readonly name: string }; export const Greeter = define()('my-greeter', { init: () => ({ name: '' }), intent: { NameChanged: ({ value }) => ({ _tag: 'NameChanged', name: value ?? '' }), }, update: { NameChanged: (s, m) => ({ ...s, name: m.name.trim() }), }, view: (s, i) => html`

    ${s.name === '' ? 'Hello!' : `Hello, ${s.name}!`}

    `, }); ``` `define()(tag, spec)` registers `` and returns its class. It is two calls: the first takes the types you write (state, messages, props, outputs), the second the tag and spec, so TypeScript can still infer the intent names from the spec's `intent` keys. A component needs `init` and `view`; `intent` and `update` come with messages, and the rest is optional: | Part | What it is | Guide | | --------------- | -------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `init` | The starting state, computed from props. May also start one-shot commands. | [Model and update](https://gyral.dev/docs/update/) | | `subscriptions` | The long-running commands that should be running for this state and props (clocks, feeds, watches). | [Subscriptions](https://gyral.dev/docs/update/#subscriptions) | | `intent` | Parsers that turn platform events into messages. Their keys are the component's intent names. | [Intent](https://gyral.dev/docs/intent/) | | `update` | One pure reducer per message tag: state in, next state (and commands) out. | [Model and update](https://gyral.dev/docs/update/) | | `view` | A pure function from state to an `html` template that _names_ intents. | [Views](https://gyral.dev/docs/views/) | | `props` | Inputs from the parent or from attributes, declared with `prop.*` builders. | [below](#props) | | `styles` | Shadow-root CSS: `css` values or strings. | [Styling](https://gyral.dev/docs/styling/) | | `stores` | Shared state the component reads. | [Shared state](https://gyral.dev/docs/stores/) | | `shadow` | `false` renders into light DOM; `{ delegatesFocus: true }` passes focus to the first focusable element inside. | [Styling](https://gyral.dev/docs/styling/#light-dom-components), [Views](https://gyral.dev/docs/views/#focusing-into-a-child-component) | | `hydrate` | When a server-rendered instance hydrates: `load`, `idle`, `visible`, `interaction`. | [Server rendering](https://gyral.dev/docs/server-rendering/) | A few more optional fields cover rarer needs: `error` (a fallback view, see [Error handling](https://gyral.dev/docs/error-handling/)), `states` (custom states for CSS, see [Styling](https://gyral.dev/docs/styling/#custom-states)), `viewTransition` (render a change inside a View Transition, see [Views](https://gyral.dev/docs/views/#view-transitions)), `renderOnFrame` (message tags from bursty sources, such as a socket feed or a sensor, that render once per animation frame), and `formAssociated` (a component that takes part in a native form, see [Custom form controls](https://gyral.dev/docs/form-associated/)). The type parameters are the contract. `State` is a plain data record (JSON, when the component is [server-rendered](https://gyral.dev/docs/server-rendering/#loading-components-in-the-browser)), `Msg` a union of tagged objects, and TypeScript checks that `update` has a reducer for every tag and that the view only names intents that exist. `define()` registers the tag when its module runs, so the page's entry module (or a module it imports) must import every component module: `import './greeter.js';` in `src/main.ts`. A component module that nothing imports never runs, and its tag stays an empty unknown element; development builds warn (G0014) when a tag is still undefined 3 seconds after the page loaded. A parent that renders a child imports it too, as `Total` imports `Stepper` [below](#child-components-and-outputs). `define()` returns the element class. Its `spec` property is the object you passed in, which is how [tests](https://gyral.dev/docs/testing/) run `update` without a DOM. Export it as `export const Greeter = define…`, and the Vite preset writes `HTMLElementTagNameMap` entries for every such component into `.gyral/elements.d.ts` (list that file in your tsconfig's `include`), so `document.querySelector('my-greeter')` is typed with no declaration of your own. It reads calls in code, not in comments or strings. For `tsc` outside Vite, the `gyral-types` command writes the same file. ## Props Props are the component's inputs. Declare each one with a `prop` builder. A builder says how an attribute's string becomes a value, and a prop whose type can't be `undefined` must say how a value is guaranteed: `required` or `default`. ```ts // src/badge.ts import { define, html, prop, type Stateless } from '@gyral/core'; export interface Props { readonly label: string; readonly count: number; readonly note: string | undefined; } export const Badge = define()('my-badge', { props: { label: prop.string({ required: true }), count: prop.number({ default: 0 }), note: prop.string(), }, intent: {}, update: {}, view: (_s, _i, { props }) => html` ${props.label}: ${props.count} ${props.note === undefined ? '' : html`${props.note}`} `, }); ``` - **Read props as context.** Every reducer and the view get `{ props }` as their last argument. Don't copy props into state just to read them. - **`required: true`** is a promise from the parent. A missing value logs one warning per instance at first render. - **`default`** is used whenever the element's own value is missing. - A component with no state of its own uses `Stateless` and can leave out `init`. - Don't name a prop after a built-in element property such as `hidden`, `title` or `id`. It is an error in development, because the prop would replace the platform's behaviour. ### Builders and attributes | Builder | The attribute is parsed with | Attribute name | | --------------------------- | -------------------------------------- | ---------------------- | | `prop.string(opts?)` | nothing: the string as is | kebab-case of the prop | | `prop.number(opts?)` | `Number(v)`; empty or `NaN` is invalid | kebab-case | | `prop.boolean(opts?)` | present is `true`, absent is `false` | kebab-case | | `prop.json(schema, opts?)` | `JSON.parse`, then the schema | kebab-case | | `prop.value(schema, opts?)` | no attribute: set it as a property | none | A prop named `maxValue` reads the attribute `max-value`; pass `attribute: 'name'` to choose another, or `attribute: false` for a property only. The options are `required`, `default`, `attribute` and `schema`, and `equals` for `prop.json` and `prop.value` ([below](#when-a-prop-counts-as-changed)). The schemas are [Standard Schema](https://standardschema.dev), so any library that implements it works: valibot, zod, ArkType. `string`, `number` and `boolean` carry tiny built-in schemas, so simple props need no library; pass `schema` to narrow one, such as `prop.string({ schema: v.picklist(['asc', 'desc']), default: 'asc' })`. Attributes are strings from outside your code, so they are **always validated**. An invalid value is logged with the tag, the prop and the schema's issues, then treated as missing, so its `default` applies. Property sets come from typed code and are validated in development only. Props never write attributes back; state that CSS needs goes through [custom states](https://gyral.dev/docs/styling/#custom-states). ### Objects, type guards and identity Objects and arrays travel as properties, declared with `prop.value`. Its check can be a Standard Schema or a plain type guard: ```ts // src/seat-picker.ts import * as v from 'valibot'; import { define, html, prop, type PropsOf, type Stateless } from '@gyral/core'; export interface Seat { readonly row: number; readonly label: string; } /** A type guard: the simplest check for a property. */ const isSeat = (u: unknown): u is Seat => typeof u === 'object' && u !== null && 'row' in u && typeof u.row === 'number' && 'label' in u && typeof u.label === 'string'; /** Declared once and passed by name, so production builds can leave it out. */ const Seats = v.array(v.object({ row: v.number(), label: v.string() })); const props = { seats: prop.value(Seats, { default: [] }), chosen: prop.value(isSeat), /** `null` means "nobody"; leaving it unset means the default. */ holder: prop.value(v.nullable(v.string()), { default: 'Box office' }), }; export const SeatPicker = define>()('my-seat-picker', { props, intent: {}, update: {}, view: (_s, _i, { props: p }) => html`

    ${p.seats.length} seats, held by ${p.holder ?? 'nobody'}

    ${p.chosen === undefined ? '' : html`

    Chosen: ${p.chosen.label}

    `} `, }); ``` - **A type guard** `(u: unknown) => u is T` works in `prop.value()` and `prop.json()`, and the prop's type is its `T`. A guard that returns `false` is reported like a schema issue, with the guard's name. - **A property keeps the object it was given**: after `el.seats = seats`, `el.seats === seats`. Development checks the value and keeps the input, not the schema's output; production skips the check. So a schema for a property should **check, not decode**: turning strings into `Date`s, filling in defaults or stripping keys would happen only to attributes, whose value is the parse's output. - **Declare a schema once and pass it by name.** Production client builds never run a `prop.value` check, and the Vite preset removes it when it is a name (`prop.value(Seats)`, `schemas.seat`) or an inline function, so the schema's code can leave the bundle. A call written in place, such as `prop.value(v.array(Seat))`, stays, because evaluating it could have effects. `prop.json` keeps its check: attributes are always parsed. - **Whether the schema really leaves is up to the bundler.** Vite 8.3 (Rolldown) keeps schema code in a lazily loaded chunk once the schema library sits in a chunk shared with the entry, even when its builders are marked free of side effects. So the saving shows in single-chunk builds, and for schemas whose library isn't shared across chunks. - **`null` for an explicit "nobody".** A `default` replaces only `undefined`: setting a prop to `undefined`, or removing its attribute, brings the default back. Make the schema nullable and pass `.holder=${null}` (or the attribute `holder="null"` with `prop.json`). ### When a prop counts as changed A write whose value equals the old one changes nothing: no render, no `PropsChanged`. That holds for property sets and attribute changes alike, so a parent may write the same value on every render without making the child do any work. | Builder | Equal when | | ----------------------------- | ---------------------------------------------------------- | | `string`, `number`, `boolean` | `Object.is(old, new)` | | `json` | `JSON.stringify` gives the same text (it is JSON data) | | `value` | `Object.is(old, new)`, or what its `equals` option returns | So a parent can bind a fresh object on every render, `.range=${{ min: s.min, max: s.max }}`, to a `prop.json` prop: the same JSON is the same value. Keys in a different order only cost an extra render. `prop.value` holds anything, class instances and DOM nodes included, so it keeps identity unless you say what "the same" means. `equals` receives the old and the new value, `undefined` when unset, and `prop.json` takes it too: ```ts // src/results-table.ts import { define, html, prop, type PropsOf, type Stateless } from '@gyral/core'; export interface Sort { readonly key: string; readonly dir: 'asc' | 'desc'; } const isSort = (u: unknown): u is Sort => typeof u === 'object' && u !== null && 'key' in u && typeof u.key === 'string' && 'dir' in u && (u.dir === 'asc' || u.dir === 'desc'); const isRange = (u: unknown): u is { readonly min: number; readonly max: number } => typeof u === 'object' && u !== null && 'min' in u && typeof u.min === 'number' && 'max' in u && typeof u.max === 'number'; const sortLabel = (sort: Sort): string => `Sorted by ${sort.key}, ${sort.dir === 'asc' ? 'ascending' : 'descending'}`; const props = { /** Compared by JSON text: a fresh but equal object changes nothing. */ range: prop.json(isRange, { default: { min: 0, max: 100 } }), /** Compared field by field, instead of by identity. */ sort: prop.value(isSort, { equals: (a, b) => a?.key === b?.key && a?.dir === b?.dir }), }; export const ResultsTable = define>()('my-results-table', { props, intent: {}, update: {}, view: (_s, _i, { props: p }) => html`

    Prices ${p.range.min} to ${p.range.max}

    ${p.sort === undefined ? '' : html`

    ${sortLabel(p.sort)}

    `} `, }); ``` When no `equals` fits, keep bound objects stable in the parent instead: build them in a reducer, so they live in state, rather than in the view, so an unchanged object is the same object. ### Reacting to prop changes When a declared prop changes after the first render, the component receives the framework message `PropsChanged`, with the new and previous props (a write of an [equal value](#when-a-prop-counts-as-changed) sends nothing). Its reducer is optional. It is the only way props enter state, so "reset when the user changes" is explicit: ```ts // src/user-notes.ts import { define, html, prop } from '@gyral/core'; export interface State { readonly draft: string; } export type Msg = { readonly _tag: 'Typed'; readonly text: string }; export const UserNotes = define()('my-user-notes', { props: { userId: prop.string({ required: true }) }, init: () => ({ draft: '' }), intent: { Typed: ({ value }) => ({ _tag: 'Typed', text: value ?? '' }) }, update: { Typed: (s, m) => ({ ...s, draft: m.text }), // A different user: start a fresh draft. PropsChanged: (s, m) => (m.props.userId === m.prev.userId ? s : { draft: '' }), }, view: (s, i, { props }) => html` `, }); ``` Like any reducer, the `PropsChanged` reducer may return commands, for example to load data for the new user. ## Child components and outputs A parent sets a child's props in its view, as attributes (`step="5"`) or as properties (`.step=${5}`, the way objects and arrays travel), and listens to the child's **outputs**. A child reports up by returning an output from a reducer, a command like any other, so the child stays pure and testable. Build the helper once with `const emit = outputs()`, a module constant like `intentsOf()`: typed by the output union, so an output of the wrong shape fails to compile in the child. ```ts // src/stepper.ts import { define, html, outputs, prop, type Stateless } from '@gyral/core'; /** What the stepper tells its parent. */ export type StepperOutput = { readonly _tag: 'Stepped'; readonly by: number }; const emit = outputs(); type Msg = { readonly _tag: 'Step'; readonly by: number }; export const Stepper = define()( 'my-stepper', { props: { step: prop.number({ default: 1 }) }, intent: { Step: ({ value }) => ({ _tag: 'Step', by: Number(value) }), }, update: { Step: (s, m, { props }) => [s, [emit({ _tag: 'Stepped', by: m.by * props.step })]], }, view: (_s, i, { props }) => html` `, }, ); ``` The fourth type parameter is the output union. The parent parses outputs as intents, with `child()`, which types the output by the child class: ```ts // src/total.ts import { child, define, html } from '@gyral/core'; import { Stepper } from './stepper.js'; export interface State { readonly total: number; } export type Msg = { readonly _tag: 'Add'; readonly by: number }; export const Total = define()('my-total', { init: () => ({ total: 0 }), intent: { Add: child(Stepper, (out) => ({ _tag: 'Add', by: out.by })), }, update: { Add: (s, m) => ({ total: s.total + m.by }), }, view: (s, i) => html`

    Total: ${s.total}

    `, }); ``` - Outputs are delivered as a `gyral-output` event from the child's host, and they reach only the direct parent: once the parent's `child()` intent takes one, it goes no further. A grandchild's outputs never reach the grandparent, and content slotted into a child belongs to the component that wrote it. - Leave the mapper's return type off, as above. Annotating it with the whole union, `(out): Msg => …`, fails with a long `IntentParser<…>` error, because each intent produces its own variant (see [Intent](https://gyral.dev/docs/intent/#typing-parsers)). - The mapper also receives the child element, typed with its props, which is how an item in a list says which item it is: `child(Item, (out, el) => ({ _tag: 'Item', id: el.itemId, out }))`. - For lists, render children with `each(items, key, row)`. Keys keep each child, and its state, attached to its item when the list reorders (see [Views](https://gyral.dev/docs/views/#lists)). - A component that contains itself (a folder tree) passes a function, `child(() => Folder, …)`, so the class can refer to itself. Shadow DOM does the isolating, and `each` does the list. ### Outputs and other code A parent that isn't a Gyral component, such as page script or an element from another library, hears a Gyral child's outputs as events. `OUTPUT_EVENT` (`'gyral-output'`) names the event, its `detail` is the output, and `OutputEvent>` types it: ```ts // src/reviews.ts import { OUTPUT_EVENT, type OutputEvent, type OutputsOf } from '@gyral/core'; import { Stepper } from './stepper.js'; // Listen on the child or on an ancestor in the same tree. document.querySelector('#reviews')?.addEventListener(OUTPUT_EVENT, (event) => { const { detail } = event as OutputEvent>; console.log(`Stepped by ${String(detail.by)}`, event.target); }); ``` The event bubbles but isn't composed, so it stays in the tree the child sits in. The other direction works too: any custom element talks to a Gyral parent by dispatching `new CustomEvent(OUTPUT_EVENT, { detail: { _tag: 'Picked' }, bubbles: true })` on itself, so children don't have to be Gyral components. [Using Gyral in other frameworks](https://gyral.dev/docs/other-frameworks/#outputs-the-gyral-output-event) has recipes. ## Escape hatches - **`el.send(msg)`** feeds a message into a component from imperative code (a canvas, an observer). Prefer intents and drivers where you can. - **Any custom element.** Gyral components are plain custom elements, and any other custom element works alongside them, whether you wrote it by hand or with another library (see [Using third-party web components](https://gyral.dev/docs/third-party-components/)). Gyral adds a loop, not a walled garden. --- # Intent > How Gyral turns clicks, input, form submissions and keys into typed messages, and why the view never holds event handlers. Source: https://gyral.dev/docs/intent/ **Intent** answers one question: what did the user mean? It turns platform events into messages, and it is the only place where raw input is read. Everything after it, the model and the view, works with typed, already-parsed data. Building a form with validation? The [Forms](https://gyral.dev/docs/forms/#complete-example-a-validated-form) page starts with a complete, copyable component: the submit intent, the messages, the reducers, focus and the error markup in one file. [Form submissions](#form-submissions) below sums it up. ```ts // src/search-box.ts import { define, html } from '@gyral/core'; export interface State { readonly query: string; readonly submitted: string | undefined; } export type Msg = | { readonly _tag: 'Typed'; readonly query: string } | { readonly _tag: 'Search'; readonly query: string } | { readonly _tag: 'Clear' }; export const SearchBox = define()('my-search-box', { init: () => ({ query: '', submitted: undefined }), intent: { Typed: ({ value }) => ({ _tag: 'Typed', query: value ?? '' }), Search: ({ formData }) => { const q = formData?.get('q'); return typeof q === 'string' && q.trim() !== '' ? { _tag: 'Search', query: q.trim() } : undefined; }, Clear: ({ key }) => (key === 'Escape' ? { _tag: 'Clear' } : undefined), }, update: { Typed: (s, m) => ({ ...s, query: m.query }), Search: (s, m) => ({ ...s, submitted: m.query }), Clear: () => ({ query: '', submitted: undefined }), }, view: (s, i) => html`

    ${s.submitted === undefined ? 'Nothing searched yet.' : `Results for “${s.submitted}”`}

    `, }); ``` ## Naming intents in markup The view puts `data-intent=${i.Search}` on an element. `i` holds the intent names (the keys of `intent`), typed, so a misspelt intent is a compile error. When that element's trigger event fires, Gyral calls the parser registered under the same name in `intent`, and sends whatever it returns through `update`. A parser that only sends its tag can be written `true`: `intent: { Clear: true }` is `Clear: () => ({ _tag: 'Clear' })`. It works when the message has no other fields, and is a type error otherwise. A component with no intents leaves `intent` out, and one with no messages leaves `update` out too. The view never attaches a closure. That keeps it a pure function of state, which matters for [server rendering](https://gyral.dev/docs/server-rendering/) (the markup the server sends already names every intent) and for testing (there's nothing to call but `update`). ## Trigger events Each element has a natural event, so you rarely say which one: | Element | Fires on | | ---------------------------------------- | --------------------------------------- | | `
    ` | `submit` (Gyral prevents the page load) | | `` (text-like), `

    Tags: ${s.tags.join(', ')}

    ${s.saving ? 'Saving…' : ''}

    `, }); ``` A keystroke in the textarea has no `keydown` intent of its own, so it goes straight to `Save`. In the tag field, `AddTag` answers first: it takes Enter and declines everything else, and the section's `Save` sees Ctrl+S from there too. - **Only a synchronous `undefined` declines.** A parser that returns a promise has taken the event, whatever the promise resolves to: lookup can't wait for it. - **Declining stays inside the component.** A nested component's lookup ends at its own root. The outer component sees the same event through its own listener, once. - **To skip events by where they started** rather than by key, compare `event.target` (the element that was hit) with `target` (the element with the intent). ## Props, state and stores in a parser A parser's second argument is a read-only context: `props` and the component's `state`, as they are when the event fires, and `read(store)` for the stores in the spec's `stores`. Parsers that don't need it take one parameter. Use it when the decision must be made during the event, such as whether to call `preventDefault()`: by the time a reducer runs, the browser has already acted. A listbox owns the arrow keys of its orientation and leaves the others to the page: ```ts // src/folders.ts import { define, html, prop } from '@gyral/core'; export interface Props { /** `vertical` lists take ArrowUp and ArrowDown; `horizontal` ones ArrowLeft and ArrowRight. */ readonly orientation: string; } export type Msg = { readonly _tag: 'Step'; readonly by: -1 | 1 }; const FOLDERS = ['Inbox', 'Drafts', 'Sent'] as const; const KEYS: Readonly> = { vertical: ['ArrowUp', 'ArrowDown'], horizontal: ['ArrowLeft', 'ArrowRight'], }; export const Folders = define<{ readonly active: number }, Msg, Props>()('my-folders', { props: { orientation: prop.string({ default: 'vertical' }) }, init: () => ({ active: 0 }), intent: { // Only the arrow keys this orientation owns are taken; the others still scroll the page. Step: ({ key, event }, { props }) => { const [back, next] = KEYS[props.orientation] ?? KEYS.vertical ?? ['', '']; const by = key === back ? -1 : key === next ? 1 : 0; if (by === 0) return undefined; event.preventDefault(); return { _tag: 'Step', by }; }, }, update: { Step: (s, m) => ({ active: (s.active + m.by + FOLDERS.length) % FOLDERS.length }), }, view: (s, i, { props }) => html`
      ${FOLDERS.map( (name, n) => html`
    • ${name}
    • `, )}
    `, }); ``` Where the user is often decides the default, and that's state. A seat grid keeps Tab while there is a next seat, and lets it move focus on from the last one: ```ts // src/seats.ts import { define, html } from '@gyral/core'; interface Grid { readonly cell: number; readonly cells: number; } type Msg = { readonly _tag: 'NextCell' }; export const Seats = define()('my-seat-grid', { init: () => ({ cell: 0, cells: 12 }), intent: { NextCell: ({ key, event }, { state }) => { if (key !== 'Tab' || state.cell === state.cells - 1) return undefined; event.preventDefault(); return { _tag: 'NextCell' }; }, }, update: { NextCell: (s) => ({ ...s, cell: s.cell + 1 }) }, view: (s, i) => html`
    Seat ${s.cell + 1} of ${s.cells}
    `, }); ``` `state` includes messages sent earlier in the same task, before the next render. Read it, don't write it: apart from `preventDefault()`, a parser stays pure, and the view doesn't need to write `data-first`/`data-last` attributes just so a parser can read them. Keep parsers pure apart from `preventDefault()`: they read the context, they never write. `form()`, `field()` and `child()` return one-parameter parsers, so code that calls one directly, `field(schema, toMsg)(input)`, still works. ## Typing parsers Each key in `intent` produces its own variant: the `Search` parser above returns a `{ _tag: 'Search'; … }`. Inside the spec, leave the return type off, and the key types it. Don't annotate a parser with the whole union. `(): Msg => …` widens it, and TypeScript answers with a long error that ends in "`IntentParser` is not assignable to …". A parser written outside the spec returns its variant: ```ts // src/search-parser.ts import type { IntentInput } from '@gyral/core'; import type { Msg } from './search-box.js'; /** The variant, not the union; `undefined` declines the event. */ export const parseSearch = ({ formData, }: IntentInput): Extract | undefined => { const q = formData?.get('q'); return typeof q === 'string' && q.trim() !== '' ? { _tag: 'Search', query: q.trim() } : undefined; }; ``` `_tag: 'Search' as const` in the returned object works too. The same holds for the mappers of `child()`, `form()` and `field()`. To type a parser that reads the context, use `IntentParser`, or `IntentParser` when it reads `state`. `Variant` from `@gyral/core` names the variant without `Extract`, and `ParseResult` is what a parser may return: a message, `IntentRejected` or `undefined`. `StandardSchemaV1` is exported too, for helpers that take a schema the way `field()` and `defineForm()` do. ## Buttons carry their own data A button's `value` is part of `IntentInput`, so a list of buttons needs one intent, not one closure per row: ```html ``` The parser reads `value` and the reducer finds the product. This is also what keeps the markup meaningful without JavaScript. ## Several controls, one intent Controls that all change one thing don't need a message each. Give them the same intent and tell them apart by their `name`, as in a search filters panel: ```ts // src/search-filters.ts import { define, html } from '@gyral/core'; export interface Filters { readonly category: string; readonly sort: string; } export type Msg = { readonly _tag: 'Filter'; readonly field: keyof Filters; readonly value: string; }; const FIELDS: readonly (keyof Filters)[] = ['category', 'sort']; const isField = (name: string): name is keyof Filters => FIELDS.some((f) => f === name); export const SearchFilters = define()('my-search-filters', { init: () => ({ category: 'all', sort: 'relevance' }), intent: { // One parser for every `, }); ``` Give each control its own message only when the reducers really differ. A view that names an intent with no parser fails to compile: `Property 'Category' does not exist on type 'IntentNames<"Filter">'`. ## Intent names that aren't messages Sometimes each control does something different, but all of them end in the same message. A table toolbar's Archive, Restore and Duplicate buttons all become one request to the server. Give each control its own parser key: the keys of `intent` are the component's intent names, and a key that isn't a message tag may return any message. ```ts // src/doc-table.ts import { define, each, html, intentsOf, type TemplateResult } from '@gyral/core'; export interface Doc { readonly id: number; readonly title: string; readonly archived: boolean; } export type Action = { readonly kind: 'archive' | 'restore'; readonly id: number } | { readonly kind: 'duplicate' }; export type Msg = { readonly _tag: 'Request'; readonly action: Action }; export interface State { readonly docs: readonly Doc[]; readonly pending: readonly Action[]; } const i = intentsOf(); // the parser keys, for rows const idOf = (target: Element): number => Number(target.closest('[data-id]')?.getAttribute('data-id')); const request = (action: Action): Msg => ({ _tag: 'Request', action }); const Row = (doc: Doc): TemplateResult => html`
  • ${doc.title}
  • `; export const DocTable = define()('my-doc-table', { init: () => ({ docs: [], pending: [] }), intent: { Archive: ({ target }) => request({ kind: 'archive', id: idOf(target) }), Restore: ({ target }) => request({ kind: 'restore', id: idOf(target) }), Duplicate: () => request({ kind: 'duplicate' }), }, // One reducer for all three. In an app, it also returns the command that sends the request. update: { Request: (s, m) => ({ ...s, pending: [...s.pending, m.action] }) }, // The view uses its own `i`; the module constant is for rows. view: (s, i) => html`
      ${each(s.docs, (doc) => doc.id, Row)}
    `, }); ``` - **The intent names are the keys of `intent`.** `define()(…)` takes them from the object you write, so there is nothing to declare twice. - **A key that is a message tag returns that message**, as before. Any other key may return any message and gets no reducer: `update` has keys only for messages. - **`i` holds exactly the keys.** Markup that names an intent with no parser fails to compile. For rows, `intentsOf()` gives the same names as a module constant; write the row's return type (`: TemplateResult`), and use the view's own `i` inside the component. - **A misspelled key is a new intent name.** The view's correct name then fails with `Property 'Archive' does not exist on type 'IntentNames<"Archvie" | …>'. Did you mean 'Archvie'?`, so fix the key; a key no template names at all is reported by the `gyral/unused-intent` lint rule. It is a type-level feature: the keys were always the names at runtime. Prefer one shared intent ([previous section](#several-controls-one-intent)) when one parser that reads `name` is clearer; give each control its own key when the parsers differ. ## Press and hold A hold-to-record button, like a voice message in a chat, needs the press and the release. List both in `data-intent-on` and read `event.type`: one intent, one message with a `down` flag. The release must arrive even when the pointer leaves the button before it lets go. That is **pointer capture**: an [element hook](https://gyral.dev/docs/views/#element-hooks) of a few lines calls `setPointerCapture` on `pointerdown`, so the pointer's events stay with the element until release. ```ts // src/capture-pointer.ts import { defineHook } from '@gyral/core'; function capture(this: Element, event: Event): void { try { this.setPointerCapture((event as PointerEvent).pointerId); } catch { // Not an active pointer (a synthetic event): nothing to capture. } } /** Keeps a pointer's events on this element from `pointerdown` until release. */ export const capturePointer = defineHook<[]>({ client: (el) => { el.addEventListener('pointerdown', capture); }, dispose: (el) => { el.removeEventListener('pointerdown', capture); }, }); ``` ```ts // src/record-button.ts import { define, html } from '@gyral/core'; import { capturePointer } from './capture-pointer.js'; export interface State { readonly recording: boolean; } export type Msg = { readonly _tag: 'Record'; readonly down: boolean }; const PRESS = new Set(['pointerdown', 'keydown']); export const RecordButton = define()('my-record-button', { init: () => ({ recording: false }), intent: { Record: ({ event, key }) => { if (event instanceof KeyboardEvent) { if (key !== ' ' || event.repeat) return undefined; event.preventDefault(); // no page scroll, and no click when Space comes up } return { _tag: 'Record', down: PRESS.has(event.type) }; }, }, update: { // In an app, these also start and stop the microphone with commands. Record: (_s, m) => ({ recording: m.down }), }, view: (s, i) => html` `, }); ``` `pointercancel`, when the browser takes the touch over for scrolling, is a release too. For the keyboard, the same button holds while Space is down, and the parser ignores auto-repeat. - Give the button `touch-action: none` in CSS, so a held finger doesn't scroll or zoom, and `user-select: none`, so a long press doesn't select the label. - A button that may disappear mid-press, in a render that drops it, never gets its `pointerup`. Also list `lostpointercapture` and treat it as a release, ignoring ones from other elements (`event.target !== target`): the event bubbles. - While an element holds capture, the browser sends that pointer's events to it. Intents on the capturing element keep firing, but intents on elements _inside_ it stop until release. Put `capturePointer()` on the element whose intent needs the release, not on a container of other controls. - Keys reach the button only while it has focus. For a shortcut anywhere on the page, read keys in a driver: a [subscription](https://gyral.dev/docs/outside-state/) to `keydown` and `keyup` on `window`. ## The element under a captured pointer While an element holds pointer capture, every move lands on it, not on what the pointer is over. To trace a path across items (drawing across a grid of cells, selecting a range of days by dragging), capture on the container and ask the component's root which element is under the pointer: `elementFromPoint` on the shadow root (or `document` for a light-DOM component) sees inside the shadow tree. The cell carries its index in `data-cell`: ```ts // src/path-grid.ts import { define, html } from '@gyral/core'; import { capturePointer } from './capture-pointer.js'; interface State { readonly path: readonly number[]; } type Msg = { readonly _tag: 'Trace'; readonly cell: number; readonly start: boolean }; const CELLS = Array.from({ length: 16 }, (_, n) => n); export const PathGrid = define()('my-path-grid', { init: () => ({ path: [] }), intent: { Trace: ({ event, target }) => { if (!(event instanceof PointerEvent)) return undefined; if (event.type === 'pointermove' && event.buttons === 0) return undefined; const root = target.getRootNode() as Document | ShadowRoot; const under = root.elementFromPoint(event.clientX, event.clientY); const cell = under?.closest('[data-cell]')?.getAttribute('data-cell'); if (cell == null) return undefined; return { _tag: 'Trace', cell: Number(cell), start: event.type === 'pointerdown' }; }, }, update: { Trace: (s, m) => m.start ? { path: [m.cell] } : s.path.at(-1) === m.cell ? s : { path: [...s.path, m.cell] }, }, view: (s, i) => html`
    ${CELLS.map( (n) => html`${n}`, )}
    `, }); ``` Give the container `touch-action: none` so a finger traces instead of scrolling. ## Invoker commands An element with `data-intent-on="command"` receives [invoker commands](https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API) aimed at it, from buttons anywhere in the component: ```ts // src/shopping-list.ts import { define, each, html } from '@gyral/core'; export interface State { readonly items: readonly string[]; } export type Msg = { readonly _tag: 'Command'; readonly command: '--add' | '--clear' }; const Item = (item: string) => html`
  • ${item}
  • `; export const ShoppingList = define()('my-shopping-list', { init: () => ({ items: [] }), intent: { Command: ({ command }) => command?.command === '--add' || command?.command === '--clear' ? { _tag: 'Command', command: command.command } : undefined, }, update: { Command: (s, m) => m.command === '--add' ? { items: [...s.items, `Item ${String(s.items.length + 1)}`] } : { items: [] }, }, view: (s, i) => html`
      ${each(s.items, (item) => item, Item)}
    `, }); ``` Invoker commands are newly available in browsers. Where the native `CommandEvent` is missing, Gyral dispatches an equivalent event for custom (`--…`) commands, so the same markup works everywhere. Built-in commands such as `show-modal` are left to the browser. ## Isolation Only `data-intent` elements in the component's own render root count. A click inside a child component belongs to the child, never to the parent, because the child's shadow root is a boundary. Light-DOM components keep the same rule by stopping at the next Gyral host. A parser that declines passes the event outward only within its own component. --- # Model and update > State as plain data, one pure reducer per message, commands returned as data, and the framework messages Gyral sends you. Source: https://gyral.dev/docs/update/ The **model** is your component's state. **Update** is how it changes: a record of reducers, one per message tag, each a pure function from the current state and a message to the next state. Nothing else changes state, so every change is visible to tests and to [devtools](https://gyral.dev/docs/devtools/). ```ts // src/todo.ts import { define, each, html, intentsOf, type TemplateResult } from '@gyral/core'; export interface Todo { readonly id: number; readonly text: string; readonly done: boolean; } export interface State { readonly todos: readonly Todo[]; readonly nextId: number; } export type Msg = | { readonly _tag: 'Add'; readonly text: string } | { readonly _tag: 'Toggle'; readonly id: number } | { readonly _tag: 'ClearDone' }; const i = intentsOf(); const Item = (t: Todo): TemplateResult => html`
  • `; export const TodoList = define()('my-todo-list', { init: () => ({ todos: [], nextId: 1 }), intent: { Add: ({ formData }) => { const text = formData?.get('text'); return typeof text === 'string' && text.trim() !== '' ? { _tag: 'Add', text: text.trim() } : undefined; }, Toggle: ({ value }) => ({ _tag: 'Toggle', id: Number(value) }), ClearDone: () => ({ _tag: 'ClearDone' }), }, update: { Add: (s, m) => ({ todos: [...s.todos, { id: s.nextId, text: m.text, done: false }], nextId: s.nextId + 1, }), Toggle: (s, m) => ({ ...s, todos: s.todos.map((t) => (t.id === m.id ? { ...t, done: !t.done } : t)), }), ClearDone: (s) => ({ ...s, todos: s.todos.filter((t) => !t.done) }), }, view: (s, i) => html`
      ${each(s.todos, (t) => t.id, Item)}
    `, }); ``` ## State is plain data Keep state a record of JSON values: strings, numbers, booleans, `null`, arrays and plain objects. No `Date`, `Map` or class instances. Three things depend on it: - **Server rendering** writes each component's state into the page as JSON, and the browser resumes from it. A `Date` would come back as a string. In development, the server warns when a seed won't survive the trip. - **Devtools** and tests can print and compare state. - **Reducers return new objects.** State is never mutated, so "did it change?" is a reference check. Model what can happen with unions rather than flags. `{ _tag: 'Loading' } | { _tag: 'Found'; repos } | { _tag: 'Failed'; message }` can't be loading and failed at once; two booleans can. The module's `i` is `intentsOf()`: the same typed intent names the view receives, as a module constant, so the list's `Item` row can name an intent and stay a pure function of its todo ([Lists](https://gyral.dev/docs/views/#lists)). The row's return type is written out, and the view uses its own `i`, so the row and the component don't infer each other's types. `aria-pressed=${t.done}` writes `"true"` or `"false"`, the way ARIA expects. ## One reducer per message `update` is keyed by message tag, and its type requires a reducer for every tag in `Msg`. Add a message and the compiler shows you every component that has to handle it. A reducer receives the state, the message (narrowed to its variant, so `m.id` is typed) and the context, `{ props, read }`: the component's [props](https://gyral.dev/docs/components/#props) and a reader for [shared stores](https://gyral.dev/docs/stores/). Reducers are pure: no `fetch`, no `Date.now()`, no `Math.random()`, no DOM. A reducer written outside the spec, to share it or test it alone, is typed with `Reducer` and `Variant` from `@gyral/core` (type-only exports). `Variant` is the member of `Msg` tagged `'Add'`: ```ts // src/shelf.ts import { define, html, type Reducer, type Variant } from '@gyral/core'; interface State { readonly books: readonly string[]; } type Msg = { readonly _tag: 'Add'; readonly title: string } | { readonly _tag: 'Clear' }; export const add: Reducer> = (s, m) => ({ books: [...s.books, m.title], }); export const Shelf = define()('my-shelf', { init: () => ({ books: [] }), intent: { Add: ({ value }) => ({ _tag: 'Add', title: value ?? '' }), Clear: true }, update: { Add: add, Clear: () => ({ books: [] }) }, view: (s, i) => html` ${s.books.join(', ')} `, }); ``` ## Returning commands To do anything outside the model, a reducer returns a tuple: the next state and a list of **commands**. A command is a description of a side effect, run by a driver. The answer comes back as another message. ```ts // src/save-button.ts import { define, html } from '@gyral/core'; import { delay } from '@gyral/time'; export interface State { readonly saving: boolean; } export type Msg = { readonly _tag: 'Save' } | { readonly _tag: 'Saved' }; export const SaveButton = define()('my-save-button', { init: () => ({ saving: false }), intent: { Save: true }, update: { // State plus a command: answer `Saved` after half a second. Save: (s) => (s.saving ? s : [{ saving: true }, [delay(500, { _tag: 'Saved' })]]), Saved: () => ({ saving: false }), }, view: (s, i) => html` `, }); ``` The return type is `Next`: either a state, or `[state, commands]`. State is never an array, so the two can't be confused. `init` can return commands too, to load data when the component appears. `Save: true` is the short form of a parser that only sends its tag; it works for messages with no other fields. [Effects and drivers](https://gyral.dev/docs/effects/) covers commands in depth. ## Subscriptions Work that should keep running while some condition holds (a clock, polling, a socket, a store or media-query watch) doesn't start in a reducer. `subscriptions(state, props)` returns the commands that should be running now, and Gyral keeps the running set in step with it: ```ts // src/clock.ts import { define, html } from '@gyral/core'; import { every } from '@gyral/time'; export interface State { readonly running: boolean; readonly now: number; } export type Msg = { readonly _tag: 'Toggle' } | { readonly _tag: 'Tick'; readonly now: number }; export const Clock = define()('my-clock', { init: () => ({ running: false, now: 0 }), // While running, tick every second; pausing stops the timer, resuming starts it again. subscriptions: (s) => (s.running ? [every(1000, (now): Msg => ({ _tag: 'Tick', now }))] : []), intent: { Toggle: true }, update: { Toggle: (s) => ({ ...s, running: !s.running }), Tick: (s, m) => ({ ...s, now: m.now }), }, view: (s, i) => html`

    `, }); ``` After the first render and after every update and props change, Gyral compares the list with what is running, lane by lane (a command's `key`, else its driver's name): a new lane starts, a missing lane is stopped, a lane whose input changed restarts, and an unchanged lane keeps running. Inputs are compared as data: functions inside them are ignored, so an inline selector such as `(s) => s.unread` doesn't restart a lane on every update. To restart when a selector changes, put what it depends on in the key, for example one that includes the room id. Moving the element keeps subscriptions running, removing it stops them, and attaching it again starts them from the current state. So there is no start in `init`, no restart on `PropsChanged` and no reconnect handling. `every(ms, (now) => msg)` sends the time at once and then every `ms`. In model tests, `subscriptionsFor(Clock.spec, state, props)` returns the list. ## Framework messages Gyral sends five messages of its own, plus three to [form-associated components](https://gyral.dev/docs/form-associated/) (`FormReset`, `FormDisabled` and `FormStateRestore`). Their reducers are optional: leave one out and the message changes nothing. | Message | Sent when | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `PropsChanged` | A declared prop changed after the first render. Has `props` and `prev`. [Props](https://gyral.dev/docs/components/#reacting-to-prop-changes) | | `IntentRejected` | Input failed a schema in `form()` or `field()`, or the server rejected a form. [Forms](https://gyral.dev/docs/forms/) | | `StoreChanged` | A store the component reads changed. [Shared state](https://gyral.dev/docs/stores/) | | `Hydrated` | The component is live in the browser, once, after its first render. Has `serverRendered`. | | `Errored` | An update, parser or command of this component failed. Has `phase` and `error` (a `GyralError`). [Error handling](https://gyral.dev/docs/error-handling/) | `Hydrated` is the hook for progressive enhancement: render the no-JavaScript version on the server and in the first client render (so hydration matches), then switch to the enhanced UI in the `Hydrated` reducer. ## Starting from messages Every Gyral element has an `initialMessages` property. Messages listed there run through `update` right after `init`, before the first render, on the server too. The server uses it to render a form with the errors from a rejected submission through the component's own `IntentRejected` reducer, so errors look the same with or without JavaScript. ## State in CSS and in transitions Two optional spec fields read the model, so the model stays the one source of truth: - **`states: (s) => ({ loading: s.results._tag === 'Loading' })`** mirrors boolean facts onto the element's [custom states](https://gyral.dev/docs/styling/#custom-states), for CSS: `:host(:state(loading))`. - **`viewTransition: (prev, next, msg) => boolean`** renders a state change inside `document.startViewTransition`, for route changes and list reorders. It is skipped where the browser doesn't support it, or when the user prefers reduced motion. --- # Views > Views are pure functions from state to html templates. Name intents, render keyed lists, bind form state, and let the compiler check markup. Source: https://gyral.dev/docs/views/ A **view** is a function from state to HTML. It receives the state, the typed intent names `i`, and the context `{ props, read }`, and returns an `html` template. It has no other inputs and no side effects, so the same state always renders the same markup, on the server and in the browser. ```ts // src/profile.ts import { define, html } from '@gyral/core'; export interface State { readonly editing: boolean; readonly name: string; } export type Msg = { readonly _tag: 'Edit' } | { readonly _tag: 'Save'; readonly name: string }; export const Profile = define()('my-profile', { init: () => ({ editing: false, name: 'Ada' }), intent: { Edit: () => ({ _tag: 'Edit' }), Save: ({ formData }) => { const name = formData?.get('name'); return typeof name === 'string' ? { _tag: 'Save', name } : undefined; }, }, update: { Edit: (s) => ({ ...s, editing: true }), Save: (_s, m) => ({ editing: false, name: m.name }), }, view: (s, i) => html`

    ${s.name}

    ${ s.editing && html`
    ` } `, }); ``` ## Templates Everything a view needs comes from `@gyral/core`: `html`, `svg`, `css`, `nothing`, `each`, `keyed`, `raw` and `defineHook`. Gyral renders with its own small view layer, built for this one job: no virtual DOM, no runtime dependencies. A template is prepared once per call site. After that a render only compares each `${…}` with the value it wrote last time and touches the DOM where they differ. | Binding | Sets | | --------------------------- | ------------------------------------------------------------------------ | | `${value}` | Text content (escaped), a nested template, a list, or nothing | | `attr=${value}` | An attribute; `null`, `undefined` and `nothing` remove it | | `class="card ${s.kind}"` | An attribute built from several pieces (quote it) | | `?attr=${bool}` | A boolean attribute, present or absent | | `.prop=${value}` | A property: data for a child Gyral component, such as objects and arrays | | `` | An [element hook](#element-hooks) on that element | | `` | The textarea's value (see [Form state](#form-state)) | Boolean attributes (`selected`, `checked`, `disabled`, `open`, `required`, `hidden`'s siblings and the rest of HTML's list) take `?attr=${bool}`. Writing `selected=${bool}` would render `selected="false"`, which the browser reads as selected, so it's a template error with the fix in the message. In a text position, `false`, `null`, `undefined` and `nothing` render nothing, so ``${s.open && html`…`}`` works. Classes and inline styles are plain strings: `class=${s.done ? 'done' : ''}`, `style="--w: ${s.width}px"` (under a strict Content Security Policy, see [inline styles](https://gyral.dev/docs/styling/#inline-styles-under-a-strict-csp)). Inline `` works inside `html`, so icons and charts need nothing special; an SVG fragment that is a template of its own uses [`svg`](#svg-fragments). Property bindings carry data, never functions: events are [intents](#no-event-handlers), and behaviour on an element is a [hook](#element-hooks). Whitespace is normalized once per template, the same way on the server and in the browser: indentation between block-level tags disappears, and a run of whitespace between inline elements becomes one space. `
    ` and `` |
    
    A binding writes the control only when the model's value for it changes. Then the model wins,
    even after the user has edited the control (the view compares with what the control shows now
    and writes only if they differ). A render for any other reason, such as another field's
    message or a reducer that refused the edit, leaves what the user typed or toggled alone. On the
    server the bindings write the attribute, so the page is right before JavaScript loads, and
    hydration keeps what the user typed before scripts ran until the model's value changes.
    
    To put a control back after a refused edit, change the model: clamp or normalize to a value
    that differs from the one rendered last, or re-create the form with a key. Keep a counter in
    the state, bump it on reset, and render `keyed(s.formKey, fields(s))`: the new key brings fresh
    elements with the model's values. `form.reset()` is no substitute; it restores the first values, not the model's.
    
    Never bind form state as a property (`.value=`, `.checked=`): the server can't write a
    property, so the page would arrive empty. The compiler rejects it. A checkbox's `value` and a
    `
        
          

    Delete this task?

    `, }); ``` On the server neither binding writes anything; a dialog that should start open opens when the component hydrates. Use `?open` only for a non-modal `` or `
    `. ## Element hooks A hook is a small behaviour attached to the element it sits on, written inside the start tag. Core ships one, **`invalid(errors)`**, which mirrors model errors into native validity (`setCustomValidity` and `aria-invalid`); see [Forms](https://gyral.dev/docs/forms/). Others are a few lines each, such as capturing a pointer for [press-and-hold](https://gyral.dev/docs/intent/#press-and-hold). Write your own with `defineHook`. `client(el, args, prev)` runs after the render whenever the arguments change (`prev` is `undefined` the first time). An optional `server(args)` returns attributes for the server-rendered start tag, so the page is right before scripts run. A hook acts only on its own element, and listeners it adds there go away with the element, so most hooks need no teardown: ```ts // src/steps.ts import { define, defineHook, html } from '@gyral/core'; /** Marks the current step, and scrolls it into view when it becomes current. */ export const currentStep = defineHook<[active: boolean]>({ // The server writes the attribute into the start tag, so it's there before scripts run. server: ([active]) => (active ? { 'aria-current': 'step' } : {}), client: (el, [active], prev) => { if (active) el.setAttribute('aria-current', 'step'); else el.removeAttribute('aria-current'); if (active && prev !== undefined && !prev[0]) el.scrollIntoView({ block: 'nearest' }); }, }); export interface State { readonly current: number; } export type Msg = { readonly _tag: 'Next' }; export const Steps = define()('my-steps', { init: () => ({ current: 0 }), intent: { Next: () => ({ _tag: 'Next' }) }, update: { Next: (s) => ({ current: (s.current + 1) % 3 }) }, view: (s, i) => html`
    1. Choose
    2. Pay
    3. Done
    `, }); ``` Hooks run after the whole render, children before parents, so a parent's hook can measure the children that just rendered. A hook runs again when its arguments change; pass `update: 'always'` to `defineHook` for one that runs after every render of its component, such as a hook that keeps a scroll position anchored while a list grows. ### Custom properties To hand a value to CSS, bind a custom property in a `style` attribute and let the stylesheet use it, with a fallback: ```ts // src/upload-progress.ts import { css, define, html, prop, type Stateless } from '@gyral/core'; export interface Props { readonly sent: number; readonly total: number; } export const UploadProgress = define()('my-upload-progress', { props: { sent: prop.number({ default: 0 }), total: prop.number({ default: 1 }) }, intent: {}, update: {}, view: (_s, _i, { props }) => html`

    ${props.sent} of ${props.total} files

    `, styles: css` .bar { block-size: 0.5rem; background: linear-gradient(to right, currentColor calc(var(--fill, 0) * 100%), #0000 0); } `, }); ``` Gyral writes `style` bindings through the CSSOM, so they work under a Content Security Policy without `'unsafe-inline'`. In server-rendered markup such a policy blocks the attribute until the component hydrates, which is why the stylesheet keeps a default, `var(--fill, 0)`. [Inline styles under a strict CSP](https://gyral.dev/docs/styling/#inline-styles-under-a-strict-csp) has the details. A `style` binding owns the whole attribute. For the rare element whose inline style a hook writes too, let a hook set only the properties it names: ```ts // src/css-vars.ts import { defineHook } from '@gyral/core'; /** Sets the named custom properties, leaving the element's other inline styles alone. */ export const cssVars = defineHook<[vars: Readonly>]>({ client: (el, [vars]) => { if (!(el instanceof HTMLElement)) return; for (const [name, value] of Object.entries(vars)) el.style.setProperty(name, value); }, }); ``` ### Replaying a CSS animation A CSS animation runs once, when its element appears. A "flash on change", such as an unread badge that pulses when a message arrives or a field that shakes on a rejected value, has to replay it. Count the replays in state, and restart the element's animations with the Web Animations API when the count changes: ```ts // src/inbox-badge.ts import { css, define, defineHook, html } from '@gyral/core'; /** Restarts the element's CSS animations whenever `count` changes, not on the first render. */ export const replay = defineHook<[count: number]>({ client: (el, [count], prev) => { if (prev === undefined || prev[0] === count) return; for (const animation of el.getAnimations()) { animation.cancel(); animation.play(); } }, }); export interface State { readonly unread: number; readonly pulses: number; } export type Msg = { readonly _tag: 'Arrived' }; export const InboxBadge = define()('my-inbox-badge', { init: () => ({ unread: 0, pulses: 0 }), intent: { Arrived: () => ({ _tag: 'Arrived' }) }, update: { Arrived: (s) => ({ unread: s.unread + 1, pulses: s.pulses + 1 }) }, view: (s, i) => html` ${s.unread} `, styles: css` .badge { animation: pulse 300ms ease-out; } @keyframes pulse { 50% { scale: 1.3; } } @media (prefers-reduced-motion: reduce) { .badge { animation: none; } } `, }); ``` `getAnimations()` covers the CSS animations and transitions running on the element; pass `{ subtree: true }` to include its descendants. The count lives in state because the model decides when something deserves attention, and a count, unlike a boolean, changes every time. Without a hook, `keyed(s.pulses, Badge(s))` replays by replacing the element, at the cost of a new node (and its focus, if it had any). ## Widgets with a lifecycle Something with setup and teardown, such as a WebGL stage, a chart or map library, an observer or a connection, has a native home: **a custom element of its own**. Give it one property for its input, keep its state inside, set up in `connectedCallback` and tear down in `disconnectedCallback`. The browser then tells it about every connect, disconnect and move, the view stays a description of data, and the widget can be tested on its own: ```ts // src/scene-editor.ts import { define, html } from '@gyral/core'; interface Scene { readonly cubes: number; } /** The widget: one property in, its own lifecycle. */ class StageElement extends HTMLElement { #scene: Scene = { cubes: 0 }; #frame = 0; set view(scene: Scene) { this.#scene = scene; this.#schedule(); } connectedCallback(): void { this.#schedule(); // set up the renderer, canvas and observers here } disconnectedCallback(): void { cancelAnimationFrame(this.#frame); // dispose the renderer and listeners here } #schedule(): void { cancelAnimationFrame(this.#frame); this.#frame = requestAnimationFrame(() => { this.textContent = `${String(this.#scene.cubes)} cubes`; }); } } customElements.define('my-stage', StageElement); interface State { readonly scene: Scene; } type Msg = { readonly _tag: 'Add' }; export const SceneEditor = define()('my-scene-editor', { init: () => ({ scene: { cubes: 1 } }), intent: { Add: () => ({ _tag: 'Add' }) }, update: { Add: (s) => ({ scene: { cubes: s.scene.cubes + 1 } }) }, view: (s, i) => html` `, }); ``` The widget reports back with events: it dispatches `new CustomEvent(OUTPUT_EVENT, { detail: { _tag: 'Picked' }, bubbles: true })` on itself, and the parent parses that like a [child component's output](https://gyral.dev/docs/components/#outputs-and-other-code). When the widget has a model of its own, make it a Gyral component (`prop.value` for its input, `outputs()` for its events). For a **small imperative behaviour** on an element of the view, such as a timer, a `ResizeObserver` or a third-party enhancer on one input, a hook with a teardown is lighter: `defineHook` takes `dispose(el, args)` next to `client`. ```ts // src/flash.ts import { defineHook } from '@gyral/core'; const timers = new WeakMap>(); /** Highlights the element for a moment whenever `value` changes. */ export const flash = defineHook<[value: unknown]>({ client: (el, _args, prev) => { if (prev === undefined) return; // not on the first render el.classList.add('flash'); clearTimeout(timers.get(el)); timers.set( el, setTimeout(() => { el.classList.remove('flash'); }, 600), ); }, dispose: (el) => { clearTimeout(timers.get(el)); }, }); ``` - `dispose` runs when Gyral removes the element (its part cleared, its template replaced, its list row removed), when the position stops holding the hook, and when the component disconnects. It never runs for moves: rows reordered in a list, or a component moved with `moveBefore()`. - After a component disconnects and reconnects, `client` runs again with `prev` undefined. - The teardown tracking is bundled only when some hook has a `dispose`, so apps whose hooks need none don't pay for it. ## Trusted markup with raw() `raw(markup)` renders a string as HTML: Markdown you converted at build time, highlighted code, a JSON-LD script. Use it only for markup your own code produced, never for user input. It belongs mostly on the server: in a component that renders in the browser, every change parses the string again, and development mode warns about it. This site renders every docs page with `raw()` inside a server-only template. ## Focus is a command Moving focus is a side effect, so it comes from `update`, not the view. `focus(selector)` focuses the first match in the component once the update has rendered, so it can target an element the same update creates: ```ts // src/pager.ts import { define, focus, html } from '@gyral/core'; export interface State { readonly page: number; } export type Msg = { readonly _tag: 'Next' }; export const Pager = define()('my-pager', { init: () => ({ page: 1 }), intent: { Next: () => ({ _tag: 'Next' }) }, update: { // Move focus to the new page's heading, as a screen reader user would expect. Next: (s) => [{ page: s.page + 1 }, [focus('h2', { preventScroll: true })]], }, view: (s, i) => html`

    Page ${s.page}

    `, }); ``` Headings aren't focusable by default, hence `tabindex="-1"`. ### Focusing what a later render brings `focus()` runs after the render its update caused. When the target only appears later, such as the first result once a search answers, pass `wait: true`: the request stays pending until a render of the component produces the target. A newer `focus()` from the component replaces it, and after one second it gives up with the usual warning. `settled()` doesn't wait for it. ```ts // src/result-search.ts import { command, define, defineDriver, focus, html } from '@gyral/core'; interface Result { readonly id: string; readonly title: string; } export interface State { readonly query: string; readonly results: readonly Result[]; } export type Msg = | { readonly _tag: 'Search'; readonly query: string } | { readonly _tag: 'Found'; readonly results: readonly Result[] }; const search = defineDriver({ name: 'search', run: async (query, { signal }) => { const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`, { signal }); return (await response.json()) as readonly Result[]; }, }); export const ResultSearch = define()('my-result-search', { init: () => ({ query: '', results: [] }), intent: { Search: ({ formData }) => ({ _tag: 'Search', query: String(formData?.get('q') ?? '') }), }, update: { // The first result exists only after `Found` renders: the focus waits for it. Search: (s, m) => [ { ...s, query: m.query }, [ command(search, m.query, { onSuccess: (results): Msg => ({ _tag: 'Found', results }) }), focus('#results li:first-child a', { wait: true }), ], ], Found: (s, m) => ({ ...s, results: m.results }), }, view: (s, i) => html`
    `, }); ``` ### Focusing into a child component `focus()` looks inside the component's own root, so it can't reach an element in a child's shadow root. Give the child `shadow: { delegatesFocus: true }` and focus the child itself: focusing the host then focuses its first focusable element. ```ts // src/name-field.ts import { define, html, type Stateless } from '@gyral/core'; /** Focusing focuses its input. */ export const NameField = define()('my-name-field', { shadow: { delegatesFocus: true }, intent: {}, update: {}, view: () => html``, }); ``` ```ts // src/contact-form.ts import { define, focus, html, type Stateless } from '@gyral/core'; import './name-field.js'; export type Msg = { readonly _tag: 'Edit' }; export const ContactForm = define()('my-contact-form', { intent: { Edit: () => ({ _tag: 'Edit' }) }, update: { Edit: (s) => [s, [focus('my-name-field')]] }, view: (_s, i) => html` `, }); ``` The same delegation works for `el.focus()` from page script and for a click on a part of the child that can't take focus, and `:focus` matches the host while focus is inside it. The server writes `shadowrootdelegatesfocus` on the declarative shadow root, so a server-rendered child delegates focus before and after hydration. ### Keeping focus across renders Focus stays only as long as the focused element does. When a render removes it, the browser moves focus to the page body. Render focusable rows with a keyed `each`, so a row moves instead of being created again, and when the focused item really goes away, such as a deleted row, return a `focus()` command for its neighbour or for the list. ## Calling a method on an element Some elements are driven by methods: a `