Strata usage rules — do / don’t

Founder rulings, distilled. These override personal taste and any generic convention. When a rule here conflicts with something you’d normally do, the rule wins. Read this before ANY visual or copy work.

Spacing & sizing

  • 8px grid. Spacing values are multiples of 8; 4px is the acceptable fallback. 6px (or any off-grid value) is never acceptable.
  • Don’t invent arbitrary CSS values (heights, offsets, spacing). If the design doesn’t specify one, ask.
  • Toolbar controls are uniformly 32px: size="sm" on every control inside a toolbar.
  • Toolbars fill from the left; negative space lives on the right (2026-07-21). Controls pack left-to-right, matching the NavTabs above them — a lone filter is left-aligned, never justify-end into an awkward left gap. Filling the right side is the deliberate option, not the default: anchor a control right (via justify-between) for visual balance or to separate it from the left group when the separation means something — the accounts scope filter, a primary action like Create Content.
  • A toolbar is exactly ONE line. Never wrap (2026-07-31). Controls do not flow onto a second row at any width — a wrapped toolbar double-stacks the chrome’s rhythm and pushes the content down unpredictably. If the controls cannot fit, the fix is fewer controls, narrower ones, or an overflow menu; it is never a second row.
  • Two or more items split with justify-between (2026-07-31). A toolbar holding more than one control puts them in a single flex items-center justify-between gap-2 row and lets the space fall in the middle, rather than packing everything left and leaving one ragged gap at the end. Which control earns the right side is judgment, and the useful question is what the control ACTS ON: something that refines what is already on screen (a search box, a scope tab set) reads as part of the content and sits left; something that narrows the whole surface or starts a new thing (a status filter, Add accounts, Create content) sits right. Worked examples: Accounts is scope tabs left / Add accounts right; Connections is search left / status filter right.
  • Popups anchor to their trigger’s side (2026-07-21, and it is the toolbar split above that decides the side). Popovers, dropdown menus, and select popups align to the side of the screen their trigger sits on: a left-side control opens align="start" (popup grows rightward), a right-side control opens align="end" (popup grows leftward). A popup must never hang off its control toward the empty side. Alignment is the CALL SITE’s responsibility — a shared control that can sit on either side (the accounts filter) exposes align instead of hardcoding one. A right-anchored select whose content forgets align="end" is the most common miss.

Typography

  • The Strata type scale is dense (text-base = 14px — see tokens.md). Never assume Tailwind’s defaults.
  • Mono (Berkeley Mono) only on ALL-CAPS text — section labels, table headers, eyebrows. Never on mixed-case values, IDs, emails, or URLs. Exception (2026-07-08): code IS mono — pre/code blocks, SDK snippets, secrets, keyword/code chips.
  • The mono label recipe: font-mono text-xxs font-medium text-muted-foreground uppercase tracking-widest — for column headers, calendar day names, section markers. Never for data values or body text.
  • No letter-spacing on body text. tracking-tight belongs on display headings only.
  • Education and help content is reading content, in a card (founder rulings 2026-08-02, from the library education round and the connections pass). A block that explains or teaches — “what this powers”, “how this one works”, a why-connect list — renders at text-base with full-contrast ink and card containment, never as a loose text-sm text-muted-foreground paragraph. The carriers are PowerList (enumerable outcomes) and EducationCard (prose), which own the treatment so call sites cannot drift. Form-adjacent helpers (FieldDescription, an error line under an input) are not education and stay as they are.
  • Section labels sit flush on the grid, at content height (2026-08-02). SectionLabel carries no horizontal padding and no fixed height: the marker starts on the same left edge as the content it names, and the PARENT owns the label-to-content gap (gap-2). Its old px-2 indented every header 8px off the grid, and its old h-10 — a relic of the #2126 prototype’s 40px row grid — centered the text in 40px of slack that read as uneven padding wherever the label led a card. Call sites were fighting both defaults with px-0 and h-auto overrides.
  • An education card matches its siblings’ padding (2026-08-02). The PowerList/EducationCard card is a DEFAULT-size Card, because it sits beside PropertyList cards and DestructiveActionCards in the same panel — a smaller padding there reads as a broken grid, not a hierarchy.
  • A focused text control is never under 16px on a touch surface (2026-08-19, superseding the marketing-only carve-out below). Mobile Safari zooms the page whenever a focused input computes under 16px and does not zoom back out. apps/web enforces it in ONE unlayered rule (client/css/mobile-shell.css) over input/textarea/select/ contenteditable, under (pointer: coarse), (max-width: 799.98px) — so every field the app grows later is covered without anyone knowing the constraint exists. It is a floor for TOUCH, not a new type scale: above the breakpoint on a mouse the app is unchanged, and 14px is still the input size there. The trap it fixes is worth knowing, because it will recur. Input and Textarea carry stock shadcn’s text-base md:text-sm, which IS this mitigation on Tailwind’s default scale (16px → 14px). Strata’s scale is dense — text-base is 14px, text-sm is 12px — so the same classes render 14px → 12px and the guard silently never applied. Any stock shadcn sizing that encodes a browser threshold has to be re-checked against tokens.md, not trusted. 16px stays a LITERAL rather than text-lg: it is a browser constant, and a scale token that happens to equal it today must not carry it. The marketing hero composer keeps its own literal text-[16px], and surface-terminal/chat-composer their pointer-coarse:text-[16px] — that app owns its own base styles.

