Agents (llms.txt)

Component patterns

The canonical rules every Playstack component conforms to — file layout, server/client boundary, anatomy, variants, tokens, and accessibility.

1. Purpose & scope

This is the canonical rules document for the Playstack component library. Every component in src/foundations/ui/ and src/foundations/blocks/ conforms to it, and every new component is written against it. It encodes the house style the library already converged on, plus the explicit rules adopted in the registry-readiness pass. Conformance means three things: registry-ready (the file is portable — it drops into any client repo with no project-specific imports and no untokenized literals), consistent (it solves shared problems the same way every other component does), and predictable (an engineer or agent reading one component can predict the shape of every other). It is written for engineers and AI agents who already know React 19 — it states rules, not React fundamentals.

2. File & folder conventions

One folder per component. No exceptions, no shared “misc” files.

  • Primitives live in src/foundations/ui/<name>/. Composed marketing/page sections live in src/foundations/blocks/<name>/.
  • Folder names are kebab-case (color-picker/, app-shell/). Exports are PascalCase (ColorPicker, AppShell). Rationale: matches the filesystem-to-symbol mapping used everywhere else in the repo.
  • A component folder contains exactly: <name>.tsx (the source), page.mdx (the docs — required, see §15), examples/ (preview files). Nothing else. Rationale: a registry packages a folder; anything extra ships as noise.
  • The main file is <name>.tsx matching the folder name. All sub-components and JSX live in <name>.tsx. Non-component helpers (pure functions, constants, types) may move to sibling files in the same folder (<name>.utils.ts) when the main file exceeds ~400 lines — they still ship as part of the folder. Sub-components never move out. Rationale: a compound component is one unit — it copies as one folder, with the JSX in one file.

3. Server vs Client boundary

Resolution. The patterns audit found 'use client' applied to ~4 components and missing from ~20 others that use hooks. This section makes the rule mechanical.

Every file that uses any of the following has 'use client' as its first line:

  • React hooks — useState, useEffect, useRef, useId, useCallback, useMemo, useLayoutEffect, use().
  • createContext / Context consumption.
  • useRef/useImperativeHandle, or reading .current off a ref, or browser APIs — document, window, localStorage, IntersectionObserver, setTimeout/setInterval. (Accepting a ref prop and passing it to a host element is server-safe — see §8.)
  • An event handler function the component defines (const handleClick = …) or attaches to a DOM element it renders. Merely forwarding ...props (which may contain handlers) to a host element does not force 'use client'.
  • The motion library.

Server components — pure presentational components with none of the above — carry no directive. Rationale: the absence of the directive is itself the signal; adding it everywhere is as wrong as omitting it.

Blocks default to Server Component. A block opts into 'use client' only when it owns interactive state (e.g. Carousel). Rationale: marketing sections are mostly static; keep them off the client bundle.

4. Component anatomy

The structural template every component follows.


          'use client'; // only if §3 applies

import { useState } from 'react';            // 1. react
import { motion } from 'motion/react';        // 2. motion (if used)
import { ChevronDown } from '@untitledui-pro/icons/solid'; // 3. npm deps
import { cn, cva } from '@/lib/utils/classnames';          // 4. internal utils
import { Slot } from '@/foundations/components/slot/slot';
import { Surface } from '@/foundations/ui/surface/surface'; // 5. sibling components
import type { VariantProps } from 'cva';      // 6. types

// cva — see §5 for when
const widgetStyle = cva({
  base: 'inline-flex items-center',
  variants: { variant: { default: '…', subtle: '…' }, size: { sm: '…', md: '…' } },
  defaultVariants: { variant: 'default', size: 'md' },
});

interface WidgetProps
  extends React.ComponentPropsWithRef<'div'>,   // extend the host element
    VariantProps<typeof widgetStyle> {
  asChild?: boolean;                            // slot/config props
}

function Widget({ className, variant, size, asChild, ...props }: WidgetProps) {
  const Comp = asChild ? Slot : 'div';
  return <Comp className={cn(widgetStyle({ variant, size }), className)} {...props} />;
}

export { Widget };
export type { WidgetProps };
        

