Major pages share one structure: sticky TopBar (no scroll) + single scroll container (.scrollbar-custom) + sticky ActionBar (no scroll). Only the middle layer scrolls; head and foot stay put.
src/pages/components/use-is-mobile.ts is the only breakpoint source: MOBILE_BREAKPOINT = 768; a viewport < 768px is mobile.
import { useIsMobile } from "@App/pages/components/use-is-mobile";
function Page() {
const isMobile = useIsMobile();
return isMobile ? <MobileShell /> : <DesktopShell />;
}| Desktop (≥768px) | Mobile (<768px) |
|---|---|
| Left nav rail | Bottom tab bar + drawer (Sheet) — tabs for high-frequency, drawer for everything else |
| Multi-column table | Single-column cards |
| Side-by-side panels | Vertical stack; detail/code collapsed by default |
| Inline dropdowns | Drawer / Accordion overlays |
| Categories in a left rail | Categories in a top horizontal-scroll chip bar |
The bottom bar is BottomTabBar.tsx. Mobile re-shells, it doesn't shrink — see Principle 4.
Long pages (settings / tools) use scroll-spy: scrolling the content highlights the current category, and clicking a category smooth-scrolls to its section. See SettingsLayout.tsx + useScrollSpy.ts. On desktop the categories sit in a left rail; on mobile they become a top horizontal chip bar (the active chip scrollIntoViews to center).
Stacking only works if everyone agrees on the order. Use this fixed ladder — don't invent magic numbers (z-[1000] / z-[200] have leaked in; they're bugs waiting to happen). Pick the lowest layer that works:
| Layer | Class | What lives here |
|---|---|---|
| Base content | (default) / z-0 |
Normal page flow |
| Sticky chrome | z-10 |
Sticky TopBar / ActionBar / table header / BottomTabBar — pinned, but below anything floating |
| Floating layers | z-50 |
Dialog, Sheet, DropdownMenu, Popover, Select, Tooltip — this is the shadcn/Radix default; leave it, don't bump it |
| Toast | (owned by sonner) |
The global Toaster portals above everything; never hand-roll a layer above it |
- Same tier ties break by DOM order, not by a bespoke number. If two floating layers fight, fix the nesting/portal, don't escalate to
z-[999]. - A new "always on top" need is a smell — it usually means the element should be a real floating primitive (Dialog/Popover) that already portals correctly, not a high-
zdiv.
Script list and Logger can hold thousands of rows. Keep large lists responsive: page or windowed-render rather than mounting every row, and never block first paint on the full set — show the skeleton/shell (state patterns) while the list streams in. Don't introduce a virtualization lib unprompted; if a list is bounded (settings, permissions) plain rendering is fine.
Copy is translated into the locale set translation.md
currently lists — don't hardcode a locale count here, it drifts. There's no maintained measurement of how much
longer any given locale runs versus English, so don't cite a fixed percentage; instead pick an actual long
string from the current locale resources as your test fixture. Layouts must flex or truncate, never clip:
let labels wrap or truncate with a title/tooltip, give buttons/badges min-w room instead of fixed widths,
and don't pin a control's width to its English string. Verify with a real long-locale fixture on the tightest
screens (mobile cards, the ActionBar). RTL is not a target for the current locale set and product scope — reconfirm
both before treating it as settled if either changes.
Sources: src/index.css (custom keyframes/utilities) + tw-animate-css (the @import provides animate-in/out, fade-*, zoom-*, slide-*, accordion-*, …) + Radix data-state. No Framer Motion — all motion is CSS.
- Fast and light: enter/leave in
150–250ms,ease-out; the built-in collapse/progress animations use200ms ease-out. - Hover/focus via CSS pseudo-classes, not React state (
hover:bg-primary-background/90,focus-visible:ring-ring/50) — aDEVELOP.mdrule. - Enter/leave via Radix
data-state— don't hand-roll show/hide withsetTimeout. - Prefer
transition-colorsovertransition-all: animate only what should move, avoiding layout thrash and wasted work. - Reuse existing utilities; don't inline
@keyframesin a component. New animation → add an@utilityinsrc/index.cssso it's globally reusable. - Large looping animations (e.g. the indeterminate bar) should animate
transform(alreadytranslateX) for performance. - Respect
prefers-reduced-motion. A global@media (prefers-reduced-motion: reduce)block insrc/index.csscollapses every animation/transition to near-zero for users who ask for less motion, so reusing the shared CSS utilities is reduced-motion-safe for free. Don't route around it with JS-driven tweens (setTimeout/requestAnimationFrame) the reset can't reach; gate any long or looping decorative animation on the preference yourself.
| utility / pattern | Source | Use |
|---|---|---|
animate-collapsible-down / -up |
index.css |
Radix Collapsible expand/collapse (uses --radix-collapsible-content-height) |
animate-expand-bar / animate-collapse-bar |
index.css |
Height expand/collapse of bars/rows (incl. border and opacity) |
animate-indeterminate-bar |
index.css |
Indeterminate progress bar (translateX loop, 1.1s) |
data-[state=open]:animate-in data-[state=closed]:animate-out + fade-* / zoom-95 / slide-* |
tw-animate-css |
Dialog / Dropdown / Sheet enter/leave (Radix state driven) |
animate-spin |
Tailwind built-in | Spinner rotation — the Loader2 / RefreshCw icons used for inline, button, and full-page loading (animate-spin, usually text-primary) |
animate-pulse |
Tailwind built-in | Skeleton placeholder pulse |
transition-colors / transition-transform / duration-200 |
Tailwind | hover/focus color transitions, icon rotation |
// Floating layer enter/leave (Radix data-state + tw-animate-css)
<div className="data-[state=open]:animate-in data-[state=open]:fade-in-0
data-[state=closed]:animate-out data-[state=closed]:fade-out-0
data-[state=open]:zoom-in-95 duration-200">…</div>
// Indeterminate progress bar
<div className="h-0.5 w-full overflow-hidden bg-muted">
<div className="h-full w-1/3 bg-primary animate-indeterminate-bar" />
</div>Every async flow covers the states below, presented consistently:
| State | Standard presentation |
|---|---|
| Loading | A skeleton that preserves the layout, a centered spinner, or a thin top indeterminate bar — pick by where the wait happens (see Loading patterns below) |
| Empty | Centered muted icon (e.g. lucide PackageOpen/Inbox) + title + explanation + primary CTA |
| Error | Centered red icon + an "X failed" title + a monospace (font-mono) box with the raw error + retry/close |
| Success | Centered green icon + title + summary stats + next-step CTA; for transient feedback use notify.success |
| In-progress | Top progress bar + per-row status icons (✓ green done / ○ brand in-progress / ⏱ muted pending / ✗ muted skipped) + readable copy ("Importing… 2/5, keep this page open") |
These states have canonical shared components — reuse them rather than re-implementing: StateScreen (full-area loading/empty/error/success), EmptyState / LoadingState (inline), Skeleton, and Progress (top / indeterminate); see primitives & shared composites.
A loading state is not one thing — and a centered spinner is the last resort, not the default. The guiding rule is keep the page's shape stable: show a placeholder where the content will land instead of collapsing the layout to a spinner and snapping it back when data arrives. Match the indicator to where the wait happens:
| Where the wait is | Indicator | Reference in code |
|---|---|---|
| First load of a whole page / screen (no shape yet) | Centered Loader2 (size-12 animate-spin text-primary) + title/desc; pair with a determinate bar (transition-[width]) when bytes/percent are known, else an indeterminate fill |
InstallLoading (install/components/InstallStates.tsx) |
| Reloading content that already has a shape (table / list) | A skeleton that keeps the real header + placeholder rows (animate-pulse rounded bg-muted) — not a centered spinner — so the layout doesn't collapse and reflow |
SkeletonTable / SkeletonBar (batchupdate/components.tsx) |
| Background refresh / check while content stays visible | A thin top animate-indeterminate-bar (h-0.5, role="progressbar" + aria-label) pinned under the TopBar, not scrolling with content |
TopProgressBar (batchupdate/components.tsx) |
| A single action (button, connection test, fetch) | Disable the control and show an inline Loader2 size-4 animate-spin; if the action already has an icon, spin that icon instead (RefreshCw className={cn(checking && "animate-spin")}) |
McpFormDialog test button, ScriptList / Agent Skills refresh |
Practical rules:
- Never freeze and never wait silently. A region that is loading must show a skeleton, spinner, or bar — never a blank or stale frame with no signal (Constraint 8).
- Don't fake determinism. Use the determinate progress bar only when the percent/bytes are actually known; otherwise use an indeterminate fill or a skeleton.
- One indicator per wait. Don't stack a full-page spinner over content that is already skeletoned, or two bars for one fetch.
- The spinner is always
Loader2+animate-spin(text-primarywhen it should read as active), sized to context —size-3.5/size-4inline,size-12full-page (motion).
The rule: no silent operations — after any action the user can see success / failure / in-progress.
Forms are plain useState + controlled components (components — no form library). Keep their feedback consistent:
- Validate late, forgive early. Don't show errors while a field is still being filled. Validate on blur and on submit; once a field is showing an error, switch it to live revalidation so the message clears the instant it's fixed.
- Error message sits with the field, not in a far-off banner: a short
text-destructive text-xsline directly under the input, and mark the control (aria-invalid,border-destructive). For form-level failures (the save request itself failed) raise anotifyerror toast (state patterns) — there is noAlertprimitive; if an inline form-level banner is unavoidable, build it ad-hoc withborder-destructive/text-destructive. - Required vs optional: mark the rarer one. If most fields are required, tag the optional ones "(optional)" rather than starring everything.
- Submit button: keep it enabled and validate on click (a disabled button can't tell the user why) — unless submission is genuinely impossible (nothing entered yet). While the request is in flight, disable + inline
Loader2(loading patterns). - Don't lose input on failure. A failed save keeps every field as-is; never clear the form on error.
Consistent words are part of a consistent UI.
- Sentence case for everything — buttons, titles, labels, menu items ("Import data", not "Import Data"). Product names keep their own casing.
- Buttons are verbs naming the action ("Install", "Save changes", "Delete"), not "OK"/"Submit". The in-flight label restates it as progress ("Installing…", "Saving…").
- Errors are specific and actionable: what failed + why + what to do ("Update failed: network error — check your connection and retry"), not "Something went wrong". Put raw error detail in the
font-monobox (state patterns), not the headline. - Don't blame the user, don't over-apologize. State the fact and the next step.
Core Constraints covers hover/focus (CSS pseudo-classes, never React state). For completeness every interactive control also needs:
- Disabled: the shadcn primitives already apply
disabled:opacity-50 disabled:pointer-events-none— reuse them; don't hand-roll a greyed-out look. A disabled control still needs a reason nearby (helper text/tooltip) if it's non-obvious. - Active / pressed: rely on the primitive's built-in
active:; addactive:utilities only for custom controls. - Selected / current: persistent selection (active nav item, chosen tab, picked row) uses
accent/sidebar-accentfills or theprimarytext/underline — a state, distinct from transienthover:accent. Pair color with a non-color cue (icon, weight, indicator bar) so it isn't color-only (accessibility).
Friendly UX includes users on keyboards, screen readers, low vision, and motion sensitivity. These are requirements, not extras — verify alongside the both-themes check.
- Target WCAG AA: ≥ 4.5:1 for normal text, ≥ 3:1 for large text (≥ 18.66px bold / 24px) and for meaningful UI/icon edges. The tokens are tuned to this —
foreground,fg-secondary, and the*-fgbadge pairs pass comfortably. muted-foregroundis the edge case. It's AA-tuned (light#767676≈ 4.5:1 oncard/background) but only just — keep it for secondary/large/descriptive text, and useforegroundorfg-secondaryfor anything dense or critical. On amuted/secondaryfill its contrast drops further, so don't stack smallmuted-foregroundtext on amutedbackground.- Never encode meaning in color alone (Principle 3 is about adding meaning, not replacing the label). Pair every status color with text/icon/shape — a red dot also says "Error", an enabled row also shows a label, a selected item also has a non-color cue.
The base layer in src/index.css intentionally removes the native outline on button / a / [role="button"] and relies on shadcn's focus-visible:ring-ring/50 box-shadow ring instead (so programmatic refocus after a Radix layer closes doesn't flash an outline). The cost: any custom interactive element you build has no visible keyboard focus unless you add the ring yourself. So:
- Every custom clickable (a
div/spanwithonClick, a bespoke card action) must addfocus-visible:ring-2 focus-visible:ring-ring/50(and be reachable — real<button>/<a>, ortabIndex={0}+ key handlers). - Don't re-disable focus styling to "clean up" a layout; the ring is the only focus signal there is.
- Everything actionable is reachable and operable by keyboard — prefer native
<button>/<a>/<input>; the Radix primitives (Dialog, Sheet, DropdownMenu, Tabs…) already ship focus trap, arrow-key nav, Esc, and return-focus — that's a reason to reuse them over hand-rolled overlays (components). - Icon-only controls need an accessible name:
aria-labelon every iconButton(an icon alone is invisible to a screen reader). - Announce async state: loading/empty/error/progress regions carry
role="status"/role="progressbar"+aria-label(theTopProgressBaralready does) so non-visual users hear the same "no silent operations" guarantee (state patterns). - Decorative icons (next to a text label) are
aria-hiddenso they aren't double-announced.
The shared Button tops out at h-9 (36px) and the compact sizes (xs/icon-xs = 24px) are below the ~44px comfortable-touch minimum. On the mobile shell, primary tap actions should use default/icon (or larger) and avoid xs. When a control must stay visually small, expand the hit area (extra padding, or a ::before overlay) rather than shrinking the tap zone — and keep tappable items spaced so neighbors aren't mis-hit.
A global @media (prefers-reduced-motion: reduce) reset (motion) honors the system preference for all shared CSS animations/transitions. Keep new motion on the shared utilities so it inherits that; don't bypass it with JS tweens.
- Text meets AA contrast on both themes; meaning never carried by color alone.
- Every custom interactive element is keyboard-reachable and shows a visible
focus-visiblering. - Icon-only buttons have
aria-label; decorative icons arearia-hidden. - Async/loading/error regions expose
role+aria-label. - Mobile tap targets ≥ ~44px (or an expanded hit area).
- Motion still works (and calms down) under
prefers-reduced-motion.