Buttons & icons

  • Icons on buttons are the exception, not the default (refined 2026-07-08; the rule exists to prevent icon slop). Allowed: CREATE buttons (Plus on “Create campaign” / “Add creative”), route/navigation buttons, and primary-action-dispatch buttons — OAuth provider buttons (Google/GitHub logo + name) are explicitly sanctioned. Everything else is text-only, or icon-only with size="icon" + aria-label. (The chat view-link’s sheet glyph remains a deliberate exception — see AGENT_APP_UI_CONVENTIONS.md.)
  • Pending buttons use Button’s loading prop (2026-07-08): the spinner replaces the label, absolutely centered, while the invisible label holds the button’s exact size; the button disables for the duration. Never a spinner beside “Saving…” text. Never a swapped label either (2026-08-02). {m.isPending ? 'Saving…' : 'Save'} is worse than the banned example: there is no spinner at all, and the button RESIZES mid-request because “Creating…” is not the width of “Create Organization”. Six shipped before button-pending-uses-loading existed, every one written by someone who knew the button needed a pending state and reached for the nearest thing that expressed it — which is the argument for the gate rather than the ruling.
    • loading disables the button for you; a separate disabled={m.isPending} is redundant. Keep disabled only for a DIFFERENT condition (an invalid form).
    • EmptyState’s action takes loading too. It renders a real Button, and before it exposed the prop a caller whose action mutated had no way to reach the pattern and swapped the label instead.
    • The dialog stays open until the mutation settles. A confirmation that closes on click reports success before the server was asked; DestructiveActionCard refuses to close while pending and disables its Cancel, and any hand-built confirm owes the same.
  • A CardAction beside a CardTitle is size="xs" (h-6), not sm (founder ruling 2026-08-17, recorded here 2026-08-21 after it was missed a second time). A header action sits against text-base body copy in a bounded box, and a 32px control there competes with the content it belongs to. sm (32px) is the TOOLBAR size; toolbars are chrome, cards are content, and the two are not the same budget. Reference: the builders’ “Change Hook” / “Change Inspiration Source” in the generation plan.
    • THE HEADER IS THE WHOLE SCOPE (founder clarification 2026-08-29). This rule does not reach the card BODY or the card FOOTER, and it used to read as though it did (“an action inside a card”, “a dense in-card action row”) — which is how the generation plan’s customization rows ended up with xs buttons beside a Select, two controls doing the same job at two heights down one card. A control in the body takes whatever lines up with the controls around it; Select has no xs, so those rows are all default 36px. Footers have their own sizes — see the modal-footer bullet below. Reference: CustomizationRow.
  • Modal footer buttons are size="lg" (40px) — Dialog, AlertDialog, and WindowSheet footers (2026-07-08 ruling; xl/48px is too large, default/ 36px too small). AlertDialogAction/AlertDialogCancel default to lg in the wrapper; plain Dialog and WindowSheet footers set size="lg" at the call site. xl stays legitimate for full-width auth/onboarding CTAs — the rule is footers only.
  • Footer buttons right-align and a Cancel/dismiss is always present (2026-07-08): Cancel (or Back) is the outline button, first in the footer, primary action last/bottom-right — Dialog, AlertDialog, and WindowSheet alike. Narrow exceptions: chrome-less preview shells (no footer) and single-acknowledgement dialogs whose lone button already dismisses (“Done”, “I’ve saved it”). Never a footer whose only action commits with no way out.
  • CanvasSheet footers align LEFT, primary first (2026-07-31) — the one surface that inverts the rule above, and the reason is width. The right-align ruling covers Dialog, AlertDialog and WindowSheet: narrow surfaces where the content spans the full width, so the bottom-right corner IS the end of the form. A CanvasSheet is ~900px wide with its content in a max-w-2xl column on the LEFT, so anchoring the actions right strands them across a field of empty space, disconnected from the form they submit and away from the direction the eye reads. The principle both halves share: actions align with the column they act on. Primary takes the position the eye reaches first — leftmost here, bottom-right in a modal — and the Cancel requirement binds either way. This was already the house convention in four of five CanvasSheet footers before it was written down; the fifth was drift. CanvasSheetFooter is the carrier — the standard sub-component for the footer slot, never hand-rolled JSX (the same way headers are CanvasSheetHeader/CanvasSheetChrome, see layouts.md). It owns the row recipe; call sites only supply children in visual order. The creative builders are a deliberate exception (founder ruling 2026-08-21): their plan footer puts the Social Account select on the LEFT and Start Generation on the RIGHT, inverting primary-first. That footer is not a pure action row — it carries the last unset FIELD beside the commit, and reading the field before the button you press with it is the stronger order. Do not “fix” those three back; the rule above still governs every footer that is only actions. The dismiss on a canvas sheet reads CLOSE, never Cancel (2026-08-06). A sheet is a place you leave, and several auto-open (an account detail after a connect): “Cancel” beside a disabled Save implies aborting edits that were never made. Save stays disabled until the form is dirty — that disability is the affordance that says an edit now needs persisting — and Close is the honest way out either way. Dialogs keep “Cancel” (the 2026-07-08 ruling above): a dialog is an interruption you decline, a sheet is a room you exit.
  • Save closes the sheet. So does Close. A canvas sheet has exactly two ways out (2026-08-06) — one commits and one discards, and neither leaves the user sitting in a form they have already finished with. The account settings sheet saved, toasted, and stayed open, so the only way to learn nothing more was expected of you was to dismiss a second time — reading as though the save had not taken. Reset the form BEFORE closing (it clears the dirty state, so nothing downstream mistakes a closing sheet for one abandoning edits), and let the success toast carry the confirmation the sheet used to imply by lingering. A failed save is the exception that proves it: the sheet STAYS open, because the edits still need somewhere to live. This is a sheet rule, not a form rule — an inline settings card on a page has nothing to close, and a dialog keeps its own Cancel/confirm contract (above). Amendment — per-SECTION commit sheets (founder ruling 2026-08-16): a sheet whose left column is a sequence of independently-committing sections (the container Post Detail is the reference: Approval, Caption, one Post to {Platform} per platform) has NO sheet-level Save and NOTHING in it closes the sheet — each section carries its own commit, reveals its Save/Cancel only while dirty, and refreshes in place so the user sees the updated state of the section they acted on. Sections never disappear across states; only their content changes. The two-ways-out rule above still governs sheets whose whole body is ONE form; which contract a new sheet takes is decided by whether its content is one commitment or several independent ones.
  • Every CanvasSheet names itself: at least a header or nav tabs, always (2026-08-06) — a sheet carries CanvasSheetChrome (tabbed sheets) or a CanvasSheetHeader title in the header slot (everything else). A sheet whose body opens straight into content is anonymous chrome: the brand folder sheets shipped that way and no two surfaces agreed on where a title belonged, so some hand-rolled an in-body <h2> intro block and most had nothing. Titles are Title Case (“Brand Brief”, “End Cards”). Descriptions under the title are OPTIONAL and default to absent — a sheet needs a name, not a paragraph; explanation belongs to the education pattern, not the header. The in-body intro block (OnboardingSheetIntro) is deleted; do not reintroduce one.
  • Platform/brand icons come from PlatformIcon, never Lucide.
  • The logo always sits inside its bounded squircle box (BrandLogo / .logo-wrap) — never a bare mark.
  • A card’s buttons live in the CardAction slot beside the title (founder ruling 2026-08-29, superseding the 2026-07-09 CardFooter rule). CardAction inside CardHeader, size="xs" per the header-action sizing rule above; several buttons wrap in one right-justified flex inside the slot. Never a hand-rolled flex justify-end action row inside CardContent, and never a CardFooter button row. The one inline exception survives: a single value paired with its action on one justify-between row (a balance beside its “Add funds” button). Amended 2026-09-27: the Save and Discard for an inline edit are a SaveBar at the bottom right of the edit, shown only while dirty. See Drill-in pages below.
  • A CardTitle never carries an icon (founder ruling 2026-08-29 — “clear AI slop”). “Members” beside a person glyph says the word twice; the word is enough, exactly as StatCard labels lost their leading icons on 2026-08-05. The icon medallion in an EmptyState is unaffected — that is the empty state’s anchor, not a title decoration.
  • variant="destructive" is for actions that TEAR SOMETHING DOWN (2026-08-02, superseding the 2026-07-09 ruling). Read the verb. Connect, Add and Save build, and take a normal button. Disconnect, Remove, Delete and Revoke tear down, and take red.
    • Reversibility is not the test. The earlier ruling sorted on “routine and reversible” and it does not survive contact with the surfaces we have: a kill switch is completely reversible and obviously destructive, and deleting a project is reversible for a week and obviously destructive. Users do not run that calculus. They read the verb.
    • A destructive action always carries a confirmation, and it belongs in DestructiveActionCard, which owns both. A destructive action with no confirmation is the thing that component exists to prevent.
    • The CONFIRMATION inherits the verb (2026-08-02). A destructive action’s AlertDialogAction takes variant="destructive" too. Four teardown confirms in ads shipped a black button — “Delete campaign on Meta? This is permanent and can’t be undone.” with a default-variant Delete — because the ruling stopped at the card and nothing covered the dialog. The confirmation is the LAST thing shown before the irreversible act, so it is the worst place for the colour to say routine. confirm-action-matches-verb gates it.
      • A button whose label TOGGLES (Archive/Unarchive, Pause/Resume) takes a conditional variant, never a fixed red: painting the recovery half as the danger is its own lie. ads-settings.tsx is the reference — variant={isResume ? 'default' : 'destructive'}.
    • This rule is about CARD-LEVEL actions only (founder ruling 2026-08-02). A row-level or sheet-level icon action — the trash icon on a library row, remove-a-file in an upload list, remove-a-slide in the slideshow editor — is out of scope and stays as it is. A card cannot wrap a table row, and painting every trash icon in a dense list red spends the alarm on the most routine gesture in the app. Eight such buttons were inventoried and deliberately left alone.
    • This changed eleven card-level actions: six former DangerZone consumers, the ads Disconnect, the social-accounts Disconnect, delete-project, and the account phone Remove. Five of them gained a confirmation they never had.
  • Alert variants say what HAPPENED, not how loud you want to be (2026-08-02). Alert gained warning and success; it had only default and destructive for years, and that gap is the whole reason the app was full of miscoloured and hand-rolled notices.
    • destructive — something failed, or is about to be destroyed.
    • warning — needs attention; nothing is broken.
    • success — a confirmed outcome.
    • default — neutral context.
    • The loop test for success: does the message close the loop or continue it? Success closes it. If the sentence ends by telling the user what to do next, it is default. “Conversion tracking is set up” closes. “Already authorized with Apple Search Ads. Pick the app to run on.” does not — it reports pre-existing state AND hands you a next step, so it stays neutral.
    • Red for a heads-up is how red stops meaning failure. Three notices were destructive purely because warning did not exist: the ads mixed-permissions notice, the emergency-stop banner, and the partial-stop notice. None of them was a failure; two of them sat on screen permanently.
    • All three states share one model: a faint opaque state tint, the state ink, and a state-tinted hairline at 20% of the ink. Never hand-roll it — the boxes this replaced each chose their own tint strength (/5, /10), border weight (/20, /30, /40) and radius, so no two notices in the app agreed. alert-is-a-component catches new ones.