Rules encoded above:

  • Import order: react → motion → npm deps → internal utils → sibling components → types. Rationale: one scan order across the whole library.
  • Props type extends the host elementReact.ComponentPropsWithRef<'div'> when forwarding a ref, ComponentPropsWithoutRef<'div'> otherwise, ComponentProps<typeof X> to inherit another component. Rationale: consumers get the full native API for free.
  • Ref-as-prop, no forwardRef (see §8).
  • className is destructured and merged last via cn() (see §6).
  • Named exports only. export default is permitted only in *.preview.tsx files. Rationale: named exports are greppable and tree-shake predictably.
  • Prop type interfaces are always exported. Unlike cva style objects (private by default per §5), prop types are public API for composition — they let other components extend or pass through props with full typing. Rationale: Hero consumes ContainerProps['gutter']; a private prop type would break that.
  • Compound components re-export via Object.assign (see §9).

5. Variants

Resolution. The audit found three competing approaches — cva, prop-union + inline cn() ternaries, and hardcoded class strings. cva is the only approach. ~9 components (tooltip, menu, toaster, tabs, segmented-control, drawer, switch) carry prop-union variants and are non-conforming.

  • A component with 2+ variant axes, or any variant axis with 3+ options, uses a cva style object. Prop-union + inline cn() ternaries are not allowed at that threshold. Rationale: one mechanism for “a prop selects a class set” — the library does not ship two ways to do it.
  • A single boolean toggle with no other variant axis (e.g. inline) uses an inline cn() conditional, not cva. Rationale: a cva object for one boolean is ceremony.
  • One axis with two non-boolean options (e.g. orientation: 'horizontal' | 'vertical') uses cn() with a ternary. cva is required only at 2+ axes or 3+ options per single axis. Rationale: the 3+ rule is the floor for cva; below it, cn() is the rule, not a fallback.
  • The cva style object is named <name>Style (buttonStyle, badgeStyle).
  • Keep the cva object private (the default). Export it only when another primitive in the library composes it (IconButton extends buttonStyle, OTPInput extends inputStyle). Do not export “for consistency.” Rationale: an exported style object is public API — widen the surface only when there’s a real composition consumer. The audit found containerStyle exported with zero consumers; that’s the anti-pattern.
  • Variant names are semantic, not visualdefault / destructive, never blue / red (see §13).

6. className handling

  • cn is imported from @/lib/utils/classnamesnever from clsx, tailwind-merge, or cva directly. Rationale: cn is the repo’s pre-configured tailwind-merge wrapper; the raw packages don’t dedupe Tailwind classes the same way.
  • The consumer’s className is always merged last so it wins conflicts:

          className={cn(widgetStyle({ variant }), className)}
        

Rationale: a consumer passing className expects to override, not be overridden. Last position is the contract.

This is the one pattern the audit found 100% consistent across the library. It stays.

7. asChild / Slot

A component supports asChild if it renders exactly one host element AND that element is a container, trigger, or link — something a consumer might render as a different tag (<a> vs <button>, <section> vs <article>). A component does not support asChild if it renders a native form control (the control is the contract), renders multiple elements, or renders purely decorative markup.

The current library, applying that rule:

  • Supports asChild: structural/layout components (Section, Container, Flex, Grid, Surface, AppShell, Sidebar, PageHeader), triggers (*.Trigger, *.Close), link-like elements (Breadcrumb.Link, Nav.Item, Pagination.Link).
  • Does not support asChild: leaf form inputs (Input, Checkbox, Radio, Switch — they are the native element), pure visual components (Skeleton, Divider, Spinner).

          const Comp = asChild ? Slot : 'div';
return <Comp {...props} />;
        

Slot is imported from @/foundations/components/slot/slotnever from Radix. Rationale: asChild lets a consumer compose without wrapper <div>s; leaf inputs and visuals have nothing meaningful to swap, so the prop would be dead weight. The audit flagged Sidebar/PageHeader/AppShell as missing it — structural compounds qualify and should adopt it.

8. Refs

