All concepts
08 — Concept

The UI shell

A React shell with design tokens instead of colours, one guard for pages and navigation alike, and a data layer that never shadows the server.

Tokens, not colours

The stylesheet defines two themes, and the brand values for both live in one block at the top of one file. Below them sit semantic tokens — surfaces, borders, text, shape, motion — and a small set of component primitives. Components use the semantic classes or the kit's own tokens; a palette utility like a raw blue-50 is not something you will find in a component, because the point of a rebrand being one block is that nothing else may hard-code a colour.

A contrast test runs over the emitted tokens and gates the build against the accessibility floors. Changing a brand colour is therefore a change you find out about immediately rather than one a user finds out about later.

Why the scan is scoped by hand. Tailwind's automatic source detection is switched off and re-enabled with explicit paths pointing only at the UI tree. Left on, it detects sources from the package root and scans the whole repository — documentation, server code, tests — and the component library then emits a component for every stray word in a comment that happens to look like a class name. Scoping the scan is the difference between a stylesheet that reflects the app and one that reflects the prose around it.

The same reasoning applies to dependencies. A package is only added to the scan if it ships uncompiled classes, and only after measuring the output — a dependency whose styles are already compiled and prefixed contributes nothing but stray components.

Providers, in an order that means something

The tree is an error boundary, then the query client, then auth, then abilities, then the websocket provider, then the router. Each layer depends on the one above it and on nothing below.

LayerWhat it adds
Error boundaryOutermost, so a failure anywhere below renders a page rather than a blank screen.
Query clientServer state, and one global handler that clears the cache and redirects on a 401.
AuthThe session, fetched once and parsed with the same schema the server validated it with.
AbilitiesThe CASL rules the server computed and shipped with that session.
WebSocketConnects once the session has a tenant, reconnects when the tenant changes.
RouterRoutes, with the layout mounted once and a second error boundary inside it.

One guard, composed

There is a single guard primitive. Coarse role guards and fine ability guards are compositions of it, not parallel implementations — and a navigation item uses the same guard as the page it opens.

That last detail is the one that matters in practice. When the nav and the route each decide visibility their own way, they disagree eventually, and the symptom is a link that leads to a 403. Sharing the guard makes the two agree by construction.

The data layer is deliberately boring

All HTTP goes through one client that sends credentials, turns the shared error envelope into a typed error, and optionally parses the response with a zod schema — the same schema the server validated the response against. There is no RPC client generated from the server's types, which would drag the server's type graph into the browser bundle.

Above that: one hook file per resource, query keys from a central factory rather than inline arrays, and shared query definitions as reusable option factories. Mutations invalidate through the same key factory. Pagination has one shape everywhere.

Server data lives in the cache, and only there

The query cache owns server state. The one client-side store holds exactly one thing: websocket connection status, written only by the socket client. Component-local interface state is local state; the theme and the density are DOM attributes. There is no store shadowing data the server already owns.

Streaming chat is the single exception, and it is a narrow one. Assistant text accumulates in local state as deltas arrive, because it is not true yet — then, when the stream signals the end of the message, the finished message is written into the cache, which the server had already persisted before sending that frame. The exception exists for the duration of one reply and then closes.

Why the socket does not write state. Realtime events name a query-key root and the browser invalidates it — the mapping lives in the shared package, so a new server event type gets client behaviour without any hook subscribing to a socket. Components subscribe to query state, never to the connection. A resource that nudges under a given entity name simply names its query family the same thing and gets live refresh for free.

Weight is kept out of the shell

Heavy dependencies ship in lazy chunks and are imported by path from the pages that need them, never from the shared component barrel that the shell loads eagerly. Markdown rendering lives with the chat and agent pages; the charting and dashboard libraries live only in the analytics chunk.

The constraint is enforced by watching the build output rather than by convention alone: the main bundle has a size it is expected to stay near, and an import that merges an analytics dependency into it is visible immediately.