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():
| Control | Range | Effect |
|---|---|---|
| Max Signals | 0..limitSignals | Live active-signal cap (currentMaxSignals). |
| Signal Size | 0.2..2 | Particle point size. |
| Signal Min Speed | 0..8 | Lower bound of signal speed. |
| Signal Max Speed | 0..8 | Upper bound (clamped to ≥ min). |
| Neuron Size Mult | 0..2 | Neuron point-size multiplier. |
| Neuron Opacity | 0..1 | Neuron shader opacity. |
| Axon Opacity Mult | 0..5 | Axon line opacity multiplier. |
| Signal / Neuron / Axon Color | color | Recolors the respective elements. |
| Background | color | Stage background (sceneSettings.bgColor). |
| Floor Platform | toggle | Show/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"(nodata-mode) and opens the inline chat (see Brain-assistant chat).bindModes()branches ondata-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 a stimulus is loaded, the UI polls telemetry every 100 ms:
telemetryTimer = setInterval(updateFromTelemetry, 100)
It maps getTelemetrySnapshot() into the cortical response index (x/100),
per-region bars, and the rolling activity windows.
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:
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 stream from the Supabase run-chat Edge Function, which routes to
9Router with server-only credentials and saves each turn as a trace/span. 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.
The header holds the model picker, New chat button, and close button. The
picker is loaded through GET /functions/v1/run-chat?models=1 and renders as
an app-owned dark popover instead of a native browser select. The trigger shows
the provider icon, readable model label, and raw model id. Opening it reveals a
search box, provider groups, selected/active row states, and variant badges such
as review, mini, high, low, none, spark, and xHigh.
The picker supports click, outside-click dismissal, Escape, ArrowUp/ArrowDown,
Enter, and search filtering. 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 AI provider/model handled the response. If 9Router 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 default-model
action switches to cx/gpt-5.5 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.
Tile structure
#leftChat is a flex-direction: column tile whose five regions are direct
siblings: they must not be nested inside one another:
<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>
<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.
.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.
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.
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.
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.