Skip to main content
Version: Next

UI guide

The interface has two halves: a developer-facing settings panel (lil-gui) and a product-facing dashboard (src/brain-ui.ts).

For the visual language behind these surfaces: tokens, palette, typography, and radii: see the Design system.

Settings panel (lil-gui)

src/gui.ts mounts a lil-gui panel inside the #guiHost card (falling back to a floating panel if the host is missing). It has two folders.

Info: live, read-only counts, polled via .listen():

  • Neurons
  • Axons
  • Signals

Settings: every control routes through Brain.updateSettings():

ControlRangeEffect
Max Signals0..limitSignalsLive active-signal cap (currentMaxSignals).
Signal Size0.2..2Particle point size.
Signal Min Speed0..8Lower bound of signal speed.
Signal Max Speed0..8Upper bound (clamped to ≥ min).
Neuron Size Mult0..2Neuron point-size multiplier.
Neuron Opacity0..1Neuron shader opacity.
Axon Opacity Mult0..5Axon line opacity multiplier.
Signal / Neuron / Axon ColorcolorRecolors the respective elements.
BackgroundcolorStage background (sceneSettings.bgColor).
Floor PlatformtoggleShow/hide the floor.

The "Signal Max Speed" control guards min <= max: if you drag max below min, it snaps max back up to min.

Dashboard (brain-ui.ts)

The product UI ports the original app's panels, arranged as a bento layout (see Bento layout below).

Mode tabs

Three tabs (.mode-tab) sit as a persistent header at the top of the left region, above the scrolling content:

  • Virality Study and EXEPERT Brain are the two simulation modes. They call applyMode(...) and own the active highlight. Switching to the brain mode sets the stage label to "EXEPERT Human Brain Online". (The brain mode is keyed internally as 'meta' in the UI state: a residual name from before the rebrand.)
  • Chat is an action, not a mode: it carries data-action="chat" (no data-mode) and opens the inline chat (see Brain-assistant chat). bindModes() branches on data-action === 'chat'; the three tabs stay mutually exclusive: selecting a mode tab closes the chat, and selecting Chat clears the mode highlight.

Stimulus upload and Analyze

You can upload an image or video as stimulus. The UI previews it, reports its size ("… stimulus loaded. Analyze injects a controlled pulse into the live brain simulation."), and the Analyze button injects a pulse. Under the hood this calls Brain.injectStimulus, which releases a burst of signals scaled by intensity: see Regions & Stimulus.

Telemetry readouts

Once the product UI boots, it polls telemetry every 100 ms whether or not a stimulus is loaded:

src/brain-ui.ts
telemetryTimer = setInterval(updateFromTelemetry, 100)

It maps getTelemetrySnapshot() into the cortical response index (x/100), per-region bars, and the rolling activity windows.

The Dashboard presents Cortical Response, Duration, Onset Response, and Sustained Activation as one compact 2x2 metric group beside the stimulus preview. Cortical Response is normalized to an integer from 0-100 and shares one presentation value across its number, accessible meter, fill width, and semantic band: Low from 0-39, Medium from 40-69, and High from 70-100. This replaces the old static LOW/HIGH legend, which looked live but did not react to telemetry. The heading stays on one line, the score occupies a fixed three-character slot, and the semantic band has a reserved column beside the meter. Changing from High to the longer Medium label therefore changes only the value, color, and fill instead of reflowing the card.

The score continues to represent the free-running neural simulation when no stimulus is loaded. At typical right-panel widths the empty preview is a compact side tile; below a 360px panel width, the metrics move first and the empty state collapses to 64px. Loaded image and video previews retain their 16:9 ratio.

Activity-bar scaling

The activity bars normalize against the configured signal cap (settings.limitSignals, default 10000 in the UI fallback), so they read as a percentage of capacity rather than an absolute count:

src/brain-ui.ts
const cap = (neuralNet.settings && neuralNet.settings.limitSignals) || 10000
const v = Math.max(0, value / cap) * 100

If typical activity is a few hundred signals, the bars will sit low on this scale: that is expected given the normalization.

Brain-assistant chat

The Chat tab swaps the left region's study content (.study-content) for an inline chat tile (#leftChat) that answers questions about the readout: it is not a separate full-screen overlay. openChat() toggles .chat-active on the left panel and .active on #leftChat; the close button and Escape both route through applyMode(state.mode), which restores the previously active mode and its tab highlight.