Rule. React 19 ref-as-prop only. forwardRef is forbidden anywhere in the library. A reviewer flagging “this component doesn’t use forwardRef so it can’t accept a ref” is a false positive — in React 19, ref is just a prop and ComponentPropsWithRef<'…'> already types it. Do not “fix” components by reintroducing forwardRef.

  • A component exposes a ref to its outermost host element. A component that renders no host element of its own (pure composition / context provider) exposes no ref. Compound sub-components each expose their own.
  • Type the ref via the host-element props — React.ComponentPropsWithRef<'div'> already includes ref. Use ComponentPropsWithoutRef<'…'> only when the component deliberately does not forward a ref (rare — pure composition only).
  • Destructure ref from props and pass it through. Do not wrap with forwardRef. Do not declare a separate React.Ref<…> prop by hand.
  • Merge an internal ref with a forwarded one using composeRefs from @/foundations/utils/compose-refs/compose-refs.

          interface WidgetProps extends React.ComponentPropsWithRef<'div'> {}

function Widget({ ref, ...props }: WidgetProps) {
  const internalRef = useRef<HTMLDivElement>(null);
  return <div ref={composeRefs(ref, internalRef)} {...props} />;
}
        

          // ❌ Forbidden — forwardRef is legacy and not used in this library.
const Widget = React.forwardRef<HTMLDivElement, WidgetProps>((props, ref) => (
  <div ref={ref} {...props} />
));
        

Rationale: forwardRef is legacy as of React 19; ref-as-prop is one less wrapper and consistent with how every other prop flows. The audit found divider, skeleton, switch, radio, select forwarding no ref — leaf elements still expose theirs so consumers can measure and focus them.

9. Compound components

A multi-part component is assembled with Object.assign and re-exported under the clean name.


          function Modal(props: ModalProps) { /* … */ }
function ModalTrigger(props: ModalTriggerProps) { /* … */ }
function ModalContent(props: ModalContentProps) { /* … */ }

const CompoundModal = Object.assign(Modal, {
  Trigger: ModalTrigger,
  Content: ModalContent,
});

export { CompoundModal as Modal };
        

Consumers write <Modal.Trigger>, <Modal.Content>. Rationale: one import, discoverable surface, parts that can’t drift from their parent.

State shared between compound parts uses a Context consumed with the React 19 use() hook — never useContext(), and the provider is passed as JSX directly — never .Provider:


          const ModalContext = createContext<ModalContextValue | null>(null);

const useModalContext = () => {
  const ctx = use(ModalContext);
  if (!ctx) throw new Error('Modal.* must be used within <Modal>');
  return ctx;
};

// provide:
<ModalContext value={ctx}>{children}</ModalContext>
        

Context objects are named <Name>Context; consumer hooks use<Name>Context. Sub-components are prefixed with the parent (ModalContent, not Content).

10. Tokens