Surfaces & frames

  • Prose never sits directly on a canvas surface (founder ruling 2026-08-06, from the Loops bento review). A sentence, a stat footnote, a description — every prose block lives inside a containing element: a card, a table, a header, an alert, an empty state. The Loops stat row grew a bare <p> footnote (“780 earlier posts in the window carry no hook on record”) and the ruling deleted it rather than restyled it: a stray fact with no container that owns it is usually a fact that does not belong on the surface at all. If it must be said, an Alert (state facts), a card slot, or a table’s own note slots are the carriers. Mono section markers are NOT prose and are fine on the canvas (founder, same day): an ALL-CAPS SectionLabel-recipe header naming the content below it (“ASSETS”) is chrome, not a text block, and needs no card around it. The line is grammatical, same as Title Case vs sentence case: if it labels, it may stand alone; if it says something, it needs a container.

  • Band captions live OUTSIDE the frame: mono captions go mt-4 below a rounded band, never padded inside it (inside creates unbalanced negative space).

  • Squircles: authored ~2x radius + half-value @supports fallback; never inline-style the radius (see tokens.md).

  • Semantic color tokens only — never text-neutral-900-style raw utilities. Warning states use the --warning tokens (text-warning, bg-warning/10), never raw amber utilities (2026-07-08).

  • Cards are borderless (2026-07-08: maia’s ring explicitly rejected). Separation comes from the surface stack: a container that establishes the secondary plane declares data-surface="secondary", which supplies the fill and remaps --card to --background for its subtree. Never fix a card-on-secondary collision with a call-site background class or a descendant CSS override — see the Surfaces section of tokens.md.

  • Empty states carry no dashed border and no hand-rolled centering wrapper (2026-07-21). An empty state is alone in its space, so it needs no outline to declare its extent; the icon medallion (a bg-muted circle with the glyph in muted-foreground) is the anchor. EmptyState picks its own layout from variant: page fills and centers the CanvasSheet content area, section sizes to content where the rows would be, inline is a small slot in a dense composition. CanvasSheetBody is a flex column by default so page resolves — a call-site flex min-h-0 flex-1 items-center justify-center wrapper is now a bug, not a pattern. A filter-empty stays section; only “there is nothing at all” earns page.

  • EmptyState is also the honest carrier for a stated failure (amended 2026-07-21; supersedes “never paper over an error with an empty state”). The ban was always on the DISHONESTY, not the component: rendering “No posts yet” when the request failed is what’s forbidden. A failure that names itself is correct here, and gets the full treatment — icon={AlertCircle}, a title that says what broke (“Couldn’t load videos”), and a Retry action so the user has a way out. Retry is a normal button; destructive is for dangerous actions, never for recovery. Variant follows blast radius exactly like an empty: a failure that takes down the whole surface is page, one that only kills a card body or a table is section. Loading is never an EmptyState — a pending request renders Skeleton rows shaped like the content that’s coming.

  • In the canvas, z-10+ is chrome’s tier; scrollable body content uses z-[1] (2026-07-22). CanvasSheetChrome is sticky top-0 z-10, and it is a SIBLING of the page content inside the same canvas-sheet-content scroll container — so a body element that also grabs z-10 shares the chrome’s stacking context and, being later in DOM, paints OVER the chrome as it scrolls under it. Content that needs local layering (a card’s badge, a gradient scrim, a selection check, a sticky sub-header) uses z-[1] — it only ever needs to sit above its sibling media (z-auto), never above chrome. Reserve z-10 and up for chrome: the sticky header, the close affordance (z-20), true app chrome like the chat-rail handle. Reaching for z-10 on a card overlay is the bug.

  • A dialog’s body is a stack of CARDS, one per subject (founder ruling 2026-08-06). Every distinct thing a dialog lets you do gets its own Card + CardContent — the form in one, the teardown in another, the explanation in a third. One subject means one card, and that is the point rather than an edge case.

    The rule is a forcing function, not a look. A dialog starts life with a single job and bare fields read fine; then the next person adds a second job, and with no grouping mechanism already in place the two dissolve into one undifferentiated column. Shipping the first subject already carded leaves an obvious slot for the second, so growth arrives pre-sorted instead of needing a cleanup pass nobody schedules.

    It also settles the asymmetry that surfaced this ruling: ManageProjectDialog had loose fields above a DestructiveActionCard, which cannot give its card up. One chunk contained and one spilling reads as an unfinished screen, and the fix is never to strip the card off the teardown.

    Two things are NOT dialog content and stay uncarded: a confirmation (AlertDialog — one question, three slots, no body chunks) and a dialog whose entire body is a sentence of prose. Both have nothing to group.

    Not gate-enforceable: “how many subjects does this dialog have” is a judgement no matcher makes, and a rule counting Cards per DialogContent would fire on every confirmation. It is on the author and on review.

  • Only the CONTENT of a tall dialog scrolls (founder catch, 2026-08-02). A dialog whose body can outgrow the viewport takes scrollable on DialogContent and wraps its body in DialogBody, so the title and the footer’s actions stay put while the middle scrolls. Without it the POPUP is the scroll region — the safety net that keeps content reachable, but the title scrolls off the top and the footer sinks below the fold, which is how the library education dialog shipped with its 9:16 demo video. Media is the mechanical tell (dialog-media-needs-scrollable gates it): intrinsic height no copy edit will shrink. The sanctioned exceptions are pure media lightboxes whose media is viewport-capped (max-h-[calc(100dvh-…)]) — bounded by construction, the popup never overflows.

  • A confirmation has three slots and no X (founder ruling 2026-08-04, from a screenshot of the slideshow builder’s “Discard changes?”). An AlertDialogContent holds AlertDialogHeader, an optional AlertDialogBody, and AlertDialogFooter. Nothing else is a direct child.

    • The title asks the question, the description answers it. “Discard changes?” / “Going back drops every edit you have made to this slideshow.” Never a title that narrates the click (“Confirm to remove slide” — the phrasing four dialogs shipped).
    • AlertDialogDescription is text-base in full-strength popover ink, NOT the muted 12px a DialogDescription gets. The two differ because the surfaces differ: a Dialog description subtitles the content beneath it, and an AlertDialog has nothing beneath it, so that sentence IS the decision. Rendering the whole question as de-emphasised fine print is what every shipped confirmation was doing.
    • AlertDialogBody carries the specifics — the record being deleted, the reason it was flagged, the one field the confirm collects. It is a Card, so the block reads as its own plane inside the popup, exactly as blocks do inside a panel (the ruling below). Eight confirmations each invented their own instead: a bare <p className="p-6"> double-padding a popup that already owns p-6, so one sentence sat marooned in 48px of nothing; a rounded-md border bg-secondary p-3 well written three separate times with three different paddings; a <Field> dropped in raw.
    • Reaching for Dialog to get a close button is the bug. An AlertDialog offers exactly two exits, Cancel and the action, and that is the point of asking. Six confirmations were built as Dialogs and every one shipped an X. A surface with FIELDS is correctly a Dialog (delete-project types a name and ticks a box per running job) — that is the line, not how serious it feels.
    • DestructiveActionCard picks the shell from the gate, so no call site decides this: ungated it is an AlertDialog, and the moment a requireTypedConfirmation or an extraGate is present it is a Dialog, because the surface now carries fields. It was a Dialog either way until this ruling, which is how SEVEN of its eight call sites — every routine disconnect, revoke and remove in the app — shipped the X. One component carrying the whole family is why the founder kept finding the same dialog in different places.
    • alert-dialog-body-is-a-component, confirmation-is-an-alert-dialog and alert-dialog-description-is-not-fine-print gate all of it.
  • Inside a panel, every block is a card — and none of them is titled “Connected” (founder ruling 2026-08-04, from the connections dialogs). Two halves of one rule:

    • What you have is a card. The connection’s facts, the connect form, the account summary, the teardown: each is a Card, so a panel reads as a stack of one-idea blocks rather than cards interleaved with loose fields. Instagram’s 23 accounts were an avatar row and a bare button floating on the dialog background while every other provider’s connection sat in a card; the ads paste-key and store-link forms were the same drift.
    • A SectionLabel reading “Connected” is deleted, not restyled. The panel header already carries the status badge, and the card underneath states the thing itself, so the eyebrow was a third voice for one fact. The same logic kills the per-row Connected badge when a provider has exactly ONE connection, and a row title that only repeats the provider name. Keep an unhealthy badge always: “Sync failed” and “Key rejected” say something the header’s “Needs attention” cannot.

    The general form: before adding a label, check whether the surface already says it. A panel is ~600px wide, so a fact repeated twice is a fact repeated in the same glance.

  • A card divider is opt-in and semantic (2026-07-09). <Card divided> draws a full-bleed hairline between adjacent CardContent blocks — use it ONLY when the card holds genuinely distinct concerns (plan vs. credits, connect vs. connected list). When the second block is progressive disclosure or a breakdown OF the block above it (a revealed sub-form, a balance’s line items), leave it borderless — the card gap is the correct separation. Never hand-roll the line with a border-t utility or a loose Separator between content blocks; the prop owns it.

