Frontend Standards: otari dashboard (web/)
web/ is the dashboard shared by standalone, hosted, and hybrid deployments. It renders the
management UI or hybrid landing page from /v1/bootstrap and uses an HttpOnly session for
management calls. It is an operator tool, not a marketing surface. The base tracker under
src/shared/telemetry/ is a no-op seam an overlay may replace; do not give it an analytics
implementation in this repository.
Stack: React 19 (with the React Compiler), TypeScript (strict), HeroUI v3 (@heroui/react),
Tailwind CSS v4, TanStack Query, TanStack Router (file-based, web/src/routes/), Vite,
Vitest + Testing Library, Playwright (web/e2e/, behavioral and screenshot suites). Package
manager is pnpm.
web/design/DESIGN.md owns the design system: which component
to reach for, which variant applies where, which token layer is allowed, and the three
components that still exist but must not be used in new code. Eleven short topic files; load the
one covering the work at hand.
The primitives live in web/src/design-system/, which may import nothing else under
src/. It is the one layer here meant to leave as a package: React, HeroUI,
react-aria, react-icons, recharts, react-markdown and its own modules, and nothing more. Biome rejects
the rest and src/architecture.test.ts proves each rejection. A component that needs the
transport, a domain formatter or a generated API type is an application component and
belongs in src/shared/components/ composing the primitive. DESIGN.md's "The extraction
contract" has the whole rule, including the two directories that stayed behind and the
five plausible primitives deliberately not built.
Reach for Button from @/design-system/actions/Button rather than @heroui/react: it is
the same component with the variant union narrowed to the three that exist, which makes a
retired variant a compile error instead of a silently unstyled button.
Every primitive has a story beside it. pnpm --dir web run storybook is the catalog, and
.github/workflows/otari-design-system.yml publishes it from main. A PR is gated on the
catalog building; the sweep that renders every story in both themes runs on main and on
demand, so run it locally (pnpm --dir web exec node .storybook/smoke.mjs, about ten
seconds) before pushing a story.
web/AGENTS.md owns the structure and is worth reading first: the
features/ / shared/ / app/ layout it mirrors from otari-ai/frontend, the three
lint-enforced import rules, the routing conventions, the generated API client, and the design
foundation. This file is the house style for writing code inside that structure.
Build and check from the repo root:
make dashboard (installs from the lockfile if needed, then pnpm --dir web run build).
Output goes to the gitignored src/gateway/static/dashboard/; there is nothing to commit.
Build only when you need to run the dashboard locally; Docker builds it in its own Node stage.
pnpm --dir web run lint (Biome: formatting, recommended rules, layer boundaries; lint:fix writes)
pnpm --dir web run typecheck
pnpm --dir web test
pnpm --dir web run e2e (behavioral) and pnpm --dir web run e2e:screenshots (visual)
pnpm is pinned by packageManager in web/package.json; CI and Docker both take the version
from there. pnpm-workspace.yaml carries the build-script approvals, and it is why an install
can link Vite's esbuild binary at all.
Critical rules
Always:
- Reach for a HeroUI component or a shared primitive before a native element: it arrives with
the tokens and its states (pointer, focus ring, disabled dimming) already wired, where a
hand-rolled
<button> starts from Tailwind's reset and every state becomes a class somebody
has to remember. Then change how it looks in this order: a variable (ours as a token, or one
of HeroUI's own aliased onto ours; its documented knobs include --radius,
--cursor-interactive and --disabled-opacity, not just color), a wrapper or utility once
the look repeats, the component's own prop (variant, size, isDisabled, isPending,
fullWidth, isInvalid), and last a rule against HeroUI's own classes. HeroUI supports that
last one and it stays discouraged: a selector fixes one case where a variable fixes every rule
that reads it, it is invisible from the call site, and because the rules in globals.css are
unlayered they outrank a Tailwind class at the call site too. Write one only when nothing above
reaches the value, and say so in its comment. className is for layout/position, not for restyling a component HeroUI
already styles. See components.md.
- Style from the semantic tokens in
web/src/styles/globals.css, through the utilities they
back (text-muted, bg-surface, border-border, text-danger, text-heading). The
tokens are the design system and HeroUI is a consumer of it: a utility that does not resolve
to a token will not follow a retheme. Never a raw hex, never a numbered Tailwind palette
class, never bg-white / text-black; src/styles/foundation.test.ts fails on all three,
over the whole of web/src. design-tokens.md has the families, the
HeroUI mapping, the chart palettes, the type scale, and the near-synonym utilities HeroUI
ships that look like ours and are not.
- Space siblings with
gap-* on the parent, and write arbitrary values in rem. See
responsiveness.md.
- Fetch server state through the TanStack Query hooks in
web/src/shared/api/ (one module per
domain, with every query key in queryKeys.ts), guard
on isPending && !data, keep the previous page with placeholderData on a filtered query,
and bound every "fetch everything" walk with a hard page cap. See
data-fetching.md.
- Design for the phone as well as the desk. The dashboard is installable to a home screen, the
shell already switches to a drawer below
md, and the screenshot matrix captures every page
at 390px. Touch targets ≥44px, no hover-only controls, a table needs an answer below md.
See responsiveness.md.
- Prefer
undefined over null for absent values in your own types (the API layer may return
null; convert at the boundary). See typescript-and-react.md.
- Gate a deployment-dependent surface through
useDeployment() / useSurfaces(), the one place
that knows which deployment served the page. Mind the vocabulary: a surface is the
deployment axis, a capability is the entitlement axis.
- Declare a new rail destination in the nav registry (
web/src/app/nav/registry.ts), never
as a hand-written link, with whichever of the two gates it needs (surface, capability).
EntitlementGate is the component form for wrapping a page. See
web/AGENTS.md for how the gates compose and where a capability
the base build ships has to be declared. A route the chrome reaches is the exception and
is a hand-written Link on purpose: /docs from the top bar and the account menu, and
/account from that menu's first row. The registry is what the rails render, so an entry
there would duplicate into the sidebar a row the design draws once. Adding to the sidebar
has no exception.
- Add a Vitest test for any component, hook, or helper whose behavior you change (
Foo.tsx →
Foo.test.tsx, colocated), and a screenshot entry for any new page. The screenshot suite
runs on demand rather than as a PR gate while the migration lands, so the entry is owed
even though nothing fails without it. See testing.md.
Never:
- New HeroUI v2 patterns: granular imports,
HeroUIProvider, classNames={{ slot }},
onValueChange, color on Button, or a content1/content2 utility. v3 ignores some of
these silently, which is why the full v2-to-v3 table is in components.md.
- Inline
style={{}} or <style> tags. Use Tailwind utilities or a token. (The pre-paint
block in index.html is the one exception, and layout-stability.md
says why.)
- A HeroUI
<Link href> for an internal route: it is a full page reload. Use TanStack
Router's <Link to>.
- Manual polling with bare
setInterval/setTimeout. Use TanStack Query's refetchInterval
(see useDashboardBuild).
- A raw
fetch() for authenticated management requests. Go through apiFetch, which owns the
HttpOnly session-cookie flow, error extraction, and 401 sign-out. Pre-authentication helpers such
as createSession use public requests so a refused sign-in does not trigger that sign-out path;
data-fetching.md says why.
- Client-side filtering/sorting/pagination of large server datasets when the endpoint can do
it. (Small, already-loaded lists rendered in a
Table are fine.)
- Memoization by reflex. The React Compiler is enabled; add
useMemo/useCallback/memo
only with a measurement behind it. See performance.md.
- A second export from a route file. It defeats
autoCodeSplitting and lands the page in the
entry chunk. See component-architecture.md.
- Barrel files, default exports, or namespace imports. See
imports-and-modules.md.
getByTestId when a semantic query (getByRole, getByLabelText, getByText) works.
- A page component that branches on the gateway's mode (reading
/v1/settings's mode, or
probing whether a management endpoint 404s). The deployment bootstrap answers that once,
for the whole shell; see the rule above.
A note on status colors
The foundation names the status roles, so use them: text-danger / bg-danger-subtle for
errors, text-warning / bg-warning-subtle for caution, text-success / bg-success-subtle
for healthy, text-info / bg-info-subtle for neutral notices, and the separate
bg-attention family for "look here" (a required action, an unread marker) as distinct from
"be careful". Every one of them is defined for both themes.
Two pairings are worth knowing because the obvious guess is wrong. A status word on its own
subtle fill wears the status color (text-danger on bg-danger-subtle), and the light
theme's danger, warning, and attention values are a step darker than otari-ai's swatches for
exactly that reason: at the original values those pairings sat between 3.2:1 and 4.4:1, under
AA for the small text a pill uses. Brand text on the brand tint does not follow that rule:
--color-primary on --color-primary-subtle is 3.8:1, so a chip or an active nav item takes
text-primary-subtle-foreground instead.
Topic guides
- design-tokens.md: the semantic tokens, the HeroUI mapping, the type scale, the chart palettes, and how to translate otari-ai's utility names.
- components.md: HeroUI v3 patterns, the order to reach for when customizing (variable, shared utility, prop, then a rule into the library's DOM), internal links, the UI primitives in
design-system/.
- component-architecture.md: what a page composes, what gets its own file, route files, no duplicated markup.
- data-fetching.md: TanStack Query conventions: query keys,
staleTime, guards, invalidation, bounded pagination.
- typescript-and-react.md: strict TS,
undefined over null, discriminated unions, hook and effect hygiene.
- responsiveness.md: breakpoints, touch targets, tables on a phone,
rem over px.
- layout-stability.md: loading guards, skeletons, the pre-paint theme script, no reload-as-refresh.
- performance.md: the React Compiler, code splitting, lazy loading, bundle watch, effect cleanup.
- imports-and-modules.md: named exports, no barrels, path aliases, the lint-enforced layer boundary, test mocking.
- naming-conventions.md: files, variables, callbacks, constants, and the vocabulary that carries meaning.
- formatting-and-i18n.md:
Intl for numbers, money, and dates, through shared/helpers/format.ts.
- testing.md: Vitest conventions, the harnesses in
src/tests/, the behavioral e2e suite, and the screenshot matrix.
1---2name: frontend-standards3description: Guidelines for the otari admin dashboard (`web/`), React 19 + TypeScript (strict) + HeroUI v3 + Tailwind v4 + TanStack Query. Use when writing or reviewing dashboard components, styling, data fetching, or tests.4---56# Frontend Standards: otari dashboard (`web/`)78`web/` is the dashboard shared by standalone, hosted, and hybrid deployments. It renders the9management UI or hybrid landing page from `/v1/bootstrap` and uses an HttpOnly session for10management calls. It is an operator tool, not a marketing surface. The base tracker under11`src/shared/telemetry/` is a no-op seam an overlay may replace; do not give it an analytics12implementation in this repository.1314Stack: React 19 (with the React Compiler), TypeScript (`strict`), HeroUI v3 (`@heroui/react`),15Tailwind CSS v4, TanStack Query, TanStack Router (file-based, `web/src/routes/`), Vite,16Vitest + Testing Library, Playwright (`web/e2e/`, behavioral and screenshot suites). Package17manager is **pnpm**.1819[web/design/DESIGN.md](../../../web/design/DESIGN.md) owns the design system: which component20to reach for, which variant applies where, which token layer is allowed, and the three21components that still exist but must not be used in new code. Eleven short topic files; load the22one covering the work at hand.2324**The primitives live in `web/src/design-system/`, which may import nothing else under25`src/`.** It is the one layer here meant to leave as a package: React, HeroUI,26react-aria, react-icons, recharts, react-markdown and its own modules, and nothing more. Biome rejects27the rest and `src/architecture.test.ts` proves each rejection. A component that needs the28transport, a domain formatter or a generated API type is an *application* component and29belongs in `src/shared/components/` composing the primitive. DESIGN.md's "The extraction30contract" has the whole rule, including the two directories that stayed behind and the31five plausible primitives deliberately not built.3233Reach for `Button` from `@/design-system/actions/Button` rather than `@heroui/react`: it is34the same component with the variant union narrowed to the three that exist, which makes a35retired variant a compile error instead of a silently unstyled button.3637Every primitive has a story beside it. `pnpm --dir web run storybook` is the catalog, and38`.github/workflows/otari-design-system.yml` publishes it from main. A PR is gated on the39catalog building; the sweep that renders every story in both themes runs on main and on40demand, so run it locally (`pnpm --dir web exec node .storybook/smoke.mjs`, about ten41seconds) before pushing a story.4243[web/AGENTS.md](../../../web/AGENTS.md) owns the structure and is worth reading first: the44`features/` / `shared/` / `app/` layout it mirrors from `otari-ai/frontend`, the three45lint-enforced import rules, the routing conventions, the generated API client, and the design46foundation. This file is the house style for writing code inside that structure.4748Build and check from the repo root:4950- `make dashboard` (installs from the lockfile if needed, then `pnpm --dir web run build`).51 Output goes to the gitignored `src/gateway/static/dashboard/`; there is nothing to commit.52 Build only when you need to run the dashboard locally; Docker builds it in its own Node stage.53- `pnpm --dir web run lint` (Biome: formatting, recommended rules, layer boundaries; `lint:fix` writes)54- `pnpm --dir web run typecheck`55- `pnpm --dir web test`56- `pnpm --dir web run e2e` (behavioral) and `pnpm --dir web run e2e:screenshots` (visual)5758pnpm is pinned by `packageManager` in `web/package.json`; CI and Docker both take the version59from there. `pnpm-workspace.yaml` carries the build-script approvals, and it is why an install60can link Vite's esbuild binary at all.6162## Critical rules6364**Always:**6566- Reach for a HeroUI component or a shared primitive before a native element: it arrives with67 the tokens and its states (pointer, focus ring, disabled dimming) already wired, where a68 hand-rolled `<button>` starts from Tailwind's reset and every state becomes a class somebody69 has to remember. Then change how it looks in this order: a variable (ours as a token, or one70 of HeroUI's own aliased onto ours; its documented knobs include `--radius`,71 `--cursor-interactive` and `--disabled-opacity`, not just color), a wrapper or utility once72 the look repeats, the component's own prop (`variant`, `size`, `isDisabled`, `isPending`,73 `fullWidth`, `isInvalid`), and last a rule against HeroUI's own classes. HeroUI supports that74 last one and it stays discouraged: a selector fixes one case where a variable fixes every rule75 that reads it, it is invisible from the call site, and because the rules in `globals.css` are76 unlayered they outrank a Tailwind class at the call site too. Write one only when nothing above77 reaches the value, and say so in its comment. `className` is for layout/position, not for restyling a component HeroUI78 already styles. See [components.md](./components.md).79- Style from the semantic tokens in `web/src/styles/globals.css`, through the utilities they80 back (`text-muted`, `bg-surface`, `border-border`, `text-danger`, `text-heading`). The81 tokens are the design system and HeroUI is a consumer of it: a utility that does not resolve82 to a token will not follow a retheme. Never a raw hex, never a numbered Tailwind palette83 class, never `bg-white` / `text-black`; `src/styles/foundation.test.ts` fails on all three,84 over the whole of `web/src`. [design-tokens.md](./design-tokens.md) has the families, the85 HeroUI mapping, the chart palettes, the type scale, and the near-synonym utilities HeroUI86 ships that look like ours and are not.87- Space siblings with `gap-*` on the parent, and write arbitrary values in `rem`. See88 [responsiveness.md](./responsiveness.md).89- Fetch server state through the TanStack Query hooks in `web/src/shared/api/` (one module per90 domain, with every query key in `queryKeys.ts`), guard91 on `isPending && !data`, keep the previous page with `placeholderData` on a filtered query,92 and bound every "fetch everything" walk with a hard page cap. See93 [data-fetching.md](./data-fetching.md).94- Design for the phone as well as the desk. The dashboard is installable to a home screen, the95 shell already switches to a drawer below `md`, and the screenshot matrix captures every page96 at 390px. Touch targets ≥44px, no hover-only controls, a table needs an answer below `md`.97 See [responsiveness.md](./responsiveness.md).98- Prefer `undefined` over `null` for absent values in your own types (the API layer may return99 `null`; convert at the boundary). See [typescript-and-react.md](./typescript-and-react.md).100- Gate a deployment-dependent surface through `useDeployment()` / `useSurfaces()`, the one place101 that knows which deployment served the page. Mind the vocabulary: a *surface* is the102 deployment axis, a *capability* is the entitlement axis.103- Declare a new **rail** destination in the nav registry (`web/src/app/nav/registry.ts`), never104 as a hand-written link, with whichever of the two gates it needs (`surface`, `capability`).105 `EntitlementGate` is the component form for wrapping a page. See106 [web/AGENTS.md](../../../web/AGENTS.md) for how the gates compose and where a capability107 the base build ships has to be declared. A route the *chrome* reaches is the exception and108 is a hand-written `Link` on purpose: `/docs` from the top bar and the account menu, and109 `/account` from that menu's first row. The registry is what the rails render, so an entry110 there would duplicate into the sidebar a row the design draws once. Adding to the sidebar111 has no exception.112- Add a Vitest test for any component, hook, or helper whose behavior you change (`Foo.tsx` →113 `Foo.test.tsx`, colocated), and a screenshot entry for any new page. The screenshot suite114 runs on demand rather than as a PR gate while the migration lands, so the entry is owed115 even though nothing fails without it. See [testing.md](./testing.md).116117**Never:**118119- New HeroUI **v2** patterns: granular imports, `HeroUIProvider`, `classNames={{ slot }}`,120 `onValueChange`, `color` on `Button`, or a `content1`/`content2` utility. v3 ignores some of121 these silently, which is why the full v2-to-v3 table is in [components.md](./components.md).122- Inline `style={{}}` or `<style>` tags. Use Tailwind utilities or a token. (The pre-paint123 block in `index.html` is the one exception, and [layout-stability.md](./layout-stability.md)124 says why.)125- A HeroUI `<Link href>` for an internal route: it is a full page reload. Use TanStack126 Router's `<Link to>`.127- Manual polling with bare `setInterval`/`setTimeout`. Use TanStack Query's `refetchInterval`128 (see `useDashboardBuild`).129- A raw `fetch()` for **authenticated** management requests. Go through `apiFetch`, which owns the130 HttpOnly session-cookie flow, error extraction, and 401 sign-out. Pre-authentication helpers such131 as `createSession` use public requests so a refused sign-in does not trigger that sign-out path;132 [data-fetching.md](./data-fetching.md) says why.133- Client-side filtering/sorting/pagination of large server datasets when the endpoint can do134 it. (Small, already-loaded lists rendered in a `Table` are fine.)135- Memoization by reflex. The React Compiler is enabled; add `useMemo`/`useCallback`/`memo`136 only with a measurement behind it. See [performance.md](./performance.md).137- A second export from a route file. It defeats `autoCodeSplitting` and lands the page in the138 entry chunk. See [component-architecture.md](./component-architecture.md).139- Barrel files, default exports, or namespace imports. See140 [imports-and-modules.md](./imports-and-modules.md).141- `getByTestId` when a semantic query (`getByRole`, `getByLabelText`, `getByText`) works.142- A page component that branches on the gateway's mode (reading `/v1/settings`'s `mode`, or143 probing whether a management endpoint 404s). The deployment bootstrap answers that once,144 for the whole shell; see the rule above.145146## A note on status colors147148The foundation names the status roles, so use them: `text-danger` / `bg-danger-subtle` for149errors, `text-warning` / `bg-warning-subtle` for caution, `text-success` / `bg-success-subtle`150for healthy, `text-info` / `bg-info-subtle` for neutral notices, and the separate151`bg-attention` family for "look here" (a required action, an unread marker) as distinct from152"be careful". Every one of them is defined for both themes.153154Two pairings are worth knowing because the obvious guess is wrong. **A status word on its own155subtle fill wears the status color** (`text-danger` on `bg-danger-subtle`), and the light156theme's danger, warning, and attention values are a step darker than otari-ai's swatches for157exactly that reason: at the original values those pairings sat between 3.2:1 and 4.4:1, under158AA for the small text a pill uses. **Brand text on the brand tint does not follow that rule**:159`--color-primary` on `--color-primary-subtle` is 3.8:1, so a chip or an active nav item takes160`text-primary-subtle-foreground` instead.161162## Topic guides163164- [design-tokens.md](./design-tokens.md): the semantic tokens, the HeroUI mapping, the type scale, the chart palettes, and how to translate otari-ai's utility names.165- [components.md](./components.md): HeroUI v3 patterns, the order to reach for when customizing (variable, shared utility, prop, then a rule into the library's DOM), internal links, the UI primitives in `design-system/`.166- [component-architecture.md](./component-architecture.md): what a page composes, what gets its own file, route files, no duplicated markup.167- [data-fetching.md](./data-fetching.md): TanStack Query conventions: query keys, `staleTime`, guards, invalidation, bounded pagination.168- [typescript-and-react.md](./typescript-and-react.md): strict TS, `undefined` over `null`, discriminated unions, hook and effect hygiene.169- [responsiveness.md](./responsiveness.md): breakpoints, touch targets, tables on a phone, `rem` over `px`.170- [layout-stability.md](./layout-stability.md): loading guards, skeletons, the pre-paint theme script, no reload-as-refresh.171- [performance.md](./performance.md): the React Compiler, code splitting, lazy loading, bundle watch, effect cleanup.172- [imports-and-modules.md](./imports-and-modules.md): named exports, no barrels, path aliases, the lint-enforced layer boundary, test mocking.173- [naming-conventions.md](./naming-conventions.md): files, variables, callbacks, constants, and the vocabulary that carries meaning.174- [formatting-and-i18n.md](./formatting-and-i18n.md): `Intl` for numbers, money, and dates, through `shared/helpers/format.ts`.175- [testing.md](./testing.md): Vitest conventions, the harnesses in `src/tests/`, the behavioral e2e suite, and the screenshot matrix.