Every color, spacing value, radius, duration, easing, shadow, and font value comes from the token contract — the @theme blocks in src/foundations/setup/globals-data-attr.css, theme.css, and motion.css. No literals.

  • Use the Tailwind utility that maps to the token (rounded-xl, shadow-sm, bg-background-secondary, text-foreground, duration-(--duration-base)).
  • A raw value with an existing token equivalent is a bug. The token audit found 15 mechanical mismatches — e.g. modal/dialog/drawer inline rounded-[1.25rem] when rounded-2xl resolves to exactly that, and --radius-2xl was created in theme.css for that purpose.
  • Tailwind’s default scale is permitted and is the default for layout values. Tailwind’s built-in spacing, type sizes, and container sizes (py-16, text-xl, max-w-4xl, gap-6) are the default for layout. The family @theme contract adds values not in Tailwind’s defaults — colors, motion, custom radii, family-specific shadows. A component reaches for a family contract token when one exists for that role; otherwise it uses Tailwind’s scale directly. Family contract tokens are not parallel duplicates of the Tailwind scale. Rationale: “no literals” means no arbitrary […] values and no raw style numbers — not “rewrite Tailwind’s scale into custom tokens.”
  • When a literal has no matching token, decide: system-wide token or one-off? System-wide values are added to the appropriate @theme block (and to the motion constants module if motion). One-offs are documented in the component’s page.mdx with rationale. The default answer is system-wide — one-offs require justification. Rationale: an undocumented one-off is just a hardcoded value with extra steps.
  • Component-local CSS variables for layout coordination (--button-height, --surface-pad) are fine; they’re derived from contract tokens, not literals.
  • JS-side motion values do not use CSS tokensmotion/Framer configs take JS objects, not var(--token). They import from the motion constants module (see §11).
  • Literals are acceptable only in /demos (the marketing/demo route at src/pages/demos/** and its component tree under src/components/demos/**). Those files are presentational scaffolding for the docs site — they never ship as part of the library, so an arbitrary gap-[13px] or bg-[#1a1a1a] there is fine. Anywhere else — src/foundations/**, shippable blocks, primitives, examples (*.preview.tsx) — literals are a bug. Rationale: a reviewer or agent finding a literal in /demos should leave it alone; finding one outside /demos should treat it as a token regression. Making the boundary explicit prevents both false flags and missed regressions.

See the Token contract for the full token list. The library does not invent values; it composes the contract.

11. Animation

motion (motion/react) is the standard animation library.

Resolution. The token audit found ~10 components (button, modal, toolbar-menu, toaster, disclosure, segmented-control, …) hand-coding cubic-bezier(0.25, 0.1, 0.25, 1) and duration: 0.2 — values bit-identical to --ease and --duration-base. The root cause: a motion config cannot reference a CSS variable.

  • All motion transition / animate configs import their durations and easings from the motion constants module — the JS mirror of the CSS motion tokens. No inline cubic-bezier(...), no inline numeric durations.
  • CSS-driven animation (Tailwind utilities, data-motion attributes) uses the CSS motion tokens directly (duration-(--duration-hover), ease-(--ease)).
  • Reduced motion is the component’s responsibility — one mechanism per case. A component using motion wraps its motion subtree in <MotionConfig reducedMotion="user">. A component animating via CSS data-motion attributes needs no handling — motion.css already disables it under prefers-reduced-motion. The useReducedMotion() hook is used only when motion must be conditionally constructed (not just disabled). Decorative motion is removed; functional motion (a drawer still needs to appear) degrades to a cut.

Rationale: motion that can’t be retuned from one place isn’t a system. The CSS contract and the JS constants module are the same values in two forms — every animation references one of them.

12. Imports & dependencies

A shippable component imports only:

  • npm packages (react, motion, cva, @tanstack/*, date-fns, chroma-js, …).
  • @/lib/utils/*classnames, compose-refs, and other framework-agnostic utilities.
  • Sibling foundations components — @/foundations/ui/*, @/foundations/components/*, @/foundations/hooks/*, @/foundations/utils/*.

Forbidden in shippable components:

  • @/components/* — docs-site code, not library code.
  • @/lib/preview, @/lib/shiki, @/lib/constants, @/lib/navigation — docs-site infrastructure.
  • astro:* and any Astro-specific import.
  • Any path that would not exist in a consumer’s repo.

External-library config is exempt from §10’s no-literals rule. Configuration that cannot reference CSS variables — @floating-ui middleware (padding, offset), Recharts geometry, charting margins — stays inline as literals. When the same values are reused across components, they may live in a shared <component>-defaults.ts constant. Rationale: §10 governs design values that can be tokenized; a padding: 8 floating-ui middleware can’t read var(--token), so tokenizing it is impossible, not optional.

In shippable components, icons come from @untitledui-pro/icons/solid only — named exports, no default import, no per-icon subpaths. Docs-site files (src/components/*, examples, previews) may use any icon source. Rationale: a registry component must drop into a foreign repo unchanged; anything that resolves only in this repo breaks on copy. Docs-site code never ships, so it’s unconstrained.

Every cross-foundations import is declared in the component’s page.mdx dependencies: block — pnpm check:deps enforces it.

13. Naming

  • Props: camelCase, spelled out in full — isLoading not ldng, description not desc. No abbreviations.
  • Variants: semantic, not visual — default / subtle / destructive, never blue / gray / red. Rationale: a visual name is a lie the moment the theme changes.
  • Boolean props: the bare adjective — disabled, loading, selected, open. No is/has prefix.
  • Data attributes: follow ARIA/HTML conventions — data-state (open/closed), data-status (transition state), data-orientation, data-selected, data-highlighted, data-disabled. Boolean states are true or absent (never false). String states drive data-[state=open]: selectors. Rationale: data-* attributes are the styling and testing contract; aligning them with ARIA means one vocabulary.

14. Accessibility

The minimum bar — every component meets it before it ships.

  • Keyboard: every interaction reachable and operable by keyboard. Composite widgets (Menu, Listbox, Tabs, SegmentedControl) implement roving tabindex and arrow-key navigation; Escape closes overlays.
  • ARIA: correct role, aria-label/aria-labelledby, and state attributes (aria-expanded, aria-selected, aria-checked, aria-orientation). Native elements are used wherever they exist — don’t role="button" a <div>.
  • Focus management: overlays (Modal, Drawer, Dialog, Popover, Menu) trap focus while open and restore it to the trigger on close.
  • Focus-visible only: focus rings render on focus-visible, never on mouse click. Use the ring-(length:--ring-width) ring-ring token pair.

By category: form inputs wire label/description/error via Field and native validity; overlays do focus trap + restore + Escape; navigation uses landmark roles and aria-current; disclosure-like components manage aria-expanded and aria-controls.

15. Documentation

Every component has a page.mdx in its folder.

  • Required frontmatter: title, description. Primitives use folder: UI (default); blocks use folder: Blocks.
  • Required dependencies: block — every cross-foundations import the source uses, internal entries as paths (/ui/surface), external as https:// URLs. pnpm check:deps fails the build if it drifts.
  • Body documents: what the component is, its props and variants, and renders its examples.
  • Examples live in examples/ as *.preview.tsx files — <name>.preview.tsx for the default, <name>-<variant>.preview.tsx for others. Each has an export default function and may export meta (layout, mode). Available at /preview/<slug> in dev.

Rationale: Foundations is consumed by copy-paste — the page.mdx is the only thing telling a consumer what they’re dragging in. An undocumented component is an unshippable one.

16. What’s explicitly NOT in this doc

Out of scope for this pass — deliberately:

  • Testing strategy — Playwright setup and conventions live in the Automated tests guide.
  • Performance optimization — memoization, bundle splitting, render profiling.
  • Theming engine internals — how the DS config panel, schemes, and light-dark() machinery work. This doc consumes the token contract; it does not define it.
  • Changelog discipline — covered in CLAUDE.md.

This doc is the structural and stylistic contract. Those topics get their own.

17. Forward references

This doc points at two artifacts beyond the core setup CSS. The motion constants module ships with the setup files; the token contract doc is still specified here for when it is built.

Token contract doc

A new guide at src/foundations/guides/token-contract/page.mdx (folder: "Guides", title: "Token contract"). It is generated, not hand-maintained — a script (scripts/extract-tokens.mjs, run in prebuild or as a pnpm task) parses the three @theme blocks in src/foundations/setup/{globals-data-attr,theme,motion}.css and emits a categorized table — Motion / Spacing / Color / Radius / Shadow / Typography — listing each token’s name, resolved value, and the Tailwind utility that maps to it (--radius-2xlrounded-2xl). The doc has no exports; its job is to be the authoritative list §10 points at, and to settle the Tailwind-default-scale boundary explicitly with a “What is NOT a token” section (Tailwind’s built-in scale — py-16, text-7xl, max-w-4xl — is permitted layout, not a contract token; see §10). Sync: because it’s generated from the CSS it cannot drift, but a pnpm check step regenerates it and fails if the committed file differs — the same mechanism as check:deps. The CSS files remain the single source of truth; the doc is a read-only projection.

Motion constants module

A new file at src/foundations/setup/motion.ts (sits beside the CSS it mirrors; ships with the setup files a consumer copies). It exports the JS mirror of motion.css’s @theme motion tokens as const objects with as const for literal typing:

  • MOTION_EASE{ default: [0.25, 0.1, 0.25, 1], spring: [0.15, 1.15, 0.6, 1] } — cubic-bezier control points as 4-tuples, the form motion accepts.
  • MOTION_DURATION{ hover: 0.1, base: 0.2, box: 0.15, sheetIn: 0.3, sheetOut: 0.13 }in seconds, since motion uses seconds while CSS uses ms. This conversion is the module’s reason to exist.
  • MOTION_SCALE{ press: 0.97, enterUp: 0.95, enterDown: 1.1, box: 0.97 }.

Naming: SCREAMING_SNAKE for the objects (module-level constants); camelCase keys mirroring the CSS token suffix (--duration-sheet-insheetIn). Sync: the CSS @theme block is the source of truth; this file is hand-written but kept honest by a test (motion.test.ts) that parses motion.css, converts each --duration-* from ms to seconds and each --ease* to a tuple, and asserts the JS values match — drift fails CI. The file header documents that it is a hand-maintained mirror and the CSS is canonical. §11 imports point here.

Previous

Stack

Next

useDetectDevice