Metrics & numbers

Binding on every summary tile, table cell, chart label and inline figure — not just StatCard, which is merely where they were ruled.

  • A change is a percentage, never a percentage beside the count it came from (founder ruling 2026-08-05). The ads dashboard shipped 168 +1374.4% vs last 30d: the prior value, the change, and the window — three figures for one fact, in the narrowest column on the page. The percentage IS the comparison. The count it was computed from is the arithmetic, not the reading, and a reader who wants it wants a table. StatCard’s previousValue prop was deleted outright rather than deprecated, along with the thirty call sites that fed it.
  • Format a percent change to the precision it can carry (same ruling). toFixed(1) on everything produced “+1374.4%”, where the tenth of a percent is four significant figures of noise on a number whose leading digit is the whole story. The rule, implemented in StatCard’s formatDelta:
    • under 10%, one decimal — “+8.2%” and “+9.1%” are genuinely different readings;
    • 10% and over, round to the integer — “+37.4%” and “+37.0%” are the same reading;
    • 1000% and over, group the digits — “+1374%” is a number you have to count, “+1,374%” is one you can see.
  • Name a comparison window in days, not in calendar periods (same ruling). “vs last 30d”, never “vs previous period” or “vs last month”. It is shorter, and it is more truthful: the window a surface actually queried is a day count it already holds, while “last month” is a claim about a calendar the arithmetic never made. A window that cannot be stated honestly — an all-time range whose “previous period” predates the product — prints no comparison at all.
  • Metric copy is as short as it can be and still be true. These strings are read at a glance, eight at a time, in the narrowest column on the page. Anything that reads as a sentence is too long. This is the general form of the three rules above, and it is the one to apply when a fourth case appears.

