| name | dashboard-ui |
| description | Enforce shadcn/ui component usage in apps/dashboard, the "avoid manual input" UX principle, AND the mobile chrome rules — pages declare title/back/actions via usePageHeader (one global mobile bar; a single primary action stays inline, two or more collapse to a `⋯` overflow menu; no per-page sticky headers); html/body never scroll (rubber-band disabled, the app shell's <main> is the only scroll container); account/avatar lives in the sidebar footer; "create" and navigation are unified in a global ⌘K command palette (CommandPaletteProvider) — no chrome `+` button, no FABs. Linkable fields (repo owner/name, installation IDs, project slugs, URLs) must be solved with OAuth pickers or deep-links-out, never typed inputs. Native HTML form and interactive elements (input, select, button, textarea, checkbox, radio, dialog, etc.) are forbidden — use the shadcn/ui primitives under @/components/ui instead. Trigger when editing or creating .tsx files under apps/dashboard/, when adding forms/buttons/inputs/selects/modals to the web app, when wiring up GitHub/Railway/OAuth integrations, when adding/changing a page header / back arrow / page title / sticky toolbar / mobile chrome / sidebar footer / command palette / quick-create / FAB / `+` button, or when the user mentions "shadcn", "UI component", "form control", "dashboard component", "manual input", "paste URL", "linkable field", "page header", "page title", "back arrow", "mobile bar", "rubber-band", "overscroll", "command palette", "⌘K", "Cmd+K", "Ctrl+K", "overflow menu", "FAB", "borrow UX from Railway/Vercel/Claude". |
dashboard-ui
The Onsager dashboard (apps/dashboard) standardises on shadcn/ui for all
interactive and form UI. Native HTML elements for these controls are not
allowed — they bypass the design system, theming (next-themes + CSS variables),
and accessibility behavior baked into the shadcn primitives.
The rule
In any .tsx under apps/dashboard/src/ — except for the shadcn
primitives themselves in apps/dashboard/src/components/ui/**, which
legitimately wrap native elements:
- Do not write
<input>, <select>, <textarea>, <button>,
<option>, native <dialog>, native checkbox/radio <input type="...">,
or an unstyled <a> that behaves like a button.
- Do import the shadcn equivalent from
@/components/ui/<name>.
- Plain structural/text tags (
<div>, <span>, <p>, <h1>–<h6>,
<ul>, <li>, <form>, <label>, <a> for real navigation, etc.) are
fine — the rule is specifically about interactive / form controls.
Mapping
| Native | Use instead |
|---|
<button> | Button from @/components/ui/button |
<input type="text|email|..."> | Input from @/components/ui/input |
<input type="checkbox"> | Checkbox from @/components/ui/checkbox |
<input type="radio"> | RadioGroup from @/components/ui/radio-group |
<textarea> | Textarea from @/components/ui/textarea |
<select> / <option> | Select from @/components/ui/select |
<dialog> / custom modal | Dialog from @/components/ui/dialog |
| Drawer / off-canvas | Sheet from @/components/ui/sheet |
| Tooltip | Tooltip from @/components/ui/tooltip |
| Dropdown menu | DropdownMenu from @/components/ui/dropdown-menu |
| Tabs | Tabs from @/components/ui/tabs |
| Table | Table from @/components/ui/table |
Installed components
These are already present under apps/dashboard/src/components/ui/:
badge, button, card, command, dialog, dropdown-menu, input,
input-group, popover, scroll-area, select, separator, sheet,
sidebar, skeleton, table, tabs, textarea, tooltip.
If you need a component that isn't in the list (e.g. dialog, checkbox,
radio-group, form, switch), add it with:
cd apps/dashboard
pnpm dlx shadcn@latest add <name>
The CLI writes the file to src/components/ui/<name>.tsx using
components.json (style base-nova, neutral base color, @/components/ui
alias). Commit the generated file alongside the change that uses it.
Checking existing code
Before claiming a dashboard change is done, grep for violations in files you
touched:
rg -n '<(button|input|select|textarea|option|dialog)[ />]' apps/dashboard/src \
--glob '!apps/dashboard/src/components/ui/**'
Any hit in your diff should be replaced with the shadcn primitive.
Why
- Consistent theming via CSS variables +
next-themes dark mode.
- Keyboard / focus / ARIA handling lives in the primitive, not in each call site.
- Styling lives in one place (the
ui/ component) — ad-hoc native elements
diverge quickly and are expensive to re-skin later.
Vocabulary: the canonical 4 nouns
User-facing surfaces (page titles, headings, button copy, route
segments, public API field names, user-visible docs) use exactly four
top-level nouns: Workflow, Run, Artifact, Stage. Per
spec #286 / root CLAUDE.md § "User-facing vocabulary":
- Internal-only (stay in Rust / migrations / spine, never in
user copy):
shaping, bundle, sealed, spec,
ArtifactVersionId.
- Surface-internal (visible only inside a workflow / run
drill-down, never as a top-level navigation noun):
gate,
verdict, governance, session, node, issue.
When you write a page title, a button label, a heading, or a
field name, default to one of the four canonical nouns. Use the
demoted vocabulary only inside a workflow- or run-detail drill-down,
and never as a top-level navigation item or noun. Enforcement is
PR review — there is no mechanical lint — so spotting drift in your
own diff before opening the PR is the bar.
UX principle: avoid manual input, streamline everything
When designing any flow that touches external systems (GitHub, Railway,
deploy providers, cloud accounts), default to the patterns established
platforms like Railway, Vercel, and Claude use — not "show a form with a
bunch of fields to type into." On mobile especially, typing owner/name,
IDs, URLs, or secrets is a dead-end UX.
Order of preference, highest to lowest:
- OAuth / App install — one button, platform owns identity. Deep-link
out and come back via redirect; no IDs or secrets touch the user.
- Searchable picker (combobox) from already-authorised data — a
Popover + Command (cmdk) combobox populated from the linked
install, with typeahead filtering. Never a plain Select once a list
can grow past a handful — on mobile, scrolling 100+ flat SelectItems
is a dead-end. The shadcn primitives are in
@/components/ui/{popover,command} (installed via pnpm dlx shadcn@latest add command popover).
- Deep-link back to the source of truth — when the picker doesn't
contain what the user wants, link out ("Configure repository access on
GitHub →") instead of showing a manual form. Use the platform's own
settings page — it owns the state.
- Pasted URL — acceptable only when the platform has no App/OAuth
model for the resource (rare for GitHub; common for one-off public
links). Still prefer a picker.
- Typed identifiers / split fields (owner + name, id + login, etc.) —
do not use. If you find yourself writing two
<Input> fields for
something a user has in a URL bar, you're building the wrong UI.
Freestyle input is never acceptable when the domain is known. If the
set of valid values is finite and we can enumerate it (repos on an
install, nodes online, branches in a repo, members of a workspace), the
input must be a search + selection combobox, not a free-text field.
The minimum bar is: type to filter, click to select, backed by an actual
data source — never a bare <Input> with validation hoping the user
typed the right thing.
Concrete patterns to follow:
- When a GitHub App installation exists but the desired repo isn't in the
accessible-repos list, surface a "Configure repository access on
GitHub →" deep link to
https://github.com/organizations/<login>/settings/installations/<install_id>
(org) or https://github.com/settings/installations/<install_id> (user).
Vercel, Railway, and Render all use this pattern.
- When an App credential isn't configured server-side, show an
informational message ("Ask an administrator to set up the App") rather
than a manual-entry fallback form. A half-working manual path is worse
than a clearly-blocked one.
- Infer derived values (default branch, account type, etc.) from the
picker's payload — never ask the user to repeat what the API already
knows.
Concrete patterns to avoid:
- A form that asks for "Installation ID (numeric) + Account login +
Account type + Webhook secret" — that's engineering plumbing leaking
into UI. OAuth is the only acceptable path for App install linking.
- A "paste a repo URL" or "enter owner/name manually" escape hatch next
to an OAuth-backed picker. The deep-link-out pattern is strictly better.
- Separate inputs for values that live together in a single URL or
identifier (e.g.
owner + name for a GitHub repo). Either parse from
a paste, or don't ask at all.
Anti-pattern: input box for linkable fields
Any value that the user can obtain by clicking a link on another page
is a "linkable field." These are almost always wrong to ask for as a typed
input — the user has to context-switch, copy, paste, and verify. Every
step is a mobile-hostile tax and an opportunity for a typo.
If a field is linkable, do not use an <Input> for it. Instead:
- Pick it from a list the linked system already hands us (repos,
branches, environments, service names, deploy targets).
- Deep-link out to where the user can grant or configure it, then
re-fetch when they return.
- Infer it from another selection (default branch from repo, account
type from install payload, organisation from user session).
Linkable fields include, but aren't limited to:
- GitHub repo identifiers: owner, name, full URL, SSH URL, default branch,
PR number, issue number, installation id.
- Cloud resource identifiers: project id, service id, region, environment
slug, secret reference.
- Third-party account identifiers: workspace slug, team id, seat id,
org login.
Rule of thumb: if you're about to label an input "Installation ID",
"Repo owner", "Project slug", or anything that is already a hyperlink on
the source platform's dashboard, stop and redesign. Use a picker, a
deep-link, or inference. Typing one of these is never the right UX — not
on desktop, and definitely not on mobile.
When in doubt, open Railway/Vercel, walk through the analogous flow, and
copy the pattern.
Mobile chrome — one bar, declarative title/actions
The dashboard ships a single mobile chrome bar rendered by
AppLayout. Pages must not stack their own sticky top bar on top — the
result is a 100px+ chrome stack on a 700px-tall screen, the same
content-shift bug usePageHeader exists to solve, and back-button
ambiguity (which arrow goes "up"?). One bar, one source of truth.
Pages declare what goes in it via:
import { usePageHeader } from "@/components/layout/PageHeader"
usePageHeader({
title: workflow?.name ?? "Workflow",
backTo: "/workflows",
actions: actionsNode,
})
AppLayout's mobile bar renders (ADR 0026):
[← backTo OR ☰ drawer] [title] [...actions] [⌘K]
☰ opens the nav drawer (MobileNavDrawer, a left Sheet holding the
same SidebarBody as the desktop rail); drill-down pages show ←
instead. The bar holds only page actions + the ⌘K trigger — account
and the chat/Inbox entries live in the drawer footer, not the bar.
Rules
- Always call
usePageHeader at the top of a routed page, even if
it's just usePageHeader({ title: "Sessions" }). Without a title
registration the bar falls back to the Onsager wordmark, which is
fine on / but useless context everywhere else.
backTo is an absolute path, not navigate(-1). Deep links
open detail pages directly; relative back is unreliable. Detail
pages always pass backTo; top-level pages always omit it.
- Hide the page H1 on mobile with
hidden md:block — the bar's
title replaces it. Keep description text and any subtitle below it
visible on both viewports.
- Mobile actions are icon-only, and overflow when there's more than
one. A single primary action stays inline (one ghost-icon button,
aria-label + title). Two or more actions collapse into a
single ⋯ (MoreHorizontal) DropdownMenu with full-width
DropdownMenuItem rows showing label + icon — including
destructive items via variant="destructive". The bar is busy
enough with title + back + ⌘K trigger; multiple action icons make
the title truncate aggressively and create inconsistency between
pages. Predictable beats compact.
- Memoize JSX
actions (and JSX title). The hook's effect
re-runs when its deps change, so a fresh JSX object every render
triggers needless setState. Wrap with useMemo keyed on the data
it depends on.
- Desktop keeps its own page-level title + actions block in flow
(typically
hidden md:flex / md:block wrappers). The mobile bar
is a mobile-only surface; desktop has the room for a proper page
header. Don't try to consolidate them.
- No new sticky/fixed/
top-0 chrome bars on a page. If you find
yourself reaching for sticky top-0 to keep something visible
during scroll on mobile, the answer is almost always
usePageHeader.
Existing actions: shared component, two variants
When the same action set has both a desktop labeled-button rendering
and a mobile overflow-menu rendering, parameterise the action
component with a variant prop — don't duplicate the mutations.
WorkflowActions is the canonical example: variant="buttons" for
the desktop block, variant="menu" for the mobile slot registration.
Both variants share the same useMutation setup and confirmation
Dialog; only the trigger surface differs.
App shell scrolling — body never scrolls, <main> does
On mobile especially, the app must feel native: no rubber-band, no
pull-to-refresh on a page that has no refresh, no chrome scrolling
out of view. The shell is fixed; only the content area scrolls.
Already wired in:
index.css sets html, body, #root { height: 100% } and
overflow: hidden + overscroll-behavior: none on html and
body. Do not undo these.
AppLayout's SidebarProvider is h-svh overflow-hidden. The
inner <main> is flex-1 overflow-y-auto overscroll-contain min-h-0 — the min-h-0 is load-bearing (flex items default to
min-height: auto, which would let the column grow and clip
content instead of scrolling).
Rules
- No
overflow-y-auto / overflow-y-scroll / sticky scroll
containers at the page level. The shell already provides the
scroll context; nesting another scroll container creates
swipe-direction ambiguity on mobile and breaks position: sticky
inside it.
- Tall lists / tables that need their own scroll belong in a
<ScrollArea> from @/components/ui/scroll-area with a defined
max-h-…. The body of the page still scrolls in <main>; the
list scrolls inside the scroll-area.
- Don't set heights in
vh / dvh / lvh on page-level
components. The shell pins to svh; everything inside should
flow naturally. vh units inside a fixed shell produce off-by-the-
URL-bar bugs on iOS.
- Safe-area insets:
<main> already adds
pb-[calc(env(safe-area-inset-bottom)+1rem)] on mobile. Don't
re-apply at the page level.
Global "do something" surface — the command palette, not +
The dashboard ships one chrome-level action: a search-icon trigger
that opens a CommandDialog (cmdk) with Create and Go to groups.
Trigger lives in the mobile bar and the desktop header; the dialog
itself is rendered once at the layout level via
CommandPaletteProvider, with the ⌘K / Ctrl+K hotkey bound
globally.
Rules
- Don't add a
+ button to chrome. A global "create" + doesn't
scale: every new primitive forces a chrome redesign, and on
info-only pages it has nothing to offer. The palette absorbs all
create actions.
- Don't add a second palette instance. Mount
<CommandPaletteProvider> once (already in AppLayout); use
<CommandPaletteTrigger /> to render the search icon in any chrome
surface. Multiple providers means multiple dialogs and a duplicated
hotkey listener.
- All "create primitive X" entries live in the palette. When
adding a new primitive, register an item in the
Create group of
CommandPalette.tsx. Provide a stub CreateXSheet /
NewXDialog that the palette opens, the same way
CreateSessionSheet / NewWorkspaceDialog are wired today.
- Page-level "Create" buttons can still exist — e.g. the "Create
workflow" button on
/workflows. Those are page-context
shortcuts; the palette is the global one. Don't drop the page-level
one to "force" palette use.
- Don't reach for FABs (floating action buttons). Discoverable
affordances belong in the palette; an extra floating element on
every page restarts the chrome-debt cycle the palette exists to
end.
Item conventions
- Use lowercase action labels for created items: "New workflow",
"New session", "New workspace" (not "Create…" or "Add…").
- Use lowercase capitalized destinations for navigation: "Workflows",
"Artifacts", etc. (matches the sidebar labels).
- Add
keywords={["new", "github", …]} for synonyms users will
type — cmdk fuzzy-matches keywords alongside the visible label.
- Gate auth-required items on
authEnabled && !!user. Anonymous
users shouldn't see "New workspace" → 401.
Account / user menu lives in the sidebar footer
The avatar / account dropdown lives in the footer of SidebarBody
(AppLayout.tsx) — the shared body rendered both as the desktop
DesktopSidebar rail and inside the mobile MobileNavDrawer. Do not
add another UserMenu instance to a header, page, or any other
surface — there is one account control, in one place, on every
viewport. Mobile users open the drawer (☰ in the chrome bar) to reach
it; desktop users see it at the bottom of the always-visible sidebar.
This frees the mobile top bar for page-specific actions.
If you need to surface a sub-action (e.g. "Sign out" from a settings
page), link the user to /settings or use the existing
DropdownMenuItems — don't fork a parallel menu.
New primitive = new surface
When the dashboard grows a new user-facing resource (workspace, project,
credential, node type — anything a user can create), shipping just the
CRUD UI isn't enough. A primitive without entry points is functionally
invisible: users land on empty pages with no hint that the resource
exists, and the feature ships as dead code.
This is the product-side complement to the issue-spec "Reach ships
with the primitive" principle — the spec scopes these in, the UI
implementation checks them off. If a spec lands on your desk without
them, push back at the spec stage. Don't quietly defer.
Before a PR that introduces a new primitive is ready:
The cheap option is usually wrong. It's tempting to ship just the
list + create card inside Settings and plan the surface as a follow-up.
In practice the follow-up PR is bigger than building it up front, the
primitive is invisible in the meantime, and reviewers/users start
reporting "there's no way to do X" despite the code being live.
See #70 / /workspaces as the canonical example of what this shape
looks like when done right.