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.
| Role | Use for | Shades |
|---|---|---|
primary | Main brand actions, links, selected state | DEFAULT, dark (hover), light, 50, 100 |
accent | Highlights, CTAs, focus rings, warning-lite | DEFAULT, dark, light, 700 (text on light paper), 50, 100 |
info | Informational callouts, metadata badges | DEFAULT, dark, light, muted, 50, 100 |
danger | Destructive actions, error messages | DEFAULT, dark, light, 50, 100 |
success | Positive confirmations, connected state | DEFAULT, dark, light, 50, 100 |
warning | Caution, dirty state, notes | DEFAULT, dark, light, 50, 100 |
coral | App-nav gradient only (no semantic role) | DEFAULT, dark, light, 50 |
warm-50..warm-900 | Neutral surface + text scale (backgrounds, body text, borders) | Self-documenting scale |
card | In-card surfaces and text that follow the card theme's material; app palette outside a card. CSS variables, so no opacity modifiers | paper, sheet, tint, ink, soft, pen, rule |
Rules:
- Never use Tailwind defaults (
red-*,green-*,yellow-*,amber-*,purple-*,gray-*,blue-*, etc.) for appearance. They're not part of the brand palette. The one exception isgray-*in dark-overlay UI (CapturePage, CommandRunner) that's intentionally Tailwind-default dark-theme. - Never add new colors to
tailwind.config.jswithout a semantic role. If you need a new color category, define a new role. - For shade variation on an existing color, use opacity syntax (
bg-primary/50,text-danger/70) before reaching for additional numeric shades. - Components in
components/own their appearance — they reference these semantic names directly. Page-level code (outsidecomponents/) can only use outer-layout classes viaclassName(enforced byrestrict-component-classesESLint rule).
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:
- The name says the action and, on a per-row control, its object: "Delete Soup", not "Delete" on every row. When an item repeats several controls (a question card's "Submit", "Later", "Answer in chat"), name the item instead:
role="group"witharia-labelledbyits heading (<Card role="group">). The box agent's scan reports a named group as the container of those controls. - An icon-only control sets
aria-label, or the primitive'slabelprop.titlealone is not enough;jsx-a11y/control-has-associated-labelenforces this on native elements. - A toggle or disclosure keeps one name in both states and exposes the state:
aria-checkedon a switch,aria-pressedon a toggle button,aria-expandedon a disclosure.
Actions
<Button>— labeled or icon-only commit-action. Props:intent(primary/secondary/destructive/accent/success/ghost),shape(rect/circle),size,icon,loading(auto-on for PromiseonClick),loadingLabel(takes a function(secs) => ...to show elapsed time),flash(post-click feedback like "Copied!"). Discriminated union enforceslabelfor icon-only variants.<CloseButton>— X-icon dismiss.onDarkprop for dark backdrops.<CancelButton>— semantic shortcut for<Button intent="secondary">Cancel</Button>.<InlineAction>— inline action embedded in prose ("(reset)", "retry", "+ Add comment"). Intents: emphatic/subtle/danger/quiet (quietkeeps the surrounding color and underlines on hover). Hasflashfor clipboard copies etc.
Inputs (all in ui/fields.tsx)
<TextField>,<TextareaField>,<NumberField>— label + input + error + helper, pass-through of native validation attrs (required,minLength,pattern, etc.).hideLabelfor sr-only labels. Value/onChange are controlled.<CheckboxField>— inline checkbox with label to the right.<SelectField>— dropdown select with options array.<RadioGroup>— stacked (variant="list") or card-style (variant="cards") radios.<Toggle>— iOS-style pill switch for immediate-apply settings (distinct from CheckboxField, which is form-bound). Props:checked,onChange,disabled,label.
Navigation
<Link>— use TanStack Router's Link for any internal nav.<TextLink>— styled inline internal link (primary + underline-on-hover).<ExternalLink>— external-url link with target=_blank, rel=noopener, trailing icon. Variants: inline / plain / button.<ExternalIconLink>— icon-only "open in new tab" affordance with requiredlabelfor aria.onDarkvariant.<OpenInPanelButton>— icon-only button that opens a file in the chat's companion side panel; sibling to<ExternalIconLink>'s open-in-new-tab action. Sizessm/md.
Display
<Image>— src/alt/size with built-in lightbox, error placeholder, bbox overlay, rotation. Lightbox and onClick are mutually exclusive at the type level. Onlysize="thumb"reserves its box; for larger sizes, set dimensions or an aspect ratio when known so the layout does not shift, and useloading="lazy"below the fold.<VideoEmbed>— responsive 16:9 lazy-loaded video iframe (privacy-friendly nocookie domain), block-level like a figure; used for embedded video in rendered markdown.<Avatar>— user profile image with initial fallback.fallbackClassNamefor dark nav contexts.<FriendlyDate>— renders an ISO timestamp as a localized date/time (the browser's locale and zone) in a semantic<time>carrying the machine-readable ISO.modeprop:"datetime"(default) or"date".<VisuallyHidden>— renders content for screen readers/the a11y tree only (sr-only), hidden visually; use for page-level headings that live inside a component which can collapse or move off-screen at some viewports.asprop picks the rendered tag (e.g."h1").<Pre>— preformatted block (code, xml, JSON dumps). Props:size,boxed,scroll,error,muted.<HighlightedCode>— wraps highlight.js output in a<code class="hljs">. Use inside a<Pre boxed>.<JsonView>— human-friendly JSON renderer (keys label indented values, arrays as hanging-indent0:/1:lists, recursive). Strings keep whitespace and wrap on any character; numbers/booleans/null show viaJSON.stringifyin a distinct color. Use for tool-call args and similar object dumps instead ofJSON.stringifyin a<Pre>.<Badge>— pill label. Tones: neutral/info/success/warning/danger/accent. Sizes: sm/md.<StatusBadge>— card-status badge that mapsstatus(new/pending/answered/processing/processed) to the right Badge tone. Passchildrento override the displayed text (e.g., show a count instead).<Heading level={2|3}>— section heading. Renders a realh2/h3; the level sets the look (2: lg semibold, 3: sm semibold). Pick the level from the page outline, never for size.<Hint>— secondary explanatory text under a heading or control: a small muted<p>. Inline metadata beside other content (a timestamp in aRow) stays<Text size="xs" tone="muted">.<ErrorText>— an error message: a small danger-colored<p>. Not a live region; the component that shows a new error owns any announcement.<StatusMessage>— a pane's placeholder while it has nothing to show (loading, empty, not found): padded,role="status". Errors use<ErrorText>.<Text>— typography primitive for text no role above covers.as(span/p/div/h1..h6),tone,size,weight,italic,mono,truncate,center,uppercase(uppercase + tracking-wide, for small section headings),breakAll(break long URLs across any character).
Layout containers
<Stack>— vertical flex column withgap: none/xs/sm/md/lg(defaultmd). Children stretch to the full width; an inline-level child (aButton, aBadge) that should keep its natural width takesclassName="self-start". A child's own margin adds to the gap, so space children withgap, not margins. Also the page-level scroll container:overflow(visible/hidden/auto/scroll),focusable(keyboard-scrollable; set it withoverflow="auto"),align(start/center/end/stretch), andhideOnMobile(hidden sm:flex, for the detail pane of a two-pane layout).<Row>— horizontal flex with gap/align/justify/wrap.<Card>— bordered box with padding/background/shadow/rounding props.muteddims the card to indicate inactive/archived content.<Accordion>— collapsible disclosure.variant: bordered/plain.
Overlays & Composite
<FileEntry>— canonical one-line file representation (icon + title/path + peek button) shared across the recent-files dropdown and tool-use expansions. Expands in place to the full file viewer;onPanelescalates to a companion side panel;onOpenmakes the title open the file instead of previewing it.<Dropdown>+<MenuItem>+<MenuDivider>— menu with click-outside, escape-to-close, auto-close on select.<Dropdown>takes a render-proptrigger. AMenuItemwithhrefalso acceptsdownload(filename) andtarget="_blank"for file actions.<TabBar>— generic tab group with active state.<ImageLightbox>— fullscreen image overlay (usually accessed via<Image lightbox>rather than directly).<MobileBackButton>— chevron + label back-navigation button for two-pane mobile layouts. Hidden atsm+breakpoints. Used at the top of the detail pane.
Renderer-specific
<BboxOverlay>— bounding box for image renderer (rectangle with color + position).
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
- Does an existing primitive with a new prop cover this? If so, add the prop.
- Is the pattern used 3+ times with uniform look? If so, extract a primitive.
- 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.