AI rules for building with cascivo
Drop this into your AI agent's system prompt, Cursor rules (.cursor/rules), or
AGENTS.md / CLAUDE.md so it generates CSS that keeps cascivo's cascade intact.
cascivo create scaffolds an AGENTS.md with the same contract automatically; this
page is for existing projects.
Looking things up (copy-paste)
Add this first. The contracts below tell an agent the rules; this tells it where to check anything the rules don't cover — against the versions you actually installed, rather than whatever it remembers or whatever cascivo.com documents today.
Install the docs as a local search index:
pnpm add -D @cascivo/docspack docspack
npx docspack sync
Then give the agent one line:
Run `docspack ask "<question>"` for documentation on this project's
dependencies, including cascivo. It answers offline from the installed
versions. Prefer it over recalled knowledge and over anything fetched from
cascivo.com, which documents the latest release rather than ours. Ask a
question, not a name — `docspack ask "verify a theme is applied"` beats
`docspack ask "theming"` — and prefer several narrow questions over one
broad one, because an answer is capped at three chunks.
Answers are bounded (three chunks, ~3,000 tokens), so asking costs a fraction of
fetching the page that holds the answer. Without an index, https://cascivo.com/llms.txt
and npx -y @cascivo/docs are the read-everything fallbacks.
The CSS layer contract (copy-paste)
## cascivo CSS layer contract
cascivo styles live in CSS cascade layers. Layer order beats selector specificity, so
follow these rules whenever you generate or edit CSS:
1. Every declaration goes inside an `@layer` block. Unlayered CSS beats all layers
regardless of specificity — never emit it.
2. Never invent layer names. Write only: your app's own slot for page/app styles, and
`@layer cascivo.override { … }` for hotfixes / one-off overrides — it beats
everything cascivo ships. Your app slot goes **between `cascivo.blocks` and
`cascivo.override`** in the order statement below (rename `cascivo.myapp` after your
app) — high enough to beat cascivo's components, themes and blocks, low enough to
leave `cascivo.override` as the escape hatch.
3. Never nest layers deeper than the shipped `cascivo.blocks.<name>` pattern. For
sub-elements (a badge in a card, a dot in a badge) use native CSS nesting inside one
layer block, not new sublayers.
4. Third-party CSS: `@import url('lib/styles.css') layer(vendor);` with `vendor`
declared before the cascivo layers. Don't import vendor CSS from JavaScript — route
it through a CSS file, or use `@cascivo/vite-plugin` (`cascivoLayers`) to layer it.
5. Style with `--cascivo-*` tokens, not raw values.
Canonical layer order (lowest → highest priority):
`@layer vendor, cascivo.reset, cascivo.base, cascivo.tokens, cascivo.component, cascivo.platform, cascivo.theme, cascivo.blocks, cascivo.myapp, cascivo.override;`
The full machine-readable guide is at https://cascivo.com/llms.txt.
The reactivity contract (copy-paste)
The CSS contract above keeps the cascade intact; this one keeps the reactivity model
intact. Drop it into the same agent rules file. Without it, an agent defaults to
useState/useEffect/useContext and concludes cascivo "has no state story" — the exact
mistake that makes a signal-native system look layout-only.
## cascivo reactivity contract
cascivo is signal-driven. Do not mix React state with signals — it causes toggles that
don't toggle and UIs that freeze. Reach for the cascivo primitive, not the React hook.
**Import every primitive below from `@cascivo/react`** if you installed the prebuilt
package, or from `@cascivo/core` if you copied component source in with the CLI. Never add
`@cascivo/core` to a prebuilt-path app's package.json — it is only a transitive dependency
there, and everything is re-exported from `@cascivo/react`.
1. Local state -> `const [count, setCount] = useSignalState(initial)`: read `count.value` in
render, write with `setCount(next)` or `setCount((n) => n + 1)`. Derived ->
`useComputed(fn)`. Never `useState`. The signal IS the state.
> **Write through the setter, never `count.value = next` in a component.** Assigning to a
> value returned from a hook fails the React Compiler build ("This value cannot be
> modified") and is reported by `react-hooks/immutability`, which
> `eslint-plugin-react-hooks@7` turns on by default. The setter form passes both — checked
> by `pnpm compiler:check`. Plain `useSignal` still works, but its writes carry that cost;
> see [USING-WITH-STRICT-ESLINT.md](./USING-WITH-STRICT-ESLINT.md). Writing
> `.value` on a **module-level** `signal()` (rule 3) is fine — it is not a hook value.
2. Side effects (DOM, listeners, `showModal()`) -> `useSignalEffect(fn)`. Never `useEffect`.
3. Shared/app-wide state -> a module-level `signal` imported anywhere. Never `useContext`.
4. A controlled/uncontrolled prop bridged to a signal ->
`useControllableSignal({ value, defaultValue, onChange })`.
5. Avoiding signal leaks across route/tenant changes -> `useScope()` / `createScope()`
(disposes owned effects on unmount).
6. Forms -> `createForm` / `useForm` / `<Form>` / `field()` (`@cascivo/react`): signal store,
sync/async + Standard Schema (zod/valibot) validation, optional `validateOnChange`.
7. Theming (light/dark toggle, SSR no-FOUC) -> `<ThemeProvider>` + `useTheme()` /
`setTheme()` + `themePreloadScript()` (`@cascivo/react`). `useTheme()` returns a **tuple**
`[theme, setTheme]` where `theme` is a plain **`string`** (the current theme name) — use it
directly (`theme === 'dark'`), never `theme.value`, and never destructure `{ theme, setTheme }`
(that is next-themes' shape, not cascivo's). The hook calls `useSignals()` for you, so the
component re-renders on theme changes with no signal handling. For signal-native code
(`computed()`/`effect()`/Preact) use `themeSignal()` instead. A controlled
`<ThemeProvider value=…>` is SSR-safe by itself (emits an inline attribute setter). Never a
`useEffect` that adds a `.dark` class.
8. Token names in TypeScript -> `import type { CascivoToken, CascivoColorToken } from
'@cascivo/tokens/tokens'` (generated union — no CSS-file lookup).
9. `useSignals()` is needed ONLY for a signal you did not get from a cascivo hook: a
module-level `signal()`, a signal passed in as a prop, or `currentLocale()`. Call it as
the component's first statement. Signals returned by `useSignalState`, `useSignal`, `useComputed`,
`useDisclosure`, `useMachine`, `useTheme` and the rest subscribe you already — do not
sprinkle `useSignals()` everywhere. Symptom of getting this wrong: handlers fire, the
UI never moves, no error.
10. Mirroring a controlled prop into a signal -> `useControllableSignal` when you read it
in render; `useEffectPropSignal` when it is read only inside `useSignalEffect`. Never
hand-roll `s.value = prop` for the effect case: signals run effects synchronously on
write, so that runs the effect body during React's render phase.
Full catalogs: docs/HEADLESS.md (primitives) and docs/ENTERPRISE-READINESS.md (friction map).
Event-handler naming
cascivo names change/activation callbacks by what the handler receives, so you can predict the prop without checking the types:
| Handler receives | Prop name | Examples |
|---|---|---|
| The component's value (string / number / array / boolean / Date — not a DOM event) | onValueChange(value) | Tabs, SegmentedControl, Combobox, MultiSelect, Toggle, Search, NumberInput, DatePicker |
A raw DOM ChangeEvent from a real underlying element | onChange(event) | Checkbox, NativeSelect, PasswordInput, Select, Slider |
| Activation / selection of a discrete item | onSelect(value) | Dropdown, OverflowMenu, MenuItem, ContextMenuItem, chart point clicks |
| A raw DOM click passthrough | onClick(event) | nav items, buttons |
Rule of thumb when authoring or generating: if your handler's first argument is a value,
name it onValueChange; if it's a DOM event, name it onChange. The rule now holds with
no exceptions — the value-carrying onChange aliases that eight components once accepted
(Combobox, DatePicker, Filter, NumberInput, Search, Swap, TimePicker, Toggle)
were removed at 1.0.
These eight
Omitthe DOMonChangefrom their prop types and do not add it back. That is deliberate: without theOmit,onChangewould silently become React'sChangeEventHandler, so code passing a value-carrying handler would compile and then be called with an event. With it,onChangeis a compile error namingonValueChange.
SelectandSliderare native-element wrappers, not composite components: they spread onto a real<select>/<input type="range">and carry only the DOMonChange(event). Read the value offevent.target.value. They were previously listed in theonValueChangerow, which was wrong and cost an adopter a build cycle —scripts/checks/handler-naming-parity.test.tsnow fails the build if this table names a handler a component does not have.
onSelecton menus lives on the item, not the menu.MenuItem/ContextMenuItemtakeonSelect: () => void(the item already knows which item it is).DropdownandOverflowMenuare config-driven, so their root takesonSelect(value).
Accessible-name and item-identity props
The sibling of the handler rule: name a prop by what it is, not by the component.
| The value is | Prop name | Examples |
|---|---|---|
| Text the component renders | label | Field, Checkbox, Radio, Toggle, Slider |
An invisible accessible name for an icon-only control or a nav landmark (goes to aria-label) | ariaLabel | OverflowMenu, SideNav, Breadcrumb, Dock, Steps |
| The identity of an item that is handed to a callback | value | OverflowMenu, Dropdown, Select, Combobox |
| A React key for an item — never passed anywhere | id (rows/items) / key (table columns) | CommandMenu, StructuredList, Timeline, DataTable.columns[].key |
ariaLabel and label are two spellings of one idea, and every component that accepts one
accepts the other. That is the whole rule, and it is mechanically true rather than merely
documented: scripts/checks/aria-label-universality.test.ts fails the build if a component
takes an invisible name under only one spelling. ariaLabel stays the preferred name in new
code — it says "invisible" out loud — but label is the guess an adopter makes before they
have read this page, and a guess that compiles costs nobody anything. <OverflowMenu label=…> was a type error until 0.19 and is not any more (2026-08-21 report item 1).
Two components predate the rule and require the name, so they type it as an XOR union:
IconButton and Fab accept exactly one of label / ariaLabel, because a control with no
accessible name is a bug the compiler should catch. Sparkline does the same.
Two components are exempt, with reasons recorded in the guard: DataTable's visible name is
title (a caption), and Menubar's required name is an XOR of ariaLabel / aria-label.
Every component that took only the DOM spelling aria-label now accepts both
ariaLabel and aria-label — two spellings of one idea inside one package was a coin flip
on every component. That covers Filter, StructuredList, Progress, Menubar,
NavigationMenu, TreeView, Swap, RadialProgress, SplitView and StatsBand. Where
the name is required (Menubar, IconButton), an XOR union enforces that exactly one
is present, so the a11y guarantee survives the alias.
value vs id is a real distinction, not an inconsistency. value is the identity the
component hands back to you — onSelect(value). id is a React key the component uses
internally and never passes anywhere: CommandMenu's items take onSelect: () => void, so
their id could not be delivered even in principle. Reach for value when a callback
receives it, id when it is only identity. OverflowMenu items additionally accept id
as an alias of value, because it is the common wrong guess by analogy with CommandMenu.
Guessing across components is the failure this prevents: an adopter wrote
<OverflowMenu label=… items={[{ id, label }]}> by analogy with CommandMenu and
IconButton and got two type errors — OverflowMenu takes ariaLabel and value. The
per-component pages were correct; the cost was that the convention was never stated.
Data and shape props — the vocabulary an agent has to guess
The two tables above cover handlers and names. This one covers the props that carry the data and the look, which is where a 2026-08-08 adopter lost nine compile cycles in one small dashboard — the single largest friction in that report.
| The prop carries | Prop name | Never | Why |
|---|---|---|---|
| A config-driven collection (nav, list, menu) | items | rows, entries | Nav, list and menu components. The full, current list is generated into llms.txt from the registry — it is not repeated here, because the hand-written version of this row named Steps and CommandMenu under items when they take steps and groups (2026-08-22 report item 10) |
| The choices on a form control | options | items, choices | Select, NativeSelect, Combobox, MultiSelect, Filter, SegmentedControl, WheelPicker. A choice control takes options, not items |
| A chart's data points | data | items, series | Every chart — PieChart, Sparkline, Heatmap, CalendarHeatmap, … (series is the grouping prop on multi-series charts, not the data) |
| The rows of a table | rows | items | DataTable only — it renders a <table>, where "rows" is the domain word, not a synonym for items. (Textarea.rows is the HTML attribute, not a collection.) Steppers keep the domain word too: Steps.steps and ProgressIndicator.steps — Steps also accepts items |
| A visual style enum | variant | shape, kind, type, appearance | Badge, Tag, Button, Alert, Card, Notification |
| The tag of a discriminated union | kind | type | AreaChart.annotations[].kind, and every new union — type is reserved for HTML-ish meanings (input type, edge/node renderer keys) |
| A space-scale step | numeric gap={4} | gap="4" | Flex, Grid, AutoGrid, AppShell.padding. ⚠ See the warning below — this is the one that breaks the pattern |
| A rich, replaceable slot | actions (ReactNode) | action={{ label, onClick }} | Notification, CardHeader, PageHeader. Alert.action is the one {label,onClick} shorthand left; it is not the pattern to copy |
| The body text of a feedback component | description | children | Notification, Alert, EmptyState — passing children renders nothing |
| Supporting text under a form control | hint | description | Input, Textarea, Select, NumberInput, Combobox, DatePicker, TimePicker, FileUploader. Field predates the split and takes description; it accepts hint as an alias, so either guess compiles |
| A visible text label | label | title, text, caption | The default — most components that take label render it on screen (Toggle, Checkbox, Input, Slider, Stat, Kpi, …). ⚠ See the warning below |
| An invisible accessible name | ariaLabel — and label is accepted as an alias everywhere it exists | — | OverflowMenu, SideNav, Breadcrumb, Steps, Switcher, CommandMenu, Spinner, ProgressCircle, Resizable, Sparkline. Always accepted alongside the raw aria-label |
⚠
labelrenders on screen — check the prop docs before assuming it is a11y-onlyA 2026-08-14 adopter learned
labelfromSparkline, where it is explicitly an invisible accessible name, and passed<Toggle label="Automatic deployments">into a settings row that already had a visible title. The string rendered next to the switch, duplicating the row's own heading.The catalog rule is:
labelis visible unless its own description says otherwise, andariaLabelis never visible. When a row, heading orFieldalready labels the control, omitlabeland passaria-labelinstead — every component forwards it.Enforced by
vocabulary.test.ts: alabelprop whose manifest description states neither failspnpm meta:check. Silence is the bug — the reader cannot tell it from either case.
⚠ Near-miss prop names — what you probably wrote, and what the prop is
Every row here is a real wrong guess from an adopter report, not a hypothetical. They are near-misses rather than blunders: each one is the name the rest of the system, or the rest of the ecosystem, would lead you to. Where the fix was to accept both spellings we did; where accepting both would have made something else worse, the reason is in the last column.
| You probably wrote | The prop is | On | Why not just accept yours |
|---|---|---|---|
tone="subtle" | muted (boolean) | Text | tone is the catalog's severity vocabulary (Status, Badge, Timeline, SideNav). Text emphasis is a different idea; a third meaning for tone would cost more than it saves |
gap="4" | gap={4} (numeric SpaceStep) | every layout | A string union would let gap="7" type-check into a token that does not exist |
<Flex justify="between"> with no direction | it is already vertical | Flex | direction="vertical" is the default, unlike CSS and unlike Chakra/MUI/Radix. Pass direction="horizontal" for a row |
const { theme } = useTheme() | a tuple: const [theme, setTheme] = useTheme() | @cascivo/core | The object shape is next-themes'; the tuple is useState's |
orientation="vertical" meaning "stack the items" | it stacks the value under its label | DataList | Items stack vertically in both modes; orientation moves the value relative to its label |
<DataListItem> as a component | DataList takes items | DataList | DataListItem is the interface describing an item, not a component |
import { Switch } | Toggle — and Switch now works too | @cascivo/react | Fixed: Switch is exported as an alias, and cascivo add switch resolves |
<OverflowMenu label=…> | ariaLabel — and label now works too | OverflowMenu, SideNav, Breadcrumb, Steps | Fixed: both spellings are accepted everywhere either was |
<Field hint=…> | description — and hint now works too | Field | Fixed: hint is the form-control word, description the feedback word; Field takes both |
Importing the shared types
Tone, Progress and SpaceStep are the types of published props — Status.status and
Badge.variant are ToneInput, every layout gap is a SpaceStep — so the first thing a
typed dashboard writes needs them:
// Path B (prebuilt, `@cascivo/react`) — the vocabulary types ship from a subpath:
import type { Tone } from '@cascivo/react/types'
// Path A (copied source) — import from core directly, which you already depend on:
import type { Tone } from '@cascivo/core'
const DEPLOY_TONE: Record<DeployState, Tone> = {
building: 'info',
ready: 'success',
error: 'danger',
}
<Status status={DEPLOY_TONE[deployment.state]} />
@cascivo/react/types re-exports Tone, ToneAlias, ToneInput, Progress,
ProgressAlias, ProgressInput, SpaceStep and RovingOrientation. Do not add
@cascivo/core to a prebuilt app's dependencies to reach them — on that path it is a
transitive dep, and pinning it invites a lockstep-version trap.
They sit on a subpath rather than the main entry for a mechanical reason: the component
sources already import those names from core, so re-exporting them from @cascivo/react
makes the dts bundler emit ToneInput as ToneInput$1 and every prop switches to the aliased
name. type-exports-parity fails the build when a core type naming a published prop is
reachable from neither entry.
⚠
gaptakes a NUMBER, and it is the one prop that breaks the patternEvery other size-ish prop in the catalog is a string union —
size="sm",padding="md",density="compact". The space scale is a numericSpaceStep(1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12), so it isgap={4}, notgap="4".This is deliberate: the steps are a scale with an order, and a string union would let
gap="7"type-check into a token that does not exist. But it is genuinely surprising, and one adopter'sgap="4"produced 20 type errors in a single run — by far the largest single cost in their build. Write the braces.
Items-prop-driven vs children-driven — and the types that look like components
DataList, StructuredList, Timeline and Steps are items-prop-driven: they take an
array and render it. ListItem, ContainedListItem, MenuItem and TabsTrigger are
children-driven: you compose them as elements. Nothing in the name tells you which, so
the catalog's export list mixes real components with the interfaces that describe their
items — DataListItem is an interface, not a component, and
<DataList>
<DataListItem label="Domain">{project.domain}</DataListItem> {/* ✗ not a component */}
</DataList>
<DataList items={[{ label: 'Domain', value: project.domain }]} /> {/* ✓ */}
DataList's items are { label, value }. They render into a <dl>, so term/description
is the natural guess from the HTML and it is wrong — label/value is the catalog-wide
naming (see the accessible-name table above), and consistency across components beats
mirroring one element's vocabulary.
Status and progress vocabularies — one set of words
Four display components and two sequence components used to ship six overlapping enums for two ideas. There is now one canonical vocabulary for each, and every historical spelling is accepted as an alias — so one domain enum drives the whole catalog with no lookup table.
Severity — Tone (@cascivo/core): neutral | info | success | warning | danger
| Component | Prop | Also accepts (aliases) |
|---|---|---|
Badge | variant | default→neutral, destructive/error→danger, plus secondary/outline (looks, not tones) |
Tag | variant | default→neutral, error/destructive→danger |
Status | status | error/destructive→danger, default→neutral |
Notification | variant | error/destructive→danger |
Position in a sequence — Progress (@cascivo/core): pending | active | complete | error
| Component | Prop | Also accepts (aliases) |
|---|---|---|
Steps | Step.state | current→active, upcoming→pending |
Timeline | TimelineItem.status | current→active, upcoming→pending |
Write the canonical value in new code. scripts/checks/vocabulary.test.ts fails a component
that models either idea with a private union.
Links in a routed app
Two kinds of link, wired two different ways. Getting this wrong costs either a full page reload or a hand-rolled copy of cascivo's link CSS — both were reported by adopters.
| The link is | Do this |
|---|---|
Rendered by cascivo from config (SideNav, ShellHeader, Header, Breadcrumb, Switcher, Dock, NavigationMenu) | setLinkComponent(...) once at app startup |
| Written by you in page content (a project name, a branch in a table cell) | <Link asChild><RouterLink to="…">…</RouterLink></Link> |
| A call-to-action that navigates | <Button asChild><RouterLink to="…">…</RouterLink></Button> |
Never write a bare <Link href="/x"> in a routed app — cascivo's Link renders a real
<a>, so it is a full page reload. setLinkComponent does not apply to it: Link is a
component you place, so it takes the child you hand it. Never copy cascivo's link CSS into
your own layer — override the tokens (--cascivo-link-color) instead.
Full guide: USING-WITH-A-ROUTER.md.
No styling at a distance
Every style on an element comes from that element. A component's own CSS never reaches down into somebody else's subtree, and nothing outside a component reaches into its internals except through a name that is published, versioned and checked.
This is a property cascivo has, not an aspiration. It is enforced three ways:
- CSS Modules hash internal class names (
_navWrapper_1r5fv_83, different on every build), so there is no selector for a component's internals to accidentally depend on. data-cascivo-*style hooks are the sanctioned exception, and they are public API: semver'd, declared in each component's manifest, present inregistry.jsonand thellms/*.mdfiles, and checked in both directions by CI — a hook cannot be renamed without the manifest changing, and a manifest cannot promise a hook the component does not stamp. See STYLING-INTERNALS.md.@layerdecides who wins, not selector specificity. You never need a descendant selector to out-rank cascivo, becausecascivo.overridealready does.
The point is not tidiness. .card > div > nav is a rule that works until the day somebody
adds a wrapper, and then fails silently — no error, no warning, just a component that
stopped being styled. Structural selectors are load-bearing dependencies on markup nobody
promised to keep. Write against a hook, a token, or a prop instead; all three are checked,
and all three survive a refactor.
Overriding styles the sanctioned way
Every cascivo component spreads {...props} onto its root element, so style and
className already pass through on every component — you do not need (and there is
no) sx/css styling prop. When a component's props and tokens don't cover a one-off,
climb this ladder in order and stop at the first rung that works:
-
Component props + tokens — the intended path.
variant,size, and setting a--cascivo-*component token cover almost everything. -
className+ a rule incascivo.override— for a reusable override. Thecascivo.overridelayer beats everything cascivo ships. -
Inline
stylewithvar(--cascivo-*)values — for a fast one-off. This stayscascivo audit --ai-clean because the values are tokens. Type it and the token names are checked too:import type { CSSProperties } from 'react' import type { CascivoTokenStyle } from '@cascivo/tokens/style-contract' const brand = { '--cascivo-link-color': 'var(--cascivo-color-accent)', } satisfies CascivoTokenStyle <Link style={brand as CSSProperties}>Docs</Link>The cast is unavoidable — React's
CSSPropertieshas no index signature for--*keys — butsatisfiesruns before it, so a misspelled token is a compile error instead of a declaration that silently does nothing. -
/* cascivo-audit: allow <rule> */— the rare remainder. A comment on the same line as, or the line above, a flagged line downgrades that finding so the audit no longer fails on it (e.g.allow unknown-prop,allow hardcoded-value, orallow all). Suppressed findings still print, so nothing is hidden.
cascivo audit --ai treats an inline style value that happens to equal a token as a
gentle warning, not an error — it never blocks a build on a fast-prototyping override.
Genuinely invented props (sx, elevation, …) remain errors; use rung 4 only when you
mean it.
A name that does not exist is an error, everywhere
Two mistakes are silent in CSS itself and therefore checked for you, because nothing else in the stack will ever mention them:
- An unknown custom property is dropped.
--cascivo-color-acent: reddoes not warn, does not fail the build, does not appear in DevTools, and does not throw. It has no effect, and the hunt for the cause starts in the component. - A selector that matches nothing is not an error.
[data-cascivo-modl-body] { … }styles zero elements forever, and because CSS Modules hash the real class names you cannot tell a typo from a component that changed shape.
So the shipped name sets are enforced, not merely published:
| Where you are | What checks it |
|---|---|
| Your editor | cascivo/token-values (in @cascivo/eslint-config, at warn) |
| Your types | CascivoTokenStyle + satisfies, as in rung 3 |
| Your CI | cascivo audit --ai — unknown-token and unknown-style-hook, at error |
All three read the same generated set, so they cannot disagree with each other or with
the CSS. The names live in @cascivo/tokens/style-contract.json.
Running the audit
It works in any project with no setup — the contract ships inside the CLI, so there is nothing to download and no network needed:
npx cascivo audit --ai src # or add it to your lint script
npx cascivo audit --ai --json src # machine-readable findings
npx cascivo audit --ai --fix src # rewrite unambiguous literals to tokens
A good CI gate pairs it with doctor, which checks the install itself:
{ "scripts": { "lint": "cascivo doctor --ci && cascivo audit --ai src" } }
Only error-level findings fail --ci. info findings (a component using a {...spread},
whose props can't be known statically) and warn findings (an inline style literal that
happens to equal a token) never do — so the gate stays green on correct code. A realistic
router-based dashboard is audited in cascivo's own CI
(packages/cli/src/audit-ai/adopter-app.test.ts) and must report zero errors; that
fixture is what keeps this recommendation honest.
--contract <path> points at a specific audit-contract.json (pin a version, or run
fully air-gapped); --verbose reports which contract source was used.
Layout primitives — structure with these before writing CSS
Page structure (dashboard shells, toolbars, card grids, multi-column sections) has
first-class primitives, all exported from @cascivo/react — reach for them before
hand-writing grid/flex CSS or inline style layout:
Flex— the gap-based flex container (direction,gap,align,justify,wrap).Grid/GridItem— CSS grid with responsive object props:<Grid cols={{ base: 1, md: 2, lg: 3 }} gap={4}>,<GridItem span={{ base: 1, lg: 2 }}>.AutoGrid— responsive card grid that fills columns by available width, no media queries.Columns,Center,Spacer— equal columns, centered max-width column, fixed gap.
The published Stack is a visual card-pile (overlaps children by an offset) — for
gap-based layout use Flex, not Stack.
Coming from utility-first (Tailwind)?
cascivo has no utility classes. You express the same intent with plain CSS properties
reading --cascivo-* tokens, inside a layer. The mapping is mechanical:
| Tailwind utility | cascivo CSS (inside @layer …) |
|---|---|
p-4 | padding: var(--cascivo-space-4); |
px-2 | padding-inline: var(--cascivo-space-2); |
gap-2 | gap: var(--cascivo-space-2); |
flex items-center | display: flex; align-items: center; |
flex items-center gap-2 | display: flex; align-items: center; gap: var(--cascivo-space-2); |
text-sm | font-size: var(--cascivo-text-sm); |
text-muted-foreground | color: var(--cascivo-color-text-subtle); |
font-semibold | font-weight: var(--cascivo-font-semibold); |
rounded-md | border-radius: var(--cascivo-radius-md); |
bg-card | background: var(--cascivo-color-surface); |
Two habit changes:
- Structure vs. style split. Markup stays semantic; all styling lives in a CSS module inside a layer. You are not decorating JSX with class strings.
- Tokens, not values. Reach for a
--cascivo-*token instead of a raw16px/#111. The closed token set is athttps://cascivo.com/tokens.catalog.jsonand documented in TOKENS.md.
Server-rendering setup (Vite SSR / TanStack Start / Remix / workerd)
If you scaffold an app that server-renders through Vite (TanStack Start,
vite-ssr, Remix on Vite, or a workerd/Cloudflare target), do two things or the
build throws Unknown file extension ".css" and silently falls back to
client-only rendering:
- Add
ssr: { noExternal: [/^@cascivo\//] }tovite.config.ts— or add thecascivoSsr()plugin from@cascivo/vite-plugin. This makes Vite process the packages' per-component CSS imports instead of leaving them for the server runtime to load raw. - Import a theme (
@cascivo/themes/light-dark.css) once in the root route/entry. Do not import@cascivo/react/styles.css: per-component CSS rides the client module graph and tree-shakes, and the aggregate replaces the few KB you use with all 199 components' worth.
No <ClientOnly> wrappers are needed — components ship 'use client' and render
their server HTML normally. Next.js App Router needs none of this (the
react-server export condition handles it), and plain Vite CSR/SPA needs none
of it either — only Vite SSR runtimes do. Full recipe:
USING-WITH-VITE-SSR.md.
TypeScript + CSS imports. The import '@cascivo/themes/light-dark.css' (and
@cascivo/react/styles.css) side-effect imports need ambient CSS-module types under
tsc --noEmit, or they error with TS2307 (TS2882 under
noUncheckedSideEffectImports). Add src/vite-env.d.ts with
/// <reference types="vite/client" />, or declare module '*.css'. This is a
type-only declaration — no runtime effect.
Reading a UI instead of looking at it
An agent that needs to know what a rendered page currently says — not what its source
says — can serialize it to Markdown with @cascivo/text:
import { renderToStaticMarkup } from 'react-dom/server'
import { toMarkdown } from '@cascivo/text'
const doc = toMarkdown(renderToStaticMarkup(<Dashboard />))
No CSS, no hydration, no interactivity. Affordances come through named —
[button: Save], [input: Email = "[email protected]"], [tab: Overview (selected)] — and a
chart arrives as a Markdown table. In the browser, elementToMarkdown(element) reads the
live DOM, so it reports what someone has actually typed, checked and opened.
See MACHINE-MODE.md.
See also
@cascivo/docspack— every guide on this page as a searchable offline index (docspack ask,docspack mcp).- USING-WITH-A-ROUTER.md —
setLinkComponentvsasChild. - USING-WITH-VITE-SSR.md — the SSR
ssr.noExternalrecipe. - CSS-LAYERS-PITFALL.md — the canonical order and the
cascivo.overrideescape hatch. - THIRD-PARTY-CSS.md — the
layer(vendor)recipe. - USING-WITH-TAILWIND.md — running cascivo alongside an existing Tailwind v4 setup.
- TOKENS.md — the full token catalog.
- MACHINE-MODE.md — a rendered UI as a Markdown document.
As Markdown: /docs/ai-rules.md