Responses come from the Supabase run-chat Edge Function, which routes to 9Router with server-only credentials, validates one structured research object, and saves each turn as a trace/span. While the request runs, Chat shows a compact progress state. The result renders safe Reply, Key Points, Evidence, Limitations, and Next Steps sections. The browser keeps the visible transcript in localStorage; thumbs feedback on a saved assistant message calls chat-feedback and writes a user_feedback annotation. Quick-prompt buttons seed common questions. At compact widths, those prompts remain available for an empty or newly reset chat and then disappear after the first user turn so the transcript can use their space.

The header holds the model picker, New chat button, and close button. The picker is loaded through the dedicated chat-models function and renders as an app-owned dark popover instead of a native browser select. Loading, manual refresh, stale-cache, retry, and unavailable states are explicit. The trigger shows a provider icon, friendly model name, and public family. Opening it reveals a search box, EXEPERT/Gemini/Claude/OpenAI/MiMo/Other family groups, selected/active row states, and variant badges such as Low, Preview, Agent, and Thinking.

The picker supports click, outside-click dismissal, Escape, ArrowUp/ArrowDown, Enter, and search filtering. Raw identifiers and technical source names remain searchable but appear only when the user enables the default-collapsed, non-persistent Technical names control. It closes and disables while a response is streaming, preserving the same selected-model persistence key: exepert.brainChat.model.v1. The "Brain Mode" context pill describes the simulation mode; the separate "Model" pill and assistant message footer show which friendly route and resolved model handled the response. Exact requested, route, and resolved identifiers live in a collapsed technical disclosure under each assistant message. If the service reports the selected model as unavailable or deprecated, chat renders a compact .chat-error-card, offers retry/default-model/model-picker actions, and marks that model as unavailable in the picker for the current browser session. The Use EXEPERT Chat action switches to exepert-chat and restores the prompt without auto-sending, so the user stays in control of the next request.

The Affective Field below the context pills is a synthetic research layer for emotion-reactive canvas behavior. User text is classified locally before the chat request, assistant output is classified when streaming completes, and chat route failures become assistant_error affect events when the failed span is available. The readout shows dominant affect, intensity, valence, arousal, and toxicity; message chips show the dominant affect per turn; the brain canvas pulses with the corresponding emotion color. These are conversational tone signals for EXEPERT research, not clinical or literal emotion detection. In compact Chat, the full meter bars collapse into a summary strip that retains all four values. Narrow tiles keep that strip on one line and scroll it horizontally with touch, trackpad, or keyboard input; the scrollbar stays visually hidden and the next clipped metric acts as the continuation cue. The full meters and context pills remain unchanged above 1240px.

Tile structure

#leftChat is a flex-direction: column tile whose six regions are direct siblings: they must not be nested inside one another:

index.html
<div class="left-chat" id="leftChat" role="dialog" aria-label="Neural Brain chat" aria-hidden="true">
<div class="brain-chat-header">searchable model menu / new chat / close</div>
<div class="chat-context">Brain Mode / Response / Signal / Model pills</div>
<section class="affect-field">dominant affect / phase / four values</section>
<div class="brain-chat-messages" id="brainMessages">messages</div>
<div class="quick-prompts">seed buttons</div>
<div class="brain-chat-input">input / send</div>
</div>

The column relies on .brain-chat-messages { flex: 1 } to absorb the free space, so the header pins to the top and .brain-chat-input pins to the bottom. In compact Chat, the surrounding chrome is non-shrinking and the message list is the only flexible vertical scroller. Programmatic input focus uses preventScroll, preventing the clipped chat tile from scrolling its own header out of view.

Keep the header self-closed

.brain-chat-header is a horizontal row (display: flex; align-items: center). If its closing </div> is missing: or the opening tag is accidentally duplicated: the context pills, messages, quick-prompts, and input get absorbed into the header and collapse into that narrow centered row, breaking the layout. The sibling structure above is the contract.

Bento layout

The workspace (.workspace) is a three-column bento grid: a left region, a dominant center brain-canvas cell, and a right metrics stack, with every section sharing a flat tile chrome (.bento-tile: 1px --line border, 2px radius, --panel background). The grid collapses to fewer columns at the 1240 / 960 / 520px breakpoints. See the Design system for the tokens.

The left region stacks the persistent mode tabs and one active content tile: study content or chat. Trace Ingestion no longer lives below the chat tile, so the chat area keeps the full vertical space in Chat mode.

Compact workspace order

