npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@clossys/designer

v0.6.0

Published

The designer role: is it well made? A complete visual system — design tokens, theme CSS, accessible React components, icons, charts, and visual quality gates.

Readme

@clossys/designer

The designer role — is it well made? This package is named for the job, not the artifact. What it ships is still a visual system, and the vocabulary inside it (tokens, atoms, blocks, shell, charts, theme, icon glyph data, and every gate name they compose into) is unchanged: a role owns artifacts, and renaming the role does not rename what it reasons about.

Design tokens, theme CSS, and React components for Tailwind CSS v4. This package ships reusable visual vocabulary built on its own token layer:

npm install @clossys/designer

This package is published to the public npm registry, https://registry.npmjs.org. Installing it needs no authentication: no npm token, no .npmrc registry override, and no GitHub credential of any kind.

Design conformance rate

Independent consumer evidence shows the position's owned metric meets its setpoint over the declared review cadence. The owned metric is design conformance rate, computed by assessDesignConformanceRate(). An empty evaluated set is indeterminate, never a perfect rate of 1. designer-token-check, designer-brand-check, designer-contrast-check, and designer-environment-check remain the gates they are; none is this rate. This package does not measure consumer evidence and does not close the loop. A green run of this package's tests is not a close.

import { assessDesignConformanceRate } from "@clossys/designer/gate";

const report = assessDesignConformanceRate(input);
designer-rate-check assessment.json

The command prints JSON and exits 0 for satisfied, 1 for violated, and 2 for indeterminate, unreadable, or invalid input.

This package declares that command as its first-day assessment surface in its own manifest:

"foundry": { "assessment": { "bin": "designer-rate-check", "invocation": "single-json-input" } }

Pre-auth page quality

Pre-auth marketing pages use a five-star contract: done is exceptional (5); mechanical gates (designer-hero-css-check, designer-fold-check, and writer-check --live on the publishing repo) prove good (3) only. The full rubric — floor, authored great, review keep, and who certifies what — is in PRE-AUTH-QUALITY.md.

Onboarding discovers that declaration from the installed manifest and never infers a surface. The four existing designer bins remain gates and are not the assessment surface. Designer is not a required first-day role; Advisor remains the only required first-day assessment.

Package structure

tokens → icons → atoms → blocks
                       ↘ shell
                       ↘ charts
                       ↘ theme

atoms, blocks, shell, charts, and theme all ship today, alongside icons — pure glyph DATA sitting BELOW atoms, not a sixth rung of content (see "Icon glyph data" below for the full reasoning; the short version: a [tag, attrs] tuple has no rendering logic and depends on nothing else in this package, so it sits even more foundational than atoms itself, the same way tokens sits below all of ui). See "Placement rules" below for what distinguishes reusable atoms and blocks. Whole-page compositions are surfaces and live in @clossys/publisher/web.

  • icons — glyph data only, no components: 32 IconNode exports (AlertTriangle, BookOpen, Box, Building2, Calendar, Check, CheckCircle, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Clock, CreditCard, ExternalLink, FileText, Folder, Grid3x3, Home, Info, List, Lock, Monitor, Moon, Plug, Receipt, Search, Settings, Sun, User, Users, X, XCircle) — each a ReadonlyArray<readonly [tag: string, attrs: Record<string, string>]>, meant to be passed to the Icon atom's glyph prop. See "Icon glyph data" below.
  • atoms — single-purpose: composes no other atom, or its parts are homogeneous repeats rather than named regions. Thirty-one ship, the complete set for this layer: Button, Icon, TextField, Badge, Card, Breadcrumb, Link, Checkbox, Switch, Select, Textarea, Avatar, Spinner, Menu, Dialog, Tabs, Table, Field, Skeleton, Tooltip, Banner, RadioGroup, Popover, DateField, ComboBox, SearchField, FileTrigger, Disclosure, ProgressBar, Separator, Chip.
  • blocks — owns the internal layout of multiple named regions, typically by composing one or more atoms (and/or layout) into something with a real job on a page. Twenty-one ship: PageHeader, EmptyState, DataTable, DetailView, Pagination, Stat, Form, FieldGroup, ConfirmDialog, Toolbar, NavGrid, SectionHeader, Hero, MarketingChapter, FeatureGrid, OrderedStepSequence, StatusList, Faq, PricingTable, Testimonial, ArticleBody — the last eight are marketing/editorial content blocks, completing this layer (see "Blocks" below).
  • shell — the persistent frame around content (nav, layout chrome) that provides the slots content fills. One per app; survives route changes that swap out the content underneath it. Shell ships with five slots (Header, SideNav, Main, Rail, Footer) for an authenticated-app frame; SiteHeader, NavShell, SiteFooter, and SkipLink ship alongside it for the simpler persistent chrome a public SITE (as opposed to an app behind auth) needs — a brand/nav/actions header, a responsive nav with a mobile drawer, grouped footer link columns, and the keyboard affordance to bypass either. Toaster — a runtime service, not itself a rung of this ladder — ships alongside both. See "Shell" below.
  • charts — dependency-free SVG chart primitives: ChartFrame (the shared plot/axes/grid/legend/table container), BarChart, LineChart, and Sparkline. A sibling of shell, not a sixth rung of the ladder — see "Charts" below.
  • theme — the JavaScript half of this package's theming contract (the CSS half already ships from tokens.css/theme.css — see "CSS layers, fallbacks, and themes" below): getThemeInitScript, a self-contained head script that stamps data-theme before first paint; ThemeProvider/useTheme, which hold and persist the three-state preference at runtime; and ThemeToggle, an accessible control built from this package's own Button/Icon atoms. A sibling of shell and charts, not a sixth rung — see "Theme" below.

A layer may only import toward something more foundational: blocks may import atoms, never the reverse. shell, charts, and theme are narrower sibling domains built from those primitives. charts is a narrower sibling: it may import atoms, and nothing else in this package imports from it. theme is the same shape: it may import atoms and icons (its Button/Icon atoms and Sun/Moon/Monitor glyphs), and nothing else in this package imports from it. icons sits at the very bottom: atoms may import icons (and does — atoms/Icon.tsx imports the IconNode type from icons/types.ts), and nothing under icons/ may import from anywhere else in this package. src/ladder.test.ts enforces every one of these directions structurally, not just by convention: it scans every file under src/atoms/, src/blocks/, src/shell/, src/charts/, src/theme/, and src/icons/ for an import referencing a layer it isn't allowed to reach, and fails the build if it finds one.

The token layer is part of this package — every class its components render (bg-accent, text-ink-primary, rounded-control, ...) is a Tailwind utility generated from its tokens. Without the token CSS imported, those class names don't correspond to anything and every component renders unstyled, with no error anywhere to explain why.

