src/renderer
The UI stack sits on prerelease channels your training data does not cover: solid-js@2.0.0-rc.18
with @solidjs/signals and @solidjs/web at the same RC, @kobalte/core@2.0.0-alpha.0 (patched
here), plus patched lucide-solid and solid-sonner. Read props in the compute function of a
createEffect, not in its callback. The compiler makes a conditional prop (a() && b, x.y ? a : b)
a getter that creates a memo, and a memo created in an effect callback throws
PRIMITIVE_IN_EFFECT_CALLBACK and halts the UI. Use untrack in the compute function when the read
must not run the effect again. Do not trust your memory of these APIs —
check package.json for what is actually installed, and load the reference that matches the work:
node_modules/solid-js/CHEATSHEET.md— the source of truth for core Solid APIs. Read it before using a reactive primitive you have not already used in this repository..agents/skills/react-to-solid/docs/kobalte-patterns.md— when composing or typing a Kobalte primitive part..agents/skills/react-to-solid/docs/corvu-patterns.md— when using a Corvu primitive..agents/skills/react-to-solid/docs/base-ui-mapping.md— when porting Radix or Base UI markup, data attributes or CSS variables..agents/skills/react-to-solid/docs/third-party-deps.md— when replacing a React-only package..agents/skills/zaidan/references/— when choosing or composing a component pattern.
Those two skills are vendored and re-synced from upstream, so their code samples are 1.x-era and cannot be corrected here. Where a skill and the cheatsheet disagree, the cheatsheet wins. Read the skills for the component patterns, not the imports.
bun run devfor integrated work — it starts the local Auth API and the Electron dev app together.bun run dev:apiis for API-only debugging.bun run storybookverifies isolated components. CI builds it; do not runbuild-storybook.bun run check:uiis the design-system guard for this directory: shared primitives over native controls, Kobalte and Lucide only inside@openbot/ui, and palette tokens instead of colour, size, radius and transition literals. The pre-commit hook runs it on each commit that stages code, so do not run it by hand — its budgets only ever go down. All of them sit at zero.- Nothing here imports
src/main,src/backendorsrc/preload.biome.jsonrejects it by path, so reaching past the IPC contract failsbun run lintrather than review. - Never verify UI with
dist/, a packaged.app, a production build, or an ad-hoc preview — those are for release verification the user asked for. If the dev app will not start, report the blocker instead of falling back to one.
Where a file goes
App-specific domains live in src/renderer/src/features/<domain>/, flat except for stores/.
Shared feature components live in packages/ui/src/features/<domain>/; see
shared UI instructions. Import them from @openbot/ui package
subpaths. Keep application contexts, persistence, and platform adapters in the renderer.
Its context, its DOM-free logic, its pane, its components, its tests and its stylesheet are
siblings, so "fix the pin ordering" is answerable by opening one path. There is no barrel —
packages/ui/src/index.ts is still the only one — and feature-local imports stay relative. Import shared primitives from @openbot/ui.
features/<domain>/
<domain>-context.tsx the domain context: createSimpleContext provider + use*() pair
<domain>-scope.ts the view-side composer, where one exists
<Domain>*.tsx entry component and rendered regions, PascalCase
<domain>-*.ts DOM-free logic, kebab-case
<domain>-port.ts the bridge calls this domain makes
<domain>.css the stylesheet partial, @import-ed from styles.css in cascade order
stores/ one create*Store per concern, plus *-actions.ts command bundles
The context file is always <domain>-context.tsx, even where nothing forces it. sidebar.tsx
and Sidebar.tsx coexisted only because they were in different directories; in one directory they
are the same filename on case-insensitive APFS. That breaks the Check job on macos-14 alone
while every ubuntu job stays green, so the suffix is uniform rather than applied where a collision
happens to exist today.
Outside a feature: @openbot/ui (the shared patched-Kobalte layer), the app shell and its
wiring (App.tsx, AppView.tsx, app-providers.tsx, app-bootstrap.tsx, WorkspaceShell.tsx,
WorkspaceOverlays.tsx, WorkspaceOverlayViews.tsx, lazy-views.ts), the cross-domain modules every feature reads and none
owns (navigation.tsx, layout.tsx, turns.tsx, providers.tsx,
simple-context.tsx, scope-lifetime.ts), preview/ — whose mocks are the second implementation
of the IPC surface and belong beside mock-openbot.ts — and the base stylesheets in styles/
(base.css, transitions.css; action-menu.css and sliding-tabs.css are now in
packages/ui/src/styles/, and otp-input.css is in packages/ui/src/features/account/) —
plus styles/app-shell.css, which ends in a theme layer that assigns the palette across every domain
at once and cannot be split until that layer is lifted out; its header says so. Stories stay in
src/renderer/stories/, where the test rules relax.
A cluster earns a directory when the feature is the only thing that reads it. That test applies to
helpers and components, not to contexts: a context is read broadly by design, and agents-context
having eighteen readers is not evidence it belongs at the root. Something genuinely shared by two
domains stays flat rather than being imported sideways out of one of them.
Reactive state shape
Prefer one createStore per concern over a row of createSignal calls. Fields that change
together are one record — a saved-and-draft form pair, a data/loaded/loading/error quad, a
phase plus the numbers only one phase uses, several Records keyed by the same agentId. Declare the
shape up front, so replacing one field re-renders only what read that field;
packages/ui/src/features/custom-providers/CustomProviderDialog.tsx is the form version. Keep the setter private
behind named mutations where the store is a module's or a hook's exported surface, as app-stored-values.ts does. Inside a
component, write the field where it changes — setPanels((state) => { state.x = value; }) at the
call site, as ServerGeneralSection.tsx does — and let a named mutation there earn its name: more than one
field, a guard or a side effect, or enough call sites that the name deduplicates something. A
function whose whole body is state.x = value is the signal wall one layer down. Ten keyed
Record signals are one row type shredded into ten columns, every write spreads the whole map so
every consumer of every key re-runs, and parallel signals let a screen hold states
the product does not have. createSignal still fits an element ref, a single measurement, a one-off
boolean, and a record you always replace whole. Stores come from "solid-js" — there is no
solid-js/store in this RC — hold plain values rather than accessors, and are never destructured.
A Map or Set never becomes a store — isWrappable rejects platform objects — so the
reference is the reactive unit: write a collection held in a signal by copying
(new Set(current).add(id)), and keep one you never read reactively in a closure.
Component reuse
Search for an existing component, hook, style, utility or story first, and prefer reuse, composition
or a small extension. The shared layer is packages/ui/src over patched Kobalte:
extend a primitive there rather than copying one into a feature, and update its story when it gains
a visual or interactive state. Build from scratch only after the search comes up empty, and keep it
reusable. The Zaidan catalog is a source of reference
patterns, not a dependency — it is not installed here; adapt its source to this repository's
SolidJS conventions, tokens, typography, spacing, icons and accessibility requirements.
A story that shows a feature in context renders the real App (OpenBotPlayground, or
PrototypeApp from stories/prototype-support.tsx) or the real container, such as
ServerSettingsStory from stories/server-settings-story.tsx or StoryConversation. Do not
hand-build the shell, a header, a sidebar, a settings menu, or a composer: a copy keeps the old
design when the app changes. Delete a concept or prototype story when its decision is made.
Design system
Use lucide-solid icons, and reuse a suitable one before adding an inline SVG or a local icon
component — document the exception next to any custom icon. The :root properties in
packages/brand/src/tokens.css are the palette, shared with the web and mobile apps: use the
closest semantic --openbot-* token, including opacity variants, instead of a colour literal, and
add a token only for a new semantic role — there, not in a renderer stylesheet. A token whose dark
value does not read on a light canvas also needs a value in tokens-light.css; a surface that stays
light in both themes uses the paper-* tokens, not the inverted *-on-light ones. Keep compatibility
aliases that are in use, and isolate fixed integration, generated-asset, SVG and platform colours at
their boundaries.
Waiting in a renderer test
*.test.tsx renders JSX in jsdom; *.dom.test.ts is the narrow case of needing a DOM without a
component. Needing either for logic means the logic is not separable from the DOM yet.
| Barrier | Use it for |
|---|---|
await screen.findByRole(role, { name }) |
The normal case. It retries until the element exists, so it is the wait — a getBy* after a manual wait is the same query with a worse failure message. |
await screen.findByText(...) |
Copy that is a product contract and has no accessible role of its own. |
vi.waitFor(() => expect(spy)...) |
A call that produces no visible change — an analytics event, an IPC invoke. |
vi.useFakeTimers() + vi.advanceTimersByTime(n) |
A debounce or a poll interval. Advancing the clock is input, not waiting. |
Never raise a vi.waitFor timeout to make a test pass: that is the sleep the no-sleep-in-tests
rule rejected, one layer down. Find the barrier, or add one. Animation timing is not a barrier and
not a subject — it belongs in a Storybook story.