Dashboard and Arcade use view-specific reflows at the existing compact breakpoints:

ViewportDashboardArcade
Above 1240pxStudy/Chat, Brain, Metrics in three columnsStudy/Chat, Brain, Game Boy in three columns
961–1240pxBrain and Metrics above a full-width Study/Chat rowBrain above Study/Chat in the left column; Game Boy spans the full right column
960px and belowBrain, then Study/Chat, then MetricsBrain, then Study/Chat, then Game Boy

The tablet layout removes the brain card's desktop 560px minimum height and uses explicit tracks for both Dashboard and Arcade: minmax(260px, 0.85fr) for Brain and minmax(0, 1.15fr) for Study/Chat. At 970x820, this yields approximately 300px and 406px. Dashboard metrics share the Brain row; Arcade's Game Boy spans both rows. The tracks remain unchanged when Chat opens or restores a long transcript, preventing content-driven compression of the brain, metrics, or console. On compact tablets no taller than 720px, Dashboard preserves 260px and 390px rows and makes the workspace itself the local vertical scroller.

The WebGL renderer follows the actual #canvas-container rather than relying only on browser-window resize events. A ResizeObserver schedules one animation-frame update for the camera projection, renderer dimensions, and device-pixel-ratio backing buffer. Hidden zero-sized states are ignored and the observer resynchronizes when Dashboard becomes visible again, so CSS and buffer aspect ratios cannot diverge when Chat changes the grid.

The existing phone height clamps remain active below 960px and 520px. In the single-column layout, only the active Chat panel uses clamp(420px, 72svh, 640px); Study mode retains its original clamp. Compact Chat hides the redundant context-pill row, reduces the composer padding, and uses an understated accent underline for the selected tab. This allocation keeps the composer pinned and leaves a readable transcript even on a 390x667 viewport.

The reflow is scoped to the initial, Dashboard, and Arcade workspace states. Prompt Playground, Observability, Journals, and Settings keep their focused layouts. Chat remains the same inline .left-chat takeover inside the shared left panel; only the panel's visual grid position changes.

Arcade LCD and device power

The Arcade console keeps the Runner's logical canvas at 320x180. When a game is active, the canvas is top-aligned and rendered at the full LCD width with its 16:9 aspect ratio intact. The LCD's unused vertical remainder stays below the game and uses the same #0a0a0f background, avoiding a false gray band without cropping the HUD, stretching pixel art, or changing gameplay coordinates. Menu, startup, and preflight screens continue to use the full 280x200 LCD.

The Game Boy POWER ornament progressively reports the host device battery when the browser exposes the navigator.getBattery() API. It shows the rounded percentage, three coarse level arcs, a level-aware LED, and a charging glyph. Its status label announces the exact percentage and charging state; color and arcs are supplementary rather than the only signal.

Battery access is best effort. It requires a secure context, is not available in every browser, and can be blocked by browser privacy behavior or Permissions Policy. While access is loading, missing, invalid, or rejected, the console retains its classic static POWER label and red LED without presenting a fabricated percentage. Battery values remain in component memory only: the app does not persist, log, transmit, or poll them. levelchange and chargingchange listeners provide live updates and are removed when Arcade unmounts.

Observability activity page