Content grids

  • Variants are creatives, not a set (2026-07-27 founder ruling). Asking for N versions of one prompt produces N distinct creatives: they fan out in the grid as N cards and stay fanned out. No stack card, no ×N badge, no grouped inspector, and no shared identity behind them. The user’s mental model when they ask for ten variants is ten things, not one thing with ten faces — and it matches what the system does downstream (the ads placer builds one ad per ads_content row and never reads creative_set_id). This binds every surface that grows a variants feature, social content included, not just the ad library it was ruled on. A grouping key that nothing renders is how the concept comes back: don’t write one.

Interaction

  • A blocking dialog is an education surface, never an alert (2026-08-06). When a click is intercepted — a gate, a hold, a “not yet” — the dialog owes the user a reason to continue, not a restatement of the rule it just enforced. Its body is a PowerList of value props (each point iconed, one sentence each: what finishing buys them), never a bare DialogDescription carrying the instruction. Reference implementation: the onboarding board-hold dialog (onboarding-running.tsx) — unlock, signals, Elle meeting the app, free credits. The functional alert is the floor this ruling exists to raise: it is what a computer outputs, and it was shipped first the very day this rule was written.

  • No optimistic saves in FORM contexts. Form mutations get an explicit Save button (the loading prop); never silently persist form edits. Sanctioned non-form exceptions (2026-07-08): picker modals, and inline rename-in-place (thread titles commit on blur/Enter).

  • Scoping to an account is ONE account or all of them, never a set (2026-08-28). Every Social surface — Content, Schedule, Live, Comments, Research and the adapt builders’ gate — narrows through the same AccountScopeFilter: a Select whose value is either the All sentinel or a single account id, mutually exclusive by construction. The filter it replaced was a Popover + Command with a search box and checkbox multi-select on four of those surfaces; the founder’s word for it was “too advanced”. A set is a question none of these surfaces was asking, and the search box was a control inside a control for a list that is one account long in most projects (9 of 840 hold more than ten accounts). No “clear all”, no empty-search dead end, and one component so two surfaces cannot answer the same question differently. The stored params stayed comma lists (?accounts=, ?accountIds=, ?commentsAccounts=, Schedule’s localStorage) because pre-ruling two-account values are still out there and Social replays the last URL you had open — they are read through lib/account-scope, which honors the first id and nothing else.

  • Match the control to the intent; use the boring, conventional one (2026-07-09). Design is Strata’s job, not the developer’s — “do it the Strata way” means reaching for the most common control for what the data actually is, never getting clever. Over-designed forms read as unintuitive. The mapping is not a judgment call:

    • One of several options → RadioGroup (or the Choice Card variant — a RadioGroup of FieldLabel cards — when each option needs a description). Never a segmented ToggleGroup / toggle buttons to pick a form value; a toggle bar reads as a control strip, not a field.
    • Several options at once (multi-select) → Checkbox (or a Choice Card group with checkboxes). A lone boolean opt-in is a single Checkbox; Switch is only for a live-applied setting, not a Save-gated field.
    • Options are labeled with text, never icon-only. (Icons follow the Buttons & icons rules above.) When a form feels complex, the fix is almost always a plainer control and fewer of them — not a fancier one.
  • No silent fallbacks and no swallowed errors — degraded states are shown honestly (“data unavailable”), never faked.

  • A dead affordance is removed, not disabled (2026-07-23). A control whose action would only reproduce the current state doesn’t render: every New chat button (the chat column header’s, the /chats list header’s) hides while the open chat is still empty — pressing it would just mint another empty chat — and returns once there is a conversation to leave. Disabled is for “not yet valid” (a Save with unmet required fields), never for “already there”.

  • Chat header actions consolidate into the thread menu (2026-07-23, tightened 2026-08-07). The chat header is a title and TWO controls: the triple-dot menu, then pane chrome (hide chat) at the far corner. Everything else is a row inside that menu — New chat above Rename/Delete with no separator, and chat history as a FLYOUT (the settings rail’s submenu shape), never a standalone icon beside the trigger. A standalone New chat button appears only in the brief window before the thread’s list row lands (there is no menu to house it yet).

    The order is not decoration. Hide chat is the way OUT of the pane, so it holds the corner unconditionally — the menu steps aside during an inline rename, and a control that moves when its neighbour disappears is a control the user has to re-find. Icons multiplying to the left of it is the failure this ruling exists to stop: the cluster reached three (hide, history clock, New chat pencil) beside a static “Chat” title that named the panel rather than the conversation, so the header said nothing about where you were and Rename and Delete had no door at all.

    The title is the open chat’s NAME, falling back to “Chat” for one the agent has not named yet — never the agent’s name, which a first-time reader has no way to recognise.

  • A modal always dims what’s behind it, even when nested (2026-07-08: the backdrop directs focus to the topmost dialog). Base UI suppresses nested-dialog backdrops by default; every house overlay (dialog, alert-dialog, sheet, window-sheet) sets forceRender to restore it. A dialog that opens undimmed is a bug — it’s bypassing the house wrapper.

  • Popover/menu triggers never sit as the last child of a space-y-* (or divide-*) container. Tailwind v4 compiles those to :not(:last-child) margins, and Base UI mounts hidden focus-guard siblings after the trigger while its popup is open — the trigger stops being last-child and visibly shifts. Stacks that contain triggers use flex flex-col gap-*, which the out-of-flow guards can’t disturb.

