# Gesso > A declarative UI toolkit for the web in the spirit of SwiftUI and Jetpack Compose: components that run once, a real layout engine with typed props, and a theme instead of CSS. The interface is drawn to a canvas from a worker. Gesso is not React and has no DOM. A component function runs once, state is a cell bound into the tree, layout is `row`, `column` and `box` with typed props, and colour comes from theme tokens. Read "Components run once" and "Cells and bindings" before writing a component. Every page below is also available as markdown at its URL with `.md` appended, and the whole site is one file at https://gesso-docs.vercel.app/llms-full.txt. --- # Why Gesso The front page makes the claim. This page is the argument behind it, including the parts that are not flattering: what has been measured and by what, how Gesso compares to the frameworks you would otherwise reach for, and the kinds of screen it is the wrong tool for. Gesso is an application toolkit for the web, in the spirit of SwiftUI and Jetpack Compose. A screen is a tree of declarative components, laid out by a real layout engine with typed props and coloured by a theme, with no DOM or stylesheet underneath. The result is an app that feels native in a browser tab or a desktop window. It is also fast in a way the web usually is not. Your application has real work to do: parsing a file, diffing a document, simulating a system, sorting a hundred thousand rows. On the web that work and your interface share one thread of execution, so they take turns, and the person using it watches them take turns. The pointer sticks. The list stutters mid-scroll. The keystroke lands a beat late. Gesso moves the entire interface somewhere else. Components, layout, paint, text and input run in a render worker. Your application logic runs in another. The main thread is left holding a canvas and forwarding events, so nothing you compute can drop a frame. **Try it.** Two copies of the same component, one in a render worker and one on the main thread. Block the main thread for three seconds and see which one cares. Each copy advances on its own 40 ms timer and shows the longest gap it has ever seen between two ticks. Through the block, the copy in the worker reports **41 ms**: it did not miss a tick. The copy on the main thread reports **3,010 ms**, which is the block itself. ## Declarative, with nothing to re-run If you have written SwiftUI or Jetpack Compose, the shape is familiar: a function describes a screen, and the screen follows some state. What differs is what happens when the state changes. | Framework | On a state change | | ----------- | -------------------------------------------------------------------------------- | | **SwiftUI** | `body` is recomputed and the result is diffed against the last one | | **Compose** | the composable is invoked again (recomposition), with unchanged subtrees skipped | | **Gesso** | nothing is invoked again; one property on one node is written | That difference is the one you feel while writing. There is no re-run to make cheap, so there is nothing to memoise, no dependency array to keep honest, no stability annotations, no `remember`, and no view identity to reason about. Your component function runs once, so every closure in it is stable. And the cost of a change is visible in the source: the binding _is_ the update. A counter, as a function component: ```ts function Counter(inputs, _context) { const label = input(inputs.label, 'Count'); const count = internalState(0); const caption = computed(() => `${label.value}: ${count.value}`); return Row( { gap: 12, x: 'center', y: 'center', width: percent(100), height: percent(100) }, Text({ text: caption, fontSize: 18 }), Button({ label: 'Add one', onClick: () => count.value++, backgroundColor: 'primary' }, Text({ text: '+1' })) ); } ``` `input(inputs.label, 'Count')` is an input with a default. `internalState(0)` is the value this component owns, the equivalent of `@State` or `mutableStateOf`. The caption is `computed`: one expression over the other two, following the cells it reads, subscribed to by the node that shows it, so it cannot be stale and there is nothing to invalidate. JSX is optional. It compiles onto those element factories and produces the same tree in a different spelling. This is the counter above as it is actually written, and the file the test suite asserts on: ```tsx import { percent } from 'gesso-core'; import { Button } from 'gesso-components'; import { type ComponentContext, type Inputs, computed, input, internalState } from 'gesso-framework'; export function Counter(inputs: Inputs<{ label?: string }>, _context: ComponentContext) { const label = input(inputs.label, 'Count'); // inputs are cells; this one has a default const count = internalState(0); const caption = computed(() => `${label.value}: ${count.value}`); return ( ` binds an element to the button's label and draws nothing; the framework warns once when a text turns out not to be text. Put such a child in an array, `{[icon$]}`, or in a ``. - A `button` stacks its children as a `box` does, but centred both ways rather than at its origin, as an HTML button centres its label. To lay out an icon beside a label, put a `` inside it, and give the button `x="stretch"` when that row should span it. - A modifier written inline, `modifiers={[interactive({ hovered: ... })]}`, is compared by what it holds, so it is the same modifier across a rebuild. A modifier whose arguments include a fresh callback each time is a new one; hoist the callback, or the whole modifier. - `Presence` fills whatever holds it. Placed in an app's root it sits over every screen and takes their clicks; hold it in a box the size of what it animates. - Paint and hit order among siblings is tree order, then `zIndex`. An absolutely positioned control followed by a positioned column is painted under the column and cannot be clicked; give it a `zIndex`. ## What the body may ask for The second argument to a component function is its context: what the function may ask of the framework while it runs. | On `ctx` | What it does | | -------------------- | ---------------------------------------------------------------- | | `inject(Service)` | The runtime service of this class | | `channel(token)` | The replica of a channel: `view` keys to read, `send` to command | | `onMount(fn)` | Runs once after the nodes exist and the bindings are connected | | `onUnmount(fn)` | Runs once when the component leaves the tree | | `effect(source, fn)` | Follows a stream for as long as the component is in the tree | | `bounds()` | A cell holding a node's box, with the modifier that fills it | Most values a component reads are bound into the tree, and a binding needs no lifecycle: it is torn down with the node it feeds. `effect` is for the other kind, a value the component has to **act** on rather than draw, such as telling the audio element to load a track or asking a channel for the page a url names. ```ts ctx.effect(queue.view.current, track => audio.load(track.stream)); ``` The subscription belongs to the host and goes when the component does, after `onUnmount` has run. Writing that pair by hand, a `subscribe` in the body and an `onUnmount` that unsubscribes, is the same thing with two places to forget. `bounds()` answers the other question a body cannot answer for itself: where is this node. Turning a pointer position into a fraction of a track needs the track's box, and a canvas has no `getBoundingClientRect`. ```tsx const track = ctx.bounds(); seek((e.x - track.value.x) / track.value.width)} />; ``` It is an ordinary cell: read it in a handler, bind it, derive from it. It reports a move and only a move, so a box that has not changed does not wake everything reading it; a list notifies its listeners on every frame it scrolls, and its viewport is exactly what stays still while it does. ## Where state goes instead If the body does not re-run, a `useState` equivalent would have nowhere to live. It lives in a cell instead: a value the component holds, which bindings read. That is [the next page](https://gesso-docs.vercel.app/guide/cells-and-bindings). --- # Cells and bindings A cell is a value a component holds and a binding reads. There are three kinds, and choosing between them is most of what writing a Gesso component is. | Where the value comes from | What to use | What it is | | -------------------------- | --------------------------- | ----------------------------------------------- | | The parent | `input(inputs.x, fallback)` | An input cell the host keeps feeding | | This component | `internalState(value)` | A cell only this component writes | | Other cells | `computed(() => ...)` | A cell that is a function of the cells it reads | The third row is the one people overlook. A value that is a function of other values needs no store and no synchronisation: `computed` reads the cells its function touches, follows them, and cannot be stale because there is no copy of it anywhere that a change could miss. Each line keeps its own quantity. The currency comes from the parent and changes both. The total is neither: it is one expression over the two. ```tsx /** * One order line, holding all three kinds of value at once. * * - `currency` comes from the parent. `input()` reads the prop cell * and gives it a default, so the line follows a parent that changes * its mind, without this body running a second time. * - `quantity` is the line's own. `internalState` is for what * originates here and dies with the component. * - `total` is `computed` from the two above. It reads them as values * and follows them as cells: nothing to keep in sync, nothing to * invalidate, and no way for it to disagree with them. */ function OrderLine(inputs: Inputs<{ currency?: Currency }>, _ctx: ComponentContext) { const currency = input(inputs.currency, 'USD'); const quantity = internalState(1); const total = computed( () => `${currency.value === 'EUR' ? '€' : '$'}${(quantity.value * PRICE_USD * RATES[currency.value]).toFixed(2)}` ); return ( (quantity.value = Math.max(1, quantity.value - 1))} /> String(quantity.value))} fontSize={14} color="text" width={20} textAlign="center" /> quantity.value++} /> ); } ``` ## `input`, a prop that keeps arriving Inputs arrive as cells, not as values, which is what lets the body run once and still follow its parent. `inputs.currency` is an `InputCell`, and `input(inputs.currency, 'USD')` reads it with a default: ```ts function OrderLine(inputs: Inputs<{ currency?: Currency }>) { const currency = input(inputs.currency, 'USD'); } ``` A parent can pass a plain value or an Observable, since every input is `Reactive = T | Observable`, and the child cannot tell the difference. That is why the example passes `currency` (a cell) straight down: to `OrderLine` it is simply an input that changes. ## `internalState`, what the component owns For what originates here and dies with the component: a tooltip's open flag, a caret, a scroll offset, the active tab, the quantity above. ```ts const quantity = internalState(1); quantity.value++; // marks exactly the bindings that read it ``` Reading `.value` is synchronous, writing it is a notification. Nothing is scheduled twice: several writes in one handler mark their nodes and the next frame draws the result once. The name is on purpose. `internalState` says _where the value came from_ rather than what it is, so `internalState(products)` reads as the mistake it usually is. Anything that survives a reload, or that another screen cares about, is application state and belongs on a channel or in a service, not in a component. ## `computed`, a function of other cells ```ts const total = computed(() => format(quantity.value * PRICE * RATES[currency.value])); ``` Write the value as the expression it is, reading each cell with `.value`. The function runs, `computed` notes which cells it read, and from then on follows exactly those: when one changes the function runs again, and if the result differs, everything bound to `total` is written. A cell the function did not read this time is not followed; a branch that reads a different cell next time is. There is no effect that recomputes `total` into a second piece of state, and no moment where it disagrees with `quantity`. Anything that would be a `useMemo` or a `useEffect` writing into state is this instead. Read it in a handler with `total.value`; bind it to a prop like any cell. Pass `{ equal: 'structural' }` when the function builds an object and a rebuilt equal one should not count as a change. The rule of thumb: **if you can write the value as an expression over other cells, it is a `computed`, not a state.** `internalState` is for a value that nothing else determines. ## Observables underneath Every cell is an RxJS `Observable`, and every prop accepts one, so a value that arrives as a stream rather than a cell binds the same way: `text={stream}`. A stream is not a cell, though: it has no current value to read with `.value`, so `computed` hands its function a `read` for exactly that case. ```ts const late = computed(read => read(clock) > deadline); ``` `read` answers with the stream's latest value and follows it as `computed` follows a cell, and one subscription is shared by every computed reading the same stream. Pass it a cell and it simply reads it, so a call site does not have to know which it has, and a service that later turns a stream into a cell breaks nothing. That makes `computed` the one derivation to reach for, whatever the sources are. `derive([a, b], fn)` still works and is deprecated: it listed its sources beside the expression, which is a second thing to keep in step with the expression, and a screen with a `derive` and a `computed` in it is written in two dialects. ```ts // Before const scheme = derive([settings.appearance, shell.colorScheme], (chosen, platform) => chosen === 'auto' ? platform : chosen ); // Now const scheme = computed(() => settings.appearance.value === 'auto' ? shell.colorScheme.value : settings.appearance.value ); ``` `pipe` is still there for everything RxJS is good at, which is streams of events rather than values that are: debouncing, retrying, switching. A binding that is a value has no reason to use it. ## One field of a cell `select` is one field, or one projection, of a cell, as a cell: ```ts const title = select(inputs.track, 'title'); const tags = select(inputs.track, entry => entry.tags.slice(0, 8)); ``` It compares structurally by default, so a projection that rebuilds an equal array or object does not re-bind everything reading it, which is the reason most hand-written comparators exist. Reading four fields of one input is four of these. An optional input that the parent did not pass is a live cell holding `undefined`, not a missing one. Binding it straight to a property writes `undefined` there, which draws as though the property had never been set, so give it a value: `input(inputs.tint, INK)` for a default, or `select` for anything else. ## What a binding costs A binding is attached to one property of one node. When it emits, that property is written and the node is marked. What that costs depends on which property, and [components run once](https://gesso-docs.vercel.app/guide/components-run-once) lists the groups: content and paint properties repaint, layout properties run layout again from the nearest ancestor that can absorb the change, and `transform` does neither, moving the node without touching layout or paint. Fan-out is free when the nodes share a cell. Ten nodes bound to the same Observable are ten writes from one emission, with no list to re-render and no diff. That is why the two order lines above can share a currency cell without the parent knowing how many lines there are. What is not free is ten thousand nodes each reading a _different_ slice of one upstream value, which is the next section. ## `fanOut`, when every node wants its own slice A grid, a timeline and a log viewer all arrive at the same shape: one source, and N things on screen each reading one part of it. The natural spelling is a pipe per part: ```ts const cell = cells.pipe( map(all => all[key]), distinctUntilChanged() ); ``` It is the one spelling in this guide that does not scale. Every emission runs N pipelines whatever changed, and each pipeline is one more observer on one Subject, which RxJS removes by scanning the list: so unmounting a window of N costs N². At a few dozen nodes none of that is measurable. At ten thousand it is the frame. `fanOut` is the same idea with the costs the other way round: ```ts const cells = fanOut(sheet.view.cells, (all, key) => all[key] ?? null, { initial: null, changed: (next, previous) => next.touched }); const cell = cells.for(`${row}:${column}`); // a stable cell, made on demand Text({ text: cell }); cells.release(`${row}:${column}`); // when it leaves the screen ``` One subscription on the source for the whole registry, whatever N is. Each cell carries its own subject with one observer, so its teardown is the constant-time case. And `changed` is where the frame is won: a source that knows which keys a patch touched reads only those, so a change to one cell of ten thousand costs one read rather than ten thousand. Leave it out and every live key is read on every emission, which is correct, is what the pipe costs too, and is the honest answer for a source that genuinely does not know. Measured, ten thousand live keys, against the pipe per key: | | mount | one key changes | unmount | | ----------------------- | ------- | --------------- | ------- | | a pipe per key | 29.8 ms | 2.2 ms | 9.7 ms | | `fanOut` | 10.7 ms | 2.2 ms | 4.0 ms | | `fanOut` with `changed` | 10.8 ms | 0.1 ms | 3.7 ms | A registry is a resource: it holds a key until `release` and the source until the last key goes. That is deliberate. The surfaces that need it are the ones that mount and unmount deliberately, and a cell that vanished when its last binding did would be rebuilt on every scroll frame by exactly the code that cannot afford it. It is also the one thing here that can be got wrong silently, so a registry that has grown past a couple of thousand keys and released none of them says so once. **It compares by reference, where `select` and `derive` compare by content.** The opposite default, for the opposite reason: those run once per emission, and this runs once per live key per emission. On ten thousand keys with the source republishing the lot, which is what a scroll does, structural comparison costs 1.9 ms against 0.8. Pass `equal: 'structural'` when the reader _builds_ a value rather than passing one through. Better still, hand back the same object for an unchanged key, one `EMPTY` for every cell with nothing in it, and keep the cheap test. Reach for it when N is large **and each emission touches few of them**. That second half is the one to check: a source that republishes its whole window on every scroll gets the cheaper mount and teardown and nothing from `changed`, because every key really did change. A log that appends, a presence list, a dashboard of independent tiles are where the 0.1 ms above comes from. For a list of components, [`each`](https://gesso-docs.vercel.app/guide/components-run-once) is still the answer: a row that is a component already has its own identity and its own inputs, and does not need a registry to give it one. ## Cells outside a component `internalState` is not magic and is not tied to a component: it is a `BehaviorSubject` with a `.value` setter. A service can hold one, and a component can bind to it directly. That is how the runtime's own services expose state, and how a small application shares a value between two screens without a channel. What a cell must not become is the application's data layer. When state outlives a screen, is authoritative, or belongs on another thread, it goes behind a channel. The framework has an opinion about the barrier and none at all about what lives above it. --- # Lists and conditionals A component body runs once, so a list and a conditional are not control flow in the body: they are values that change, bound into the tree like any other. Two helpers cover the whole of it. | What changes | What to write | | -------------------------- | ------------------------------------------------ | | The rows of a list | `{row => ...}` | | Whether one child is there | `{() => ...}` | | A value inside a row | Bind it, or `select` it. Neither helper re-runs. | ## `Each`, a keyed list ```tsx {track => } ``` `of` is a cell, any Observable of an array, or a plain array. `by` says what identifies a row: a field name, or a function (`by={row => row.id}`). The key goes on the element the row function returns, so a row component is written without one. The key is what makes the list cheap and correct. A row keeps its node, its component instance and its animation for as long as it is in the list, so inserting a row at the top moves nothing else, and a text field in a row keeps the text somebody typed into it. Only what changed is redrawn. `Each` holds the element it built for each key and hands the same one back while that row's value has not changed, so a list that re-emits equal data draws nothing at all and the tree is never re-entered. A row whose value changed is rebuilt on its own. Leave `by` out and rows are keyed by their position, which is what the tree falls back to and almost never what you want: a row inserted anywhere but the end shifts every row after it onto a different node. `Each` says so once, in a warning, rather than letting it be discovered as a bug. For a list long enough that the rows out of view should not exist at all, [`LazyColumn`](https://gesso-docs.vercel.app/recipes/virtualized-feed) is the same shape with a `count` and an `index => child`. ## `Show`, one child while a condition holds ```tsx {() => } ``` `when` is a cell, an Observable or a plain value; anything truthy shows the child. `otherwise` gives the other branch: ```tsx }> {() => } ``` The child is built once, on the first frame it is shown, and kept: a condition that emits `true` again draws nothing. It carries a stable key, so the node it built is the node it gets back when the condition returns, and `Show` puts nothing else under the parent, so layout sees the child or nothing and never a wrapper. `{cond && }` still works when `cond` is a plain value, and it is the shorter thing to write when the answer is settled while the body runs. `Show` is for a condition that changes afterwards. ## What does not re-run Neither helper re-runs its function when the data inside changes, only when the list or the condition does. So a value shown inside a row or inside a `Show` is bound, not read: ```tsx {() => {select(track, entry => entry?.title ?? '')}} ``` `select` is one field, or one projection, of a cell, as a cell. It compares structurally, so a projection that rebuilds an equal array or object does not re-bind everything reading it: ```ts const title = select(inputs.track, 'title'); const tags = select(inputs.track, entry => entry.tags.slice(0, 8)); ``` Reading four fields of one input is four of these, and none of them needs an operator, a comparator or a subscription of its own. --- # Inputs and outputs A component takes inputs and sends outputs. Both are cells. An input is a cell the host feeds from whatever the parent passed; an output is a cell that holds whatever the parent gave to be called, and `emit` fires it. The body runs once, so nothing about either is read at render time except through a binding. A note on the word "props". Elements have props: `width`, `color`, `onClick` on a `` or a ``, which become properties of a node. Components have inputs and outputs. The parameter a function component receives is its inputs, and it is called `inputs` throughout the guide, the library and the examples; its type is `Inputs<...>`. A component element's `props` property, where the parent's values are held before they become cells, is the one place the framework's own types keep the other word. ## Declaring inputs The first parameter's type says what the component takes. Every member arrives as a cell, whether the parent passed a value or an Observable. ```tsx interface RowInputs { /** Required: the parent must pass it, as a value or an Observable. */ title: string; /** Optional: arrives as a cell that may hold undefined. */ emphasis?: 'normal' | 'strong'; /** An output: the parent passes a handler, or `into(subject)`. */ onPick: (title: string) => void; /** An optional output, fired with no arguments. */ onHover?: () => void; } export function Row(inputs: Inputs) { const weight = derive([input(inputs.emphasis, 'normal')], emphasis => (emphasis === 'strong' ? 700 : 400)); ``` A member typed as a function is an output. A member that admits `undefined` is optional; `input(inputs.size, 16)` gives it a default that keeps following the source. ## Reading and binding Inside the body, bind: pass the cell, or a `pipe` of it, into a node's prop. Read `.value` only in a handler, where the current value is what you want. A `.value` read in the body is a snapshot, and the framework warns once if that snapshot goes stale. `derive` is the shape most bindings take, several inputs projected into one value and emitted only when it changes: ```ts const playing = derive( [queue.view.playlistId, audio.state], (id, state) => id === card.id && state.status === 'playing' ); ``` It is `combineLatest`, `map` and `distinctUntilChanged` in one call, with `{ equal: 'structural' }` for a projection that builds a fresh object of the same shape each time. ## Sending outputs An output is fired with `emit`, from as many places as the component needs, and the component never touches the handler itself: ```tsx return ( ); ``` A parent that passed nothing is fine: the emission reaches nobody. A component that wants to react to its own output reads it as a stream through `events`. ## Receiving outputs A parent passes a function, as it always could. Or it passes `into(subject)` and receives the output as a stream, which is what lets a list of rows merge their outputs, a slider's output be debounced, or a child's output be handed straight to a channel command: ```tsx export function List() { const picked = new Subject(); const hovered = new Subject(); // Two outputs from many rows, merged into one stream for the parent. const activity = merge(picked.pipe(map(title => `picked ${title}`)), hovered.pipe(map(() => 'hovering'))); return ( {['Alpha', 'Beta', 'Gamma'].map(title => ( ))} {activity} ); } ``` Wrapped in `into` rather than passed bare, because a bare `Subject` is an Observable and would be read as an input the parent is feeding the child, which is the opposite direction. ## Class components A class declares inputs as `@Input()` fields made with `input()`, and outputs as `@Output()` fields made with `output()`: ```ts @Define('stepper') class Stepper extends Component { @Input() step = input(1); @Output() changed = output<[value: number]>(); } ``` The host wires both the same way; the decorator is so a reader can tell the directions apart. ## Who owns a value A control that shows a value and changes it has two honest forms. Controlled: the parent passes `value` and hears every change through `onChange`; the parent owns the state. Self-managed: the parent passes `defaultValue`, or nothing, and the control owns it, still reporting changes. `controlled()` implements that contract and throws when both are passed, because two owners is a bug: ```ts const value = controlled({ component: 'Slider', name: 'value', source: inputs.value, initial: inputs.defaultValue, fallback: 0, onChange: inputs.onChange }); ``` For the plain case where the owner is a local cell, `bind` is the spread: `` passes the cell as `value` and writes changes back into it. A control whose value has another name takes that name as the second argument, and the handler stays `onChange`: `` spreads beside the props around it. When the value belongs to a store or a channel, pass the cell and a handler that sends the command instead. ## Where the framework helps you - A body that reads an input with `.value` and never hears it change is warned about once, with the component, the input and the field that moved. - A `text` bound to something that is not text is warned about once. - The inspector names the cell a bound property comes from, so a value on screen can be traced to the input that fed it. --- # Layout basics Gesso's layout borrows CSS's model and most of CSS's names, so a box that would be 240 px wide in a browser is 240 px wide here, and the engine is checked against Chrome on a few hundred generated fixtures. What is different is that the names are typed, and there is no cascade: a property is set on an element, and that is where it comes from. ## Three containers | Element | Lays children out | | ---------- | ---------------------------------------------------- | | `` | Along the horizontal axis | | `` | Along the vertical axis | | `` | Stacked on top of each other, aligned within the box | `` is also the plain rectangle: a background, a border and a size. The same element is exported as `Stack` for when layering is what you mean. ## `x` and `y` place everything Two props do the work of `justify-content`, `align-items` and their per-child overrides: - **`x`** places children horizontally. - **`y`** places children vertically. - **`selfX` / `selfY`** let one child override its parent on that axis. They are named for the screen rather than for the axis, so they mean the same thing on a row as on a column. On a `row`, `x` distributes along the main axis (`start`, `center`, `end`, `space-between`, and the rest) and `y` places across it. On a `column` they swap roles without swapping names. Press either button. `x` walks the distributions, `y` walks the cross-axis values: ```tsx /** * A row of three boxes, and the two props that place them. * * `x` distributes the children along the row's main axis; `y` sizes and * places them across it. They are the same two props on a `column`, * where the axes swap, which is why they are named for the screen * rather than for the axis. * * None of the boxes sets a height, which is what lets `y: 'stretch'` * do anything: stretch gives a child the container's cross size only * when the child has not already chosen one. Give a box a `height` and * it keeps that height under every value of `y`. */ function Boxes(inputs: Inputs<{ x: UiAlignment; y: UiAlignment }>, _ctx: ComponentContext) { return ( ); } /** A box sized by what is in it, so `stretch` has something to change. */ function Tile(inputs: Inputs<{ name: string }>, _ctx: ComponentContext) { return ( ); } ``` **The cross axis defaults to `stretch`**, matching CSS. Stretch gives a child the container's cross size _only when the child has not chosen one_: the tiles above have no `height`, so they fill the row, and the moment one takes a `height` it keeps it under every value of `y`. This is the single most common surprise in the model, which is why the example is built to show it. ## Lengths are values, not strings A number is pixels. Anything else is a tagged value you import: ```tsx import { auto, percent } from 'gesso-core'; // 240 px // half the parent's content box // sized by content ``` There is no `"50%"`. A string where a length belongs throws at layout and names the property, because the alternative is a hot path that parses strings on every measure. Percentages resolve against the parent's definite content box, and behave as `auto` when it has none. ## Space `gap` between children, `padding` inside a container, `margin` outside a child, all in pixels. Both `padding` and `margin` come with an axis and four sides: ```tsx ``` `paddingX` is the left and right of the box, `paddingY` the top and bottom, and `marginX` and `marginY` are the same outside it. Nearly every box is padded more on one axis than the other, so those two are the pair to reach for; `padding` on its own is for the box that really is padded equally. The four sides are still there for the inset that is genuinely on one edge, and the most specific value wins: a side beats its axis, and an axis beats the shorthand. So ```tsx ``` is nothing on the left, sixteen on the right, and eight top and bottom. What has gone is the pair: `paddingLeft={24} paddingRight={24}` says one thing twice, and `paddingX={24}` is that thing. `padding` is a number and nothing else. It does not take a tuple or an object, deliberately. For a single horizontal edge, reach for `paddingStart` and `paddingEnd` rather than `paddingLeft` and `paddingRight`. They name the edge the reading starts and ends at, so an indent stays an indent when the application runs right to left, and they resolve between the axis and the physical sides. See [right to left](https://gesso-docs.vercel.app/layout/right-to-left). A row and a column can also space their axes apart independently with `rowGap` and `columnGap`. `margin` takes `auto` on either axis, which is still the shortest way to push one child to the far end of a row. The numbers themselves belong in a theme rather than in the markup. `theme.spacing` is an eight step scale on the same names as the shape scale, so a screen written from it reads `paddingX={theme.spacing.large}` and a compact density shrinks every box on the page at once. See [themes and the environment](https://gesso-docs.vercel.app/appearance/themes-and-the-environment). ## Who grows Sizing follows flexbox, including the parts people forget: - `flexGrow` takes a share of the space left over; `flexShrink` gives back a share of the overflow; `flex={1}` is the usual shorthand for both. - **A flex item never shrinks below its content.** An item's automatic minimum is the smaller of its declared size and its min-content size, which is the longest word or the widest summed row. That is what stops a column of cards being crushed to nothing by a tall sibling. Set `minWidth`/`minHeight` explicitly to overrule it; scroll containers and clipped text have no automatic minimum by design. - Space a clamped item cannot take is redistributed to the others, rather than being lost. ## When a box is the wrong size Ask the engine rather than guessing. Every runtime can explain any node's size: the rule that fixed each axis, the constraints it was given, and the ancestor a change would be laid out from. ```ts formatExplanation(runtime.explain(node)); // "shrank to 0 (flexShrink 1 …); its automatic minimum is 0: // a scroll container or clipped text has none" ``` The same explanation is what `gesso-testing`'s `toHaveBox` prints when an assertion misses, so a failing layout test tells you why rather than only what. ## Next [Text](https://gesso-docs.vercel.app/guide/text) is the other half of layout: wrapping, clamping, and what a paragraph does to the box around it. --- # Text Text is the input to most layout decisions. A `` element is not a rectangle with a string in it. It is measured, broken into lines, and sized by what it says, and the box around it is decided by that. The card below is 420 px wide. Press the button to narrow it and watch which parts answer: ```tsx /** * A card whose width the reader changes, so the text has to answer. * * Three text behaviours worth knowing, all of them props on `text`: * * - The title takes `maxLines` and `textOverflow`, so it clamps to two * lines and ends in an ellipsis rather than growing the card. * - The body has neither, so it wraps as far as it needs to. Width is * CSS `fit-content`: given a loose bound it takes what it wants. * - The footer row aligns on `baseline`, so a large number and a small * label sit on the same line rather than on their box centres. */ function Card(inputs: Inputs<{ width: number }>, _ctx: ComponentContext) { return ( ); } ``` The body re-wraps and gets taller. The title, clamped to two lines, does not: it drops what will not fit and ends in an ellipsis. Both are the same element with different props. ## Styling ```tsx ``` `color` takes a theme token as readily as a value, which is why nothing in these examples names a hex code. Text that sets neither size nor colour inherits both from the type scale in the environment. See [light and dark](https://gesso-docs.vercel.app/guide/appearance) for what provides that. ## Wrapping, clamping, ellipsis | Prop | What it does | | -------------- | --------------------------------------------------------------------- | | `textWrap` | `word` (the default), `char`, or `none` | | `maxLines` | Stop after _n_ lines | | `textOverflow` | `clip` (the default) or `ellipsis`, for what a clamped line ends with | A clamped title is the common case and worth spelling out: `maxLines` alone truncates, and `textOverflow: 'ellipsis'` is what makes the truncation legible. ## How a paragraph sizes its box Width is CSS `fit-content`: given room to choose, a paragraph takes what it wants up to what it is offered. Two rules follow, and between them they explain most surprises: - **A tight bound is authoritative.** Told exactly how wide to be, text wraps to that width. - **A loose bound is available space, not a clamp.** Told "up to this much", text takes what it needs, and content that does not fit overflows exactly as it would in a browser. Flex sizing then runs twice around this: items are measured at their max-content width to resolve the main axis, and any item whose width changed is measured again at the width it ended up with. That second pass is what makes a wrapped paragraph's height right instead of approximately right. ## Baseline alignment `y="baseline"` on a row puts its children on a shared text baseline rather than on their box edges or centres, which is the only way a 22-pixel number and a 12-pixel label look like they belong on one line: ```tsx ``` Baselines come from the font's own metrics, and a box with no text in it gets a synthesised one the way CSS specifies, so a badge or an icon can sit in a baseline-aligned row without falling out of it. ## One measurer Layout, both renderers and the editing caret all break lines through the same `layoutParagraph`. That is a correctness property rather than an implementation note: a line cannot wrap in one place for layout and another for paint, and a caret cannot land between two characters that the renderer drew somewhere else. The consequence for you is that text costs measurement. It is memoised per node and per constraint, so an unchanged paragraph is not measured again on a later frame, but a screen that re-wraps a thousand paragraphs on every keystroke is doing real work, and the frame profiler will say so. Text you can _edit_, with a caret, a selection and composition from an IME, is a different subject from text you lay out, and lives in `EditableText` and the `TextInput` component rather than in these props. ## Next [Light and dark](https://gesso-docs.vercel.app/guide/appearance) is where the type scale and the colours these examples inherit actually come from. --- # Rich text A `` element takes either a string or a list of runs. The runs are what make prose possible: a word in bold, a phrase in italic, a snippet in a monospaced face, a link the reader can press, all inside one paragraph that breaks its lines from all of them together. Everything on that card is four `` elements: no component, and no node per word. Runs are enough to read markdown. To edit it, the library has [RichTextEditor](https://gesso-docs.vercel.app/components/rich-text-editor), which is built from the same runs, and its read-only twin `RichTextView`. ## Runs `spans` replaces `text`. Each run carries its own text and the style fields it overrides; anything it does not set it inherits from the element, and the element from its environment, exactly as it always did. ```tsx ``` | Field | What it changes | | ------------------------------------------- | -------------------------------------------------- | | `fontFamily`, `fontSize`, `fontWeight` | The face the run is measured and drawn in | | `fontStyle` | `normal`, `italic` or `oblique` | | `fontStretch`, `fontVariant`, `fontKerning` | The width axis, small caps, and the font's kerning | | `letterSpacing` | Tracking, for this run only | | `hidden` | Keeps the run in the text but draws nothing for it | | `color`, `backgroundColor` | A value or a theme token, resolved per run | | `textDecoration` | `underline`, `line-through`, or both | | `link` | Makes the run pressable; see below | The first eight change how wide the run measures, so a paragraph is laid out again when one of them changes. The last three do not: a run that changed only its colour, its background, its underline or its handler is repainted from the lines the layout already found. ## One string, whatever the runs This is the rule everything else follows from. **The runs' texts concatenated are the paragraph**, so an offset into it means the same thing to every layer: - the line breaker measures each run in its own font and breaks the line from all of them, - a selection crosses a run boundary without noticing one, - find-in-page matches text that starts in one run and ends in another, - and the accessibility mirror reads the paragraph as prose. A run is a shape of a paragraph rather than a node inside it, which is why none of that needed a second tree. ## Inline links A run with a `link` is pressable. It shows a pointer cursor, lights up under the pointer with a wash of its own colour and an underline, and runs `onClick` when a press is released where it landed. A press that drags is a selection instead, as it is on a page. ```tsx { text: 'the reference', color: 'primary', link: { href: '/reference', onClick: open, label: 'the API reference' } } ``` `label` names the run for a screen reader when its own words would not: "here" and "read more" are links nobody can use out of context. `href` is carried for the application's own use; the runtime never navigates to it. In the accessibility mirror the paragraph becomes a `paragraph` whose children are the prose and the links in reading order, so a screen reader reaches the link the way it reaches one in a page rather than hearing its words go past inside a sentence. ## A markdown document The example above is a markdown reader in forty lines. Blocks become elements and inline markers become runs: ```tsx /** A block of the document: a heading at some level, or a paragraph. */ export interface MarkdownBlock { /** 0 for a paragraph, 1 or 2 for a heading. */ readonly level: number; readonly spans: readonly UiTextSpan[]; } /** * A deliberately small markdown reader: blank lines separate blocks, * a leading `#` or `##` makes a heading, and inside a block `**bold**`, * `*italic*`, `` `code` `` and `[label](href)` become runs. * * It is short because the interesting part is not the parsing. What * matters is what it produces: a flat list of runs per block, which is * exactly what a `` element takes. */ export function markdownBlocks(source: string, onLink: (href: string) => void): MarkdownBlock[] { return source .split(/\n{2,}/) .map(block => block.trim()) .filter(block => block.length > 0) .map(block => { const heading = /^(#{1,2})\s+/.exec(block); const level = heading === null ? 0 : heading[1].length; // A wrapped source line is one paragraph, so the newlines inside // a block are spaces rather than breaks, as markdown has them. const text = block.slice(heading?.[0].length ?? 0).replace(/\s*\n\s*/g, ' '); return { level, spans: inlineSpans(text, onLink) }; }); } /** The four inline forms, in one pass, longest marker first. */ const INLINE = /\*\*([^*]+)\*\*|\*([^*]+)\*|`([^`]+)`|\[([^\]]+)\]\(([^)]+)\)/g; function inlineSpans(text: string, onLink: (href: string) => void): UiTextSpan[] { const spans: UiTextSpan[] = []; let at = 0; INLINE.lastIndex = 0; for (let match = INLINE.exec(text); match !== null; match = INLINE.exec(text)) { if (match.index > at) { spans.push({ text: text.slice(at, match.index) }); } const [, bold, italic, code, label, href] = match; if (bold !== undefined) { spans.push({ text: bold, fontWeight: 700 }); } else if (italic !== undefined) { spans.push({ text: italic, fontStyle: 'italic' }); } else if (code !== undefined) { spans.push({ text: code, fontFamily: 'monospace', color: 'text', backgroundColor: 'background' }); } else { spans.push({ text: label, color: 'primary', textDecoration: 'underline', link: { href, onClick: () => onLink(href) } }); } at = match.index + match[0].length; } if (at < text.length) { spans.push({ text: text.slice(at) }); } return spans; } ``` And the document is one element per block, with a heading differing from a paragraph only in size and weight: ```tsx /** * One `` per block, and nothing else. * * A heading is the same element as a paragraph with a larger size and * a heavier weight; the runs inside it are the runs inside any other * block. There is no rich-text component here, because there does not * need to be one. */ function Document(inputs: Inputs<{ blocks: MarkdownBlock[] }>, _ctx: ComponentContext) { return ( {inputs.blocks.pipe( map(blocks => blocks.map((block, index) => ( )) ) )} ); } ``` ## The line box A run inherits the paragraph's line height as a length, so a run smaller than the paragraph does not change the line and a run larger than it grows the line around its own box, which is what CSS does with the same two inline boxes. Where Gesso differs from a browser is that **every line of a paragraph gets the tallest line's box**. A paragraph is its line count times one line height here, and both renderers step by that. Keep a run's size close to its paragraph's and the two agree exactly; the fixture that pins the difference is `runs/a-taller-run-grows-only-its-own-line`. ## Runs in a field being typed into `` takes `spans` too, and there it means something narrower. A `` gets its paragraph _from_ its runs; a field's paragraph belongs to the thing the user is typing, so its runs only describe that text rather than supplying it. ```tsx ``` **The runs' texts concatenated have to equal `value`.** When they do not, the field draws its text plainly and ignores them. That is not a nicety: every offset a field works in (the caret, the selection, the hit test that turns a click into a text position) is an offset into the string the model holds, so runs describing a different string would put the glyphs where the caret does not agree with them, and the field would look subtly and unfixably wrong. A frame of plain colour while the application catches up is the better failure. Everything else about a field is unchanged: it still scrolls its own text, still shows a placeholder, still carries a caret and a selection. ## Hidden runs A run with `hidden: true` stays in the text and leaves the picture: it takes no room, the line breaker counts it as nothing, and neither renderer draws it. Its characters keep their offsets, so a selection, a copy, find-in-page and undo all still see them. It is how a markdown editor draws `**bold**` as a bold word while the asterisks stay in the string being typed, which the rule above insists on. [RichTextEditor](https://gesso-docs.vercel.app/components/rich-text-editor) is that editor. ```tsx ``` In a field, the caret treats each stretch of hidden text (runs that touch count as one) as a unit it cannot stop inside, so every key moves something you can see: - **Arrows** cross one visible character, or with the word modifier one visible word, and the hidden text in the way. They stop on the side of any hidden text there nearest where they started: from the end of `**bold**`, Left stops between `l` and `d`; from just after the `d`, Right goes over the markers and on. Where nothing visible is left in that direction the caret goes past the hidden text to the end, and in an editing group it goes on to the next field. - **Backspace and Delete** remove the visible character or word next to the caret and keep hidden text at either end of it: Backspace at the end of `**bold**` leaves `**bol**`. Hidden text strictly inside what they remove goes with it. A selection is deleted exactly as selected, hidden text included. Where nothing visible is left in that direction they delete nothing. - **A press** lands on the nearest boundary as always. Where hidden text sits there, both its ends are drawn at one x, so the side of the press decides: before the boundary (left of it, in left-to-right text) is before the hidden text, past it is after. A double click selects the word as drawn, with hidden text inside the word included and hidden text round it left out. - **The selection highlight** skips hidden text, which has no width to highlight; `selectedText` and a copy still include it. - **An offset the application sets** with a selection is kept as given, even inside hidden text, since the application may be about to show that text. The next move steps out of it. - **An IME** composes at the caret as usual. While it composes the application has not been told about the new characters, so its runs describe the text without them; rather than dropping the runs and showing every hidden marker until the commit, the field moves them to make room. The composing text joins the visible run it is typed at the end of or inside, and is never made hidden. The accessibility mirror, and the editing proxy a screen reader reads a field through, still have the whole text, hidden runs included: a screen reader hears `**bold**` with its asterisks. That is a known limitation, not a decision. ## What is not here Justified text and hyphenation are still deferred. Font features beyond the ones in the table above are not reachable: a canvas font string carries style, variant, weight, size and family and nothing else, so `tnum` and `liga` cannot be asked for from here. A variable font's `wght` is reached through a numeric `fontWeight` and its `wdth` through `fontStretch`. --- # Using components `gesso-components` is the library you build screens out of: inputs, overlays, structure, data and media, twenty-eight components in all. Every one of them is themed, keyboard operable, and announces itself to an assistive technology from the day it was written. The form below is four of them. Tab through it: the fields take focus in order, Space toggles the checkbox, and the select opens and chooses from the keyboard alone. Press Continue while it is empty and nothing is sent, which is [Forms](https://gesso-docs.vercel.app/guide/forms) rather than any of the four controls. ```tsx /** * A form: three controls, three rules, and a button that will not go * until they pass. * * `form` holds the fields by name and each field carries its own * checks. `bind()` spreads onto a control everything the control needs * from the form: the value, the writer, the message to show once the * field has been left, and the ref the form uses to put the caret in * the first field that failed. Nothing about validation is written on * a control, and nothing about it is written twice. * * Press Continue with the form empty and nothing is sent: the caret * lands in the email field and every rule that failed says so, in * words a screen reader announces as well as draws. */ export function Form(_inputs: Inputs<{}>, ctx: ComponentContext) { const sent = internalState(''); const signUp = form( ctx, { email: field({ initial: '', validate: [required('We need an address'), email()] }), plan: field({ initial: 'team' }), terms: field({ initial: false, validate: [required('Accept the terms to continue')] }) }, { onSubmit: values => { sent.value = `Sent: ${values.email} on ${values.plan}`; } } ); return ( String(line.quantity)))} fontSize={13} color="textMuted" width={18} textAlign="center" /> `One fewer ${line.name}`))} glyph="−" onPress={() => basket.send.remove(inputs.line.value.id)} /> `One more ${line.name}`))} glyph="+" onPress={() => basket.send.add(inputs.line.value.id)} /> ); } ``` ### Registering it Where the data lives is decided at registration, in one place, by the option passed: | Registration | Where the data lives | | --------------------------------- | ---------------------------------------------- | | `.useChannel(Basket)` | The application worker the shell spawned | | `.useChannel(Basket, { worker })` | A worker of this channel's own | | `.useChannel(Basket, { source })` | This thread, over a `MessageChannel` to itself | ```ts /** * The render worker behind ``. * * One service and one channel, which is the whole taxonomy in two * lines. The channel is registered with a `source`, so its data lives * on this thread; naming a `worker` instead moves it, and nothing in * `StateExample.tsx` changes. */ renderRoot(exampleRoot(createComponent(StateScreen, {}))) .useService(Highlight) .useChannel(Basket, { source: basketSource() }); ``` A `source` registration still crosses a real port, and is diffed, patched and plain-data checked exactly like a worker's. That is what makes the choice a one-line change later: the code above never learns which it got. The first row needs an application worker to exist. A single-thread app has none, and so does a worker-hosted app started without `appLogicWorker`, and a channel registered there with neither a `worker` nor a `source` throws at startup saying which of the three to pass. ## What a command costs, and what it does not do A command is fire and forget. It takes as many arguments as it needs, each is structured-cloned onto the owning thread, and it returns nothing. There is no synchronous answer to be had across a thread, so the effect arrives as a patch on the view keys it changed. The spec beside this example measures both halves of that. After a click on `+`, a frame drawn in the same turn still shows the old total, because the message has not been delivered yet. The new total appears once the round trip is done. A click on a name, which writes a service, is on screen in the very next frame. So a value written and read back in the same handler has to be a cell or a service. Nothing across a barrier answers that fast, and a command that looks like it did would be lying about the thread model. ## Limits worth knowing before the first channel - **Only plain data crosses.** Primitives, arrays and plain objects. A `Date`, a `Map`, a `Set` or a class instance compares by reference, so it would report a change on every update and rebuild the subtree bound to it forever. The first value of each key is checked and the channel reports an error naming the key and the path inside it. Flatten it in the layer that owns it. A `SharedArrayBuffer` is the exception: it is shared memory, compared by identity, and crosses without being copied. - **Declare keys finely.** A key holding a large array is re-diffed whenever it changes. The differ trims a common prefix and suffix, so an append or an edit in place stays small, while a re-sort degrades to replacing the middle. Two keys that change for different reasons are two keys. - **There is no undefined window, and no implied loading state.** The token's initial value is what the screen draws until the first patch, so a channel that is still loading says so in its own shape, with a `status` key, rather than leaving the view to infer it from an absence. - **A service is per runtime and per thread.** There is exactly one instance, it is reachable only from components in that runtime, and it goes away with the app. Nothing about it is shared with another thread. - **Channels are registered before the runtime starts.** Registering one afterwards throws, and so does asking for a channel that was never registered. This page's example registers its channel with a `source`, so its data lives in the render worker beside the components, and everything above was measured in that configuration. The two-worker arrangement changes the registration line and nothing else in these files, which is a property of the API rather than something this page measured. ## Next [The action log](https://gesso-docs.vercel.app/tooling/the-action-log) records every command and every patch crossing a channel, which is the fastest way to see the round trip above as a timeline. --- # Workers The worker configuration is the one to build on, and it is two files rather than one: [installation](https://gesso-docs.vercel.app/guide/installation) sets those up. This page is about what happens after that. Where your code ends up, what the main thread is still doing on its behalf, and which habits stop working once a component can no longer see the page it is drawn on. ## What runs where | Thread | Owns | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Shell** (main) | The `` element, input forwarding, the hidden textarea an IME types into, the off-screen DOM a screen reader reads, resize and device pixel ratio, the display's refresh, the url | | **Render worker** | Components, cells, the retained node graph, layout, hit-testing, focus, gestures, text measurement, paint, and the runtime services | | **Application worker** | Your api, persistence, domain model and view models, published as channels | Everything you write as a component runs in the render worker. So does every layout pass and every draw call, so a long task on the main thread cannot make a frame late; that is the whole of the arrangement's argument. The shell is deliberately as small as it can be. It forwards raw coordinates and does not know what they hit: hit-testing, focus and gesture recognition all happen in the worker, on the tree that is there. What is left on the main thread is what genuinely cannot run anywhere else, and the list is short enough to read in full further down. The application worker is optional and separate. The shell spawns it, creates one `MessageChannel` between it and the render worker, and then holds neither end, so a patch from a view model never crosses the main thread. State and channels are their own subject; what matters here is that the data path does not pass through the thread this design exists to keep out of the way. ## What the split buys The same component, twice. On the left it is in a render worker; on the right it is mounted with `mountSync`, so it runs where this page's JavaScript runs. Block the main thread and watch which one keeps its frame: Each canvas times itself and draws its own worst frame gap, because a reading that had to cross to the main thread could not be trusted while the main thread is the thing being blocked. Three measurements behind that, all taken in Chrome: - **The main thread blocked for 2000 ms.** The worker route's worst frame gap was 101 ms; the same application on the single-thread route went to 2098 ms, which is the block itself. - **The shell busy-looped for 5000 ms, with a click delivered during it.** The render worker reported the click as 2818 ms late, and its worst frame gap stayed at 105 ms against a 106 ms baseline taken before the block. A blocked shell costs input latency and nothing else: the person waits, and the frame clock never notices. - **A 1500 ms burn inside a data worker.** The render thread's worst frame gap stayed at 106 ms. Read those as differences, not as frame times. Frames here are demand-driven, so a screen with nothing to redraw leaves gaps of its own and a baseline of 106 ms is a screen that was idle, not a screen that was struggling. The claim is that the number does not move when another thread is buried. The input latency in the second measurement is the honest cost. Nothing about this arrangement makes a blocked main thread harmless; it makes it stop being a rendering problem and start being a routing delay, which is why heavy work belongs in a worker of its own rather than on the shell. ## Moving an app in A component class cannot cross `postMessage`, so the root has to be named inside the worker that renders it. That single constraint is why an application has three files rather than two, and it is the only part of the move that is not mechanical. ```ts // main.ts: the whole of the main thread's own code createApp({ renderWorker: () => new Worker(new URL('./app.render.worker.ts', import.meta.url), { type: 'module' }), appLogicWorker: () => new Worker(new URL('./app.logic.worker.ts', import.meta.url), { type: 'module' }) }).mount('#app'); ``` ```ts // app.render.worker.ts: everything the person sees renderRoot(App).useChannel(Catalog).useService(Selection).useRoutes({ routes: ROUTES }); ``` Both worker constructors are written out literally because a bundler only emits a chunk for a worker it can see constructed; a URL held in a variable produces no chunk. `renderRoot` returns the application, and everything registered on it is registered inside the worker: | Call | For | | ------------------- | ---------------------------------------------------------------------------------------------------------- | | `useChannel(token)` | A channel, served by the application worker the shell spawned, or by a worker or source named here | | `useService(Class)` | A runtime service: a plain class this thread constructs once and hands to whatever injects it | | `useRoutes(routes)` | The routing table, which holds component classes and so could never have been given to the shell | | `reload(root)` | A new root, for hot module replacement; the channels, the services and their state survive the replacement | The shell side takes options rather than registrations, because it has nothing of yours to hold: `renderer` chooses the backend, `onFrame` and `onError` are where metrics and worker exceptions arrive, `onInspect` feeds the node inspector, `colorScheme` overrides the platform's appearance for a host with its own control, `history` says how the url is kept, and `accessibility` can switch off the off-screen mirror for a measurement. [Devtools](https://gesso-docs.vercel.app/tooling/devtools) shows those callbacks wired to something that uses them. An error thrown inside a worker is invisible to the page by default, so `onError` is the option to pass first. The worker reports what it caught while handling a message, what a frame threw, what the renderer refused and what a channel rejected, with a `source` saying which. ## What the shell still owns Everything on the canvas below runs in a render worker, and three of the things it does needed the main thread to do them: ```tsx /** * A screen in the render worker, and the three things it still asks the * shell for. * * **Keys.** The field is drawn on the canvas and holds no DOM focus of * its own. What gives it characters is a hidden textarea on the main * thread, which the shell focuses while the worker reports a focused * editable and which reports composition, so an IME works here. * * **The clipboard.** A worker has none. `ShellService.copyText` hands * the request to the runtime, which posts it to the shell, which does * the write. Every reach for the outside world takes that shape: the * component asks, and something on the other thread answers. * * **The appearance.** `prefers-color-scheme` is a media query, and a * media query needs a window. The shell watches it and reports what it * says; `colorScheme` is where the application hears about it, and it * is what this site's light and dark toggle drives. */ export function ShellNeeds(_inputs: Inputs<{}>, ctx: ComponentContext) { const shell = ctx.inject(ShellService); const message = internalState('Typed into a canvas in a worker'); const copies = internalState(0); return ( (message.value = next)} description="Every keystroke crossed a thread to get here." /> (count === 0 ? 'no request yet' : `${count} request${count === 1 ? '' : 's'} to the shell`)) )} fontSize={12} color="textMuted" /> `the shell reports ${scheme}`))} fontSize={12} color="textMuted" /> ); } ``` The line above the field is `typeof document`, evaluated once where the component was built. A component that reached for the page would not fail at some awkward later moment; it would fail there. That leaves the shell with this, and nothing else: | The shell does | Because | | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Creates the canvas and transfers it | An `OffscreenCanvas` has to come from an element, and only the page has elements | | Forwards pointer, wheel and key events | The listeners are on a DOM element; every event is stamped with the browser's own `timeStamp` | | Holds a hidden textarea | Composition, dead keys and a soft keyboard are a DOM input's, and the caret is in the worker | | Mirrors the semantics tree into off-screen DOM | The platform's accessibility layer reads elements, and a canvas is one element | | Observes the host's size and the pixel ratio | `ResizeObserver` and `devicePixelRatio` are the window's | | Forwards display refreshes, where it must | A dedicated worker has its own frame callback and uses it; this is the fallback for where it does not | | Watches `prefers-color-scheme` and reduced motion | A media query needs a window; each is reported once at start and again on every change | | Watches whether the window is in use | Focus and visibility are the window's; `ShellService.windowActive` is true while it is shown and focused | | Sets the cursor and `touch-action` on the canvas | The worker decides what the pointer is over; only the DOM can show it | | Writes the clipboard and opens urls | `ShellService` requests them, and the request is answered on the thread that can perform it | | Reports the url and performs history moves | The address bar is the window's; the routes never leave the worker | Two defaults are cancelled by the shell without asking, because the worker's answer could not come back in time to cancel anything: Tab, which would move focus out of the canvas, and Ctrl/Cmd+A, which would select the page around an application selecting its own text. The wheel is decided from what the worker last reported about the scroll chain under the pointer, which is a bet that can swallow one notch at a container's edge, and it is the reason a canvas does not simply eat every scroll. ## What changes for you **There is no DOM.** No `document`, no `window`, no `localStorage`, and no library that reaches for one. A component that needs something from the page asks the shell for it, the way the copy button does above. This is worth knowing before you pick a dependency, not after. **Only plain data crosses.** Primitives, arrays and plain objects. A `Date`, a `Map`, a class instance or a domain object compares by reference across the barrier, which reports "changed" every time it is evaluated, so view models flatten to plain shapes before they publish. The one exception is a `SharedArrayBuffer`, which is shared rather than copied, so two threads can read one large document without sending it ([Channels and the barrier](https://gesso-docs.vercel.app/structure/channels-and-the-barrier#only-plain-data-crosses)). Nothing else ever crosses at all: elements, components, observables and nodes are built in the render worker and stay there. **State goes where its lifetime is.** A value one component owns is `internalState`. A value a subtree needs is a prop, or an environment key for something that changes rarely. View state the whole render thread shares is a runtime service, registered with `useService`. Anything that survives a reload, or that another screen cares about, is a channel and lives in the application worker. [State and services](https://gesso-docs.vercel.app/guide/state-and-services) walks that ladder rung by rung. The rule to hold onto is that a hover highlight must never round-trip to another thread. **Fonts are the page's, not the worker's.** A family is a string handed to the renderer, and nothing in these packages loads a font. The examples on this site ask for system families for that reason. A custom face in the worker configuration is untested here, so treat it as something to verify in your own project rather than as something that works. ## When one thread is the right answer `mountSync` mounts the identical tree on the calling thread, and both configurations run the same runtime, so a screen written for one runs unchanged on the other: ```ts createSyncApp(App).mountSync('#app'); ``` Reach for it in tests, in headless rendering, and where `OffscreenCanvas` is not available. It is not the default for an interactive application, because everything the framework does is then competing with everything else on the main thread, which is what the right-hand canvas above is demonstrating. Mounting the worker configuration without `OffscreenCanvas` throws, and the message names `mountSync` as the way out. ## Limits **Where this was checked.** Every number on this page was measured in Chrome, on the playground's own routes, by blocking a thread on purpose and reading what each thread said about itself. No other browser has run any of it, and the desktop webviews the framework is aimed at, WKWebView and WebView2, have not been measured at all. **A 5000 ms busy loop is a mechanism, not a magnitude.** It proves that a blocked shell costs latency rather than frames. What a real application layer costs the thread it runs on is a different question, and it is the one that would decide where to put yours. **The example above is checked without a browser.** Its spec mounts the same component with `gesso-testing`, in a runtime with no DOM, and asserts the four claims the page makes about it: that the component runs where there is no `document`, that typed text reaches it through the editing path a shell forwards on, that the copy button issues a clipboard request instead of performing one, and that the appearance line follows what the shell reported. What a spec cannot check is the paint, and this canvas was not watched in a browser while the page was written, so treat it as the least verified thing here. ## Next [Devtools](https://gesso-docs.vercel.app/tooling/devtools) is how you see into a worker at all: an error overlay that draws a worker's exceptions where the application is, a node inspector, and a frame profiler fed by the metrics the worker reports. --- # Testing A Gesso component is a function that returns a tree, and the tree is not the DOM, so there is nothing here for jsdom to do. `gesso-testing` mounts the real runtime over a recording canvas and hands back queries, events and layout answers. No browser, no canvas double to write, no headless Chrome to start, and no setup file: vitest's default node environment is the one these run in. This page has no live canvas on it, because the thing it describes is a file rather than a screen. Everything quoted below comes from `TestingExample.spec.ts` in this site's own examples folder, which runs in this repository's suite. The page's code is the spec. ## The component under test A quantity row: two stepper buttons, a checkbox, and a line that says what the order now is. ```tsx /** The width the count sits in, so the row does not move when 9 becomes 10. */ const COUNT_WIDTH = 36; const MAX_TICKETS = 6; /** * A quantity row: two steppers, a checkbox, and a line that says what * the order now is. * * Nothing here is written for a test. The two buttons carry a `label` * because `-` and `+` are not names a person can act on, the checkbox * is a real control with a keyboard of its own, and the summary is * derived from both cells rather than written out twice. Those three * properties are also what makes the spec beside this file short. */ export function TicketRow(_inputs: Inputs<{}>, _context: ComponentContext) { const tickets = internalState(1); const wrapped = internalState(false); const summary = combineLatest([tickets, wrapped]).pipe( map(([count, gift]) => `${count} ticket${count === 1 ? '' : 's'}${gift ? ', gift wrapped' : ''}`) ); const step = (by: number) => () => { tickets.value = Math.min(MAX_TICKETS, Math.max(0, tickets.value + by)); }; return ( (wrapped.value = next)} /> ); } ``` ## Mounting Four imports and one call. ```ts import { describe, expect, it } from 'vitest'; import { createComponent } from 'gesso-framework'; import { renderTest } from 'gesso-testing'; import 'gesso-testing/matchers'; import { TicketRow } from './TestingExample'; ``` ```ts /** * One mount, shared by every test below. * * `renderTest` builds the runtime over a recording canvas, measures * text with the deterministic measurer, and runs the first frame, so * the tree is built, laid out and described before the first query. */ const mount = () => renderTest(createComponent(TicketRow, {}), { width: 420, height: 220 }); ``` `renderTest` takes the same options as the runtime, minus the three it decides itself, plus `autoFrame`, `allowFrameErrors` and an `onCreate` hook. Those three are decided rather than left to the caller because getting any of them wrong makes a test that passes for the wrong reason: - **The clock is manual.** Frames happen when the spec says so, so an assertion never races a scheduler. - **Text is measured by `CharacterCountTextMeasurer`,** not by the canvas double. Every glyph is 0.6 em wide, the font is 0.8 em above the baseline and 0.2 em below it, and a line is 1.2 em, so a 14 px label is 16.8 px tall on every machine. A canvas double's `measureText` answers the same width whatever the font size, which would make a heading and its caption the same size. - **A first frame has already run** when the call returns, so the tree is built, laid out and described before the first query. - **A frame that throws fails the test.** An application abandons such a frame and keeps drawing, so the user sees the last good picture; a test that did the same would pass on a frame that never ran. The error is thrown from the `frame()` or `settle()` that ran the frame. Pass `allowFrameErrors: true` for a test about that recovery, and listen with `runtime.onFrameError`. Pass `textMeasurer` if you want different metrics, and `autoFrame: false` if the spec needs to be holding the result when the first frame happens. `gesso-testing` imports no test runner, so `renderTest` works under vitest, under `node:test`, or in a plain script. The matchers are the exception: `expect.extend` is a side effect on a global, so they live behind their own entry and importing them is a decision you make. `vitest` is an optional peer dependency of the package for exactly that reason. ## Frames are yours Nothing repaints on its own. `ui.frame()` runs the pending frame and advances the clock 16 ms; `ui.frame(200)` sets the time instead. The pattern is always the same: send an event, run a frame, assert. For anything that arrives later than the next frame, a channel patch, a resolved image, a component that awaited something, use `await ui.settle()`. It runs a macrotask and a frame in a loop until nothing is pending, and throws rather than spinning forever if the tree keeps asking for another frame. The `findBy…` queries are the same loop with a query in it. Both count frames rather than milliseconds, because the clock here is manual and there is no wall clock to time out against: "a hundred frames and it is still not there" reproduces in a way that "two seconds" does not. ## Queries are the semantics tree `getByRole` and `getByLabel` read `runtime.semanticsTree()`, the same map the accessibility mirror writes into the off-screen DOM for a screen reader. There is no second definition of what a control is, which makes the property run both ways: a component that is awkward to find in a spec is exactly a component that is awkward to find with an assistive technology, and an untestable component is an inaccessible one. ```ts it('is reachable by role and name, and nothing else is announced', () => { const ui = mount(); expect(ui.getByRole('button', { name: 'One more ticket' })).toHaveSemantics({ role: 'button' }); expect(ui.getByRole('checkbox', { name: 'Gift wrap this order' })).toHaveSemantics({ states: [] }); // The glyph is not the name. A query for it finds nothing, which is // the answer a screen reader would give too. expect(ui.queryByRole('button', { name: '+' })).toBeNull(); // `getByText` reads what a node draws, so it finds the summary line // that has no role of its own. expect(ui.getByText('1 ticket')).toBeDefined(); }); ``` `getByText` is deliberately separate and reads the text a node draws, because the two differ. The `` inside a ` ))} ); } ``` ## What it sticks to **The nearest scroll container above it**, meaning a `` or any node with `overflow` set to `scroll` or `auto`. Not the window, and not the page the canvas is embedded in: an application's root is a scroll container only if you made it one, and a sticky node with no scroll ancestor above it simply stays in flow. **At the inset it named.** `top`, `right`, `bottom` and `left` are how far inside the container's edge the node is held, so `top={0}` means flush and `top={8}` leaves eight pixels of the content showing above it. A node that names no inset never sticks, because there is no edge to hold it at. One inset per axis: with both `top` and `bottom` set, `top` wins, and `left` wins over `right`. Naming one on each axis is how a table's corner cell stays put while both the header row and the label column do. ## What lets it go **Its parent's box.** A sticky node is never shifted beyond the box of the element that contains it, so when that element scrolls away the node goes with it and the next one takes the edge. That is the whole difference between a sticky header and a fixed one, and it is why each group's header in the example lives inside the group rather than beside it: ```tsx ``` The `column` is the bound, and the header travels inside it. A sticky node whose parent _is_ the scroll container is bounded by the scrollable extent instead, so that one holds at the edge for the whole list. ## It is not a layout change Sticking is applied after layout, not by it. The node's layout box is its flow box, unshifted, and the shift is added when the node is painted and undone when it is hit tested. Three consequences, and they are the ones that catch people out: - **Nothing re-measures when a list scrolls.** The offsets are recomputed on scroll-only frames, which measure nothing and place nothing but the overlays anchored to what moved, so a sticky header costs a translation per frame rather than a layout. - **Geometry read from layout is the flow position.** That is the answer a layout animation needs, since a node whose list scrolled has not moved. Where the node is _seen_ is a different question, and the accessibility mirror is given that one: a screen reader's cursor lands on the header where the eye finds it. - **It is still a real node.** The pointer meets it where it is drawn, so a hover, a press and a click land on the header rather than on the row passing behind it. ## What it covers Within one `zIndex`, positioned children paint after their in-flow siblings, which is CSS's stacking rule and what lets a sticky header paint over the rows travelling under it. Hit testing walks that order in reverse, so a press at the container's top edge meets the header rather than the row behind it. A sibling with a higher `zIndex` still paints over the header, since the ordering is by `zIndex` first. A sticky node is also a containing block for absolutely positioned descendants, as in CSS. **It carries anchored overlays with it.** An overlay anchored to a sticky node, or to anything inside one, is placed against where the node is held rather than against the box it left behind, so a menu opened from a button in a sticky toolbar stays under its button while the list travels underneath, and rides back down with the toolbar when the group lets it go. The flip reads the held position too: the overlay takes the side that has room where the anchor is seen. See [positioning and overlays](https://gesso-docs.vercel.app/layout/positioning-and-overlays) for the rest of anchored placement. ## Everything that scrolls, scrolls it The offsets are recomputed from the scroll offset the container settled on, and every way of moving a list writes that same offset: a wheel, a smoothed notch mid-animation, a scrollbar drag, a finger, and the reveal that brings a focused row on screen. There is no separate path for a sticky node to fall out of step with. ## What was checked The engine's sticky cases were compared against Chrome: the same trees in a browser, with the same `scrollTop`, agreeing with Gesso's visible boxes to within a tenth of a pixel, for `top`, `bottom` and `left` insets and for nested scroll containers. Engine specs cover an overlay anchored to a sticky header, and one anchored to a button inside such a header, following it to the scrollport edge and back when it lets go. A parity spec runs a scrolled list with a sticky header through both renderers and compares the draws, and the same tree was checked by screenshot on the WebGPU worker route in Chromium. No other browser has been measured. ## Next Back to [overflow and scrolling](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) for the container a sticky node lives in, or on to [light and dark](https://gesso-docs.vercel.app/guide/appearance) for where the colours these examples inherit come from. --- # Positioning and overlays Most of a screen is laid out by rows, columns and grids. The parts that float over it are not: a menu, a popover, a dialog and a toast all have to escape the box that opened them and be placed against something else. That is what `position` and the overlay layer are for. The panel below is anchored to the button in the list. Move the button towards the bottom edge and watch which side the panel takes; expand the notice at the top of the list and watch the panel follow a button that nothing scrolled: ```tsx /** * A panel anchored to a button, and a button the reader can send to * the bottom edge. * * Two ideas are on screen at once. * * - **The panel is not a child of the list.** It is an overlay entry, * rendered by the layer the runtime mounts above the whole app, and * it is placed by naming the button's node as its `anchor`. * - **The engine, not the component, decides which side it goes on.** * `bottom-start` is a request. With no room under the button the * panel flips above it, and it is shifted along the button so that * it stays inside the viewport rather than hanging off the right * edge. * * The button can be moved two ways, and the panel follows both: the * list scrolls under it, or the notice above it grows and pushes it * down the list. Nothing in the component re-places the panel; the * engine does it on the frame the button's box changed. * * The anchor is a `UiNode`, taken from the button's `ref` and read * inside `show()` rather than captured earlier: a ref fires after the * component's body has run, so anything reading it sooner reads null. * Passing the same node as `environment` is what keeps the panel in * this example's theme, since the layer it renders in is nowhere near * the tree that opened it. */ export function Anchored(_inputs: Inputs<{}>, ctx: ComponentContext) { const panel = useOverlay(ctx, 'docs-position'); const at = internalState(ANCHOR_HIGH); const notice = internalState(NOTICE_SMALL); let anchor: UiNode | null = null; const show = (): void => { panel.show( , { anchor, placement: 'bottom-start', offset: 8, environment: anchor } ); }; ctx.onMount(show); const toggle = (): void => { if (panel.isOpen()) { panel.hide(); } else { show(); } }; return ( (at.value = ANCHOR_LOW)} /> (at.value = ANCHOR_HIGH)} /> (height === NOTICE_SMALL ? 'Expand the notice' : 'Collapse the notice')))} onPress={() => (notice.value = notice.value === NOTICE_SMALL ? NOTICE_LARGE : NOTICE_SMALL)} /> (at.value = offset.y) })]} borderWidth={1} borderColor="border" borderRadius={8} backgroundColor="surface"> {Array.from({ length: ROWS }, (_unused, index) => index === ANCHOR_ROW ? ( ) : ( ) )} ); } ``` ## Four positions `position` takes the same four values CSS does, and reads the same way: | Value | What it does | | ---------- | ------------------------------------------------------------------------------------------- | | `static` | The default. The node is laid out in flow and the edge props are ignored. | | `relative` | In flow, then shifted by its edge offsets. Siblings do not move. | | `absolute` | Out of flow. It contributes nothing to its parent's size and is placed against a block. | | `sticky` | In flow until scrolling would carry it past an edge it named. See [sticky](https://gesso-docs.vercel.app/layout/sticky). | `top`, `right`, `bottom` and `left` are the offsets, and `inset` sets all four at once. Any of them can be a percentage or a number. ## What an absolute box is placed against **Its containing block: the nearest ancestor whose `position` is `relative`, `absolute` or `sticky`, and the layout root when there is none.** The example's list is the containing block for nothing, because nothing inside it is absolute; the panel's containing block is the overlay layer, which covers the viewport, and that is why the panel flips at the edge of the canvas rather than at the edge of the list. Three rules decide the box itself, and they are CSS's: - **Both edges on an axis, and no explicit size, is a tight box.** The node is stretched between them. - **Otherwise the node is its own size and one edge places it.** With both edges set and a size given as well, `left` wins over `right` and `top` over `bottom`. - **Margins apply.** They are subtracted from the space between the edges, as in CSS. With no edge set at all, the node sits at the containing block's origin. CSS would put it where it would have been in flow, its static position; that is the one place this deliberately stops short. Absolutes are placed after the flow, not measured with it, because a containing block's own box is only final once its in-flow children have been laid out. That is the same order the CSS specification gives, and it is why a percentage offset resolves against a block whose size is already known. **A block that changes size places its absolutes again, wherever they are below it.** An absolute is placed when its parent is, and its parent can be some way under its containing block: a panel pinned to the window's corner from inside a column of the app. When only the block changes, as the window does on a resize, the column keeps its box and is not placed again, so the engine keeps the block each absolute was placed against and, after any pass in which a positioned node or the root took a new box, places again the ones whose block is not that box any more. Until it did, such a panel stayed at the corner of the window it was first laid out in. ## Stacks align their children A `` (exported as `Stack` in the factory API, which says what the children do) puts every child at the same origin. It takes the same `x` and `y` alignment props a row or a column does, and a child can override with `selfX` and `selfY`: ```tsx ``` `stretch` skips a child that has an explicit size on that axis, and a stretched child is measured tight, so text inside one wraps at the stack's width rather than at its own. A box's children start at its top left. A ` ); } ``` ## The answer `runtime.explain(node)` returns a `LayoutExplanation`, and `formatExplanation` prints it, one fact per line. Asked about that title, mounted in the 420 by 260 viewport its spec uses, it says this: ```text text 'root:0:0:0:1' · 0 × 28 at (330, 79.6) width 0 flex item of row 'root:0:0:0': base 124.8 from its max-content width; shrank to 0 (flexShrink 1: the items' base sizes exceed the content box of row 'root:0:0:0', so they give up space); its automatic minimum is 0: a scroll container or clipped text has none, so it may shrink to nothing; its content would need 70.2; the extra 70.2 is clipped height 28 stretched across row 'root:0:0:0': 28; its content would need 31.2; the extra 3.2 is clipped constraints from row 'root:0:0:0': width 0 (tight) · height 28 (tight) padding 0 · margin 0 · content box 0 × 28 relayout: not a boundary · content matters to the parent · a change here is laid out from row 'root:0:0:0' (1 level up) state: measured · placed · position static ``` Read the width line and the bug is over. The title is a flex item; it wanted 124.8 pixels; the row had nothing left to give it; and the reason it went all the way to zero rather than stopping somewhere is that clipped text has no automatic minimum. Press the button in the example and the badge starts giving up space of its own, so the same sentence comes back with a number in it instead of a zero. Those numbers come from the test text measurer, which is deliberate: they are exact, and the spec beside the example pins this readout line for line, so a change of wording in the engine has to reach this page. In a browser the same tree produces the same sentences with real font metrics in them. ## What is in one | Field | What it holds | | ----------------- | ---------------------------------------------------------------------------------------- | | `box` | The border box, in layout-root coordinates, before scrolling | | `content` | What the content asked for on each axis, before any clamp | | `measured` | The size the node reported to its parent | | `constraints` | What the parent handed down on the last measurement | | `effective` | Those constraints after the node's own width, height, minimum and maximum | | `width`, `height` | Per axis: the `decidedBy` rule, and `reasons`, the sentences in the order they ran | | `relayout` | Whether the node is a boundary, and the ancestor a change here is laid out from | | `state` | The dirty flags, the `position`, whether it clips, and whether the last pass measured it | | `scroll` | For a scroll container, or a field that scrolls its own text | | `sources` | Per property, which modifier is writing it and what the element declared | `decidedBy` is the single rule that fixed the measured size on that axis, so a tool can group or colour by it: | Rule | Meaning | | -------------- | ----------------------------------------------------------- | | `viewport` | The layout root filling bounded constraints | | `explicit` | The node's own `width` or `height` | | `flex` | Flexed by a row or column: grew, shrank, or held at a clamp | | `stretch` | Stretched across a container's cross axis or a grid area | | `inset` | An absolute node with both edges of the axis set | | `parent` | A tight size from the parent for some other reason | | `content` | What the content needed | | `min`, `max` | Content, then raised by a minimum or capped by a maximum | | `aspect-ratio` | Derived from the other axis | A node with no layout record gets a reason instead of an explanation: it is not under the layout root, or it is a fragment, or it was added after the last pass. ## It reads; it does not recompute Everything above is built from the record and the node's properties after layout has run. Nothing is laid out a second time to answer the question, which is what makes `explain` safe to call from a hover handler sixty times a second. A handful of facts are recorded where the decision was made, because they cannot be recovered afterwards: the content's intrinsic size before the node's own clamp, and, for a flex item, the base, the minimum, the maximum, and whether that minimum was CSS's automatic one or an explicit one. Everything else is derived on the spot, including the relayout root, which is the same walk a dirty mark takes without marking anything. ## In a test `gesso-testing` exposes both forms on a mounted tree: ```ts const ui = renderTest(createComponent(Card, {})); ui.explain(node); // the LayoutExplanation ui.explainText(node); // the same, printed ``` The matchers are where it earns its keep. A `toHaveBox` that misses prints the explanation under the numbers, so the answer is already on screen when you go looking for it: ```text Expected 'root:0:0:0:1' to have width 120, but its box is {"x":330,"y":79.6,"width":0,"height":28}. Why: text 'root:0:0:0:1' · 0 × 28 at (330, 79.6) … ``` That is the whole explanation, not a summary of it, and the spec beside the example asserts as much. Import the matchers to get it: ```ts import 'gesso-testing/matchers'; ``` ## Under the pointer The same text is what the node inspector paints over a running application, on the frame's own canvas, so it works unchanged inside a render worker where there is no DOM. Hover a node and the panel shows the explanation along with what the element declared, what a modifier is writing over it, and which component rendered it. See [inspecting a node](https://gesso-docs.vercel.app/tooling/inspecting-a-node) for the rest of that report. Turning the inspector on sets the engine's trace flag, which is what fills in `state.measuredLastPass`; turning it off clears it, so an application nobody is inspecting pays nothing for the facility. ## When a modifier is the answer A modifier can write the property whose value you are asking about, and a node that says `width 240` while the element says 200 is the most confusing thing in this system. So the explanation names the writer: the axis a modifier changed carries it as its last reason, and `sources` carries every other property the same way. Nothing is computed for that; the override cascade already records who wrote what, in the order the element listed them. ## A property the engine does not know `widht: 200` used to be silent. It was stored on the node and read by nothing, so the box came out at its content width with no message anywhere. Element props are now checked against the property registry as the graph is built, and an unregistered one throws, naming the closest registered name when there is one: `widht` suggests `width`, `colour` suggests `color`, and an adjacent transposition counts as a single edit. A prop starting with `on` whose value is not a function is rejected too, where before it was stored and ignored. The practical consequence is that ad-hoc data cannot be smuggled onto a node through a prop any more. Register a property definition for it, or keep it somewhere other than the tree. ## What was checked `LayoutEngine.explain`'s own specs cover each `decidedBy` rule against a real tree: the shrink to nothing, the automatic minimum that stops a text short of it, an explicit size and the clamp applied to it, content against a raised minimum and a capped maximum, a stretched cross axis and a grown main axis. The testing library's specs cover the matcher handing back the explanation, and the inspector's cover the painting. The spec beside the example on this page pins the printed readout line for line, the sentences changing when the cause changes, the number the example draws on its own canvas, and the failure message a missed `toHaveBox` produces. ## Next [Inspecting a node](https://gesso-docs.vercel.app/tooling/inspecting-a-node) is this answer on a running application, next to everything else a node can be asked. --- # Pointer and keyboard A canvas is one DOM element, so the browser can say that something was pressed and where, and nothing more. Everything after that is the framework's: which node was under the point, whether it wants the press, what the press did to hover and focus, and where the next key goes. The conversion happens once, at the edge. Browser events become framework events at the boundary and application code never sees a `PointerEvent`, a `KeyboardEvent` or a `deltaMode` again. ## One event model Every event an application can listen for, whether it came from the platform or was synthesized by the framework, travels through the same dispatcher. A handler is a prop named `on` followed by the event in PascalCase: | Group | Events | | -------- | --------------------------------------------------------------------------------------------------------- | | Pointer | `onPointerDown`, `onPointerUp`, `onPointerMove`, `onPointerCancel` | | Boundary | `onPointerEnter`, `onPointerLeave` | | Wheel | `onWheel` | | Keyboard | `onKeyDown`, `onKeyUp` | | Focus | `onFocus`, `onBlur` | | Editing | `onBeforeInput`, `onInput` | | Gestures | `onClick`, `onLongPress`, `onDragStart`, `onDragMove`, `onDragEnd`, `onPanStart`, `onPanMove`, `onPanEnd` | There is no separate gesture pipeline. A click, a long press, a pan and a drag are synthesized from the press sequence and dispatched exactly as a `pointerdown` is, so one mental model covers all of them and a container can intercept a gesture the same way it intercepts a press. A prop whose name matches `on` plus a capital letter but is not on that list is an error naming the prop, rather than a handler that silently never fires. ## What a pointer event carries - **`x` and `y`**, in canvas pixels, measured from the top left of the drawing surface. These are the same coordinates layout works in, so a point can be compared with a node's box without conversion. - **`buttons`**, the pressed-button bitmask the DOM reports. - **`modifiers`**, an object of four booleans: `ctrl`, `shift`, `alt`, `meta`. Every event that could plausibly be modified carries one, including wheel and keyboard events. - **`pointer`**, the device: `{ id, kind }`, where `kind` is `'mouse'`, `'pen'` or `'touch'` and `id` is the DOM's `pointerId`, which is what separates two simultaneous contacts. A `pointerType` the framework does not recognise reads as a mouse, because a device behaving like the default is better than one whose events are dropped. `target` is the node the hit test found; `currentTarget` is the node whose listener is running. The difference between them is the whole of bubbling, and the example below is built on it. ## Who gets the press ```tsx /** * One handler, on the panel, and two children that answer to the hit * tester differently. * * The panel reports whether the press landed on the panel itself or * on something inside it, which is the difference between * `event.target` (the node the hit test found) and * `event.currentTarget` (the node whose listener is running). A press * on the first chip lands on the label inside it and bubbles up to * here. A press on the second lands on the panel, because * `pointerEvents="none"` takes that box and everything under it out * of hit testing entirely, and the panel is the next thing under the * point. * * `onPointerEnter` and `onPointerLeave` are target-only, as the DOM's * are: they fire on the boundary of the panel's subtree, so moving * between the two chips does not leave and re-enter it. */ const panel = ( (hover.value = 'the panel')} onPointerLeave={() => (hover.value = 'nothing')} onPointerDown={event => { const what = event.target === event.currentTarget ? 'the panel' : 'a child of it'; press.value = `${what} at ${Math.round(event.x)}, ${Math.round(event.y)}`; }}> ); ``` Hit testing walks the retained tree in the inverse of paint order: last child first, because it painted on top, and descendants before the node itself, because children paint over their parent's background. The first box the point falls inside wins. Along the way the walk carries the things that make a box's screen position differ from its layout record: - A clipping container rejects a point outside its box before testing anything inside it, so a child cut off by [`overflow`](https://gesso-docs.vercel.app/layout/overflow-and-scrolling) is not clickable either. - A scroll container shifts the point by its offset before testing descendants, because their records are in pre-scroll content coordinates. - Every node applies the inverse of its own transform, so a rotated or scaled box is tested in its own space rather than by its bounding rectangle. A degenerate transform, one scaled to nothing, draws nothing and is hit by nothing. Four props decide whether a node takes part at all: | Prop | Effect | | -------------------------------- | ---------------------------------------------------------------------- | | `disabled` | The node and its whole subtree are inert: no hit, no focus, no keys | | `pointerEvents="none"` | The node and its whole subtree are skipped by hit testing | | `hitTestable={false}` | The node itself cannot be a target; its children still can | | `visible={false}`, `opacity={0}` | The subtree is skipped, matching the renderer, which paints none of it | `hitTestable` is the one worth remembering: a decorative wrapper that must not swallow presses meant for what is drawn inside it sets that, not `pointerEvents`. Unlike CSS, a child's `pointerEvents="auto"` does not bring it back under a `"none"` ancestor, so an overlay holding a toolbar is `hitTestable={false}` with the toolbar left as it is. ## Capture, click and cancel A press is owned by the node it started on. From `pointerdown` until the release, every move and the up are routed to that node even when the pointer has left its box, so a widget keeps its own drag. A `Click` is synthesized on release when three things hold: the press travelled no more than four pixels (ten for a finger, which never holds as still as a mouse), the `pointerdown` was not `preventDefault()`ed, and no gesture claimed the press. A `pointercancel` ends the press and never produces a click. The recognizer resolves each press into at most one gesture. Moving more than eight pixels (twelve for a finger) before the hold time makes it a **Pan**; holding still for 500 ms makes it a **LongPress**, and moving after that makes it a **Drag**. A tap produces no gesture events at all, which is why click synthesis lives outside the recognizer. A press is also owned by one contact. While a press is in flight, a second finger's moves and releases are ignored, so a second contact cannot drag what the first one is holding. ## Propagation Dispatch mirrors the DOM: capture from the root down to the target's parent, then the target's own listeners, then bubble back up to the root. | Call | What it does | | ---------------------------- | ------------------------------------------------------------ | | `stopPropagation()` | Ends the current phase and skips every remaining phase | | `stopImmediatePropagation()` | Also skips the rest of the listeners on the current node | | `preventDefault()` | Cancels the framework's own default behaviour for that event | `preventDefault` is advisory, and what it cancels depends on the event: on a `pointerdown` it suppresses click synthesis and focus on press; on a `pointermove` it stops the move being read as a gesture; on a wheel it stops the automatic scroll; on a `keydown` for Tab it stops focus moving. This is documented per consumer rather than inherited from a browser, because there is no browser default here to mirror. `Focus`, `Blur`, `PointerEnter` and `PointerLeave` do not bubble. They are delivered to the target only, exactly as the DOM's `focus` and `mouseenter` are. A listener that throws is reported and the dispatch continues. One broken `onClick` must not stop an event reaching the rest of the tree. ## Hover and press state While nothing is pressed, each move hit-tests and, when the hovered node changes, `PointerLeave` and `PointerEnter` fire on the boundary between the two ancestor chains. The lowest common ancestor is in both chains and never fires, which is why moving between two chips inside one panel does not leave the panel. Hover is state, not paint. A ` (focus.value = 'the stepper')} onBlur={() => (focus.value = 'nothing')} onKeyDown={event => { if (event.key === 'ArrowRight') { steps.value++; } else if (event.key === 'ArrowLeft') { steps.value--; } }}> `steps: ${count}`))} fontSize={12} color="text" /> ); ``` A key is dispatched to the focused node and bubbles from there. With nothing focused it goes to the application's root element, the one `renderRoot` or `createApp` was given, so a listener there answers Escape or Space whether or not anything has focus, and a listener partway down the tree hears nothing. After the application's listeners, and only if none of them called `preventDefault()`, the runtime applies its own defaults in this order: 1. The focused editable's editing keys. See [text editing and IME](https://gesso-docs.vercel.app/interaction/text-editing-and-ime). 2. Find: the platform's find shortcut opens a session and Escape closes one. 3. The canvas text selection: copy, select all, or clear. 4. Tab moves focus to the next focusable node, Shift+Tab to the previous. A key one of those handles is marked default-prevented, which is how the shell learns to cancel the browser's own default for it. Keys reach the application through its canvas, which has DOM focus only once something put it there. A page that is all application loads with focus on the page's body, so without help its shortcuts do nothing until the first click. `createApp({ pageKeys: true })` says the application is the page: a key pressed while nothing on the page has focus goes to it, and the canvas takes focus. The templates `create-gesso-app` writes turn it on. An application embedded in a page with other things on it leaves it off, since a key pressed on that page isn't its. ## Focus A node is focusable when it is a `