Enterprise readiness: frictions → shipped primitives
This guide answers an "enterprise-readiness" report that proposed six architectural
additions after a Vercel-style dashboard dry run. The valuable part of that report is
the list of frictions — the recurring pain points an enterprise team (or an LLM
generating their code) hits. The proposed code, however, was written against a
generic React/Tailwind mental model, not against cascivo: it reached for useState,
useEffect, useContext, Tailwind utility classes, a data-cascivo-theme attribute,
a @cascivo/theme package, and dot-notation tokens like color.background.primary —
none of which exist here, and most of which this project's component rules
ban outright.
The important finding is that cascivo already solves five of the six frictions, and the sixth (a reusable theme runtime) now ships too. This page maps each friction to the real primitive, shows the idiomatic code, and calls out the misconception so an agent reading it never regenerates the banned pattern.
| # | Friction from the report | Real answer in cascivo |
|---|---|---|
| 1 | Signal vs. React-state cognitive split | useControllableSignal + useSignals (@cascivo/core) |
| 2 | No layout primitives for dense grids | Grid / AutoGrid / Flex / Columns / Center / Stack |
| 3 | Theme switching flashes / fights the cascade | ThemeProvider / useTheme / themePreloadScript (@cascivo/react) |
| 4 | Signal leaks across route changes | useScope / createScope (@cascivo/core) |
| 5 | Token names invisible to the LLM context window | CascivoToken / CascivoColorToken (@cascivo/tokens/tokens) |
| 6 | Forms fight signal reactivity | createForm / useForm / Form / field (@cascivo/components) |
1 — Signal ↔ React state, without a "cognitive split"
The friction is real: an author (or LLM) shouldn't have to hand-wire the
controlled/uncontrolled prop pattern onto a signal every time, and a raw React
useState next to a signal does cause double reactivity.
The report's fix was wrong for cascivo: its useSignalState is built on useState
useEffect+ aforceUpdate({})— all three hooks are banned in cascivo components, and it re-introduces the very React render cycle it claims to avoid. The$isActive={signal}JSX directive it proposes doesn't exist and contradicts the owned-code model (there is no compiler intercepting your JSX).
The shipped answer is useControllableSignal —
it codifies the controlled/uncontrolled bridge once, with no effect:
import { useControllableSignal, useSignals } from '@cascivo/core'
function Toggle({ pressed, defaultPressed, onPressedChange }: ToggleProps) {
useSignals() // subscribe this React component to signal reads (no Babel transform in app code)
const [on, setOn] = useControllableSignal({
value: pressed, // controlled when defined; uncontrolled otherwise — fixed for the component's life
defaultValue: defaultPressed,
onChange: onPressedChange,
})
return <button data-state={on.value ? 'on' : 'off'} onClick={() => setOn(!on.peek())} />
}
The rule an agent must internalize: the signal is the state. You never pair a
signal with useState. In React apps that don't run the Babel signals transform
(apps/examples/*, any consumer app), a component that reads signal.value during
render calls useSignals() as its first statement — that is the entire "bridge."
2 — Layout primitives for dense grids
The friction is real: without layout components, an LLM falls back to ad-hoc inline styles or Tailwind, fragmenting the cascade.
The report's fix was wrong for cascivo: it emits Tailwind-style className utility
strings (c-layer-layout) and imports a @cascivo/theme package that doesn't exist.
cascivo bans Tailwind and applies layout via CSS Modules inside @layer cascivo.component,
with dynamic values passed as scoped custom properties — never utility classes.
The shipped answer is the @cascivo/layouts primitive set,
re-exported from @cascivo/react. Gaps are token-scaled; column counts are responsive
objects:
import { Grid, GridItem, AutoGrid, Flex } from '@cascivo/react'
// Dashboard card grid: 1 col on phones → 4 on desktop, gap from the space scale.
<Grid cols={{ base: 1, md: 2, lg: 4 }} gap={4}>
<GridItem span={{ base: 1, lg: 2 }}>{/* wide KPI tile */}</GridItem>
</Grid>
// Self-sizing card wall — no breakpoints, tracks wrap at a min width.
<AutoGrid min="16rem" gap={4}>{cards}</AutoGrid>
// Alignment-sensitive rows (e.g. status badges) — align/justify map to the grid axes.
<Grid cols={3} gap={2} align="center" justify="start">{statusCells}</Grid>
gap is constrained to the token scale (1 | 2 | 3 | 4 | 5 | 6 | 8 | 10 | 12 →
--cascivo-space-*), so there is no arbitrary spacing to hallucinate. See
the layout cookbook for the full set (Columns,
Center, Spacer, Stack, Section, SplitView, Masonry) and the page/app
scaffolds (AppShell, dashboard-layout, console-app).
3 — Theme switching that never flashes or fights the cascade
The friction is real, and this was the one genuine gap. Every consumer app had to
hand-roll the "persist the choice, mirror it onto data-theme, and pre-paint it to avoid
a flash" wiring; only an app-local copy existed in apps/site/src/theme.ts.
The report's fix was wrong for cascivo: it uses useEffect (banned) and writes a
data-cascivo-theme attribute the themes don't read — cascivo themes are scoped by
[data-theme='…']. FOUC isn't solved by a React effect at all; it's solved by a
pre-paint script in the document head.
The shipped answer is ThemeProvider, now exported
from @cascivo/react. It packages the exact reference wiring, rules-compliant: the DOM
write happens in useSignalEffect (not useEffect), the active theme is a module-level
signal (not React context), and it drives the real data-theme attribute.
import { ThemeProvider, useTheme, themePreloadScript } from '@cascivo/react'
function App() {
return (
<ThemeProvider defaultTheme="dark" storageKey="app-theme">
<ThemeToggle />
</ThemeProvider>
)
}
function ThemeToggle() {
const [theme, setTheme] = useTheme() // [themeName: string, setter]; the hook calls useSignals() for you
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
{theme === 'dark' ? '☀︎' : '☾'}
</button>
)
}
For SSR / Next.js, inline the pre-paint script so the persisted theme paints on the first byte — no flash on a hard reload:
// app/layout.tsx (before the app bundle)
<head>
<script dangerouslySetInnerHTML={{ __html: themePreloadScript({ storageKey: 'app-theme' }) }} />
</head>
ThemeProvider is uncontrolled (persists to localStorage) by default; pass value to
control it from server state, target to scope a subtree to its own theme, and attribute
if you drive a non-default attribute. See THEMING.md for the cascade model
these hook into.
4 — Signal lifecycles that don't leak across routes
The friction is real: a global signal that outlives the screen that created it shows stale state when the user navigates back.
The report's fix was wrong for cascivo: its useComponentSignals registry disposes
inside useEffect (banned), and Preact signals have no .dispose()/.reset() — only
effects hold subscriptions worth tearing down, and a plain signal is garbage-collected
with the component that owns it.
The shipped answer is useScope / createScope — a
disposable owner for a set of effects, torn down on unmount via useSignalEffect, no
useEffect anywhere:
import { useScope } from '@cascivo/core'
function DeploymentSettings() {
const scope = useScope() // disposed automatically when this route unmounts
// Every effect started through the scope stops firing at once on navigate-away —
// no orphaned subscriptions running against unmounted UI.
scope.effect(() => {
document.title = `Editing ${projectName.value}`
})
return /* … */
}
Use one scope per tenant boundary (a workspace, an org, a repo group): on switch, call
dispose() and create a fresh one. This is the enterprise SPA pattern the report asked
for, minus the leak the useEffect version would itself introduce.
5 — Token names the LLM can see in its context window
The friction is real: if the model has to open a .css file to learn which tokens
exist, it hallucinates class names like text-vercel-black.
The report's fix was wrong for cascivo: it invents a dot-notation union
('color.background.primary') that maps to nothing in this codebase and would drift from
the CSS the moment either side changed.
The shipped answer is a generated union that reflects the real custom properties,
so it can't drift — packages/tokens/src/tokens.d.ts,
regenerated by pnpm tokens:generate:
import type { CascivoToken, CascivoColorToken } from '@cascivo/tokens/tokens'
// Both are string-literal unions of every shipped `--cascivo-*` property.
// A prop typed `CascivoColorToken` gives the LLM exact autocomplete with no CSS lookup:
interface SwatchProps {
token: CascivoColorToken // e.g. '--cascivo-color-accent' | '--cascivo-color-surface' | …
}
Because the manifest is auto-generated from the token CSS, the type is always in sync with what actually ships — the opposite of a hand-authored union. See TOKENS.md for the three-tier catalog these names come from.
6 — Forms that don't fight signal reactivity
The friction is real: React-state form managers re-render on every keystroke and don't observe signal updates.
The report's fix was close in spirit — a createForm / Field API — but cascivo
already ships a superset.
The shipped answer is createForm / useForm / Form / field:
a signal-backed store (values, errors, touched, submitting are all signals), with
optional Standard Schema validation (zod, valibot, arktype)
in addition to a plain validate function:
import { useForm, Form, Field, Input } from '@cascivo/react'
function DomainForm() {
const form = useForm({
initialValues: { domain: '' },
validate: (v) => (v.domain.includes('.') ? {} : { domain: 'Invalid domain format' }),
validateOnChange: true, // revalidate the edited field on every keystroke — still zero re-renders
})
const domain = form.field('domain') // { value, onChange, onBlur, error }
return (
<Form form={form} onValid={(v) => save(v)}>
<Field label="Domain" error={domain.error}>
<Input value={domain.value} onChange={(e) => domain.onChange(e.target.value)} />
</Field>
</Form>
)
}
Validation runs against the signal store, so keystroke-frequency updates never trigger a
React re-render of the whole form. Field wires the label, description, and aria-invalid
/ role="alert" accessibility for you.
7 — For a procurement reviewer: the version-number question
The question comes up before any of the above, and it comes up looking at an install list. Before 1.0 it looked like this:
@cascivo/[email protected] @cascivo/[email protected] @cascivo/[email protected]
@cascivo/[email protected] [email protected]
A 2026-08-21 evaluator put it plainly: a 0.0.4 next to a 0.18.0 "reads as half of this
is pre-alpha. A procurement reviewer will ask." Here is the answer as it stands today, in
the order it is usually asked.
What is stable. Every package an application depends on at runtime is on 1.x and
covered by semver — see the stability contract for
exactly what that promise covers and what it does not. The ten packages that share
@cascivo/core (core, react, charts, editor, flow, i18n, storage, ai,
text, render) release in lockstep at one version, so a mismatched pair cannot be installed by
accident; cascivo doctor flags one if it happens anyway.
Why some numbers still differ. @cascivo/tokens, @cascivo/themes, @cascivo/icons and
the cascivo CLI are 1.x on their own version lines — none of them link against the
signal registry, so they do not need to move with it. Tooling that is still settling
(@cascivo/mcp, @cascivo/registry, @cascivo/docspack, @cascivo/email, the ESLint
packages, @cascivo/vite-plugin, @cascivo/platform) stays on 0.x, where semver gives no
stability promise; the coverage table lists
them. Compatibility between entries is expressed per registry entry as peerVersions in
registry.json — a real constraint the CLI checks.
What bounds the remaining exposure.
breaking-changes.json— every major/minor release of every package, with notes, machine-readable. Published, not a changelog you have to read.cascivo doctor --drift— reads that file against your lockfile and tells you what changed under you. Run it before an upgrade, in CI if you like.- A published deprecation policy — a component marked
deprecatedin its manifest keeps working and is signposted at discovery time:cascivo list,cascivo search,cascivo add(warned before the copy), the MCP tools, the docs site andllms.txtall render it, so it reaches you before the source is vendored rather than after.
What you own. On the copy-paste path the component source is in your repository, under your review, and upgrades are a diff you accept. The worst case for a component you have already copied is that you stop upgrading it.
The rest of the procurement checklist — who maintains cascivo (one person, with AI agents
under the same CI gates), the release cadence, support windows, npm provenance, SBOMs, and the
fact that there is no VPAT or paid support yet — is answered in
GOVERNANCE.md.
The meta-lesson
The report's premise — "these frictions prove cascivo isn't enterprise-ready" — inverted
the truth: the primitives existed; they were just undiscoverable at the point of need,
so a capable developer regenerated inferior, rules-violating versions of things that
already ship. That is a discoverability problem, and this document (plus the new
ThemeProvider that closed the one true gap) is the fix. When you build on cascivo, reach
for the primitives above before writing behavior by hand — and never port a useState /
useEffect / Tailwind snippet in verbatim; it will fail the component rules
gate, and there is almost always a signal-native primitive that does it better.
As Markdown: /docs/enterprise-readiness.md