Drill-in pages — the connection detail layout (founder ruling 2026-09-27)

Ruled on MMA’s connection pages (Connections › Meta Ads) and binding on any page you drill into from a list. Where these conflict with an older rule above, these win, and the older rule names the amendment.

  • The breadcrumb is the way back. A drill-in page carries a breadcrumb in its page header (@layers/ui/breadcrumb: Connections › Meta Ads). There is no Back button among the page’s actions.
  • Save and Discard sit at the bottom right of the thing being edited, and only while it has unsaved changes — SaveBar, with a short summary (“2 selected · 1 change”). Never a Save at the top of a card. This amends the CardAction ruling (2026-08-29) for EDIT COMMITS: a card’s own actions still go in CardAction, but the Save for an inline edit goes under the edit.
  • One place, one meaning. A button never changes its label or its job in place: no “Edit Campaigns” that becomes “Cancel / Save Campaigns”, no “Choose → Save → Browse”. A list that can be edited is always the editable picker (checkboxes), with no separate read and edit modes.
  • Rare actions live in the ··· menu in the page header (Change Account, Reauthorize, Replace Key). A different task is a link row in its own section (“Conversions → Set up conversion forwarding”), never the page’s dark primary.
  • Names, not ids twice; no jargon rows. The status shows the account’s name and its id once. A problem shows only when something is broken, with its fix beside it.
  • Settings apart from lists. A setting about the future (auto-include new campaigns, the matching rule) gets its own section below the list. A list’s filters (a scope toggle, search) sit on the table’s toolbar, never as labelled fields above it.
  • Below the shell breakpoint a picker table becomes a stacked list (founder QA 2026-09-27): each row is the checkbox, the name at full width, and one muted line (“Active · $181.77 · 96 installs · This app”). The table’s toolbar may take a second row there, with search at full width; this is the one exception to “a toolbar is exactly one line”.
  • One Danger Zone at the bottom — DestructiveActionGroup, a row per teardown, each with its own confirmation. Never two stacked red cards. This amends DestructiveActionCard’s “two actions, two cards”.

