Frontend Conventions

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

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 docs/attribution.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, 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

Rules:

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.

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.

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.

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

node --import tsx beebox/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.memo.card. Knowledge audits reset their test box; reinstall the gallery afterward if that box was used for an audit.