Strata components (@layers/ui)
Source of truth for per-component contracts: the strata site — every component has a page at
strata.layers.com/components/<slug>(source:apps/strata/src/component-docs/) with a live demo, when-to-use, and the house rules that bind it. Coverage is ENFORCED: a build gate (apps/strata/scripts/check-component-coverage.mjs) fails the site build for anypackages/uicomponent without a page. This doc covers the package mechanics and the cross-consumer rules.
Package mechanics
- Source-consumed.
@layers/uihas no build step — consumers transpilesrc/directly and Tailwind scans it (every consumer stylesheet needs@source ".../packages/ui/src"). Changes are live in every app on save. - Subpath imports preferred:
import { Button } from '@layers/ui/button'. The barrel (@layers/ui) exists but subpaths keep bundles honest. - CSS that a component needs beyond Tailwind lives in
packages/ui/src/css/<component>.css(media-card, media-player, stat-card, content-card) — new components should be self-contained Tailwind; a companion CSS file is only for keyframes or pseudo-selectors Tailwind can’t express. - Primitives are shadcn-derived (
base-maiastyle) on@base-ui/react(Base UI). Radix is fully retired from this package: triggers take arenderprop (neverasChild), overlay anatomy is Portal > Positioner > Popup (Backdrop for scrims), and state hooks aredata-open/data-closed/data-popup-open. Per-component migration notes live in.migration/*.md.
Inventory (by kind)
shadcn primitives (stock — structure per the shadcn website, never
modified): Alert, AlertDialog, Avatar, Badge, Button, ButtonGroup,
Calendar, Card, Chart (the ChartContainer/Tooltip/Legend layer on recharts —
one documented house deviation: tooltip values are sans, not mono), Checkbox,
Combobox, Command, Dialog, DropdownMenu, Field, Form, Input, InputGroup,
InputOTP, Label, Marker, MessageScroller, Popover, Progress,
RadioGroup, Select, Separator, Skeleton, Slider, Switch, Tabs, Textarea,
Toggle, ToggleGroup, Tooltip. (Stock Sheet is installed but never used
directly — use WindowSheet or CanvasSheet.)
Charts: new charts build on the Chart layer with the --chart-series-*
identity tokens and the --chart-1..5 value ramp (ruling in tokens.md;
binding rules in rules.md § Charts). PaceChart is the reference consumer;
PlanTimeline (day-axis schedule) and MeterList (labeled progress rows) round
out the dashboard vocabulary. TrendChart/RateChart predate the layer and stay
as they are.
Chat surfaces compose the chat vocabulary, never hand-rolled equivalents:
MessageScroller owns conversation scrolling (auto-follow, yield on
scroll-up, jump-to-latest — never a manual overflow container or
stick-to-bottom hook); Marker
owns dividers and system notes; the shimmer utility owns live-status text.
Composition rules: the vendored skill at
.agents/skills/shadcn/rules/chat.md. In web-v2, ChatPane owns the
scroller and ChatEntryList wraps rows in MessageScrollerItem — surfaces
never touch scrolling.
Layers wrappers (exist because stock defaults didn’t fit): CommandInline, DateTimePicker, HashtagTextarea.
Layers components: the authoritative inventory is packages/ui/src/
(one file per component) rendered exhaustively at
strata.layers.com/components — the coverage gate keeps the two identical,
so a component absent from the site does not exist. Props and usage context
live on each component’s page and in its source.
Blocks (a tier, not a kind): most of packages/ui is components —
primitives and small compositions (ChatPane, CanvasSheet, DataTable,
WindowSheet). A block is the larger tier: a composed, multi-component
surface installed and used whole, in the shadcn dashboard-01 sense (a block
composes components; DataTable is a component, dashboard-01 is the block).
ElleBlock is the first — the one surface you talk to Elle in, anywhere
(goal, onboarding, /chats, the area companions). It composes ChatPane + an
optional companion Canvas/CanvasSheet + HITL gate auto-open + resize +
working-state. What varies between callers is the engine driving the chat,
never the surface; see its page and layouts.md (the five-mode canvas
contract). Blocks live in packages/ui and carry a doc page like any
component (the coverage gate applies), but route under
strata.layers.com/blocks in their own nav section — set tier: 'block' in
the doc page’s meta to move it there.
Drawing the line (composition alone never promotes a component): “composes
many components” is not the test — Dialog composes its parts, ChatPane
composes Heading + MessageScroller + Spinner, and both stay components.
Two tests decide the tier: (1) terminal vs. building material — a block is
the thing you place on a route and configure; a component is material you
compose into something else (you drop ChatPane and Dialog inside your own
layouts, so they are components no matter how many parts they hold). (2)
owns product orchestration — a block owns product-specific state (engine
wiring, gate→artifact, canvas ownership); a component owns only generic
presentation. ElleBlock passes both; ChatPane passes neither. Expect blocks
to stay rare — the tier is precise, not a bucket for anything large.
Integrations — the fixed UI pattern
Adding a connectable source is a filling-in exercise, not a design exercise (2026-07-31). Every integration wears the same five components in the same places, so a new one is predictable to build, predictable to review, and predictable to use. Do not invent surface for a provider.
| Surface | Component | What it carries |
|---|---|---|
| Directory tile | ConnectionCard | mark, name, one line, one action. featured for a banner. |
| The provider’s own surface | ConnectionPanel | a Dialog: mark, name, status, what it powers, what you have, one commit. |
| Provider mark | PlatformTile | brand fill + mark, or a monogram. Never a Lucide stand-in. |
| Connection facts | PropertyList | labelled label: value facts — what it is bound to. |
| The one destructive act | DestructiveActionCard | its own card, with the consequence stated, behind a confirm. |
| Nothing connected / failed | EmptyState | variant="card" inside a Card, page for a whole surface. |
The rules that bind all of them:
- Name what a connection is bound to. A bare
act_1404602064702805beside a Connected badge is what this pattern exists to prevent — it is an id, not an answer, and a sheet whose only working affordance is Disconnect reads as a disconnect widget. Every provider states its binding as a labelled property. - Disconnect is never inline beside a status badge. It goes in a
DestructiveActionCardunder the facts. “Healthy” and “destroy this” must not share a row at one weight. - A provider’s brand mark and colour are DATA, in
PLATFORM_BRAND/CONNECTION_PROVIDERS, sourced from the vendor and never drawn or guessed. A provider with no sourced mark renders a monogram and still ships. - Instances of a provider are a list; per-instance CONFIG is a table. A connection whose instances carry settings (persona, language, automation) belongs in the accounts table, not a sheet of rows — see the Connections area’s two tabs.
- Sort alphabetically, after severity. Registry order is deliberate for the catalog’s sections and arbitrary for anything a user already owns.
- ONE panel, connected or not (2026-08-01). A provider does not get a
“connect” surface and a separate “manage” surface. The user asks what is the
state of my Instagram? and the answer is the same page whether it is “none
yet” or “three accounts, one expired”. This is what let
?connect=and?manage=collapse into a single?connection=param, with no exclusivity rule left to enforce between them. - Say what the connection POWERS, before and after (2026-08-01). Every
registry entry carries
powers— two to four sentences about the user’s product, not the vendor’s features — and the panel renders them above the body in every state. The directory could not previously answer its own most important question: a tile offered one line and a Connect button, so the only way to learn what a connection did was to start making it, and nothing ever explained why an existing one mattered. “Your agents read the real codebase, so generated content describes what your app actually does” is the bar; “GitHub integration” is not. - A source you can paste, you paste (2026-08-01, founder ruling). App Store,
Google Play, the website and GitHub all connect by pasting the URL the user
already has, then confirming what came back. No catalog search, no cached repo
picker: both were list surfaces that went stale in ways nobody could see, and
both asked the user to re-find something they were already holding. The
parsing is shared (
@layers/shared-types/store-url,parseOwnerRepo) so a link the product accepts in onboarding is accepted here.
Variants
Variants are class-variance-authority (cva) props on the component —
Button’s variant/size, Badge’s custom success variant, InputGroup and
TabsList’s size (“default” 36px / “sm” 32px). Extend via variants, never
a parallel component — a ComponentSmall fork drifts from the original.
House variant additions are documented on each component’s site page.
Rules
- Never hand-roll what shadcn provides. Before building any interactive element, check the shadcn website; if a component exists, install/use it stock. A hand-rolled lookalike is a defect even if pixel-identical.
- No custom Tailwind on shadcn compounds — stock structure and spacing
are law. Audit every
classNameyou added before reporting a surface done; the only exemptions are one-off layout constraints (a popover width) and skeleton sizing. - Every
SelectItemsits inside aSelectGroup(the popover padding lives on the group) — no orphan items, even for a single group. And anySelectwhose item labels differ from theirvaluestrings passesitemsto the root (a value→label record or{ value, label }array): Base UI’sSelectValuerenders the RAW value without it, so a closed trigger showsallinstead of “All Generations”. - Forms never wrap gap-laid-out children.
PopoverContent,DialogContent,Card, andWindowSheetContentspace their DIRECT children with grid/flex gap — a<form>wrapping them becomes one child and collapses the spacing. The<form>goes inside the content region; the submit button links viaform={id}from the footer. Bare<form>, zero Tailwind on it; spacing comes fromFieldSet. - Stock shadcn docs live on the website, nowhere else. URL pattern:
https://ui.shadcn.com/docs/components/<slug>(WebFetch it). The shadcn MCP is foraddinstall commands only — its demo registry is stale. We document only what is custom to us; if an installed component drifts from the current website docs, update the component, never document the old behavior. - New component =
packages/uifirst. If more than one consumer could ever want it, it starts in the package, not in an app’scomponents/directory. Add a catalog entry (seegenerative-ui.md) when Elle should be able to compose with it.