Public contract

There is deliberately no @clossys/designer root export. Import the smallest stable subpath that owns what you need:

| Subpath | Owns | | --- | --- | | @clossys/designer/tokens | Typed TOKENS, brand CSS parsing, the brand-coverage gate, WCAG colour math (contrastRatio and friends), the contrast gate (checkTokenContrast, CONTRAST_PAIRS), and assertTokenStylesLoaded (dev-only token-CSS presence check — see "Setup" below). No React runtime. | | @clossys/designer/tokens.css | Neutral primitive custom-property defaults; works without Tailwind. | | @clossys/designer/theme.css | Optional Tailwind v4 wiring; imports tokens.css itself. | | @clossys/designer/compiled.css | GENERATED, precompiled utility CSS for atoms, blocks, and shell — the default path for a pre-auth page without Tailwind. Imports nothing itself; load after tokens.css. See "Framework-portable components, without Tailwind" below. | | @clossys/designer/brand-template.css | Copy-and-fill template for a consumer brand binding. | | @clossys/designer/icons | Tree-shakeable glyph data. | | @clossys/designer/atoms, /blocks, /shell, /charts | Reusable React visual primitives. | | @clossys/designer/atoms/server, /blocks/server, /shell/server, /charts/server, /theme/server | The server-safe subset of each sibling subpath, importable from a React Server Component. See "Server Components" below. | | @clossys/designer/theme | getThemeInitScript, ThemeProvider/useTheme, ThemeToggle — the runtime half of theming. Not to be confused with the CSS /theme.css subpath above. | | @clossys/designer/gate | Token-purity scanner/gate and the environment-conformance gate (checkEnvironmentConformance). | | @clossys/designer/render-environment | RENDER_ENVIRONMENT — a plain data declaration of every subpath's render environment ("server-safe" | "client-only"). See "Server Components" below. |

ui never exports page views, routes, metadata, strategy facts, or copy. Components receive resolved ReactNodes, labels, data, callbacks, and URLs through props. Product/page composition belongs to @clossys/publisher; audience-facing words belong to @clossys/writer.

Token-only use

Tokens can be the only thing a consumer installs and imports:

npm install @clossys/designer
@import "@clossys/designer/tokens.css";

The package has no regular runtime dependencies. React, React DOM, React Aria, Tailwind, Tailwind Merge, and the date helpers are optional peers: install them only when importing the React component subpaths. tokens.css is ordinary CSS custom properties, so it has no React or Tailwind requirement.

For React components, install the peers used by the subpaths you import:

npm install @clossys/designer react react-dom react-aria-components \
  tailwind-merge tailwindcss @internationalized/date

@internationalized/date is only needed when using DateField; the other component peers support the interactive primitives and their Tailwind classes.

Registry note: the token-only path above installs the full peer set anyway. All six peers listed in peerDependencies — react, react-dom, react-aria-components, tailwind-merge, tailwindcss, and @internationalized/date — are correctly declared optional: true in peerDependenciesMeta, and that declaration is honored by the tarball this package publishes. It is not honored by npm.pkg.github.com: the registry's packument omits peerDependenciesMeta entirely, so an installer resolving against this registry sees six required peers, not six optional ones. In practice that means a consumer who runs npm install @clossys/designer to get only tokens.css or compiled.css — the two paths that exist specifically so React and a Tailwind pipeline are not required — still has all six installed. There is no per-subpath way to avoid it from this side; the fix would have to happen registry-side. See issue #226 for the full evidence and the decision to document rather than restructure around it.

CSS layers, fallbacks, and themes

The visual contract is ordered:

  1. tokens.css defines neutral light defaults and automatic/explicit dark overrides. Every token has a literal primitive default.
  2. A consumer brand file copied from brand-template.css overrides only brandable roles under :root[data-brand-bound].
  3. A consumer master brand mark — three SVG documents (lockup, mark-only, inverse), validated with validateMasterMark from @clossys/designer/tokens — sits alongside the brand binding. This package does not ship a product logo, favicon PNGs, or social images.
  4. Consumer extension CSS can add product-specific values under its own prefix; it must not redefine UI's token vocabulary.

For Tailwind v4, use theme.css instead of importing tokens.css separately: it imports the primitives and exposes supported token families through @theme inline, keeping utilities live against later brand overrides. The emitted utilities and UI fallbacks use var(--token, default), so an unbound token layer remains legible rather than failing invisibly. With no data-theme, CSS follows the OS; set data-theme="light" or data-theme="dark" on the document root to force a theme. Put that attribute in server-rendered markup to avoid a flash.

Breakpoints are the one family that is NOT overridable this way. Every other family in theme.css's @theme inline block is deliberately --token: var(--token, default) so a later :root[data-brand-bound] rule redefining the plain custom property is still picked up. --breakpoint-* (and, if this package ever ships one, --container-*) cannot use that pattern: @theme inline substitutes the declared value directly into the generated utility's @media/@container condition, and a media-query condition cannot contain var() — a self-referential breakpoint compiles to literally invalid CSS (@media (width >= var(--breakpoint-tablet, 768px))), which fails to parse and can take down every rule that follows it in a consumer's stylesheet. theme.css therefore declares --breakpoint-* as plain literal lengths, not the self-referential form. If your product needs different breakpoints than this package's defaults (375/480/768/ 1024/1280/1440px), redeclaring --breakpoint-tablet as a plain custom property anywhere (:root { --breakpoint-tablet: ...; }, a brand file, data-brand-bound) has no effect on the generated tablet: utility — @theme inline only listens for @theme blocks, not arbitrary :root declarations, and by the time it does, the media condition is already a literal. What DOES work, verified against a real compile: declare your own @theme { --breakpoint-tablet: 900px; } block AFTER importing this package's theme.css in your CSS entry point — Tailwind v4 merges @theme blocks in source order, so a later block's value for the same key wins over an earlier one, theme.css's own declaration included. Put it before, and this package's value wins instead. Order matters here in a way it doesn't for any other token family in this file.

tokens.css's own declarations live in a named @layer foundry-ui-tokens rather than unlayered :root — an unlayered rule always outranks a layered one regardless of import order, which would make these tokens win over a host app's own Tailwind v4 @layer theme unconditionally. Layering it puts the two on ordinary layer-order footing instead: a host app that wants the final say can put its own override in an unlayered rule, or in a layer it declares later than foundry-ui-tokens.

Until data-brand-bound is set, tokens.css also renders a fixed "No brand binding" badge on every page — deliberately: an unbranded render should never quietly pass as finished. Set data-suppress-brand-banner on <html> (same placement rule as data-theme/data-brand-bound — in server-rendered markup, not a post-hydration effect) to suppress it for a consumer that's shipping unbranded primitives on purpose.

