Skip to content

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:

PhaseConsumesProduces
Render (React)props, statesemantic tree (Box / Text / signals)
Layout (Flexily)semantic treegeometry (rects, cursor, focus, scroll)
Output (ANSI / DOM)geometrypainted 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:

SignalDeclared viaConsumed 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:

tsx
<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:

tsx
<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:

tsx
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:

tsx
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:

tsx
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:

  • TextArea runs soft-wrap math inside React (wrappedLines depend on wrapWidth) before the layout engine ever sees the wrapped output.
  • Image encodes Kitty/Sixel escape sequences with explicit pixel/cell dimensions.
  • Divider / ProgressBar repeat 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:

tsx
// 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:

bash
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-readable

What 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:

SignalTerminalCanvasDOM
boxRectcell coordinatespixel rectgetBoundingClientRect()
cursorRectDECSCUSR + cursor positiondrawn caret bitmapCSS caret-color + <input> focus
focusedNodeIdroute input eventsdispatch keyboard listenerDOM focus / blur / tabindex
selectionFragmentsinverse-paint cell rangesfilled rect overlays::selection pseudo + Range API
decorationRectsoverlay cell rangesfloating overlay rectsCSS 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 the cursorOffset Box prop (Phase 2 of the migration).
  • docs/guide/layout-engine.mdFlexily's layout pass, which produces the geometry that the output phase consumes.