Forms — build from the intent, not the mockup

When a form is wrong — over-designed, off-convention, someone tried to be a designer — do NOT patch it detail by detail. Disregard the existing UI entirely, understand the intent of the data model, and re-implement from scratch on Strata. Over-design is the usual failure: making a thing fancy makes it unintuitive. The method:

  1. Read the data model, not the JSX. The Zod schema and types in @layers/shared-types are the source of truth — every field, its type, its enum, its optionality, its guardrails. The form’s only job is to express THAT. If the current UI invents concepts the model doesn’t have (modes, presets, reveals), they are probably the bug.
  2. Map each field to its conventional control by intent (see “Match the control to the intent” under Interaction): one-of-a-few → RadioGroup (the Choice Card variant when options need descriptions); one-of-many/long → Select; multi-select → Checkbox; a lone boolean → Checkbox (Switch only for a live-applied setting); free text → Input / Textarea. Never a segmented ToggleGroup for a form value; never a bespoke button dressed up as a control. Pick the boring one.
  3. Compose the standard way. Form + FormField for state/aria, the Field family for layout, a bare <form> with zero custom Tailwind, the submit button outside via form={id}. One explicit Save (loading, gated on formState.isDirty), toast + form.reset on success. No optimistic saves (see Interaction).
  4. Write signal-only copy (see Copy & voice): a card title only if it adds context, a description only if it says something the controls don’t, “agent” not “AI”. Cut the rest.
  5. Match the surface: card actions in CardFooter, destructive only where it’s earned, borderless cards, the divided prop (not a border-t) for genuinely distinct sections.

If the result still feels complex, the data model is being over-exposed (hide advanced guardrails behind a sane default) or the controls are too fancy. A plainer control and fewer of them is the fix — never a fancier one.

Charts

  • Build new charts on the chart layer (@layers/ui/chart — ChartContainer/Tooltip/Legend); TrendChart and RateChart predate it and stay as they are.
  • Identity vs value: --chart-series-1..6 (four brand pastels plus rose and sand, fixed CVD-validated order) say WHICH series; --chart-1..5 (the monochrome ramp) say HOW MUCH. Never swap the two jobs; never point a chart config at raw hex. Full ruling in tokens.md.
  • A legend is mandatory on every ≥2-series chart (the pastels sit under 3:1 on light surfaces); a single series needs none — the title names it.
  • One y-axis, always — two measures of different scale get two charts, never a dual axis.
  • Cap 6 identity series (founder ruling 2026-09-25); a 7th folds into “Other” in --muted-foreground.
  • Status colors are never series colors.
  • Honest empty/degraded states: no fabricated points, no flat lines presented as data (PaceChart’s manual variant is the reference).

Copy & voice

  • Signal over noise: fill a text slot only when it adds signal (2026-07-09). A component exposing a description / caption / CardDescription / FieldDescription prop is NOT a reason to write one. Before every string ask: does this tell the user something the title, label, and control don’t already? If not, delete it. A CardDescription that paraphrases its CardTitle, a FieldDescription that restates a self-evident FieldLegend, “you can change this anytime” filler — all noise that buries the real signal. Card titles are themselves optional — omit the title when the content stands on its own (a connection status, a balance). Concise, direct, crystal clear beats complete; word-vomiting because the slot exists is a design smell, not thoroughness.

  • An action label names the outcome, never the destination (2026-07-21). “Connect ads to get started”, not “Go to Ads”; “Set up the Events SDK”, not “View Setup Instructions”. A label that just names a route makes the user do the translating. The label also never restates the title it sits under: the title states the state (“Managed UGC isn’t active yet”), the button names the verb (“Activate Managed UGC”). Titles are sentence case, always: “No content found”, never “No Content Found”.

  • Chrome is Title Case; prose is sentence case (2026-08-01). These are two different classes and the app had drifted into picking at random — “Confirm Password” and “Full Name” sitting beside “Custom amount” and “Phone number”, inside the same dialog. The line is GRAMMATICAL, not positional:

    • Title Case for anything that labels a thing rather than saying something: menu rows, nav items, tabs, buttons, and short noun-phrase field labels. “Project Details”, “Invoice Email”, “Auto-Replenish”, “Brand Voice”, “Save Changes”. Short articles and prepositions stay down: “Amount to Add”.
    • Sentence case for anything sentence-SHAPED, whatever slot it sits in: EmptyState titles (“No content found”, per the ruling above), every description, caption and FieldDescription, toasts, and field labels that are really questions or instructions (“Replenish when credits fall below”, “Reason for cancelling”).

    The test: if it would read correctly on a signpost, Title Case it; if it reads as a sentence, leave it alone. When a label is long enough that Title Case looks shouty, that is the signal it is prose, not that the rule is wrong.

  • The product has agents, not “AI.” User-facing copy says “agent” / “your agent” / “agentic” — never “AI” (2026-07-09). “Agents” is the thesis (it’s the sanctioned jargon exception below); “AI” is a generic term we don’t use anywhere. “Default AI behavior” → “Agent permissions”.

  • No em dashes in user-facing copy (a dead AI tell) — restructure the sentence instead. Code comments and internal docs are exempt, and so is a lone ”—” as a table cell’s empty-value placeholder glyph — that’s typography, not prose (2026-07-08).

  • The verb is “post”: “Post”, “Posted”, “Live Posts” — never “publish” in user-facing copy (code identifiers stay as they are).

  • No industry jargon: plain words users recognize. Exceptions (embraced terms, 2026-07-08): “agents”/“agentic” — they’re the thesis — and “Engagement”/“Eng. Rate” as the metric label users already know from every social platform.

  • No @ prefix on usernames.

  • No vendor names in partner-facing docs and no naming convention-models in marketing copy; describe capabilities behaviorally (real integrations may be named).

  • Treat any copy you’re handed as a draft — refine it to these rules rather than pasting it verbatim.

