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 receivesProp nameExamples
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 elementonChange(event)Checkbox, NativeSelect, PasswordInput, Select, Slider
Activation / selection of a discrete itemonSelect(value)Dropdown, OverflowMenu, MenuItem, ContextMenuItem, chart point clicks
A raw DOM click passthroughonClick(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 Omit the DOM onChange from their prop types and do not add it back. That is deliberate: without the Omit, onChange would silently become React's ChangeEventHandler, so code passing a value-carrying handler would compile and then be called with an event. With it, onChange is a compile error naming onValueChange.

Select and Slider are native-element wrappers, not composite components: they spread onto a real <select> / <input type="range"> and carry only the DOM onChange(event). Read the value off event.target.value. They were previously listed in the onValueChange row, which was wrong and cost an adopter a build cycle — scripts/checks/handler-naming-parity.test.ts now fails the build if this table names a handler a component does not have.

onSelect on menus lives on the item, not the menu. MenuItem / ContextMenuItem take onSelect: () => void (the item already knows which item it is). Dropdown and OverflowMenu are config-driven, so their root takes onSelect(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 isProp nameExamples
Text the component renderslabelField, Checkbox, Radio, Toggle, Slider
An invisible accessible name for an icon-only control or a nav landmark (goes to aria-label)ariaLabelOverflowMenu, SideNav, Breadcrumb, Dock, Steps
The identity of an item that is handed to a callbackvalueOverflowMenu, Dropdown, Select, Combobox
A React key for an item — never passed anywhereid (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 carriesProp nameNeverWhy
A config-driven collection (nav, list, menu)itemsrows, entriesNav, 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 controloptionsitems, choicesSelect, NativeSelect, Combobox, MultiSelect, Filter, SegmentedControl, WheelPicker. A choice control takes options, not items
A chart's data pointsdataitems, seriesEvery chart — PieChart, Sparkline, Heatmap, CalendarHeatmap, … (series is the grouping prop on multi-series charts, not the data)
The rows of a tablerowsitemsDataTable 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 enumvariantshape, kind, type, appearanceBadge, Tag, Button, Alert, Card, Notification
The tag of a discriminated unionkindtypeAreaChart.annotations[].kind, and every new union — type is reserved for HTML-ish meanings (input type, edge/node renderer keys)
A space-scale stepnumeric gap={4}gap="4"Flex, Grid, AutoGrid, AppShell.padding. ⚠ See the warning below — this is the one that breaks the pattern
A rich, replaceable slotactions (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 componentdescriptionchildrenNotification, Alert, EmptyState — passing children renders nothing
Supporting text under a form controlhintdescriptionInput, 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 labellabeltitle, text, captionThe default — most components that take label render it on screen (Toggle, Checkbox, Input, Slider, Stat, Kpi, …). ⚠ See the warning below
An invisible accessible nameariaLabel — 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

⚠ label renders on screen — check the prop docs before assuming it is a11y-only

A 2026-08-14 adopter learned label from Sparkline, 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: label is visible unless its own description says otherwise, and ariaLabel is never visible. When a row, heading or Field already labels the control, omit label and pass aria-label instead — every component forwards it.

Enforced by vocabulary.test.ts: a label prop whose manifest description states neither fails pnpm 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 wroteThe prop isOnWhy not just accept yours
tone="subtle"muted (boolean)Texttone 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 layoutA string union would let gap="7" type-check into a token that does not exist
<Flex justify="between"> with no directionit is already verticalFlexdirection="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/coreThe object shape is next-themes'; the tuple is useState's
orientation="vertical" meaning "stack the items"it stacks the value under its labelDataListItems stack vertically in both modes; orientation moves the value relative to its label
<DataListItem> as a componentDataList takes itemsDataListDataListItem is the interface describing an item, not a component
import { Switch }Toggle — and Switch now works too@cascivo/reactFixed: Switch is exported as an alias, and cascivo add switch resolves
<OverflowMenu label=…>ariaLabel — and label now works tooOverflowMenu, SideNav, Breadcrumb, StepsFixed: both spellings are accepted everywhere either was
<Field hint=…>description — and hint now works tooFieldFixed: 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.

⚠ gap takes a NUMBER, and it is the one prop that breaks the pattern

Every other size-ish prop in the catalog is a string union — size="sm", padding="md", density="compact". The space scale is a numeric SpaceStep (1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12), so it is gap={4}, not gap="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's gap="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

ComponentPropAlso accepts (aliases)
Badgevariantdefault→neutral, destructive/error→danger, plus secondary/outline (looks, not tones)
Tagvariantdefault→neutral, error/destructive→danger
Statusstatuserror/destructive→danger, default→neutral
Notificationvarianterror/destructive→danger

Position in a sequence — Progress (@cascivo/core): pending | active | complete | error

ComponentPropAlso accepts (aliases)
StepsStep.statecurrent→active, upcoming→pending
TimelineTimelineItem.statuscurrent→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.

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 isDo 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:

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:

  1. Component props + tokens — the intended path. variant, size, and setting a --cascivo-* component token cover almost everything.

  2. className + a rule in cascivo.override — for a reusable override. The cascivo.override layer beats everything cascivo ships.

  3. Inline style with var(--cascivo-*) values — for a fast one-off. This stays cascivo 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 CSSProperties has no index signature for --* keys — but satisfies runs before it, so a misspelled token is a compile error instead of a declaration that silently does nothing.

  4. /* 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, or allow 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:

So the shipped name sets are enforced, not merely published:

Where you areWhat checks it
Your editorcascivo/token-values (in @cascivo/eslint-config, at warn)
Your typesCascivoTokenStyle + satisfies, as in rung 3
Your CIcascivo 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:

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 utilitycascivo CSS (inside @layer …)
p-4padding: var(--cascivo-space-4);
px-2padding-inline: var(--cascivo-space-2);
gap-2gap: var(--cascivo-space-2);
flex items-centerdisplay: flex; align-items: center;
flex items-center gap-2display: flex; align-items: center; gap: var(--cascivo-space-2);
text-smfont-size: var(--cascivo-text-sm);
text-muted-foregroundcolor: var(--cascivo-color-text-subtle);
font-semiboldfont-weight: var(--cascivo-font-semibold);
rounded-mdborder-radius: var(--cascivo-radius-md);
bg-cardbackground: var(--cascivo-color-surface);

Two habit changes:

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:

  1. Add ssr: { noExternal: [/^@cascivo\//] } to vite.config.ts — or add the cascivoSsr() 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.
  2. 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

As Markdown: /docs/ai-rules.md

← All guides