Strata approved layouts
The product app’s macro layout doctrine lives in
apps/web/docs/AGENT_APP_UI_CONVENTIONS.md(chat ↔ canvas ↔ sheets — read it for anything touching the chat surface). This doc is the index of approved page-level shapes; if the shape you need isn’t here, propose a new one — don’t improvise a bespoke layout.
The surface model
The product surface is chat + canvas + sheets. Chat is text and buttons only; everything else renders on the canvas as sheets. The chat column is the bounded resizable element (min 280, max 816); the canvas takes the rest.
Below 800px the two stop being side by side. The rail hides, the panes become exclusive, and navigation moves into a full-screen takeover — one breakpoint,
--breakpoint-shell, and one pair of Tailwind variants (shell:/max-shell:). Everything on this page still describes the shape;responsive.mddescribes what that shape becomes, what already adapts for you, and what a new surface owes.
- Everything right of the chat rides a CanvasSheet — bare canvas content is never legal (founder ruling 2026-07-31). The canvas is a stage for sheets, not a place to mount panels, headings, or dashboards directly. A right-hand pane of raw content is the tell of a surface built outside the system.
- No sheet open (or none exists) → the chat fills the full width.
/chatsis the reference: the canvas companion appears only when a sheet opens, and the chat reclaims the space when it closes. A new surface that pins an always-on bare right panel is wrong twice — unsheeted content AND a chat that can never go full-width. - Known migration under this ruling:
ElleBlock’s standalonecanvasprop still documents a sanctioned “populated dashboard at rest” (onboarding’s bento). That allowance predates the ruling; new surfaces must not cite it.
The one chat surface — ElleBlock
Every place you talk to Elle is one block (ElleBlock, @layers/ui, the
first Strata block tier — see components.md): goal, onboarding, /chats, and
the area companions are the same surface, not four. It composes ChatPane + an
optional companion Canvas/CanvasSheet + HITL gate auto-open + resize +
working-state. What differs between callers is the engine driving the chat (a
deterministic Mastra workflow for goal/onboarding; the agentic loop for
chats/areas) — an engine fact, invisible to the surface.
Canvas ownership is a spectrum, not owned-vs-bare — the block picks its canvas target by which props the caller passes:
| # | Mode | Canvas target | Block config |
|---|---|---|---|
| 1 | Chat only | none | layout="hosted", no canvas |
| 2 | Chat opens a generated sheet | EXTERNAL (page host) | hosted + caller’s useElleSheetHost wiring |
| 3 | Chat navigates a route | none (router) | hosted; a nav-link entry |
| 4 | Generated sheet STACKED over a predetermined canvas | EXTERNAL, stacked | hosted + sheet host + stackedDepth |
| 5 | Deterministic workflow w/ predetermined artifact sheets | BLOCK-OWNED pane | layout="standalone" + canvas/sheet/gateArtifactCard |
standalone (goal, onboarding) owns the aside + width + companion pane;
hosted (chats, areas) renders ChatPane bare — the host owns width and the
canvas is external. HITL is one primitive with two flavors: deterministic
gates (gateArtifactCard auto-opens the artifact) and emergent approvals
(rendered in the footer). The block renders both.
Big UI and small UI — the first question
Before “which component”, answer this: how much of the user’s work happens here, and do they stay in it?
Big UI is a place you work. It holds a subject you read, browse, filter or edit — tabs, a toolbar, a table, a document, a builder. You stay in it, you may open more of it on top, and where you are belongs in the URL. Big UI stacks, and the surface beneath it recedes.
Small UI is one thing you finish and dismiss. A decision, a short form, a credential, a confirm. There is nothing to browse and nothing to come back to: you complete it or you cancel. Small UI blocks — it dims what is behind it, it does not stack, and nothing recedes for it.
The shortcut: stacked canvas sheets ARE our breadcrumbs. A conventional web app would render this trail as a breadcrumb bar and navigate the page; we reinvented that pattern as a stack, where each level stays mounted behind the next and receding is the visual trail. So the question “sheet or dialog?” is really: would this have been a breadcrumb? Somewhere you navigated INTO, that a user expects to back out of one level at a time, is a stacked sheet. And where a traditional UI would have opened a plain dialog — no breadcrumb, no trail, nothing to go back up to — we open a dialog too. That convention is unchanged; the stack is only what replaced the breadcrumb half of it.
The tell that a surface is mis-sized is a mismatch between the frame and its contents: a full-height sheet hosting one 400px card, or a modal you have to scroll to read a table in. Both were real (see the connections rebuild, 2026-08-01).
| Surface | Component | Use for | |
|---|---|---|---|
| Big | Canvas sheet | CanvasSheet | An artifact rendered full on the canvas. Stacks up to 3 deep with progressive recede; base sheet stays mounted. |
| Big | Inspector panel | WindowSheet | Right-anchored resizable inspector/editor (min 400px), alongside the thing it inspects. |
| Big | Fullscreen editor | FullscreenModal | The slideshow editor. NOT the creative builders — see the ruling below. |
| Small | Wall | Dialog | One decision or one short form: auth, a paywall, a credential, connecting a source. |
| Small | Confirm | AlertDialog | One yes/no, usually destructive. Three slots (header, optional AlertDialogBody, footer) and no X — see rules.md § Surfaces. A surface with FIELDS is a Dialog, not this. |
Stock shadcn Sheet is never used directly.
CanvasSheet — the law
- Overlays in a product area are stacked CanvasSheets — when they are big
UI. A detail view, an inspector, a second document: sibling
CanvasSheetstacked above the base sheet, never a nested child.stackedDepthon the sheet below controls the recede. A small-UI overlay in the same area is a Dialog, and that is not an exception to this rule — it is the rule’s other half. The question is always the size of the job, never which area you happen to be in. - Connections are the worked example (founder ruling 2026-08-01). Every
connect/manage surface is a
ConnectionPanel, which is a Dialog, because connecting a source is small UI: you paste a link or authorize an account, you confirm, you are done. Nothing there is browsed. The arrangement it replaced is the clearest illustration of mis-sizing in the repo. The directory opened a full-height CanvasSheet to host a 400px card, while the ad flows and the paste-a-key flows had drifted into dialogs anyway — so the product answered “big or small?” differently depending on which provider you clicked. The layout then had to run arithmetic (connectionSheetDepth) over which of seventeen providers happened to render a sheet, so the base sheet would not recede behind a modal. Sizing every connection the same way deleted that function and both of its exception lists. The counter-example, so this is not cited as a licence: the accounts table beside it stays a CanvasSheet. It is browsed, filtered and scoped, with per-account settings — big UI, in the same area, one tab away. - ONLY a stacked CanvasSheet recedes the sheet beneath it. A dialog never
does (founder ruling 2026-07-31).
stackedDepthcounts sheets, and nothing else: not aDialog, not anAlertDialog, not a popover. A modal already dims what is behind it, so receding as well gives one overlay two competing depth cues — the base sheet slides back and greys out behind a scrim that was already doing that job. The bug this rule exists to stop is counting a URL param instead of a surface: a layout computingstackedDepth={openParam ? 1 : 0}is asserting that every flow that param opens is a sheet. The moment one of them is a dialog — a paste-a-key flow reused from elsewhere, a “you need X first” wall — the base recedes behind a modal. The reference used to beconnectionSheetDepth, a function whose whole job was resolving which connection flows were sheets and which were dialogs. It is gone: the fix was not a better predicate but making the surface uniform (see the connections exception above), and a param that can open either kind of surface is now a smell rather than a case to be handled. Where one still exists, the predicate lives next to the dispatcher that renders them, never re-derived in the layout. - The creative builders are stacked sheets, not a fullscreen takeover
(founder ruling 2026-08-21). Reaction + Demo, Hook Slideshow, Remix from Social
and the static-ad generator each open as a
CanvasSheeton the area page that launched them, carried by abuildsearch param on that area’s LAYOUT route (?build=social-remixon Social,?build=static-adon Ads). They were aFullscreenModalbehind their own project-level/create/<mode>routes: a surface portalled to<body>that replaced the app while you used it, so the page you were working from vanished, the chat column went with it, and closing had to pop browser history to guess its way back. A builder is a plain reading of the big-UI test above — you navigate into it, you may open more on top of it (each customization is a WindowSheet), and you expect to back out one level. That is a breadcrumb, so it is a stack. Closing now drops the param and nothing else: the page underneath never unmounted, so the user lands on the exact filters, scroll and selection they left. The param lives on the layout route rather than the section routes because the sheet does, and that is also what keeps a builder open across a section tab change. The param is still a linkable destination — that was the whole point of the routes it replaces, and it survives: the route catalog describes the param, Elle’snavigateToreaches it, and the Create Content menu’s rows are real<a href>links. A builder needing its own route to be addressable was never true. - Headers are the standard sub-components, never hand-rolled JSX:
CanvasSheetChrome— sticky header for tabbed/toolbar sheets (NavTabs+ optionalCanvasSheetToolbar).- A subpage’s toolbar mounts in
CanvasSheetToolbarinside the route layout’s chrome — never as page content inCanvasSheetBody. A toolbar row in the body double-stacks chrome + body spacing (the visible tell: too much air under the NavTabs) and scrolls away instead of sticking. Toolbar ↔ page state travels through URL search params or a small zustand store (use-schedule-store,use-keywords-store), never props across the Outlet. CanvasSheetHeader— eyebrow + title + actions for chrome-less sheets. Every sheet carries at least this orCanvasSheetChrome(founder ruling 2026-08-06): no anonymous sheets. Titles are Title Case; descriptions are optional and default to absent.- The tabless page-title row (founder convention 2026-08-09). A page
with no NavTabs titles itself with
CanvasSheetHeader: the page NAME astitle— bare, no eyebrow on a BASE sheet (the eyebrow names the parent surface a STACKED sheet belongs to, so a base sheet has nothing to put there) — and its controls right inactions, education first (EducationButton, the shared carrier) then filters. The close affordance stays the sheet’s own and appears only when stacked. The divider under the row is the component’s (canvas-sheet-header’s border), never hand-drawn. The seed specimen — the Loops base sheet,Loops · [What are Loops] [All Accounts]— has since been scrapped with the rest of that page; the convention stands on its own. This supersedes any earlier per-surface “headerless base sheet” call: headerless was only ever defensible while the header had nothing to carry. - Vertical rhythm between chrome, toolbar, and
CanvasSheetBodyis automatic — never add manualpt-*/pb-*/gap-*between them.
- The sheet owns its content padding on all four sides; call sites never
hand-pad.
CanvasSheetBodysupplies the 24px top,canvas-sheet-paddedthe 40px sides, and the scroll region supplies the bottom — 40px, mirroring the sides (founder ruling 2026-07-31). The top is deliberately the odd one out at 24px because it lands under the header chrome, which carries its own weight; the bottom has nothing below it, so it reads as the same margin as the left and right. When afooteris passed that bottom reserve becomes the footer’s height plus that same 40px (112px = a 72px house footer + 40px), because the footer is out of flow and content scrolls under its blur. Bottom clearance lives on the scroll region rather than onCanvasSheetBodybecause not every sheet uses the body — one that passes raw children still gets it. A page that must FILL the sheet rather than scroll it opts in with<CanvasSheetBody fill>, which addsmin-h-0so the body has a definite height and anh-full/flex-1child (a<DataTable fill>, the events two-pane, the schedule calendar) resolves against the space the sheet actually has, after its own chrome and its own 40px bottom padding. Any chrome height, any viewport, always 40px. It is opt-IN because the default must staymin-height: auto— that is what lets a tall document grow and scroll the sheet, and a body that could shrink below its content would strand those pages unscrollable. Apb-*on a page inside a sheet is always wrong (founder ruling 2026-07-31). Before the shell owned this, footerless sheets ended flush against the sheet’s bottom edge and the handful of pages that looked right were right by accident — a call-sitepb-8here, a grid’s own padding there. That is the signature of an unowned value: correct where somebody remembered, broken everywhere else, and impossible to fix in one place. If content crowds the footer, the reserve is miscalibrated for a taller-than-house footer: fix the constant incanvas-sheet.tsxso every sheet benefits, never addpb-*at one call site. - Which sheet is open lives in URL search params or layout state, not in the page component.
- No close behavior destroys underlying state — dismissing a top sheet lands exactly where the user left the sheet below.
WindowSheet — the composition
Right-anchored resizable inspector on Base UI Dialog (drag the left edge;
defaultWidth/minWidth/maxWidth on WindowSheetContent, max defaults to
90% of viewport).
WindowSheetHeaderchildren are the LEFT side (tabs or title); theactionsprop is the RIGHT side (icon-only buttons). Header and footer are both 64px min-height, content centered.WindowSheetBodyis the scroll region — always use it, never a hand-rolledflex-1 overflow-y-auto.WindowSheetSectionprovides the padding;variant="secondary"tints a region. Forms inside are bare<form>+FieldSet.WindowSheetTitleis required by the a11y contract —className="sr-only"when the header shows tabs instead of a visible title.
Area pages (product app)
-
Area navigation is
NavTabsinsideCanvasSheetChrome; filters are toolbar controls bound to URL search params (shareable state), allsize="sm"(32px). Filter selects are text label + chevron only (no icons insideSelectTrigger); view toggles areTabsList size="sm"with text labels when an icon alone would be ambiguous. -
Toolbars fill from the left — negative space lives on the right, matching the NavTabs above (2026-07-21 ruling, see
rules.md). Anchoring a control right is the deliberate option for balance/separation: the accounts scope filter, a primary action. -
DataTableis the one table. Every tabular surface uses it (sticky header, mono uppercase<th>, keyset-paginated data underneath — list APIs are cursor-based, never offset). No hand-rolled<table>, ever (founder ruling 2026-07-31) — not “for now”, not “just six columns”. A hand-rolled table re-implements sorting, skeletons, the sticky header, the empty row and the scroll region, and it gets them wrong in ways nobody notices for months: the accounts table shipped a cell with no matching<th>/<col>, so every header sat one column left and a phantom column opened down the right-hand side. -
Anything rendered BELOW or ABOVE a
max-heightscroll region, inside the same budget, must be subtracted from that region’s max-height, and the amount must be MEASURED, never guessed (founder ruling 2026-07-31).maxHeightis a budget against the viewport; a footer outside the scroll box spends budget the box has already claimed, so the box grows to its full height and pushes the footer flush against the bottom edge of whatever contains them. A row ABOVE the table spends it the same way, and the tell is different but worse: the sheet scrolls behind the table’s own scroll.useMeasuredHeight(@layers/ui/hooks/use-measured-height) is the one implementation —DataTableuses it for its filtered-rows note, which is its only remaining consumer. Where the region can FLEX instead, flex:AdsCampaignsManagerdeleted its measured bulk-selection reserve for a chain (shrink-0bar,min-h-0 flex-1table region,<DataTable fill>), and measuring nothing beats measuring correctly. Reach for the hook only when the block genuinely shares onemax-heightbudget with the scroll region. Two hardcoded reserves were tried first (48px, then 80px) and both were wrong, because the height depends on font scale, wrapping, the caller’s own padding, and whether a conditional row rendered at all. A magic constant here is a bug with a delay on it. A gap between the measured block and the table belongs INSIDE the measured block (its ownpb-*), not on a parent’sspace-y-*— a gap outside the measurement is just a smaller hardcoded reserve. -
A
DataTable’s container never scrolls — the table is the one scroll region, whole and visible (founder ruling 2026-07-31). The table’s bottom edge, plus reasonable clearance, sits inside the visible surface; a table taller than its container produces a container scroll stacked on the table scroll, which is always a bug. The corollary:maxHeightmust be budgeted against the surface the table actually lives on, not reflexively against the viewport. On a CanvasSheet page nobody computes a budget at all: the page renders in<CanvasSheetBody fill>and the table passesfill, somin-h-0gives the body a definite height and the table caps at what the sheet has left after its own chrome and its own 40px bottom padding — exact at any chrome height and any viewport. A viewport calc can never be exact here, because the constant has to assume a chrome height: reserving for the tallest chrome left 88px of dead space under the events two-pane (56px chrome) and 56px under a toolbar page’s table (112px chrome), against the 40px the sides use.fillis a CAP, not a stretch — a four-row table stays four rows tall. Inside a Dialog (the org-settings sections are the reference), the dialog body has its own height, so the section fills it with a flex chain — rootflex h-full min-h-0 flex-col, chrome rowsshrink-0, the table regionmin-h-0 flex-1— and the table takesmaxHeight="100%"; it composes with the measured filtered-note reserve, which becomescalc(100% - notepx). A table sitting BELOW real content inside the same scrolling container caps to the CONTAINER’s visible height, never to the flex remainder (founder ruling 2026-07-31). Filling the remainder divides what is left after the content above, which squeezed the wallet’s transaction history to 60px with a void beneath it.useScrollAreaHeight(@layers/ui/hooks/use-scroll-area-height) measures the nearest scrollable ancestor’s inner height, minus anything sticky pinned over it, and is the one implementation — the wallet section is the reference: its balance and auto-reload cards scroll away and the table fills the view. ADataTablewith no cap at all is the same bug earlier in its life: the wrap never scrolls, so the sticky header is dead and the page scrolls instead — every table gets a cap unless it is bounded-length by construction (the UGC top-5 summaries). -
Grids use the shared grid classes — never a new one-off grid:
Class Lives in Min column Context content-gridpackages/ui/src/css/canvas-sheet.css240px Generations page, content picker sheet library-gridweb-v2 client/css/influencer-card.css272px Library pages (influencers, media, products) builder-grid/builder-grid-smweb-v2 client/css/creative-builder.css240px / 180px Builder detail views inside the builder’s stacked sheet
Sheet footers
At most two actions: the primary (does the thing the sheet is about) and Discuss with Elle (always secondary, right-aligned, mono uppercase subtle style — closes the sheet and drops the artifact into the composer as context).
Marketing pages
Page shells and section building blocks are listed in brand.md — content
pages compose from ContentLayout + the section kit, never bespoke section
scaffolding.