Current implementation: the observability panel (#obs-panel, built in src/ui/observability.ts) mounts into #observabilitySlot inside the right-panel data-view="observability" workbench view. The existing activity-bar Observability button switches workspace.dataset.workbenchView to observability, hides the dashboard right-panel view, hides the center brain stage, and shows the wider Trace Ingestion page.

src/ui/observability.ts still falls back to the old #obsSlot only for older shells or alternate boot pages. The collapse chevron remains available for small screens, but the new storage key exepert.obsCollapsed.v2 defaults the activity-page panel to expanded because it no longer competes with chat for space.

The Observability page header is static HTML in index.html; the live panel body remains generated by mountObservabilityPanel(), preserving the project switcher, Supabase configured status, import drop zone, sample loader, trace viewer, live toggle, and recent activity summary.

Historical note: before the Observability activity page, the panel was mounted as a collapsible left-region tile:

Historically, the observability panel (#obs-panel, built in src/ui/observability.ts) mounts into the static #obsSlot in the left region: falling back to document.body only if the slot is absent (other entry pages / boot order). It flows in document order as an in-flow bento tile rather than a floating position: fixed overlay, so it no longer overlaps the panel content.

A chevron toggle in the panel header (#obsCollapse) collapses the tile down to just its header, hiding .obs-body so the chat tile reclaims the vertical space (the slot is flex: 0 0 auto while #leftChat is flex: 1). It defaults to collapsed and the choice is persisted in localStorage under exepert.obsCollapsed (wrapped in try/catch for private-mode safety).

Cost & token summary

Below the ingestion controls the panel renders a compact cost / health summary for the current project: total cost, tokens, traces, error rate, and p50 / p95 latency. It refreshes on mount and after each import, and hides itself when Supabase is unconfigured or the project is empty.

The figures come from a pure cost model in src/data/cost.ts. A price book (generative_models + token_prices, seeded with an OpenAI/Anthropic manifest) matches each span's llm_model to a per-token rate via an anchored, case-insensitive regex: match_priority breaks ties so specific patterns beat the catch-all, and a leading provider/ prefix (e.g. openai/gpt-4o-mini) is stripped before matching. computeSpanCost multiplies prompt/completion tokens by their rates; fillSpanCost backfills spans.cost_usd on import only when a trace didn't already report a cost (it never overwrites). rollupCost aggregates a window of spans + traces into the summary.

Recent-activity window

The summary is computed over the most-recent 1000 spans and 1000 traces (two independent bounded windows), so it is labeled "Recent activity (last 1000)" and a value shows a trailing + when its window hits the cap. It is a recent-activity readout, not a project-lifetime total; a server-side aggregate can replace it later.

FPS panel

The Stats.js FPS panel is relocated into the top nav, just left of the search box. src/scene.ts appends stats.dom to #navStats (falling back to #canvas-container) and neutralizes the library's inline position: fixed; top: 0; left: 0 so it flows inline; .nav-stats scales the 80×48 panel down to fit the 38px nav row.

When the Workbench Settings view is open, it is a fixed app-level overlay above the top nav. The app shell also hides #navStats for that state so the FPS canvas cannot bleed through the Settings panel.

Observability panel (Plans 007 / 008)

src/ui/observability.ts builds the observability panel that replaces the trace-ingestion placeholder with a full troubleshooting surface. It mounts the following features together in one panel:

Auth gate

src/auth/gate.ts renders a compact 32×32 avatar button in #authSlot in the top nav bar. Clicking the avatar toggles a dropdown card containing either the sign-in form (when unauthenticated) or the user's profile panel with name, email, avatar image, and sign-out button (when authenticated). The dropdown dismisses on outside click or via a × close button in the card header. When Supabase is unconfigured (offline dev) the gate is bypassed entirely and the observability panel mounts immediately. On sign-in the nav avatar updates to the user's OAuth avatar image or email initial; on sign-out it reverts to a generic person placeholder.

Project switcher

src/ui/project-switcher.ts renders a small dropdown (ARIA listbox) next to the panel header, listing every project the signed-in user has access to via listUserProjects(). Selecting an item calls setActiveProject() which publishes a exepert:project-changed event; the trace list, summary tile, and brain all re-render reactively. Defaults to the demo project when offline.

Session grouping

The trace list now groups traces by session_id using collapsible <details>-style headers (.obs-session-group). Traces with no session render in a flat "no-session" group. Clicking a session header opens renderSessionDetail showing rollups (traces, tokens, cost, error rate, p95) with a Replay session button that sequentially replays all traces through the brain via replaySession() in src/map/session-rollup.ts.

Toolbar

The panel toolbar (.obs-toolbar) has two toggle buttons:

  • Error focus: filters the trace list to traces containing ERROR or slow (> 5 s) spans. Opening one drops into a dedicated error waterfall (renderErrorSpans) with danger-colored bars; clicking back returns to the full trace trace detail.
  • Compare: entering compare mode lets you select two traces; the panel renders a side-by-side region-activity diff grid with language / visual / attention / auditory rows colored green/red by delta. Each trace can be replayed independently.

Human annotation form

src/ui/annotation-form.ts mounts below the span-detail waterfall. It loads the project's annotation_configs (categorical / continuous / freeform) and shows the form alongside already-annotated types. Submissions go through submitHumanAnnotation() with annotator_kind='HUMAN' and feed into the same annotationsToRegionScores() path as LLM/CODE evals, tinting the brain in real time.

Offline degradation

All observability features degrade gracefully to a typed supabase.not_configured error when Supabase is offline. The brain simulation always boots; the observability panel stays empty (or shows a "not configured" caption) rather than crashing.