React SSR, hydration, and accessibility

The component subpaths support React 18+ server rendering and hydration: they do not read browser globals while rendering. The test suite hydrates a representative Shell + PageHeader + Button tree without a recoverable mismatch. In a Next.js 16 App Router consumer, render structural markup in the server layout/page as usual and place an explicit client boundary around the interactive component tree; set data-theme and data-brand-bound in the root document/layout, not in a post-hydration effect.

Interactive controls use React Aria for keyboard, focus, and semantic contracts. Noninteractive components expose semantic labels where needed, the token suite checks contrast in light and dark themes, and every shipped animation or transition has a Tailwind motion-reduce override.

Server Components

SSR-safe and importable-from-a-Server-Component are different guarantees. Every atom, block, and shell component avoids browser globals at render time (the "React SSR, hydration, and accessibility" section above), but atoms, blocks, shell, charts, and theme are each a SINGLE barrel that re-exports every one of its members eagerly from one module — importing even one noninteractive member (Card, say) pulls in whatever interactive sibling shares that barrel (Button, Dialog, ...), and those read react-aria-components' own useContext at module scope. That fails to import under React's react-server module-resolution condition, which is exactly what blocks a React Server Component from reaching Card at all — not because Card itself is unsafe, but because of how it's packaged.

Five narrower subpaths exist for exactly this: @clossys/designer/atoms/server, /blocks/server, /shell/server, /charts/server, and /theme/server. Each re-exports ONLY the members of its sibling barrel confirmed, empirically, to import cleanly under --conditions=react-server — never a name inferred from "looks presentational" or a client-directive grep (see each *server.ts source file's own header for the exact probe and its result). Today that's:

| Subpath | Server-safe members | | --- | --- | | @clossys/designer/atoms/server | Badge, Banner, Card, Field, Icon, Skeleton, Spinner, mergeUiClasses | | @clossys/designer/blocks/server | ArticleBody, DetailView, EmptyState, Faq, FeatureGrid, FieldGroup, Hero, MarketingChapter, OrderedStepSequence, PageHeader, PricingTable, SectionFrame, SectionHeader, Stat, StatusList | | @clossys/designer/shell/server | Shell, SiteFooter, SiteHeader, SkipLink | | @clossys/designer/charts/server | ChartFrame, Sparkline | | @clossys/designer/theme/server | getThemeInitScript |

Everything not listed above stays reachable only from its original barrel, inside a client boundary — nothing was removed, renamed, or restructured to create these subpaths; each is strictly additive, a second, narrower way to reach bindings that already ship.

Faq deliberately has two implementations behind those entry points. The ordinary @clossys/designer/blocks entry keeps the React Aria disclosure and its explicit trigger/panel wiring. @clossys/designer/blocks/server renders the same public props as native details/summary, preserving independent keyboard-operable disclosures without importing the client-only React Aria graph.

@clossys/designer/render-environment exports RENDER_ENVIRONMENT, a plain Record<string, "server-safe" | "client-only"> keyed by every package.json#exports subpath this package declares — including the CSS entries, which carry no JavaScript execution context and are always "server-safe". It is data only, no resolver logic: a consumer (or a separate checker package, resolving a real module graph under a declared export condition) reads it to know which subpath to reach for without re-deriving the same probe.

This record's own internal consistency — that its key set matches package.json#exports' real subpath set, in both directions — is now verified on every run by the environment-conformance gate; see "Environment-declaration-consistency gate" below. That gate does not verify the claim itself (that a "server-safe" subpath truly resolves safely under the react-server condition) — read that section before treating a passing gate as more than it is.

Framework-portable components, without Tailwind (default for pre-auth pages)

Default CSS path — one mount, no Tailwind, no @source:

@import "@clossys/designer/tokens.css";
@import "@clossys/designer/compiled.css";
/* your brand overlay (from brand-template.css) */
npm install @clossys/designer react react-dom react-aria-components \
  tailwind-merge @internationalized/date
# tailwindcss itself is NOT needed on this path

Load exactly one styling path per project (see "Load exactly one path, never both" below). Do not also import theme.css or run a Tailwind @source scan on the same page — pick this path OR the advanced path, not both.

Scope. compiled.css is generated from src/atoms/, src/blocks/, and src/shell/ — enough for Hero, feature blocks, and site chrome on a pre-auth marketing page. charts and theme remain Tailwind-native only.

Verify the stylesheet your app loads with designer-hero-css-check path/to/your.css (or point it at node_modules/@clossys/designer/styles/compiled.css when you import that file unchanged).

Record fold evidence (from your app or a browser script) and verify it with designer-fold-check path/to/fold-measurement.json — optional --also path/to/mobile-fold.json for a second viewport. Missing evidence is not done; the gate fails closed.

Author a type brief from templates/brand-type.template.json (display face, H1 minimum, measure cap, monospace roles, orphan-word policy) and verify it with designer-type-check path/to/brand-type.json — optional --overlay path/to/brand.css to require a bound --font-display in overlay CSS. Do not invent type pairing ad hoc during the walk that proves 3.

Tailwind-native path (advanced)

When you already run Tailwind v4 and want the full token surface including charts/theme, use theme.css + @source on dist instead of compiled.css. See Setup below for that path and its @source pitfalls.

What compiled.css is. A GENERATED file — never hand-edited, checked by npm run check:compiled-css (also runs as part of npm test, so CI catches drift automatically) and regenerated with npm run generate:compiled-css. It is produced by a REAL Tailwind v4 compile (src/compiled-css/generate.ts, using the real tailwindcss package's own compile() API) of every class candidate src/compiled-css/scan-sources.ts finds by statically scanning src/atoms/, src/blocks/, and src/shell/. It is not a second, hand-maintained approximation of what bg-accent means: it is Tailwind's own real compiled answer for the SAME tokens, precomputed once instead of recompiled at every consumer's own build time.

Override precedence. Every declaration compiled.css emits lives inside a single named CSS layer, foundry-ui-compiled, declared after this package's own foundry-ui-tokens layer (tokens.css) — never a bare/ unlayered rule, for the exact reason tokens.css itself moved off unlayered :root in #148 (an unlayered rule always outranks ANY layered rule regardless of import order). Per the CSS Cascading Layers spec:

  • A consumer's own unlayered CSS (a plain stylesheet, CSS Modules, most component-scoped styling systems) always wins on a conflicting property, regardless of source/import order.
  • A consumer's own CSS inside a named layer declared after foundry-ui-compiled wins too.
  • A consumer's own className prop is merged the same way it always is on this package's atoms — via the internal cx()/tailwind-merge helper — independent of which stylesheet path is loaded; this behavior is already covered by every atom's own tests (e.g. Button.test.tsx) and does not change under the compiled-CSS path.

Load exactly one path, never both. compiled.css and the Tailwind-native path (theme.css + a consumer's own @source-driven Tailwind build) both generate declarations for the same class names, in different layers. Loading both is not verified to be safe or idempotent — this repository has no headless browser to check the resulting cascade in a real engine (see below), so rather than claim untested double-load safety, the rule is explicit: pick ONE path per project. There is no runtime double-load detector (considered and deliberately not built — there is no reliable, low-false-positive signal available without inspecting live CSSOM rules in a real browser, the same cost this repository already declined elsewhere for @clossys/designer's own test setup; see the introducing PR).

What is and is not verified. src/compiled-css/coverage.test.tsx renders real atoms and cross-checks every class actually in the DOM against a fresh compiled.css; override.test.ts proves — structurally, from the text of the generated CSS itself — that 100% of its declarations sit inside the named layer, which is what makes the override precedence above a spec guarantee rather than a claim. What is not verified anywhere in this package's test suite: the actual resolved getComputedStyle value a real browser produces for a component under this path, in either theme, with or without a brand binding. jsdom (this package's test environment) has no CSS engine — it does not parse or apply stylesheets at all — and this repository has no headless browser (declined elsewhere, in #163, for the same dependency-cost reason CONTRIBUTING.md's "the default answer is no" states generally). Because compiled.css is a real Tailwind compile of the same tokens the Tailwind-native path already compiles, its declarations are byte-identical to what a consumer's own Tailwind build would produce for the same classes — this is a structural argument about how the file is produced, not a substitute for a real-browser visual check a consumer cannot get from this package's own CI today.

Migration from split packages

| Legacy import | Use now | | --- | --- | | @example/tokens | @clossys/designer/tokens | | @example/tokens/tokens.css | @clossys/designer/tokens.css | | @example/tokens/theme.css | @clossys/designer/theme.css | | @example/tokens/brand-template.css | @clossys/designer/brand-template.css | | @clossys/designer/views | @clossys/publisher/web for generic rendered views, or compose UI primitives in a Publisher surface. |

atoms, blocks, icons, charts, shell, and gate retain their UI subpaths. There is no compatibility root barrel: importing the owning subpath keeps dependencies and bundle boundaries explicit.

Placement rules

Read this before adding a component. Where it goes on the ladder follows from what it structurally does, not from how it feels while you're writing it — run it through these tests, in order.

1. Does it survive a route change? If a component's whole job is to still be on screen after the route underneath it changes — a nav rail, a top bar, an app frame — it belongs to the shell layer, not to content. The shell provides slots; views (and the blocks/atoms inside them) fill those slots. A component whose entire point is to be replaced on every navigation is never shell.

2. Does it own multiple named regions? If a component lays out several regions that differ in kind — a title region, a description region, an actions region, each doing a different job from the others — it's a block. Otherwise it's an atom. The trap: a list of similar things is not "multiple regions." A breadcrumb trail is a list of crumbs; a tab bar is a list of tabs. Every item in that list plays the same role as every other item — swap two crumbs and nothing about the component's job changes. That's a homogeneous repeat, and it stays one atom no matter how many items are in it. A page header's title, description, and actions aren't interchangeable that way — each is a different kind of thing, and the component's job is specifically to keep those different kinds apart. That's a block.

3. Can one page contain two of them? This is what separates a block from a view. If a page could reasonably show two of the thing at once — two lists side by side, two forms on a settings page, three summary panels in a row — it's a region of a page, so it's a block. If a second one on the same page is incoherent, because the component is the page, it's a view: a page can't have two 404s, and a sign-in page either is one or isn't.

The consequence is that genuine views are rare, and that's correct rather than a gap. A page's structure encodes what a product actually is, so most page-level composition belongs to the consumer, assembled from blocks. Only pages that are genuinely product-neutral — an error page, an authentication page — are the same shape everywhere and worth shipping as views. Shipping a view for something like a list page would mean pre-assembling the exact thing a consumer is supposed to compose, and every consumer whose layout differs would immediately need an escape hatch — which is the variant-rule failure below, one rung up.

Size is not the test. A data table is large and intricate and is still a block, because a page can hold two of them.

4. Does it have a portal, a queue, and an imperative API? A toast stack, a modal manager, a global tooltip layer — anything that renders outside the normal component tree, queues its own items, and is driven by an imperative call rather than by props in the render tree — is a runtime service, not a layout component. It doesn't sit on the atoms/blocks/views/shell ladder at all; it needs its own home.

The variant rule — does the variant change the SET of named regions? If yes, it is a different component, not a prop. A slim header (just a title) and a full header (title, description, actions) have different regions — they are two blocks, not one block with variant="slim". If the difference is padding, font size, or colour — the region set is identical, only its styling changes — that's a prop, not a new component.

This is the rule most worth enforcing, because skipping it does the most damage. A variant/mode prop that starts out covering a purely visual difference is easy to reach for again the next time a structural difference shows up — and once it does, the prop has to keep absorbing every future consumer's divergence as a new named mode. The prop grows without bound, and the component's internals accrete conditionals for combinations that were never meant to compose and that nothing tests. Two components that each compose the same atoms, in two separate files, share no logic that can break that way — there's no shared branch for an untested combination to hide in, because there's no shared branch.

Slots beat mode props. The same principle applies one level down, inside a single component's own API: prefer a ReactNode slot (actions, icon, breadcrumb) over a prop that switches the component's internal structure. A slot lets the component own layout and styling around the gap while the consumer owns what fills it — nothing either side does can produce a combination the other has to guard against. A structural mode prop instead makes the component itself responsible for every shape a consumer might ever want inside it, which is the same unbounded-growth problem as the variant rule above, just scoped to one component's props instead of to which component to reach for.

Setup

Default (pre-auth pages, no Tailwind): import token + compiled styles and your brand overlay — see "Framework-portable components, without Tailwind" above. Run designer-hero-css-check on the CSS file your app actually loads.

Advanced (Tailwind v4 already in the project): the token CSS has to be imported, and Tailwind has to be told to scan this package's built output for the classes it uses. Do not also import compiled.css on the same project.

1. Import the tokens' Tailwind wiring, on top of Tailwind itself, in your CSS entry point:

@import "tailwindcss";
@import
  "@clossys/designer/theme.css";

(theme.css already pulls in the base token file, so you don't need a second line for that. The token layer's brand-template.css provides the full three-layer contract, including how to bind brand colors over the neutral greyscale default.)

2. Point Tailwind's @source at this package's built output, in the same CSS file. This is the single highest-risk step on the advanced path: if Tailwind never scans dist/, it never sees bg-accent or rounded-control as classes anyone used, so it never generates them — blocks render with zero applied styling, and nothing in your build fails or warns about it.

@source "./node_modules/@clossys/designer/dist";

That exact line was compiled for real before it was written down here: a built copy of this package was installed into a scratch project from its packed tarball, a CSS entry importing tailwindcss + theme.css + that @source line was compiled with the real Tailwind v4 CLI, and the output was grepped for classes these components actually render — .bg-accent, .text-ink-on-accent, .rounded-pill, .px-md, .text-body, and more all came back present, each resolving to the real token value with its fallback (for example .bg-accent { background-color: var(--color-accent, oklch(0.4748 0 0)); }). Adjust the path if your CSS entry file doesn't sit next to node_modules — the target is always this package's dist directory, wherever node_modules/@clossys/designer resolves from where your bundler runs.

If your bundler's default content scan already covers everything under node_modules/@clossys/designer (some do), the @source line is redundant but harmless. If you're not sure, add it — a redundant @source costs nothing; a missing one costs every component's styling.

pnpm + Turbopack: a consumer integration reported that the plain-path @source form above produces zero generated utility classes under Next.js Turbopack specifically when the project uses pnpm — no error, no warning, components just render unstyled, the same silent failure this whole section warns about, but with the @source line already present and seemingly correct. Their diagnosis: pnpm installs node_modules/@clossys/designer as a symlink into its content-addressable store, and Turbopack's file watcher/source scanner does not follow that symlink, so it never sees dist/ at all. This repository has not independently reproduced that Turbopack + pnpm interaction — treat it as a reported constraint, not a verified one, and confirm against your own Turbopack version before relying on it. Their workaround was @source inline(...) with the literal class names instead of a path:

@source inline("bg-accent text-ink-on-accent rounded-pill px-md text-body ...");

@source inline(...) takes a space-separated list of literal class names (brace-expansion like {sm,md,lg} is supported for generating variants of the same base) rather than a directory to scan, so it sidesteps file/symlink resolution entirely — at the cost of having to enumerate every class you actually use instead of Tailwind discovering them from dist/. If the directory form above silently produces no styling under Turbopack + pnpm in your project, try this instead.

3. If a component still renders unstyled, call assertTokenStylesLoaded first — before chasing your Tailwind @source config or bundler setup. Both steps above can silently fail to do anything (a missed import, a @source path that resolves to nothing, the Turbopack + pnpm symlink case just above) with no error and no warning anywhere; the result looks identical to "I styled this component wrong" from inside your own code. assertTokenStylesLoaded, from @clossys/designer/tokens, tells you which failure you actually have:

import { assertTokenStylesLoaded } from "@clossys/designer/tokens";

// Call once, near your app's root — never as a side effect of importing
// the package, and never something that renders into the page.
assertTokenStylesLoaded();

It reads back a sentinel custom property (--ui-tokens-loaded) that styles/tokens.css declares for exactly this purpose, and reports once, via console.error, if that property is missing — meaning the CSS file itself was never imported at all (step 1 above), a different failure than an @source misconfiguration (step 2), which imports the CSS fine but never generates the utility classes it needs. Dev-only (a no-op once process.env.NODE_ENV === "production"), SSR-safe (a no-op wherever document doesn't exist), and never renders anything into the page — pass your own onMissing callback instead of the default console.error if you want to route the signal elsewhere. See assert-token-styles-loaded.ts's own header for the full contract, including why this is a console signal only and not the kind of injected page banner #148 removed.

4. Optional-peer version guards. Installing the wrong version of a component peer used to fail silently too — the same #182 gap assertTokenStylesLoaded closes for the token CSS, extended to react, react-aria-components, and tailwindcss. You don't call anything for these three: importing any component subpath (@clossys/designer/atoms, /blocks, /shell, /charts, /theme) or running generateCompiledCss checks the relevant peers' installed versions automatically, and throws a named error — which peer, the range this package declares, and the version actually found — the moment an absent or incompatible one would otherwise have crashed somewhere deep inside a component's own render.

tailwind-merge is the one exception, and it needs an explicit call. Unlike the peers above, it has no way to report its own installed version that doesn't require Node's filesystem — and the one file that imports it, cx.ts, is reachable from every atom, so checking it automatically would break bundling this package's components for the browser. Call assertTailwindMergeVersion yourself, once, from Node-side tooling (a build script, a setup step, or a test — never from component code):

import { assertTailwindMergeVersion } from "@clossys/designer/tokens";

assertTailwindMergeVersion();

It throws the same three ways react's automatic guard does — absent, installed but out of range, or an installed version it cannot parse — and is Node-only: it throws its own clear error rather than a misleading "not installed" if it is ever called from real browser code. See assert-tailwind-merge-version.ts's own header for the full reasoning.

You do not have to call it just to avoid a crash — but a missing peer is never fully silent either. Before #749, an absent tailwind-merge crashed the moment cx (this package's internal class-merge helper, reachable from every atom and therefore from the server-safe barrels too) was imported at all — Cannot find package 'tailwind-merge', even on a purely server-rendered path that never mentioned styling. cx now resolves tailwind-merge lazily, so importing any component subpath without this optional peer installed no longer throws, and rendering completes. What changes is class-conflict resolution: with the peer absent, cx cannot tell that two classes conflict, so BOTH of them are emitted instead of the later one winning (bg-accent passed by this package and a consumer's own bg-status-danger override, say, would both end up in the rendered className, and which one is visually applied then depends on Tailwind's generated stylesheet order, not on argument order). That is real, wrong-relative-to-intent output, so cx logs a console.warn the first time it actually happens — once per process, not once per call, so it is not spam — naming the cause and pointing at installing tailwind-merge. Call assertTailwindMergeVersion when you specifically want a loud, thrown error instead (for example to fail a build or a startup check rather than merely log): its three failure modes (absent, out of range, unparseable) are unchanged by this.

react-dom and @internationalized/date are declared, optional peers with no guard at all — neither has an adapter import site anywhere in this package's own source to guard. react-dom is always the consumer's own render call (react-dom/client, react-dom/server), never something this package imports; DateField's controlled value needs @internationalized/date (parseDate, CalendarDate, ...), but only in code YOU write to construct that value — DateField itself never imports the package. Install both at the ranges given in "Token-only use" above regardless; there is simply nothing left for this package to check once you have.

Wiring up a theme toggle

tokens.css already defines the three-state contract (see "CSS layers, fallbacks, and themes" above): no data-theme follows the OS, and data-theme="light"/"dark" force one regardless of the OS. @clossys/designer/theme is the JavaScript that drives that attribute. Three pieces, used together:

(a) The head script — before anything else in <head>. A React component cannot run before the document paints, so ThemeProvider (below) necessarily corrects the theme one tick too late for a server-rendered page: it would render the OLD theme for one frame, then visibly flip. getThemeInitScript() returns a small, self-contained script (as a string, ready for dangerouslySetInnerHTML) that reads the same stored preference and applies the same three-state rule SYNCHRONOUSLY, before the browser paints anything — there is no component-based way to get this timing, which is why it's a separate piece rather than something ThemeProvider does automatically:

// Your Next.js app's root layout (the "layout.tsx" file in the App
// Router's "app" directory) — first thing in <head>
import { getThemeInitScript } from "@clossys/designer/theme";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <head>
        <script dangerouslySetInnerHTML={{ __html: getThemeInitScript() }} />
      </head>
      <body>{children}</body>
    </html>
  );
}

(b) ThemeProvider — wrap your tree once, near the root. Holds the three-state preference in React state, persists it, and keeps <html data-theme>/color-scheme in sync as it changes:

import { ThemeProvider } from "@clossys/designer/theme";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return <ThemeProvider>{children}</ThemeProvider>;
}

