Frontend Conventions

UI palette, primitives, data wiring, addressable ids, and the className rule. Backend code never needs to load this; code-style.md covers conventions that apply to both halves.

Bee Box is an internal app, not a public site chasing Lighthouse scores. Do not optimize before a measurement shows something is slow.

Card themes

Card presentation is theme-driven and independent of view selection. Theme surfaces live under src/frontend/src/themes/; keep theme IDs and stocks in the shared catalog and use literal class maps so Tailwind includes them in the build. The user-facing selection and read-only origin details belong in card Properties; see docs/box/card-themes.md for the authoring contract.

Data Source Tagging

UI elements that display data from a known source (card, commit, session, etc.) must be tagged with data-cb-source attributes for traceability. Use the cbSource() and cbSourceItem() helpers from src/frontend/src/lib/source-tag.ts. Tag the outermost meaningful data boundary — not every inner element. See docs/data-source-tagging.md for the full convention.

Browser Tab Titles

Every route declares its tab title in staticData, and the augmented StaticDataRouteOption in router.tsx makes that mandatory: a new route does not typecheck until it says what it is called (title: null for a layout or a redirect). A page whose real name is data it loads — a chat's label, a card's title — publishes it with usePageTitle from components/DocumentTitle.tsx, and the route's static title stands in until it arrives.

The composed title is <mark> <page> — <box>, most-distinguishing first because browsers truncate tabs hard; the app name appears only where there is no box. Nothing else writes document.title.

The box half comes from the box itself — the landmark card at the box root, whose navigation.label is the box's display name and whose symbol is its mark (core/landmark/box-identity.ts). There is no separate box-name or box-icon store: an agent renames a box by editing that card, the same way it writes any other box fact.

The icon says which box; the title says which place. components/ DocumentIcon.tsx shows the box's own mark, always, and DocumentPlace.tsx publishes the current directory's landmark emoji to lead the title (🍳 Recipes — Kitchen). The icon was landmark-first at one point and the cost was the thing a tab icon exists to prevent: two boxes' tabs wearing the same mark. A landmarked directory is titled by its landmark rather than its folder name, so the tab's two halves name the same place.

A working indicator rides in the same slot. useWorking(true) from anywhere puts a spinning glyph at the front of the title; the chat calls it while a turn streams, so a chat left in a background tab says whether the box is still going. Any caller is enough — the provider counts owners, so one finishing does not answer for another.

Presence is the signal; the spin is decoration. A hidden tab throttles timers to once a second, and Chrome drops that to once a MINUTE once a silent page has been hidden five minutes (developer.chrome.com/blog/timer-throttling-in-chrome-88). So the animation is exactly what stops being trustworthy during a long background turn — which is the case the indicator exists for. The glyph's presence carries the meaning and no throttle can take it away; built the other way round, a frozen spinner would read as "stuck" at the moment the honest answer is "still going".

Wherever a surface needs a real raster rather than a character — the Apple touch icon, the manifest icons, a notification — it comes from box-scoped routes that render the mark on demand (webapp/routes/box-identity-assets.ts, core/box/box-icon.ts). Rendering the emoji as text was measured and rejected; see the root ACKNOWLEDGEMENTS.md for why the artwork is a dependency.

The box server also stamps the box's name and mark into the served index.html (webapp/index-html.ts) so a tab is identifiable before React boots. That stamping happens only in the box server: in local dev Vite serves the document, so dev shows the built title for the moment before the app takes over.

Frontend Color Palette

Components reach for semantic color roles, not brand names. Defined in src/frontend/tailwind.config.js.

RoleUse forShades
primaryMain brand actions, links, selected stateDEFAULT, dark (hover), light, 50, 100
accentHighlights, CTAs, focus rings, warning-liteDEFAULT, dark, light, 700 (text on light paper), 50, 100
infoInformational callouts, metadata badgesDEFAULT, dark, light, muted, 50, 100
dangerDestructive actions, error messagesDEFAULT, dark, light, 50, 100
successPositive confirmations, connected stateDEFAULT, dark, light, 50, 100
warningCaution, dirty state, notesDEFAULT, dark, light, 50, 100
coralApp-nav gradient only (no semantic role)DEFAULT, dark, light, 50
warm-50..warm-900Neutral surface + text scale (backgrounds, body text, borders)Self-documenting scale
cardIn-card surfaces and text that follow the card theme's material; app palette outside a card. CSS variables, so no opacity modifierspaper, sheet, tint, ink, soft, pen, rule

Rules:

Panels inside a card theme. A Card either paints the card's material (Card material) or its own app surface, and it tells the text primitives inside it which (components/ui/material.ts): Text, Hint, Heading and field labels follow it unless given material explicitly. A panel that paints its own light background with a raw class inside a material Card must also set its own ink. Theme CSS hooks a component by a bbx- class, never by a Tailwind utility class: changing the utility silently drops the override.

When a color role doesn't feel right for a use case, that's usually a sign that a new semantic role is warranted — discuss before inventing a one-off color.

Workspace presentation

