git:20261019.4b30b82 to git:20261019.6adedc1

5 added, 2 removed. Audit A to A.

# web_client
## Purpose
The browser half of the web view: Lustre client components, written in
Gleam and compiled to JavaScript, registered as custom elements that the
server component (`packages/web_view`) renders like any other tag. The
server component holds every piece of session state and is the only thing
that talks to the session (protocol-change/051); this package holds only
behaviour that changes with nothing the server knows, so the server never
renders again just for it:
- `<loom-elapsed offset="<ms>">` counts an operation's elapsed time once a
second, on from a duration the server measured. With `remaining="<ms>"` it
counts the time left down instead (`duration.remaining`: whole minutes rounded
up, then seconds), which the admin page's `ends in 14m` pill uses; whichever
attribute arrived last sets the direction. With `since="<unix ms>"` it counts
up from a start the records give (`duration.since_offset`, never negative),
which a tool call still running uses (`Running · 1m 12s`); that count trusts
the browser's clock to agree with the daemon's.
- `<loom-switcher>` is the session switcher Command or Control and K opens
(`switcher_rule`: the shortcut, the filter and its order, the highlight). It
reads the sidebar's `.sidebar .session-open` buttons from the page's root
(`ffi_dom.root_node`) as text, lists them in a popover it draws in its own
shadow root, and presses the chosen row's own sidebar button, so the daemon
mints the ticket and `<loom-switch>` navigates. It also reads the bar's
`.session-head .home-link` and `.home-admin` buttons and lists them first, as
`Place` rows (`Home`, `Admin`). One document listener for `keydown` and
`click`, removed with the element: a click whose composed path holds an
element marked `data-opens="switcher"` (`switcher_rule.summons`) opens it, which
is how the `Search ⌘K` chip `<loom-shell>` draws in the bar (`shell_rule.has_search`,
on pages with a sidebar) reaches it across a shadow tree. Every name is a text
node of its own view. It sends the server nothing (protocol-change/051, the
addendum on the session switcher).
- `<loom-title>` (`title_rule`) is a hidden, attribute-free element
`view/heading` draws as the session bar's last child. It reads the bar's `h1`
text and the frame's `needing` attribute and sets `document.title` to
`name — Loom`, or `(N) name — Loom` while N strands wait, through a mutation
observer on both (`ffi_dom.observe_text`, `observe_attribute`, `set_title`). The
server's document says only `Loom` until then, never the identity.
- `<loom-fold>` opens and closes a fold with no round trip: the todo board's.
A settled turn's divider is not this element: it is a button the server draws,
whose press the server answers (protocol-change/070), and `<loom-follow>` tells
its click by the fixed `data-loom-fold` marker.
- `<loom-expand>` is a row of a turn's fold: one line with one chevron, and a
body behind it. The server draws the line and the body as children
(`slot="head"` and `slot="body"`); the element holds one button around the
head and the body slot while the row is open, with no round trip. A
reasoning row carries `kind="live"` while the block streams and
`kind="settled"` once it has settled: an open live row leaves
`data-reasoning-open-until` on the document element (a deadline, never
reached while the row is on the page and `handoff_window_ms` after it leaves)
and the settled row that connects takes it and opens (`expand_rule.takes`),
so a reader who opened the reasoning keeps it open when it settles. Only a
row the server marked `handoff="yes"` (the lane's newest settled reasoning
row) takes it; an older row mounted by Load older never does. Known edge: the
note names no block, so a page switch within `handoff_window_ms` of an open
live row leaving can hand its state to the first marked row of the next page.
While the body is shown the element holds the custom state `open`
(`expand_rule.open_state`), which the stylesheet reads to drop a reasoning
row's one-line preview, since the opened text begins with it.
- `<loom-follow>` is the transcript's scroll container: the page's frame is
pinned and only it scrolls. It scrolls itself to a row that lands below
its view while the reader is at the bottom; once the reader scrolls up it
stops and shows a "Jump to latest" button once it is more than
`follow_rule.jump_gap` (96px, a row and the button) from the bottom, so the
button never covers the row the reader is about to read, and scrolling back
to the bottom or pressing the button resumes it. The server's `history`
attribute distinguishes a live endpoint from a frozen historical page.
While it is `reading`, the floating jump remains offered even at offset zero;
`jump_to_latest` clicks the fixed `[data-loom-latest]` control in the slot
before scrolling. A direct press on that server control also resumes the
browser follow. An ordinary resize or strand change never clicks it.
The composer send takes the same return path. When the reader presses the lane's
"Load older" button, it keeps the row they were looking at in place while
the older rows arrive above it. A press on a settled turn's divider (the
button's `data-loom-fold` marker) is the reader's own move too: the element
becomes `Reading` and holds the pressed button and where its top edge was in
the viewport (`Divider`), and each time the lane changes size it scrolls the
transcript by however far the button moved (`follow_rule.holding`, nothing
within a pixel), so the divider stays under the pointer when the steps are
drawn; the reader's next touch of the transcript lets it go (F154). The
scroller ends in 56px of room for the "Jump to latest" button, so the last row
is not under it (F156). The button is a 32px disc with an arrow, named by its
title and label, in a 48px strip at the scroller's right edge that no row
reaches at any width: the column keeps a right padding of whatever the gutter
lacks (`clamp(0px, (876px - 100cqw) / 2, 48px)`, the scroller being a size
container), so the button is never over a line, with the strands panel open or
not and at 800px (F163). It also listens on the document for
`follow_rule.sent_event`, which `<loom-composer>` dispatches (bubbling,
composed, no data) when the reader presses Send, Queue or Steer with a draft;
the transcript then follows the tail again and scrolls to it.
- `<loom-composer returned="<n>" refused="<n>">` wraps the
operator's editor, the server's uncontrolled textarea, which is its default
slot. It lists the slash commands as the draft grows, sends the draft on
the older rows arrive above it. The server draws `data-strand-key`, a
small number the page assigned to the strand on screen (`Marks.key`, never the name),
on it; when the key changes the element saves the departing strand's place
in memory (`follow_rule.leaving`: an offset, or at the bottom) and restores
the arriving strand's (`follow_rule.arriving`), following the tail for a
strand left at the bottom or never seen. Nothing is stored outside the
element.
- `<loom-composer returned="<n>">` wraps the operator's
editor, the server's uncontrolled textarea, which is its default slot.
It lists the slash commands as the draft grows, sends the draft on
Command or Control with Enter, and puts a prompt the daemon handed back
into the editor. These react to text that only the browser has until the
form is submitted, which is why they are here. It also disables the form's
submit buttons while the editor is empty and holds no image
(`composer_rule.gate`, `Open | Shut`); `<loom-attach>` reports images in the
composer's `attached` attribute (`yes | no`), because an image alone is a
message the daemon accepts. It also shows a pressed message at once: on
the form's `submit` it draws the draft as a pending line above the editor
in its own shadow root, marked `sending`, or `queued` for the Queue
button (`pending_rule`), and clears the editor after the paint. The line
is the person's own text as a text node and never a row of the lane. It
leaves when the server takes the draft, which replaces the element (the
server keys the editor by the drafts sent), or when the server refuses,
which the `refused` attribute says: it counts the submits refused with the
draft kept (`component.refusals`, the page's own refusals and the lane's
admission check), and a count that rises while a line is shown puts the
text back in the editor; a notice that is not a refusal, the lane holding
a send until a read answers, leaves the line until the send. No nonce
travels with the submit, so a message held by the daemon (a Steer folded
in at the next boundary, a Queue run after the turn) is shown only until
the server accepts it.
- `<loom-attach name="images" limits="<json>">` is the operator composer's
image attachments (protocol-change/051, the addendum on images): an Attach
image button for the file picker, a paste into the composer's form, a chip
with a Remove button per image, and a notice for a refused file. It is
form-associated, so it submits its images under `name` as one field, a JSON
array of base64 strings, in the same `submit` event as the draft, and nothing
when it holds none. `limits` is the daemon's count, byte total and media types
(`web_view/image.limits_attribute`); one that does not decode allows nothing.
`attach_rule` decides what it accepts before a file is read: declared type,
size, count, with reads in flight counting; the daemon reads the real type
from the bytes. A paste holding image files attaches them and is cancelled,
and its listener on the form is removed when the element leaves. Image files
dragged onto the composer's form are attached through the same vetting
(`attach_rule.choose`); the form's `dragenter`, `dragover`, `dragleave` and
`drop` listeners cancel a drag only when it carries files, and while one is
over the form with a place free the element draws a tinted `.attach-drop`
overlay with a hint (`drop_rule`: `carries_files`, the `Drag` state, which a leave ends only by its `relatedTarget`, so
absorbs child enter/leave, `surface`). `drop_guard` is no element: one
document listener installed in `main` that cancels any file drag, so a file
dropped elsewhere, or on a page with no composer, never navigates the tab. The
chips are the person's own file names as text nodes, and the element draws no
image.
- `<loom-rename>` wraps the rename form's text field (the home row's form and
the Session pane's). The server cannot give the field a `value`, because a
session's name is only ever a text node (protocol-change/051), so when the
element connects it reads the text of the `[data-loom-name]` element inside the
nearest `[data-loom-renames]` container and writes it into the field, if the
field is empty (`rename_rule.copy`, cut to the field's 256 characters), then
focuses it. It takes no attribute, draws only the default slot and sends the
server nothing.
- `<loom-switch to="/ui/sessions/<id>?ticket=<t>">` moves the browser to
another session's page, as `to="/ui/home?ticket=<t>"` to the home, or, as
`to="/ui/admin?ticket=<t>"`, from the owner's home to the admin page
(protocol-change/065, the fifth addendum). The
operator's page draws it hidden (an observer's page too, when it was opened
from a home), and so does the home, and each writes `to`
once the daemon has minted a ticket; `switch_rule.target` accepts exactly
those three address shapes and nothing else, and the element then calls
`location.assign` (one export in `dom.mjs`), so each keyed page is a history
entry and Back returns to it with its own nonce (051, the addendum on
navigation). `<loom-back>Home</loom-back>` calls `history.back()` and mints
nothing (the admin bar's trailing child, and the spent-ticket document's
Go back); `<loom-waiting>` wraps the shell's waiting paragraph and after five
seconds draws the ended document's shape. It renders nothing, takes no
focus and listens for no event (protocol-change/051, the addendum on
switching sessions).
- `<loom-link>` makes a Markdown link clickable. The server draws it with two text
children, the label in `<span class="ll-text">` and the destination in
`<span class="ll-url" hidden>`, and no attribute carries either. The element
reads the `ll-url` text and `link_rule.destination` accepts it only as a plain
absolute `http` or `https` address (any scheme case; no whitespace, control
character or backslash; a non-empty authority with no `@`; at most 2048
characters), a text check on purpose because the `URL` constructor forgives what
a hostile address uses. An accepted address is drawn in the shadow root as
`<a href target="_blank" rel="noopener noreferrer" title="<address>">` around the
default slot, plus a `↗` glyph, so focus, Enter, middle click and copy-link-address
are the browser's and the new tab has no opener. That `href` and `title` are the
one attribute built from session-derived text, set in the browser from the
validated value (protocol-change/051, the addendum on clickable links). A refused
address draws the slot and, unless it is empty or repeats the label
(`link_rule.hint`), the address as quiet text in parentheses, with no anchor. The
rule also refuses bidi isolates and other invisible characters. A mutation observer on
the element's own children re-reads after a patch; it sends the server nothing.
- `<loom-time at="<ms>">` draws an instant as the time of day in the browser's own
zone (`time_rule.clock`: round up to the minute, then the browser's UTC offset
from `ffi_dom.timezone_offset_minutes`). The admin page's grant refusal uses it
for when the next place frees; the server writes the UTC time as its `title` and
light text, so the zone is the browser's and never a server guess.
- `<loom-reveal>` is an empty element that, when connected, calls
`scrollIntoView({block: "nearest"})` on its nearest `section` after the first
paint. The admin claim box opens with one, so a claim made below the fold is on
screen. It takes no attribute and sends the server nothing.
- `<loom-saved>` (`saved`, `saved_rule`) wraps the sidebar's "N saved" button
(the light child, drawn through one slot). A press, which reaches the element's
shadow tree through the slot, flips one fact (`saved_rule.State`, `Hidden |
Shown`), publishes it as the custom state `shown` (the stylesheet shows the
saved panel beside it, `loom-saved:state(shown) + .saved-panel`) and as the
button's `aria-expanded`, and writes it to the browser's storage under
`saved_rule.key` (`loom.sidebar.saved.v1`, one item for the origin: per viewer,
the same on every page, through `ffi_dom.storage_read`/`storage_write`, which
answer `Error` when the storage is blocked). When the element connects it reads
the item once; anything but `shown` is `Hidden`. It takes no attribute, sends
the server nothing and adds no socket admission.
- `<loom-popover wanted="open">` wraps the home's name button (the light child,
drawn through one slot) and toggles the account panel in the browser: it keeps
one fact, open or closed (`popover_rule.State`), publishes it as the custom
state `open` on itself (the stylesheet shows the panel under
`loom-shell:has(loom-popover:state(open))`) and as the button's
`aria-expanded`. Document listeners for `click` and `keydown` read only the
fixed `data-popover` marks (`toggle`, `panel`) of the nodes a click passed
through (`popover_rule.after_click`): the toggle flips, the panel keeps, any
other press and Escape close. The server's only input is `wanted="open"`, which
opens it while a device link is on show; any other word is no message. It
sends the server nothing and adds no socket admission.
- `<loom-dismiss>` is the empty sibling after the bar's context `<details>` (it
draws nothing). Document listeners for `click` and `keydown` read only the fixed
`data-dismiss="keep"` mark on the disclosure (`dismiss_rule.after_click`): a
press through it keeps it, any other press and Escape remove its `open`
attribute. No state, no attribute read, no server message, no socket admission.
Escape is heard in the capture phase and, only when it closes an open panel,
stopped and cancelled (`dismiss_rule.on_key`), so the shell's Escape does not
also run; with the panel shut it is untouched.
- `<loom-copy subject="command|token|link|device|claim-address|bookmark|launch|operate" text="...">`
(`operate` is an operator command for a machine, protocol-change/085, the
addendum of 2026-10-16: `loom distribution enroll`, `renew` or `revoke`, or
`loomd executor release`, each with only its own flags in its own order and each
name one shell word that is bare or single-quoted, printable ASCII, not
beginning with a dash, and nothing else) (`launch` is the command that reaches a session on another orchestrator,
protocol-change/085: `loom --addr`, an address or the placeholder `<its control
address>`, `--session`, an identity of hex digits and hyphens, and the fixed
` --token-file <owner token file on that host>`, and nothing else)
(`bookmark` is a remembered login's home address, `http://`, a loopback host,
`/ui/l/`, 32 lowercase hex digits and `/home`, and nothing else) (`device` is the
home's device-link address, protocol-change/065, PR 8: `http://`, a loopback
host, `/ui/home?ticket=` and 64 lowercase hex digits, and nothing else;
`claim-address` is the browser claim address, `http://`, a loopback host and
`/ui/claim` with nothing after it) draws one of an
invitation's two texts, or the ended page's `loom ui` command for a fresh
link (`link`, protocol-change/065, the addendum on the home list), in a
`code` element in its shadow root, with a button
that copies it to the clipboard (protocol-change/051, the addendum on
inviting from the session page). `copy_rule.subject` decodes the fixed word
and `copy_rule.text` accepts a value only if it is exactly what the daemon
writes for that subject: `loom claim --addr ` and an address of address
characters, or `loomclaim_` and 64 hexadecimal digits. Anything else, a
newline included, draws nothing and offers no button. The copy runs in the
press's own turn through `ffi_dom.write_clipboard` (one export in
`dom.mjs`), and the outcome is drawn on the button in fixed words
(`copy_rule.words`). It takes no key, no focus and sends the server nothing.
- `<loom-shell sidebar="listed" needing="0" workspace="<digest>">` is the
page's frame. The server
draws the top bar, the sessions sidebar, the centre and the strand panel as
its children, in the slots `bar`, `left`, the default and `right`, and the
element lays them out and draws a button at each end of the bar that hides
and shows a side column. Which columns are open is the reader's preference
and nothing the server holds, so the server never renders it. It is kept
in the browser's storage, per workspace: on connect the element reads its
`workspace` attribute, a SHA-256 digest of the workspace path that the
daemon computed (`component.Start.workspace_digest`), and asks
`layout_rule` for the stored layout (the two columns and the active tab,
nothing else; the focused strand is never kept, so a reload shows `main`),
and every change writes it back through `ffi_dom.storage_write`. A page
with no digest keeps nothing. A Theme icon button in the bar (a sun, moon or half disc drawn from fixed shapes, labelled by `layout_rule.label`) cycles the page
through following the system, light and dark (`layout_rule.next_theme`) by
setting or removing `data-theme` on `<html>`, which the stylesheet reads;
the choice is kept per browser under its own item, and `assets/web_view_page.js` applies it from that item before first paint, since the shell connects only after the socket opens (`js_check` pins the item name to `layout_rule.theme_key`). The server never learns
the layout
(protocol-change/051, the addendum on the storage decision). A hidden column takes no width and is `inert`, so
its content leaves the tab order. The `sidebar` attribute is a fixed word
the server writes (`listed` or `none`), so an observer's page, which has no
sidebar, gets no button for one. The strand panel has four tabs, Strands,
Changes, Trace and Session. The server draws a pane for each, all of them, as
children of the panel; the element draws the tab bar above the `right` slot
and shows the chosen tab's pane by setting a custom state on itself
(`component.set_pseudo_state`, `tab-strands`, `tab-changes`,
`tab-session` or `tab-trace`), which the stylesheet reads to hide the others
(`loom-shell:state(tab-changes) .pane:not(.pane-changes)`). A hidden pane is
`display: none`, so its controls leave the tab order too; a browser without
custom states shows every pane, stacked. The `needing` attribute is a count
the server writes, the number of strands waiting on a decision, which the
Strands tab shows as a badge and names in its label; decoding is total.
The shell also relays clicks. A server-drawn control that focuses a strand
and has no handler (a dot or a tag in the transcript, the breadcrumb's `All
strands`, a strand view's back link) carries `data-loom-focus` with the
position of a strand card, and each card carries `data-loom-card` with its
own. The element hears a `click` that reaches its centre slot or its panel
slot, reads `dataset.loomFocus` from the click's own target, decodes it
totally (`shell_rule.relay`), shows the panel on its Strands tab where the
rule says (`shell_rule.relayed`; position zero, `main`, leaves the layout
alone) and presses the card with `ffi_dom.click`. The press is an ordinary
click on the card's ordinary handler, so the socket admits nothing new
(protocol-change/051, the addendum on the marker relay).
A page that has ended or was refused needs no element here. The server draws
its notice (`web_view/view/ended`, `web_view/page.refusal`), and the shell's
own paragraph inside the component shows while nothing has mounted; this
package only styles them (`.ended-notice`, `.page-note` in `web_client.css`).
A tab that mounted and then lost its socket is the one case no element covers
yet (protocol-change/051, the addendum on an ended page).
It is the client package of Lustre's full-stack layout: `core` and
`session_view` are the shared code, `loomd` with `web_view` is the server,
and this is the client. Unlike the guide's single-page app it is a set of
client components inside a server component, because 051 keeps the session
on the BEAM (`docs/lustre.md`).
The package targets JavaScript only (`target = "javascript"`) and depends on
`lustre == 5.7.1`, `gleam_stdlib` and `gleam_json`. Its only JavaScript under `src` is
`src/web_client/internal/dom.mjs`, one DOM call or property read per export
with no logic, declared in `internal/ffi_dom.gleam`; every decision the
components make is Gleam over it (see `internal/ffi_dom` under Key Types).
`make gen-client` bundles it with
`lustre_dev_tools` (a dev dependency here and nowhere else) into
`packages/web_view/priv/static/web_client.mjs`, together with the page's
stylesheet, which Tailwind builds from `src/web_client.css`, and the two
page scripts and the tab icon (`favicon.svg`, the logo's mark, light and dark
by media query) in `assets/`. The daemon serves those files; nothing at run
time builds anything.
## Key Types
- `web_client.main()`: registers every element, once, when the page loads
the bundle.
- `elapsed.Model(reading, now, timer)`, `elapsed.Reading(offset, anchor)`
and `elapsed.Msg` (`OffsetChanged`, `Anchored`, `Connected`,
`Disconnected`, `Started`, `Ticked`): `offset` is how long the operation
had run, in milliseconds, by the server (`agent_roster.running_ms`), and
is the only attribute the element reads; `anchor` is the browser's clock
when it arrived. The element never subtracts a daemon instant from the
browser's clock. `duration.format` is the terminal strip's format.
- `fold.Model` (`Closed` | `Opened`) and `fold.Msg` (`Toggled`): the fold's
shadow root holds one button carrying the `summary` slot and, while open,
the default slot. Each toggle emits `fold.toggled_event`
(`loom-fold-toggled`, bubbling and composed, no data).
- `expand_rule.Shown` (`Closed` | `Open`), `expand_rule.toggled` and
`glyph`, and `expand.Msg` (`Toggled`): the element's shadow root holds one
button (the chevron and the head slot, `aria-expanded`) and, while open, the
body slot. It has no words of its own. It starts `Closed`, and the server never renders the state, so
a patch leaves the reader's choice alone. Each toggle emits
`fold.toggled_event`, so `<loom-follow>` hears it as it hears a fold's: it
sets `Reading`, and expanding the newest row at the bottom does not scroll
past the button the reader pressed. Collapsing shrinks the lane and the
browser fits the scroll position, which the follow rule treats as layout,
never as the reader leaving.
- `follow.Model(reader, watching, anchor)` (`follow_rule.Reader` holds
position, gap, top, extent and touched, and `follow_rule` moves it),
`follow_rule.Position` (`Following` | `Reading`) and `follow.Msg` (`Connected`,
`Disconnected`, `Watched`, `Touched`, `Scrolled(top, extent, at)`,
`Resized`, `Measured`, `Folded`, `Paged`, `Jumped`, `Held`, `Released`):
a scroll sets the position from where it ended, which way it moved and who
moved it (`follow_rule.after_scroll(current, gap, moved, origin)`). Within
`follow_rule.slack` pixels of the bottom is `Following`. A move up that ends
further away is `Reading` only when the reader made it; a move down that
ends further away changes nothing, because that is either the reader
coming back or the element's own scroll to the bottom reported after more
rows landed. `follow_rule.origin` tells who made a scroll: `Input` when a
`wheel`, `touchstart`, `touchmove`, `pointerdown` or `keydown` on the element was heard
within `follow_rule.touch_window` (`Touched`, passive listeners that read
nothing from the event), `Steady` when nothing was heard but the
transcript's `Extent` (content and view height) is what it was at the
last scroll (find-in-page, a key pressed outside the transcript, a
scrollbar drag), and `Layout` when nothing
was heard and the extent changed, which is the browser fitting the scroll
position to a box that grew or content that shrank. A `Layout` scroll
never leaves `Following`; the size change that caused it brings `Resized`,
which scrolls to the bottom. Each `Input` scroll renews `touched`, so a
slow scrollbar drag stays the reader's. A resize of the transcript or its
content scrolls to the bottom only while `Following`; a fold's toggle
event, heard on the slot, sets `Reading`, so opening a fold never scrolls
past it. A click heard on the slot whose target carries the server's fixed
`data-loom-fold` marker, a settled turn's divider, is `Folded` as well. A click
heard on the slot whose target carries the server's fixed
`data-loom-older` marker is `Paged`: it sets `Reading` and holds the
lane's first row and its place on screen (`follow.Anchor`, `None` when
the page has no lane row); a scroll by the reader measures it again, and
the first resize after which that row is no longer the lane's first
scrolls the transcript to put it back and releases it (`follow_rule.keeping`
over a `follow_rule.Standing`: `Detached`, `Leading`, `Displaced(top)`). The
watch (`follow.Watching`) is the element, its scroll listener and input
listeners, a `ResizeObserver` on the element and on each of its
children, and a `MutationObserver` that keeps those current: the
element's own box is fixed, so it is the content that changes size when a
row lands. The shadow root holds the default slot and, while the reader is
`Reading` more than `slack` pixels from the bottom, one button, "Jump to
latest" (`Jumped`), whose wrapper has no height and sticks to the
scroller's bottom edge.
- `copy_rule.Subject` (`Command` | `Token` | `Link` | `Device` | `ClaimPage` | `Bookmark` | `Launch` | `Operate`), `Copying` (`Idle` | `Copied` |
`Failed`), `subject`, `text`, `after` and `words`, and `copy.Model(subject,
held, copying)` with `copy.Msg` (`Subjected`, `Texted`, `Pressed`,
`Written`): the element keeps the raw `text` and checks it against the
subject when it draws and when it copies, because the two attributes may
arrive in either order.
- `switch_rule.target(value)`: `Ok(value)` only for exactly
`/ui/sessions/<canonical identity>?ticket=<64 hex digits>`,
`/ui/home?ticket=<64 hex digits>` or `/ui/admin?ticket=<64 hex digits>`,
`Error(Nil)` for
anything else, an absolute URL or another path included. `<loom-switch>`
navigates only to what it returns.
- `shell_rule.Motion` (`Still` | `Animated`) and `frame_classes`: the frame
starts `Still`, with the class `still` that the stylesheet reads to turn the
columns' width transition off, so the saved layout is drawn without a slide,
and `Settled` (sent after the restored layout is painted) makes it
`Animated`.
- `shell_rule.Region` (`Sidebar` | `Panel`), `Tab` (`Strands` | `Changes` |
`Session`), `State` (`Open` | `Closed`), `Layout(sidebar, panel, tab)`,
`Presence` (`Listed` | `Unlisted`) and `Reach` (`Reachable` | `Unreachable`),
with `toggled`, `chosen`, `state`, `reach`, `label`, `hint` (the visible
`⌘B` / `⌘⌥B` words each toggle draws as an `aria-hidden` `kbd.toggle-hint`),
`tabs`, `tab_label`,
`tab_state`, `has_button`, `presence` (a total decoder of the `sidebar`
attribute), `needing` (a total decoder of the `needing` attribute: a plain
number of at most four digits, else none), `badge` and `strands_words`,
`relay` (a total decoder of a marker: `Relay(card, reveal)` with `Show` or
`Keep`), `relayed` and `card_selector`, the keyboard's `Keystroke`
(`Modifiers`, `Target` as `Editor | Approvals | Elsewhere`, `Composition`,
`Prevention`, `Repetition`), `intent` (`ToggleSidebar | TogglePanel |
LeaveStrand`, or nothing), `cancels`, `candidate`, `title`, `shortcuts` and
`crumb_link`, and
`shell.Model(layout, sidebar, needing, workspace, keys, ..., width, grip)` and
`shell.Msg` (`Toggled(region)`, `Chosen(tab)`, `SidebarChanged(presence)`,
`NeedingChanged(count)`, `Relayed(relay)`, `Pressed(intent)`, `Connected`,
`Disconnected`, `Listening(listener)`, `ThemeCycled`,
`Restored(workspace, saved, theme, tab, width)`, `GripPressed`, `GripHooked`,
`GripMoved`, `GripReleased`, `GripAdjusted`).
`Listening` stops any listener the model still holds as it keeps the new
one, because `listen` registers after the paint and can arrive after a
later `Connected`. The
shadow root holds the bar (the two buttons around the `bar` slot) and the
body (a wrapper per side column around its slot, and the default slot in the
centre; the panel's wrapper holds the tab bar above the slot). A closed
column's wrapper is `inert` and the stylesheet gives it no width. The
buttons and the tabs are real buttons, the tabs `aria-pressed`, with words
from `shell_rule.label` and `shell_rule.tab_label`. A tab press changes
the custom state and nothing else: closing and reopening the panel keeps the
tab.
- `grip_rule` and the panel's grip. A separator (`role="separator"`,
focusable, `aria-valuenow`/`valuemin`) on the panel column's left edge, drawn
by `<loom-shell>` between the centre and the column while the panel is open,
lets the reader widen the panel to read a Changes diff: drag left to widen,
right to narrow, arrow keys by `fine_step` (24 px), with Shift by
`coarse_step` (96 px), Home and End to the narrowest and widest, a double click
to go back to `default_width` (340). `grip_rule` imports neither Lustre nor the
DOM and holds the decisions: `Room(panel, centre)` (the two columns' rendered
widths, which the shell measures from the event: `room_of`), `ceiling` (what
the pair shares less `centre_floor`, 360, which the stylesheet also gives the
centre column as `min-width` from 980 px up; never under `least_width`, 280, and
never over `most_width`, 4000),
`Drag(origin, start, ceiling)` from `begin`, `dragged` (computed from the start
of the drag, so overshooting a limit and returning has no dead zone),
`contact` (`Pressing` | `Lifted`, from the move's `buttons`), `adjustment`
(`Wider` | `Narrower` | `Widest` | `Narrowest` | `Reset`; any other key is
the browser's, Tab included) and `adjusted`. A drag listens on the document
(`pointermove`, `pointerup` and `pointercancel`, `shell.Hooks`), registered after
the press and removed on release, cancel, a move with no button down and
disconnect; listeners that arrive after the drag ended are stopped on arrival
(`GripHooked`). The frame's root carries the width as the custom property
`--panel-width` (`0px` while the panel is closed) and the class `dragging`
while held (width transition and text selection off); the column, `.ctx-panel`
(a slotted element, which inherits from its slot's ancestors) and the stylesheet
read the property. The width is kept per browser under `layout_rule.width_key`
(`loom.panel.width.v1`, a decimal integer; `restored_width` accepts 280 to
4000 and nothing else), written once at the end of a gesture and on each key.
Below 980 px the panel is a row of cards and the grip is not shown. The one new
DOM binding is `ffi_dom.offset_width`.
- `shell_rule.Frame` (`Wide` | `Narrow`), `narrow_query` (`(max-width: 1211px)`,
the stylesheet's breakpoint), `sidebar_state`, `sidebar_pressed`,
`Dismissal` (`Dismiss` | `Leave`), `dismissal`, `scrimmed` and
`presses_button`: below 1212px the sidebar is a drawer over the centre
behind a scrim. `<loom-shell>` keeps a `Frame` from a `matchMedia` listener
(`ffi_dom.media_query`, `media_matches`) and a drawer `State` beside the
layout; the drawer is never saved, starts closed, and closes when the frame
changes. The sidebar's button and Command/Control B flip it, a click on the
scrim or on a button in the sidebar (read from the click's composed path)
closes it, and `Escape` closes it before it leaves a strand.
- `layout_rule.Workspace` (`Identified(digest)` | `Anonymous`), with
`workspace` (a total decoder of the `workspace` attribute: exactly 64
lower-case hex digits, else `Anonymous`), `layout_key` (`loom.layout.v1.` and
the digest, or nothing), `encode` (a JSON object of three words) and
`restore` (total: any stored text, or a missing or blocked item, answers a
`shell_rule.Layout`; the default for malformed text, and the default of one
field that is missing or names an unknown word), the panel's tab per browser
(`tab_key` `loom.panel.tab.v1`, `encode_tab`, `restored_tab`, which replaces
the workspace layout's tab on restore so the tab follows the reader across
sessions and workspaces), and the theme:
`Theme` (`System` | `Light` | `Dark`), `theme` (a total decoder of the stored
word: anything but `light` and `dark` follows the system), `encode_theme`,
`next_theme`, `data_theme` (the root's attribute, or nothing for `System`),
`label` and `word` for the button, and `theme_key`. It imports neither Lustre
nor the DOM binding, and `layout_test` covers it on Node.
- `composer.Model(entries, draft, selected, palette, returns, attachments,
pending, refusals, submitting)` and
`composer.Msg` (`CommandsChanged`, `Configured`, `Returned`, `Holding`, `Typed`, `Moved`,
`Accepted`, `Picked`, `Dismissed`, `Sent`, `Ignored`, `Refused`, `Pressed`,
`Connected`, `Disconnected`, `Listening`): `commands` is the table the
server built from the terminal's suggestions (`composer_rule.entries` decodes
it, and decodes to no table if it is not one); `composer_rule.matching(entries,
draft)` is `command.suggestions`' rule over that table, one-word commands
by prefix and a word with a closed vocabulary (`/effort `, `/goal `) by
its argument rows past the space. `composer_rule.intent(key, chord, phase,
palette)` says what a key does: Command or Control with Enter is `Sent`
and its default cancelled; while the list shows, the arrows are `Moved`,
Tab and Enter are `Accepted` and Escape is `Dismissed`; everything else,
and every key during composition, is the browser's. `Returns` is `Unseen`
or `Seen(taken)`: the first `returned` count is the baseline, so an editor
drawn afresh takes none of the returns before it. `composer_rule.hear` turns
each later count into `Take(after, up_to)` when it is above `taken` and
advances `taken` in the same turn, so two returns that arrive before one
frame paints claim disjoint ranges and each prompt is taken once. The
effect reads the numbered children of the `returned` slot, and
`composer_rule.taken` picks the ones in the range, oldest first, and
`composer_rule.joined` puts each in the draft (an empty editor takes it as its
draft; a typed one keeps its text and takes it after a blank line).
`composer_rule.revealed` says where the list scrolls to keep the highlighted
row in view. The shadow root holds the pending line, then the
list, above one default slot; the list is `role="listbox"` and its rows
`role="option"`.
- `pending_rule.State` (`Clear` | `Shown(Pending(text, delivery))`),
`Delivery` (`Sending` | `Queueing`), `Refusals` (`Unheard` | `Heard(count)`)
and `Outcome` (`Keep` | `Restore(text)`), with `delivery` (the submit
button's class: `queue` queues, anything else sends), `mark` (`sending` or
`queued`), `pressed` (a draft with a word in it is shown; a second press
replaces the line) and `refused` (the first count is the baseline; one that
rises while a line is shown clears it and restores the text; one with no
line, or one that does not rise, only advances what was heard). The element
hears the form's `submit` through a listener it adds on `Connected` and
removes on `Disconnected` (`Submitting`), reads the draft and the submitter
from it, and acts after the paint, so the server's own handler has read the
form first.
- `internal/ffi_dom` and `internal/dom.mjs`: the package's only browser
API in the session page and its only JavaScript under `src`. Every function is one DOM call or property
access: `host`, `as_element`, `same`, `is_connected`, `first_element_child`,
`children`, `closest`, `query_selector`, `query_selector_all`, `dataset_get`,
`text_content`, `scroll_top`, `set_scroll_top`, `scroll_by`,
`scroll_height`, `client_height`, `offset_top`, `offset_height`,
`bounding_top`, `media_query`, `media_matches`, `add_passive_listener`, `add_listener` (called with the
event, and may cancel it), `remove_listener`, `get_document`, `composed_path`,
`tag_name`, `attribute`, `is_content_editable`, `prevent_default`,
`resize_observer`, `observe`, `mutation_observer`, `observe_child_list`,
`disconnect`, `value`, `set_value`, `utf16_length`, `set_selection_range`,
`focus`, `click`, `request_submit`, `request_submit_with`, `now`,
`set_interval`, `clear_interval`, `document_element`, `set_attribute`,
`remove_attribute`, `storage_read` and `storage_write` (each
one `localStorage` call inside a `try`, answering a `Result`, since storage
throws when blocked; the only way the package reaches storage). Its types are `Element` (an element, or the shadow root
Lustre hands an `after_paint` effect, which answers queries alike),
`Listener`, `Observer` and `Timer`. The one decision in `dom.mjs` is
turning a null or undefined DOM answer into `Error(Nil)`. Add a function
there only when a component needs a DOM call that is not bound, and keep
the logic in Gleam. There is no other `.mjs`: the lint gate below fails
one.
## Tests
What the components decide is in seven modules that import neither Lustre nor
`ffi_dom`: `attach_rule` (the limits, which files are accepted and refused,
the held images and the form field they make), `drop_rule` (which drags carry
files, the drag state, when the drop state shows), `follow_rule` (the scroll rule, `Reader` and its transitions,
`keeping`), `expand_rule` (the two states and the chevron), `shell_rule`
(which columns are open, the buttons' words, what a closed column lets the
keyboard reach), `composer_rule` (the table, `matching`, `intent`, `hear`, `taken`,
`joined`, `revealed`), `pending_rule` (the pending line: `pressed`, `refused`,
`mark`) and `duration`. `follow`, `composer` and `elapsed` are
the elements over them. The split is enforced, not just conventional: Lustre's
client runtime declares `class LustreEvent extends CustomEvent` at load, Node
18 (the signoff container's) has no global `CustomEvent`, and a test that
imports an element throws before any test runs. Lint rule R20 walks the
imports reachable from `test/` and fails the build if one reaches `lustre`
or `web_client/internal/` (`make lint-web_client`).
`test/` holds gleeunit tests for what the components decide: `follow`'s
scroll rules and the sequences of messages the page sends, `composer`'s
`matching`, `intent`, `hear`, `taken`, `joined` and `revealed`, and
`duration.format`. They run on the JavaScript target, so they need Node,
Bun or Deno: `make test-web_client` (`scripts/web_client_test.sh`, which
`scripts/check.sh` runs after the compile). The container the signoff runs in
installs Node for this; a machine with no runtime prints a `SKIP` line that
`.github/scripts/skip_census.sh` refuses. What the tests do not reach is the
DOM: the listeners, the observers, the scroll events, the caret and focus,
`requestSubmit`, and the way the browser lays the page out. Those run only in
a browser and are checked by hand.
## Relationships
- **Depends on**: `lustre` (client components, `lustre.register`),
`gleam_stdlib`, `gleam_json` (the fold event's empty payload). Dev only:
`lustre_dev_tools`, for `make gen-client`, and `gleeunit`, for the tests.
- **Depended on by**: nothing at compile time. `packages/web_view` renders
its elements by tag name, and `packages/client` serves its bundle from
`web_view`'s `priv/static` (`ui_http.Client`).
## Traffic
None over the socket. Each element is a Lustre runtime inside the browser:
attribute changes and DOM events reach its `update`; its timers dispatch
messages to it. Nothing here opens a connection. The one element that
looks outside itself is `<loom-follow>`, which reads and sets its own
scroll position, observes the size of itself and its children, and hears
(passively, reading nothing from them, not even the key) the wheel, a finger,
a pointer press and a key pressed inside itself; it reads no content. `<loom-composer>` listens to its own
editor's `input` and `keydown`, and writes the editor's value; the one thing
it sends is the form's submit, which the server already accepts.
`<loom-shell>` listens to `keydown` on the document while connected, reads the
event's key fields and, for `Escape` and `B` only, its composed path, and
sends the server nothing.
## Invariants
- **Only attributes that hold daemon identities or numbers.** An element
never renders an attribute's value as text unless it is a number it
computes from, and never takes session text as an attribute. Text inside
`<loom-fold>` is the server's light-DOM children, projected through slots.
`<loom-composer>` reads its JSON completion table and returned prompts
from escaped text-node children, never from attributes.
- **No key acts inside an approval card, and no key decides, dismisses or
focuses one; no focus near an approval card.** `<loom-composer>` acts on a
key on its own editor, through its slot. `<loom-shell>` holds the dock in its
subtree, as the page's frame, and is the one other element that acts on a
key, on three (protocol-change/051, the addendum on the keyboard): Command or
Control with `B` hides or shows the sidebar, with Alt too the panel, and
`Escape` presses the breadcrumb's `All strands` link. It listens on the
document while connected (removed on disconnect), since the page's usual
focus is `body`; decodes the keystroke into plain values; reads where it was
pressed from the event's composed path (`shell_rule.Step` per node,
`shell_rule.target`), because at the document the target is retargeted to
the outermost shadow host; and lets `shell_rule.intent` decide. A key that is
neither `Escape` nor `KeyB` is dropped before its path is looked at. The rule
takes no key at all when the path includes an element marked
`data-loom-approvals`, treats the composer and any input, textarea, select or
editable text as the editor, and takes none while
composing, when the default was cancelled, or while a key repeats; `Escape`
does nothing in the composer, and the sidebar shortcut nothing on a page with
no sidebar. The toggles cancel the browser's action, `Escape` does not.
Nothing takes focus and the element sends the server nothing: no intent
decides, sends or focuses, and the server registers no key handler (the
socket admits none). Its clicks are its own buttons and tabs and a click
whose own target carries a strand marker (which presses a strand card and
nothing else); the centre column, where the dock is, has no button, and no
approval card carries a marker. `<loom-follow>` may note that a key was pressed inside the transcript
(a passive `keydown` that reads nothing from the event, never cancels it and
sends nothing), so a keyboard scroll counts as the reader's. It calls `focus` once, on that editor, when the operator chooses
a row. The approval cards are outside it, in the dock, and no key it
handles decides one: Command or Control with Enter submits the composer's
form, which sends a prompt or a command and decides nothing.
- **No raw HTML.** Lustre renders through its virtual DOM; nothing here
uses `unsafe_raw_html` or `innerHTML`. `scripts/web_client_js_check.sh`, run
by `make lint` (so by `make lint-web_client` and `make check`), fails if any
JavaScript file under `src` is not `internal/dom.mjs`, or if one names
`innerHTML`, `outerHTML`, `insertAdjacentHTML`, `eval`, `new Function`,
`document.write`, `srcdoc`, `DOMParser`, `createContextualFragment` or a
dynamic `import(`, or if `localStorage` is used other than by
`window.localStorage.getItem(` and `.setItem(`, or `sessionStorage`,
`indexedDB`, `document.cookie` or `cookieStore` appears, or a Gleam
`@external(javascript, ...)` names a file other than `./dom.mjs`. It has its
own self-test.
- **Words use a `-text` token; marks use the plain one.** The stylesheet's
hues come in pairs (`--color-signal` and `--color-signal-text`, and the same
for `advisor`, `peer`, `danger`, `added` and `strand-2` to `strand-6`). The
plain token is the redesign's colour for a dot, ring, bar or border; the
`-text` token is the value for words in that hue, which is darker in the
light theme because the design's light hues are under 4.5:1 there
(`docs/design-notes/web-design.md`, section 5). A strand's hue is `--hue`
for marks and `--hue-text` for words. `--color-fg-faint` is never text.
`scripts/web_client_contrast_check.sh`, run by `make lint`, fails if a text
token is under 4.5:1 on a surface it is drawn on (`bg`, `bg-raised`,
`bg-sunk`, `bg-user`, `code`, and the diff backgrounds for `added-text` and
`danger-text`) in either theme, or if a rule sets `color:` from a mark
token, `--hue` or `fg-faint`. It has its own self-test. `on-danger` (the
tab badge's text) is held against `danger`, and `bg` against `fg` for the
filled primary buttons.
- **The monospace face is for code.** Labels, rows and headings are
`--font-sans`; `--font-mono` is for code, paths, tags, diffs and tool
output. `scripts/web_client_css_check.sh`, run by `make lint`, fails a rule
whose selector names `.line`, `.step`, `.chip` or `.panel-title` and sets
the monospace face; a row that carries code names the code's own class
(`pre.tool-result`, `.step-summary`, `.diff-row`). It has its own self-test.
- **Tokens live on `:root`; a shadow root only inherits them.** The Theme
button sets `data-theme` on `<html>`, and custom properties inherit through
every shadow root under it. Tailwind's `@theme` also writes the dark palette
on `:host`, which would shadow the inherited value in each element, so the
stylesheet ends the palettes with a `:host` rule that sets every token to
`inherit` (`web_client.css`, the light tokens). A new token needs a line in
the dark `@theme`, in both light palettes (`:root:not([data-theme="dark"])`
under the light media query, and `:root[data-theme="light"]`) and in that
`:host` rule; the contrast check fails when the light palettes disagree or a
token has no `inherit` line.
- **State the DOM would hold in an expando lives in the model.** The
returned-prompt bookkeeping (`Seen(taken)`), the scroll bookkeeping (`top`,
`extent`, `touched`) and the held row are Lustre model fields, never
properties set on the host element.
- **No state the server needs.** A fold's open state, a clock reading and
whether the reader follows the tail live only in the browser; the server
never reads them.
- **The follower never touches an approval card.** `<loom-follow>` wraps
the lane only, as the scroll container between the pinned header and
the pinned dock; the cards and the composer are in the dock outside it.
It scrolls instantly, never smoothly, because a smooth scroll reports
intermediate positions that read as the reader leaving the tail. The row
it holds for "Load older" is found by structure (the lane's first
child), and only its box is read.
- **Only a scroll the reader made leaves the tail.** A browser moves the
scroll position up by itself when content shrinks or the box grows, and the
event is heard after the rows that landed since, so by geometry alone it is
the reader leaving. `follow_rule.origin` refuses that (see `follow.Model`
above). Keep any change to the follow rule inside `follow_rule.after_scroll` and
`follow_rule.origin`, and add a sequence to `test/follow_test.gleam`.
- **The committed bundle is generated.** Change this package and run `make
gen-client`; `make client-check` (part of `make check`) fails on drift,
by digests, without Node, Bun or a network.
- **The proposals count is one attribute.** `<loom-shell>`'s `proposals`
attribute decodes with `shell_rule.needing` and badges the Session tab, and
`proposals_rule.session_words` is the tab's label.
## Deep Docs
- `docs/lustre.md`: server components, client components inside them, the
security rules, and how the bundle is built and gated.
- `protocol-change/051-web-view-route.md`: the page's threat model.
## Result-page layout
The stylesheet lays out the daemon's explicit result viewer and wraps its long
text lines within the viewport. The viewer uses ordinary navigation links and
adds no client component, JavaScript state or websocket event. `make gen-client`
regenerates its committed CSS and source digest alongside the existing assets.
The explicit return keeps `follow_rule.Returning` until the server acknowledges
`history="live"`. That acknowledgement restores following after paint, even
when replacing the historical DOM temporarily shrinks the scroll extent.
## Model and profile completion
The operator page captures configured model keys and profile names once at
socket admission (`ui_socket.admit`, `component.Start.completion_models` and
`completion_profiles`); observer pages receive empty lists. These are suggestions,
not authority: the existing model and profile commands validate at execution.
The composer keeps the full JSON table as escaped text in its `commands` slot.
`CommandsChanged` and connection setup read that child through existing DOM
bindings; `Configured` replaces the table and resets its selection without
changing the draft. Prefix matching, keyboard selection and click selection
use `composer_rule` as before. Reopen the page to refresh configuration names.
## Notes navigation
`<loom-notes mode="index|detail">` is an empty helper inside the Notes pane.
The server owns the board, search and selected cell. The helper saves only the
panel's scroll position and page-assigned numeric row mark on entry, focuses
Back after detail is painted, and restores row focus and scroll on return.
Its listeners belong to the enclosing notes region and are removed on
replacement/disconnect. Escape within detail presses its ordinary Back button
and stops propagation before shell strand navigation; inputs keep Escape.
It handles no key outside notes and cannot reach the approval dock or composer.
`shell_rule.Notes` is the appended panel tab, with `tab-notes` as its pseudo
state and `notes` as its existing layout-storage word. `.pane-notes` is shown
only on that tab, which also hides pending advisor nudges. Browser storage holds
the tab preference and never a note, query or selection.
## Mermaid diagrams (protocol-change/091)
`<loom-mermaid>` takes no source attribute. Its light child is the server's
`pre.mermaid-source`, drawn as text only. `mermaid_rule.State` owns source,
effective theme, revision and `Phase` (`Empty`, `Waiting`, `Rendering`,
`Shown(height)`, `TooLarge`, `Unavailable`). A changed source or theme advances
the revision before the 350 ms debounce; unchanged patches do nothing. Only
the current render can publish a height, limited to 120 through 2000 pixels.
A ten-second unanswered render becomes source fallback, not a retry loop.
- `mermaid.Resources` owns a source observer, explicit-theme observer, system
- palette listener and window message listener. Disconnect releases them and
+ `mermaid.Resources` owns a source observer, explicit-theme observer, an
+ approach observer, system palette listener and window message listener. The
+ iframe exists only after the approach observer reports the element within a
+ viewport of the visible area (`Distance` is `Distant` until then), so a long
+ transcript runs renderers only for diagrams a reader has come near. Disconnect releases them and
advances the revision, invalidating callbacks from the old connection. Bounded
callbacks retain only a revision and dispatch, not the source. The parent
checks frame-window identity before `mermaid_rule.reply` decodes a JSON string;
no SVG or vendor diagnostic is accepted. Copy source is an explicit user action
and reports clipboard refusal without hiding the selectable source.
The frame controller is the separate internal Gleam program `web_mermaid`,
not an exception to the session bundle's no-markup binding. Its pure state
machine owns queueing, fixed configuration, request/SVG validation and replies;
its runtime interprets effects over a minimal frame-only DOM/vendor FFI. Its
`bundle/` directory holds the fixed bootstrap, pinned vendor lock and frame
document; nothing of the renderer lives in this package.
They are bundled as one classic script in `web_view/priv/static/mermaid.js`.
It runs only in `mermaid.html` under an HTTP sandbox and its own restricted
policy. `make gen-client` rebuilds it and `make client-check` validates its
source/lock/body digests without npm or Bun. See
[protocol-change/091](../../protocol-change/091-isolated-mermaid-diagrams.md)
for the frame contract and deliberate restrictions. Frame controller tests
run with `make test-web_mermaid`; the parent lifecycle tests are
`test/mermaid_test.gleam`; `scripts/web_mermaid_check.sh` drives the real
seeded daemon's streaming, recovery, theme and frame-identity paths.