(c) ThemeToggle — an accessible control, anywhere inside the provider. Cycles System → Light → Dark → System (see ThemeToggle.tsx's own doc comment for why a cycling control rather than a switch-plus-reset pair):

import { ThemeToggle } from "@clossys/designer/theme";

function HeaderActions() {
  return <ThemeToggle />;
}

Reach for useTheme() directly when a component needs the current preference or resolved theme without rendering a toggle itself:

import { useTheme } from "@clossys/designer/theme";

function CurrentThemeLabel() {
  const { preference, resolvedTheme } = useTheme();
  return <span>{preference} ({resolvedTheme})</span>;
}

(a) and (b) must agree on the same storageKey (default "ui-theme" for both) — pass { storageKey: "..." } to getThemeInitScript and storageKey="..." to ThemeProvider together if you override it, or the head script will stamp the theme from one key while the provider persists to another.

Why these dependencies

  • react-aria-components — every interactive atom (Button, TextField, Link, Checkbox, Switch, Select, Textarea, Menu, Dialog, Tabs, Table, Tooltip, RadioGroup, Popover, DateField, ComboBox, SearchField, FileTrigger, Disclosure, ProgressBar) is built on its primitives rather than a hand-rolled <button>/<input>/<a>. It supplies keyboard interaction (Enter/Space activation, focus management, arrow-key navigation), the ARIA attributes a screen reader needs (aria-invalid, aria-describedby linking an input to its error text, label association, role="menu"/aria-checked/aria-expanded and the rest), and disabled-state semantics — the kind of behavior that is easy to get subtly wrong by hand and hard to notice is wrong without a screen reader or a keyboard-only pass. Badge, Card, Avatar, Spinner, Skeleton, and Banner compose no other atom and aren't interactive, so they're plain markup — there's no react-aria-components primitive for any of them; Field renders no react-aria-components primitive of its own either, since the whole point of it is wrapping a control that doesn't have one. Breadcrumb, Select, and Menu build on it for their collection components specifically: Breadcrumbs/Breadcrumb/Link supply correct nav semantics and automatic aria-current placement; Select/ListBox/ListBoxItem/Popover supply a listbox's open/close, typeahead, and selection behavior; MenuTrigger/Menu/MenuItem/ Popover supply a menu's open/close, arrow-key navigation, and disabled-item skipping. Dialog builds on DialogTrigger/ModalOverlay/ Modal/Dialog/Heading for a focus-trapped, scroll-locked, Escape-to-dismiss overlay with automatic focus restoration; Popover builds on that same DialogTrigger/Dialog pairing with an anchored, scrim-less Popover standing in for Dialog's centered ModalOverlay+Modal; Tabs builds on Tabs/TabList/Tab/TabPanel for roving-tabindex arrow-key navigation between panels; Table builds on Table/TableHeader/TableBody/Column/Row/Cell for real grid semantics, sorting, and row selection (including the indeterminate select-all state, via this package's own Checkbox atom — see Table's own section below); Tooltip builds on TooltipTrigger/Tooltip for hover-AND-focus opening, Escape-to-dismiss, and the warm-up/cool-down delay between tooltips shown in quick succession; RadioGroup builds on RadioGroup/Radio for roving-tabindex arrow-key navigation between options and role="radiogroup"/role="radio"/aria-checked wiring; DateField builds on DateField/DateInput/DateSegment for per-segment keyboard editing, auto-advance between segments, and locale-correct segment order; ComboBox builds on ComboBox/Input/ Button/Popover/ListBox/ListBoxItem for live filtering plus every behavior Select already gets from the same underlying popover/listbox shape; SearchField builds on SearchField/Input/Button for type="search" semantics, a clear button wired through context, and Escape-to-clear; FileTrigger builds on FileTrigger for OS file-picker access from an arbitrary pressable trigger; Disclosure builds on Disclosure/DisclosurePanel for aria-expanded/aria-controls wiring and keeping collapsed content in the DOM (toggling hidden, not mounting/unmounting); ProgressBar builds on ProgressBar for role="progressbar"/aria-valuenow/aria-valuetext wiring, including correctly omitting aria-valuenow while indeterminate. Chip's remove control is react-aria-components' own Button (unstyled, no props of its own beyond onPress/aria-label), for the same Enter/Space/focus-visible handling every other interactive control here gets; Separator builds on Separator for a real <hr> (horizontal) or role="separator" <div> (vertical) rather than a <div> styled to look like a rule. None of that behavior is reimplemented here — it would be easy to get subtly wrong hand-rolled, which is the whole reason this package leans on react-aria-components for every interactive atom rather than building any of it from scratch.
  • @internationalized/date — DateField's value/defaultValue are react-stately DateValues (a CalendarDate, CalendarDateTime, or ZonedDateTime), not a native JS Date or an ISO string: a plain Date has no way to represent "just a date" without smuggling in a timezone, which is exactly the ambiguity a calendar-aware type exists to avoid. react-aria-components' own date primitives are built around this type internally regardless of whether a consumer ever imports the package directly — but constructing an initial or controlled value at all (parseDate("2024-01-15"), new CalendarDate(2024, 1, 15)) means a consumer of DateField needs it too, so it is a documented optional peer rather than an unlisted transitive of react-aria-components.
  • tailwind-merge — every atom accepts a className prop (the one documented exception is FileTrigger, which does not — see its own entry below), and a consumer's value has to reliably win over this package's own default classes. Two Tailwind utilities that set the same CSS property have identical specificity, so which one wins is otherwise decided by source order in the generated stylesheet, not by which one you passed last. This package's internal cx() helper resolves that with tailwind-merge, additionally taught this package's own spacing/radius/font-size/tracking scale (px-md, rounded-pill, text-body, ...) via extendTailwindMerge — tailwind-merge's own default configuration only recognizes Tailwind's built-in scale names, so out of the box it would neither merge px-md against a consumer's px-8 nor (worse) correctly keep a font-size class like text-body and a color class like text-ink-on-accent both applied at once. Both of those exact failures are pinned as regression tests in this package's test suite.

These packages are optional peers so a token-only consumer installs none of the component runtime. A component consumer must install the matching peers alongside @clossys/designer; npm can then report a missing peer instead of allowing a hidden transitive dependency to decide the runtime version.

No class-variance-authority or similar: each atom's variants are a plain object literal mapping a variant name to a class string.

Atoms

Every example below assumes the setup above is done. variant/size are shown at their defaults for clarity; omitting them is equivalent.

Button

import { Button } from "@clossys/designer/atoms";

function SaveButton() {
  return (
    <Button variant="primary" size="md" onPress={() => save()}>
      Save
    </Button>
  );
}

variant: "primary" | "secondary" | "ghost" | "danger" (default "primary"). size: "sm" | "md" | "lg" (default "md"). Accepts every prop react-aria-components' own Button does — isDisabled, onPress, type, and so on.

Icon

import { Icon } from "@clossys/designer/atoms";
import { Clock } from "@clossys/designer/icons";

function LastUpdated({ label }: { label: string }) {
  return (
    <span>
      <Icon glyph={Clock} decorative /> {label}
    </span>
  );
}

function SearchTrigger() {
  return <Icon glyph={Clock} label="Search" />;
}

The render CONTRACT for an icon — size, colour, accessibility — applied to either structured glyph DATA (glyph, the shape @clossys/designer/icons ships) or arbitrary children (raw SVG elements, or a component that renders them, for a one-off brand mark). Exactly one of the two is required at the type level; supplying both, or neither, is a compile error, not a silent default — see "Accessibility" below for the identical enforcement shape applied to decorative/label.

No name lookup. There is no <Icon name="clock" /> string-keyed registry — a NAME string would itself be a mode prop in disguise, making Icon responsible for knowing every glyph a caller might ever want, the same unbounded-growth failure "Placement rules" → "Slots beat mode props" above describes for a structural mode prop. glyph/children are ordinary ReactNode-shaped slots instead: a consumer's own glyph — vendored from @clossys/designer/icons, hand-copied from a design tool, or a whole custom brand mark — is a first-class input with no extension mechanism to learn, the same way Menu's trigger or PageHeader's actions already are.

Colour always inherits currentColor — there is no color/fill/ stroke prop (IconProps Omits those keys from the SVG props it otherwise forwards), so passing one is a compile-time error, not a silently-ignored prop. Set CSS color on the icon itself or an ancestor instead, the same mechanism Spinner and Skeleton already use above.

Size (size: "sm" | "md" | "lg", default "md") reads this package's --ui-icon-sm/-md/-lg tokens (16px/ 24px/32px by default), each with a literal pixel fallback so Icon still renders at a sensible size even in a project that hasn't imported @clossys/designer/tokens.css. Stroke weight reads --ui-icon-stroke (default 2) the same way — a real brand lever (the token is brandable: true, the same category as --radius-default), not a per-instance prop: there is no strokeWidth/width/height prop either, for the same "the token is the only lever" reason colour has none.

Accessibility is enforced at compile time, ported from this scope's own pre-merge, standalone icons package's own contract:

// Decorative — adds no information beyond text already next to it.
<Icon glyph={Clock} decorative />

// Meaningful — the ONLY signal of what this is. Carries an accessible name.
<Icon glyph={Clock} label="Last updated 3 hours ago" />

// Compile error: TypeScript rejects this before it ever reaches a browser.
// <Icon glyph={Clock} />

decorative: true and label are mutually exclusive (also a compile error together). src/atoms/internal/icon-contract.check.tsx is a small file, compiled by the same tsc run as everything else in this package (unlike a *.test.tsx file — see that file's own header comment, and issue #24, for why that distinction matters here), that fails the build if either the accessibility contract or the glyph/children content contract ever regresses.

className/style merge with Icon's own defaults the same way every other atom's do — a consumer's value always wins on conflict.

TextField

import { TextField } from "@clossys/designer/atoms";

function EmailField() {
  return (
    <TextField
      label="Email"
      description="We'll never share this."
      placeholder="[email protected]"
      isRequired
    />
  );
}

label is required and renders a real <label>, associated with the input by id — not a placeholder standing in for it. description and errorMessage are both wired to the input via aria-describedby; errorMessage (string, or a function of react-aria-components' ValidationResult) only renders while the field isInvalid.

Badge

import { Badge } from "@clossys/designer/atoms";

function Status() {
  return <Badge variant="success">Active</Badge>;
}

variant: "neutral" | "success" | "warning" | "danger" | "info" (default "neutral").

Card

import { Card } from "@clossys/designer/atoms";

function Panel() {
  return <Card className="max-w-sm">Plain content, raised off the page.</Card>;
}

Accepts every prop a plain <div> does. No variants — a single raised surface, styled with this package's elevation token.

Breadcrumb

import { Breadcrumb } from "@clossys/designer/atoms";

function PromptTrail() {
  return (
    <Breadcrumb>
      <Breadcrumb.Item href="/">Home</Breadcrumb.Item>
      <Breadcrumb.Item href="/prompts">Prompts</Breadcrumb.Item>
      <Breadcrumb.Item>Untitled prompt</Breadcrumb.Item>
    </Breadcrumb>
  );
}

Built on react-aria-components' Breadcrumbs + Breadcrumb + Link collection components, not hand-rolled. Whichever Breadcrumb.Item is LAST among its siblings is automatically rendered as the current page — inert text carrying aria-current="page" instead of a clickable <a> — purely from its position in the list; there's no separate "is this the current one" prop to set or forget. The whole trail is wrapped in a <nav> landmark (aria-label="Breadcrumb" by default, overridable) so it's reachable as a landmark, not just an unlabeled list.

Breadcrumb.Item takes an optional href — omit it for a step with no navigable target. Composable JSX children rather than a flat items={[...]} array: react-aria-components' Breadcrumbs is itself built to take real JSX children, and breadcrumb labels are usually already ReactNodes (an icon + text, a truncated title) rather than plain strings. A genuinely data-driven trail can still .map() an array into Breadcrumb.Items — ordinary React, nothing about this API needs to change to support it.

Breadcrumb ships as an atom, not a block, even though it's composable and built from a .Item sub-component: its items are a homogeneous repeat (any crumb plays the same role as any other) rather than a set of regions that differ in kind. See "Placement rules" above.

Link

import { Link } from "@clossys/designer/atoms";

function PromptsLink() {
  return (
    <Link href="/prompts" variant="default">
      Prompts
    </Link>
  );
}

variant: "default" | "muted" | "standalone" (default "default"). default reads as inline text (colored, underlined on hover) — the right choice inside a sentence or paragraph. muted is lower-emphasis, for secondary chrome that shouldn't compete with primary content. standalone is for a link that IS the whole clickable unit on its own (a card title, a nav item), where a permanent underline would read as noise.

Renders a real <a href="..."> by default. A consumer whose app uses a router with its own link component can render that instead via react-aria-components' own render prop, rather than a bespoke as prop of this component's own:

<Link href="/prompts" render={(props) => <RouterLink {...props} to="/prompts" />}>
  Prompts
</Link>

Checkbox

import { Checkbox } from "@clossys/designer/atoms";

function SelectAllRows({ isAllSelected, isSomeSelected, onToggle }: {
  isAllSelected: boolean;
  isSomeSelected: boolean;
  onToggle: (isSelected: boolean) => void;
}) {
  return (
    <Checkbox isSelected={isAllSelected} isIndeterminate={isSomeSelected} onChange={onToggle}>
      Select all
    </Checkbox>
  );
}

isIndeterminate is presentational only — react-aria-components' own contract, not this component's addition: it doesn't change isSelected, so a select-all checkbox like the one above is still responsible for setting both from its own row-selection state. This is the state DataTable's own select-all checkbox needs — see DataTable under "Blocks" below — and the reason Checkbox was prioritized ahead of it.

Switch

import { Switch } from "@clossys/designer/atoms";

function EmailNotificationsToggle() {
  return <Switch onChange={(isOn) => save(isOn)}>Email notifications</Switch>;
}

Semantically distinct from Checkbox even though both toggle a boolean: a switch takes effect immediately (turning a setting on/off), while a checkbox marks a pending selection that typically waits for a separate submit/save action. role="switch" (not role="checkbox") is what communicates that to assistive tech, which is why this is its own component rather than Checkbox with different styling.

Select

import { Select } from "@clossys/designer/atoms";

function FavoriteFruitField() {
  return (
    <Select
      label="Favorite fruit"
      description="Used for the weekly snack order."
      placeholder="Pick one"
      options={[
        { id: "apple", label: "Apple" },
        { id: "banana", label: "Banana" },
        { id: "cherry", label: "Cherry", isDisabled: true },
      ]}
      onChange={(id) => setFavoriteFruit(id)}
    />
  );
}

A labeled dropdown of mutually-exclusive options — the same label/ description/error surface as TextField, for a closed, single-choice set instead of free text. options is a plain array ({ id, label, isDisabled?, textValue? }) rather than JSX children: a select's option set is close to always already data, rather than something a consumer hand-writes as markup. Built on react-aria-components' Select + Button (its OWN Button, not this package's atom — see "Atoms compose no other atom" below) + Popover + ListBox/ListBoxItem, which supply opening on click or ArrowUp/ArrowDown/Enter/Space, closing on Escape or an outside click, arrow-key navigation that skips disabled options, typeahead, and the aria-expanded/aria-haspopup/role="listbox" wiring a screen reader needs.

Textarea

import { Textarea } from "@clossys/designer/atoms";

function DescriptionField() {
  return (
    <Textarea
      label="Description"
      description="Markdown supported."
      rows={6}
      placeholder="What is this prompt for?"
    />
  );
}

TextField's sibling for content that runs longer than one line — the same label/description/error surface, built the same way on react-aria-components' TextField + Label + TextArea + FieldError. A separate component from TextField rather than a multiline prop on it: the two render different DOM elements (<textarea> vs <input>) with different native behavior, a structural difference rather than a purely visual one (see the README's "variant rule").

Avatar

import { Avatar } from "@clossys/designer/atoms";

function UserAvatar() {
  return <Avatar src={user.imageUrl} alt={user.fullName} size="md" />;
}

size: "sm" | "md" | "lg" (default "md"). Shows the image at src; if src is omitted, or the image fails to load, falls back to initials derived from alt. Not interactive and composes no other atom — plain markup, like Badge/Card.

Spinner

import { Spinner } from "@clossys/designer/atoms";

function LoadingPrompts() {
  return <Spinner label="Loading prompts" size="md" />;
}

size: "sm" | "md" | "lg" (default "md"). Plain SVG using currentColor, so it inherits whatever text color is already in effect at its render site (correct by default inside a colored Button, with no variant prop of its own to keep in sync with the parent's). label is optional: provide it when the spinner is itself the only signal that something is loading — it then renders role="status" with that as its accessible name. Omit it when the spinner is purely decorative (e.g. next to a button's own "Saving…" text, which already announces the state) — it then renders aria-hidden="true" instead.

Menu

import { Menu } from "@clossys/designer/atoms";
import { Button } from "@clossys/designer/atoms";

function RowActionsMenu() {
  return (
    <Menu trigger={<Button variant="ghost">Actions</Button>}>
      <Menu.Item onAction={() => edit()}>Edit</Menu.Item>
      <Menu.Item onAction={() => duplicate()}>Duplicate</Menu.Item>
      <Menu.Separator />
      <Menu.Item onAction={() => remove()} isDestructive>
        Delete
      </Menu.Item>
    </Menu>
  );
}

A dropdown menu of actions, opened from a trigger slot. Built on react-aria-components' MenuTrigger + Menu + MenuItem + Popover — the most involved composition in this package, for the same reason it was built last: opening on click or ArrowUp/ArrowDown/Enter/Space, closing on Escape/an outside click/selecting an item, arrow-key navigation that skips disabled items entirely (never just visually dimmed), typeahead, and the role="menu"/role="menuitem"/aria-expanded/aria-haspopup wiring — none of it reimplemented here.

Menu.Item takes an isDestructive prop for actions like "Delete" — danger-colored styling, purely visual, doesn't change keyboard/selection behavior. Menu.Separator is a visual divider between item groups.

There is deliberately no aria-label prop on Menu itself: react-aria-components' MenuTrigger always wires the menu's aria-labelledby to the trigger element, and per the ARIA accessible-name computation, aria-labelledby on an element always wins over an aria-label on that same element — a hypothetical aria-label prop here would render into the DOM but never actually be announced. Give the TRIGGER its own accessible name instead (visible text, or aria-label for an icon-only trigger) and the menu inherits it automatically through that same link:

<Menu trigger={<Button aria-label="More actions">⋯</Button>}>
  <Menu.Item onAction={() => edit()}>Edit</Menu.Item>
</Menu>

Menu composes no other atom of its own, even though its trigger slot is commonly filled with this package's own Button atom by a consumer (as above): that's the consumer's own composition, in their code, the same way a Button can be passed into PageHeader's actions slot without PageHeader importing Button itself.

Menu ships as an atom, not a block, despite composing a trigger and a list of items: unlike PageHeader's simul