@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.
Maintainers
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/designerThis 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.jsonThe 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
↘ themeatoms, 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: 32IconNodeexports (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 aReadonlyArray<readonly [tag: string, attrs: Record<string, string>]>, meant to be passed to theIconatom'sglyphprop. 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.Shellships with five slots (Header,SideNav,Main,Rail,Footer) for an authenticated-app frame;SiteHeader,NavShell,SiteFooter, andSkipLinkship 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, andSparkline. A sibling ofshell, 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 fromtokens.css/theme.css— see "CSS layers, fallbacks, and themes" below):getThemeInitScript, a self-contained head script that stampsdata-themebefore first paint;ThemeProvider/useTheme, which hold and persist the three-state preference at runtime; andThemeToggle, an accessible control built from this package's ownButton/Iconatoms. A sibling ofshellandcharts, 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:
tokens.cssdefines neutral light defaults and automatic/explicit dark overrides. Every token has a literal primitive default.- A consumer brand file copied from
brand-template.cssoverrides only brandable roles under:root[data-brand-bound]. - A consumer master brand mark — three SVG documents (lockup, mark-only,
inverse), validated with
validateMasterMarkfrom@clossys/designer/tokens— sits alongside the brand binding. This package does not ship a product logo, favicon PNGs, or social images. - 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 pathLoad 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-compiledwins too. - A consumer's own
classNameprop is merged the same way it always is on this package's atoms — via the internalcx()/tailwind-mergehelper — 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-describedbylinking an input to its error text, label association,role="menu"/aria-checked/aria-expandedand 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, andBannercompose no other atom and aren't interactive, so they're plain markup — there's no react-aria-components primitive for any of them;Fieldrenders 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, andMenubuild on it for their collection components specifically:Breadcrumbs/Breadcrumb/Linksupply correct nav semantics and automaticaria-currentplacement;Select/ListBox/ListBoxItem/Popoversupply a listbox's open/close, typeahead, and selection behavior;MenuTrigger/Menu/MenuItem/Popoversupply a menu's open/close, arrow-key navigation, and disabled-item skipping.Dialogbuilds onDialogTrigger/ModalOverlay/Modal/Dialog/Headingfor a focus-trapped, scroll-locked, Escape-to-dismiss overlay with automatic focus restoration;Popoverbuilds on that sameDialogTrigger/Dialogpairing with an anchored, scrim-lessPopoverstanding in forDialog's centeredModalOverlay+Modal;Tabsbuilds onTabs/TabList/Tab/TabPanelfor roving-tabindex arrow-key navigation between panels;Tablebuilds onTable/TableHeader/TableBody/Column/Row/Cellfor real grid semantics, sorting, and row selection (including the indeterminate select-all state, via this package's ownCheckboxatom — seeTable's own section below);Tooltipbuilds onTooltipTrigger/Tooltipfor hover-AND-focus opening, Escape-to-dismiss, and the warm-up/cool-down delay between tooltips shown in quick succession;RadioGroupbuilds onRadioGroup/Radiofor roving-tabindex arrow-key navigation between options androle="radiogroup"/role="radio"/aria-checkedwiring;DateFieldbuilds onDateField/DateInput/DateSegmentfor per-segment keyboard editing, auto-advance between segments, and locale-correct segment order;ComboBoxbuilds onComboBox/Input/Button/Popover/ListBox/ListBoxItemfor live filtering plus every behaviorSelectalready gets from the same underlying popover/listbox shape;SearchFieldbuilds onSearchField/Input/Buttonfortype="search"semantics, a clear button wired through context, and Escape-to-clear;FileTriggerbuilds onFileTriggerfor OS file-picker access from an arbitrary pressable trigger;Disclosurebuilds onDisclosure/DisclosurePanelforaria-expanded/aria-controlswiring and keeping collapsed content in the DOM (togglinghidden, not mounting/unmounting);ProgressBarbuilds onProgressBarforrole="progressbar"/aria-valuenow/aria-valuetextwiring, including correctly omittingaria-valuenowwhile indeterminate.Chip's remove control is react-aria-components' ownButton(unstyled, no props of its own beyondonPress/aria-label), for the same Enter/Space/focus-visible handling every other interactive control here gets;Separatorbuilds onSeparatorfor a real<hr>(horizontal) orrole="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'svalue/defaultValueare react-statelyDateValues (aCalendarDate,CalendarDateTime, orZonedDateTime), not a native JSDateor an ISO string: a plainDatehas 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 ofDateFieldneeds it too, so it is a documented optional peer rather than an unlisted transitive ofreact-aria-components.tailwind-merge— every atom accepts aclassNameprop (the one documented exception isFileTrigger, 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 internalcx()helper resolves that withtailwind-merge, additionally taught this package's own spacing/radius/font-size/tracking scale (px-md,rounded-pill,text-body, ...) viaextendTailwindMerge—tailwind-merge's own default configuration only recognizes Tailwind's built-in scale names, so out of the box it would neither mergepx-mdagainst a consumer'spx-8nor (worse) correctly keep a font-size class liketext-bodyand a color class liketext-ink-on-accentboth 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