Ordinary cards and built-in interface instruments render in the workspace. Open them through the workspace target rather than adding a parallel page, generic preview overlay, or page-level conversation shell. Passive navigation changes the visible attention target while preserving the conversation recipient, draft, and send destination; only an explicit conversation action selects a recipient. Source editing, image lightboxes, capture, and other task-specific dialogs remain appropriate overlays for their distinct interactions.

A link to a card opens it beside the chat: build it with cardUrl from src/shared/card-url.ts (/<box>/views/<path>). Notifications, chips, list rows, and dashboard items use it. Browse (/<box>/browse/<path>) is a maintenance view of the files. Link to it only when the person asks to see a directory's files ("Open <dir>/") or chose a maintenance action ("Open in browse view").

Data and state

Request/response data is a tRPC query or mutation through the client in src/frontend/src/lib/trpc/client.ts. Real-time data rides the shared tRPC WebSocket: useBusSubscription (hooks/useBusSubscription.ts) for the box event stream, useChatWs for chat sessions, and the events.turnStream subscription for a turn's output. Do not hand-roll fetch or a polling loop where a procedure or subscription exists. The documented exceptions are REST chat send (api-chat.ts), XHR uploads for progress (lib/file-upload.ts), and the chat recovery watchdog (processing-status-display.ts). View state a reload should keep (filters, the open tab, pagination) belongs in TanStack Router search params, not component state.

A container fetches and owns the data states; its presentational children take loaded props. A new data view, or a change to how one loads, handles every reachable loading, empty, and error state: a skeleton shaped like the content (aria-busy) or <StatusMessage> while loading, an empty state that names the next action, and an <ErrorText> message with a retry. A blank screen while data resolves is a bug. A narrow change keeps the container's existing handling and does not add unreachable states.

Frontend UI Primitives

Reach for a primitive from src/frontend/src/components/ui/ before writing appearance classes inline. The primitives own appearance (colors, borders, shadows); callers own outer layout (margin, padding, flex-self, sizing, position) via a className prop.

A page's own supporting components live in pages/<x>/components/; shared ones go in src/frontend/src/components/, and primitives in components/ui/. A component that is a nav, region, main area, or list renders its own landmark or role; never wrap another component in one from outside. Each area of a screen has at most one intent="primary" action.

Check changed UI in a browser with the browse skill, including the console errors in the client debug log. When a change can affect the two-pane layout (<Stack hideOnMobile>, <MobileBackButton>), also check it at phone width (set viewport 375 800).

Every static interactive control carries a stable id="bbx-<area>-<control>": kebab-case and unique app-wide (axe duplicate-id in the tours catches collisions). The box agent and bin/browse address controls by it (src/frontend/src/lib/ui-scan/resolve.ts). Per-row controls get one only when a unique derived id is obvious. Never rename an existing id; iOS mirrors some of them.

Every control also has an accessible name, which screen readers, the box agent, bin/browse, tours, and DOM tests all use:

Actions

Inputs (all in ui/fields.tsx)

Navigation

Display

Layout containers

Overlays & Composite

Renderer-specific

className convention

Every primitive accepts a className prop. It is for outer-layout classes only — margin, padding, flex/grid item behavior, sizing, position. The restrict-component-classes ESLint rule enforces this on files outside components/ subdirectories. The rule can only check classes it can prove statically (string literals, template-literal quasis, and the resolvable branches of conditional/logical expressions) — a value built from a variable, function call, or member expression is invisible to it and passes silently, so keep className values literal if you want the rule to actually catch violations.

Inside the primitive, className is merged with the component's own classes via the cn() helper in src/frontend/src/lib/cn.ts, which wraps tailwind-merge — caller values win where they overlap (caller's p-6 overrides component's default p-3).

Examples:

<Button className="mb-4" intent="primary">Save</Button>         // margin on button
<TextField className="flex-1" label="Email" ... />              // flex-1 in a row
<Image className="mt-2" src={url} alt={alt} />                  // margin on image
<Stack className="max-w-xl mx-auto" gap="md">...</Stack>        // width-bound stack

What fails lint:

<Button className="bg-danger" ...>         // appearance — use intent="destructive" instead
<Text className="text-warm-500" ...>       // appearance — use tone="muted" instead
<Card className="shadow-lg" ...>           // appearance — use shadow prop instead

Adding a new UI primitive

  1. Does an existing primitive with a new prop cover this? If so, add the prop.
  2. Is the pattern used 3+ times with uniform look? If so, extract a primitive.
  3. Is it genuinely a one-off in a specific context (inside components/)? Then keep it local — don't force abstraction.

New primitives live in components/ui/<Name>.tsx, accept className, merge via cn(), and document their semantic role in frontend.md.

For a fixed development preview and repeatable screenshots, use the theme authoring studio.

The generic theme gallery is installed explicitly into a development box from test/fixtures/theme-tour. From the monorepo root:

node --import tsx beebox/src/scripts/install-theme-tour.ts <box-root>
bin/tour card-themes
bin/tour card-theme-previews
bin/tour card-theme-interface

The installer leaves changed files alone and reports conflicts. Its entry card is _content/theme-tour/Theme_Tour.doc.card. Knowledge audits reset their test box; reinstall the gallery afterward if that box was used for an audit.