Proof — what a component owes, and what it does not

Five gates already bind EVERY component, and they are the reason most components need no test at all:

GateBindsEnforced by
Component coverageevery component has a doc pageapps/strata/scripts/check-component-coverage.mjs (blocking)
Variant coverageevery non-default cva variant is demonstrated on that pagesame script (blocking)
Consumer coverageevery component is imported by an app, or composed by another componentsame script (blocking)
Shape rulestokens, 8px grid, no hand-rolled tables, empty states, hand-rolled skeletonstools/strata-gate
Status colorsthe status token contractstylelint rule + its fixtures
Typesthe prop contracttsc --noEmit

Variant coverage: a variant nobody can see does not exist

Every variant a component accepts must appear on its doc page, as the literal group="value" in the Demo or in meta.rules (an object-literal group: 'value' counts, for a demo driven from a data array). Set it internally and never at a call site? Waive it with a reason in scripts/component-coverage.json.

This is not bookkeeping. Law 2 says never hand-roll an equivalent, and nobody can obey a law about what already exists while the catalog is incomplete. Badge is the case that produced this rule: the page showed success and destructive and its rules named success alone, while warning (a real status variant, wired to --color-status-warn) appeared nowhere. Anyone needing a needs-attention badge saw no option for one, and the rule they then broke was “never repaint a badge with raw color utilities.”

Consumer coverage: a component nothing imports is not in the system

Variant coverage keeps the catalog COMPLETE; this keeps it HONEST. Every component must be imported by an app or composed by another component. A component’s own doc page does not count — every component has one by construction, so counting it would make the check unable to fail.

An orphan is one of two opposite things and they need opposite fixes: dead (nothing ever needed it — delete the component, its page and its tests) or undiscovered (something should be using it and hand-rolled an equivalent instead — go wire it up). The second is Law 2 failing from the other end: “never hand-roll an equivalent” is unobeyable when the thing that already exists has no call site to point at.

It landed at zero, as a ratchet. The five it would have caught were all found by hand instead: connection-request-card shipped as 304 lines of component, 236 of tests and a 126-line doc page with no consumer, and was deleted the next day; resolution-badge, stream-item and tool-call-receipt were deleted by a doc-page sweep with zero importers; phone-input’s only importer was its own doc page. A component landing one PR ahead of its consumer is legitimate and says so in scripts/component-coverage.json under unconsumed.

What counts as a consumer is deliberately narrow, because everything that merely MENTIONS a component was at some point being read as something that USES it. Not consumers: the generated index.ts barrel (it re-exports all of them by construction), a component’s own doc page, any re-export including the export type spelling, a test file, a .d.ts, and a commented-out import. Each of those was a real bug in this gate before it was one of these words.

Two limits worth knowing. The check is one hop, not reachability — two dead components that import each other are invisible to it, though a dead chain with a single root is caught. And adding a component to the Elle catalog registry (elle-ui/registry.tsx) satisfies it in one line, which is legitimate but is the cheapest possible way to make something “consumed”.

What earns a vitest

The bar is a decision that can be WRONG independently of how the component looks. Write one when the component has:

  • a decision table whose rows other surfaces must agree with (CONNECTION_STATUS_PRESENTATION);
  • a pure function with edge cases (monogram());
  • a union that must stay in sync with a branch list, where the compiler cannot see the drift (PlatformIcon);
  • an invariant with a stated reason: exactly one copy button, one focus stop, a height cap that must pair with a scroll, a house mark that must not take a fixed hex.

Assert the CONSEQUENCE, not the source. A test that restates the table it reads passes forever and proves nothing.

What must never get one

Class strings, spacing, color, layout, and “does this variant render”. Those are the workbench, the shape gates, and your eye. A test that asserts the class list you just wrote pins the implementation, breaks on every legitimate restyle, and has never once caught a bug. Do not add one to reach a coverage number.

How to write one here

packages/ui tests are renderToStaticMarkup from react-dom/server, node environment, no jsdom and no testing-library. Files live in src/__tests__/, and the ROOT pnpm test runs them (the package has no runner of its own and does not need one). CI runs the directory from the status-colors job in strata-guard.yml.

If a test genuinely needs to click, that is a real cost to weigh out loud, not a dependency to add quietly.

Process

  • Batch small visual tweaks into a round rather than one PR per tweak.
  • Spacing instructions bind to their stated target: flex areas adapt, fixed-width elements change only on explicit ask — never silently resize a fixed element to absorb a tweak.
  • When several findings cluster in one area, fix the structural cause, not each symptom.

Raw markdown for agents: /docs/rules.md