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 any packages/ui component without a page. This doc covers the package mechanics and the cross-consumer rules.

Package mechanics

  • Source-consumed. @layers/ui has no build step — consumers transpile src/ 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-maia style) on @base-ui/react (Base UI). Radix is fully retired from this package: triggers take a render prop (never asChild), overlay anatomy is Portal > Positioner > Popup (Backdrop for scrims), and state hooks are data-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.

SurfaceComponentWhat it carries
Directory tileConnectionCardmark, name, one line, one action. featured for a banner.
The provider’s own surfaceConnectionPanela Dialog: mark, name, status, what it powers, what you have, one commit.
Provider markPlatformTilebrand fill + mark, or a monogram. Never a Lucide stand-in.
Connection factsPropertyListlabelled label: value facts — what it is bound to.
The one destructive actDestructiveActionCardits own card, with the consequence stated, behind a confirm.
Nothing connected / failedEmptyStatevariant="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_1404602064702805 beside 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 DestructiveActionCard under 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

  1. 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.
  2. No custom Tailwind on shadcn compounds — stock structure and spacing are law. Audit every className you added before reporting a surface done; the only exemptions are one-off layout constraints (a popover width) and skeleton sizing.
  3. Every SelectItem sits inside a SelectGroup (the popover padding lives on the group) — no orphan items, even for a single group. And any Select whose item labels differ from their value strings passes items to the root (a value→label record or { value, label } array): Base UI’s SelectValue renders the RAW value without it, so a closed trigger shows all instead of “All Generations”.
  4. Forms never wrap gap-laid-out children. PopoverContent, DialogContent, Card, and WindowSheetContent space 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 via form={id} from the footer. Bare <form>, zero Tailwind on it; spacing comes from FieldSet.
  5. Stock shadcn docs live on the website, nowhere else. URL pattern: https://ui.shadcn.com/docs/components/<slug> (WebFetch it). The shadcn MCP is for add install 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.
  6. New component = packages/ui first. If more than one consumer could ever want it, it starts in the package, not in an app’s components/ directory. Add a catalog entry (see generative-ui.md) when Elle should be able to compose with it.

Raw markdown for agents: /docs/components.md