Layout as Output
Silvery's layout engine produces geometry — sizes, positions, scroll offsets, the cursor, the focused node, the active selection. Components declare semantic intent (props on Box / Text), the layout engine resolves geometry, and the scheduler/output phase consumes that geometry to paint ANSI to the terminal (or, in the future, to a canvas / DOM target).
This document explains the pattern, what it means for component authors, and how to migrate code that historically read layout rects at render time.
The mental model
Three phases, each consuming the previous one's output:
| Phase | Consumes | Produces |
|---|---|---|
| Render (React) | props, state | semantic tree (Box / Text / signals) |
| Layout (Flexily) | semantic tree | geometry (rects, cursor, focus, scroll) |
| Output (ANSI / DOM) | geometry | painted frame |
The pattern is borrowed wholesale from the browser pipeline (DOM → layout/style → paint), Yoga's onLayout callbacks, AppKit/TextKit's text-layout decorations, and ProseMirror's Decoration model — all of which separate semantic intent from resolved geometry, and let the geometry layer feed back to consumers as output, not as a state that components have to thread by hand.
The rule for component authors:
Render is intent. Layout is output. Don't read geometry during render.
What this means for component authors
Eight layout outputs are exposed as declarative props on Box (or as signals consumed by the scheduler/renderer). Component authors set them; the layout engine resolves them; the scheduler/renderer consumes them:
| Signal | Declared via | Consumed by |
|---|---|---|
boxRect | (computed from flex / width / height) | layout-signals → component callbacks (post-layout) |
scrollRect | (computed from scroll containers) | scrolling, position registries |
screenRect | (computed including sticky clamping) | mouse hit-testing, alt-screen output |
cursorRect | <Box cursorOffset=> | output phase (DECSCUSR + cursor positioning) |
focusedNodeId | <Box focused={true}> | input dispatch, focus-aware overlays |
selectionFragments | <Box selectionIntent={...}> | render phase (selection paint walk) |
anchorRect | <Box anchorRef="trigger"> | anchored overlays, tooltips, menus |
decorationRects | <Box decorations={[...]}> | overlay layer, cross-target decoration renderers |
You don't read these in render. You declare them, and the layout engine makes them available post-layout to the consumers that need them.
For anchored overlays, declare a stable anchor id on the trigger and a decoration on the owner that needs the geometry:
<Box anchorRef="session-menu">
<Text>Sessions</Text>
</Box>
<Box
decorations={[
{
kind: "popover",
id: "session-menu-popover",
anchorId: "session-menu",
placement: "bottom-start",
size: { width: 32, height: 8 },
offset: 1,
collisionStrategy: "flip-then-shift",
},
]}
/>placement is deterministic (placeFloating). offset adds a gap along the placement axis, alignOffset nudges along the alignment axis, and collisionStrategy opts into viewport-aware flip/shift/hide behavior during the layout-signal pass. The geometry is resolved in the same frame as the anchor rect; component code does not call useBoxRect() to position the overlay.
For the common "render this fixed-size overlay at that anchor" case, use AnchoredOverlay. It declares the decoration and renders the placed content from the resolved rect:
<Box anchorRef="session-menu">
<Text>Sessions</Text>
</Box>
<AnchoredOverlay
anchorId="session-menu"
placement="bottom-start"
size={{ width: 32, height: 8 }}
offset={1}
>
<SessionMenu />
</AnchoredOverlay>Mount AnchoredOverlay inside the same overlay surface/root as the anchor. Its host is absolutely positioned and non-flowing, so the placed content uses the layout-resolved root coordinates rather than the component's local flex position.
Migration patterns
"I was reading useBoxRect().width for sizing" → use flex props
If you wrote:
function MyBox() {
const { width } = useBoxRect() // ⚠ stale-frame zero on first mount
return <Text>{"─".repeat(width)}</Text>
}…the migration is to let the layout engine size the element via flex props and avoid the rect read entirely:
function MyBox() {
return (
<Box flexGrow={1}>
<Text>{/* fill content */}</Text>
</Box>
)
}Use width="100%", flexGrow={1}, or explicit width={N} props on the parent Box. Flexily resolves them; the renderer uses the resolved cell width. No useBoxRect() call needed.
"I was reading rect for hit-test / position registry" → use the deferred reactive form + useEffect
If you need a rect to register a position with a coordinator (mouse hit-test, scroll anchor, drag target), read the deferred (committed) rect via useBoxRect() / useScrollRect() and publish via useEffect. The committed rect advances at each event-batch commit boundary — idempotent across convergence passes, one frame late on mount:
const rect = useScrollRect()
useEffect(() => {
registry.register(myId, rect)
}, [myId, rect.x, rect.y, rect.width, rect.height])This is the canonical post-layout registration pattern under deferred semantics. The lint rule scripts/lint-layout-reads.ts accepts the zero-argument reactive form unconditionally — there's no stale-frame class to lint against.
"I genuinely need width during render (text wrap, image fit)" → annotate
A small set of components legitimately need a resolved cell dimension during render — they're producing content whose semantics depend on the cell count:
TextArearuns soft-wrap math inside React (wrappedLines depend onwrapWidth) before the layout engine ever sees the wrapped output.Imageencodes Kitty/Sixel escape sequences with explicit pixel/cell dimensions.Divider/ProgressBarrepeat fill characters across a resolved cell width to produce string content.
These are the (c) class. They keep useBoxRect() and tag the call with an explicit comment marker so the lint rule accepts them:
// LAYOUT_READ_AT_RENDER: parentWidth feeds soft-wrap math in useTextArea
const { width: parentWidth } = useBoxRect()The marker can sit on the same line as the call (trailing comment) or on the immediately preceding comment block — either form is honored by the lint.
If you reach for this annotation, document why the prop-driven path isn't sufficient. Every (c) caller is a missing layout primitive in disguise; the annotation is the audit trail.
Why this is the way
Stale-frame reads. The historical pattern — useBoxRect() returning the in-flight rect at render time — produced a first-frame zero read across conditional mounts and could form a feedback loop with the renderer's convergence loop when components branched on the rect. Both classes were fixed by switching to deferred (committed) semantics: useBoxRect() now returns the value as of the most recent event-batch commit boundary, invariant across every convergence pass within one batch. The first paint still shows the empty-rect fallback (one frame late on mount), and the measured value arrives on the next batch — the same number of paints as before, with the feedback loop eliminated by construction.
Phase separation. Treating geometry as an output of layout (rather than as state that components thread back into render) makes the data flow one-directional: render → layout → output. There's no feedback edge, no "rect-then-rerender" retry loop, no first-frame zero read.
Cross-target portability. The cursor, focus, and selection signals are semantic inputs (intent props on Box) → geometric outputs (rects on the layout signals). The same component code runs on:
- Terminal (ANSI escape sequences for cursor positioning)
- Canvas (drawing position computed from rects)
- DOM (CSS
caret-position,outline,::selection)
Without React-specific machinery in each consumer.
The lint rule
scripts/lint-layout-reads.ts warns on render-time reads of the layout hooks. It runs in warn-only mode today (exit 0 with a non-empty report); CI integration and a flip to --strict follows once the remaining (c) callers are documented.
Run it manually:
bun scripts/lint-layout-reads.ts # warn-only (exit 0)
bun scripts/lint-layout-reads.ts --strict # exit 1 on any violation
bun scripts/lint-layout-reads.ts --json # machine-readableWhat it flags:
useCursor(),useFocus(),useSelection()— replaced by Box props (cursorOffset,focused,selectionIntent) in Phases 2, 4a, 4b
What it skips:
useBoxRect()/useScrollRect()/useScreenRect()— under deferred semantics there's no stale-frame class to lint against; the reads are batch-invariant and feedback-loop-free- Hook implementations themselves (
packages/ag-react/src/hooks/) - Test files (
tests/**are exempt by convention — tests intentionally read snapshots to assert layout outputs) - Lines (or preceding comment blocks) tagged with the marker
LAYOUT_READ_AT_RENDER: <reason>
Cross-target story
The view-as-layout-output substrate is the foundation for silvery's multi-target ambition. Each of the six signals maps cleanly to a target-specific paint operation:
| Signal | Terminal | Canvas | DOM |
|---|---|---|---|
boxRect | cell coordinates | pixel rect | getBoundingClientRect() |
cursorRect | DECSCUSR + cursor position | drawn caret bitmap | CSS caret-color + <input> focus |
focusedNodeId | route input events | dispatch keyboard listener | DOM focus / blur / tabindex |
selectionFragments | inverse-paint cell ranges | filled rect overlays | ::selection pseudo + Range API |
decorationRects | overlay cell ranges | floating overlay rects | CSS anchor-positioned popovers |
A silvery component author writes the same <Box cursorOffset> / <Box focused> / <Box selectionIntent> code regardless of target. The target-specific output phase is the only thing that changes.
See also
packages/ag/src/layout-signals.ts— where the layout-output signals are defined and where post-layout consumers subscribe.docs/guide/cursor-api.md— the cursor signal, declared via thecursorOffsetBox prop (Phase 2 of the migration).docs/guide/layout-engine.md— Flexily's layout pass, which produces the geometry that the output phase consumes.