A reuse-oriented design reference. It consolidates the visual language that lives in
src/index.cssand the shadcn component layer into one place you can copy from: color tokens (full light/dark values), the theming mechanism, the component palette, layout & responsive patterns, motion, state patterns, and an end-to-end new-page recipe. Read this before building any new page, dialog, or block so it stays visually and behaviorally consistent with the rest of the app.
Stack in one line: React 19 + shadcn/ui (Radix primitives,
new-yorkstyle) + Tailwind CSS v4 + React Router. Colors and motion are defined in the@theme inlineblock ofsrc/index.css. There is notailwind.config.js(Tailwind v4); PostCSS runs through@tailwindcss/postcss(postcss.config.mjs); class names have no prefix (bg-background, nottw-bg-background).
| Owned here | Owned elsewhere |
|---|---|
| Color-token values, semantics, usage | The hard rules that mandate them (no hard-coded colors, hover via pseudo-classes, cn() / CVA / lucide) → DEVELOP.md § UI |
Theming mechanism, dark: usage |
Commands, structure, coding style, testing, i18n, commit/PR → DEVELOP.md |
| Component palette, variants, selection guidance | Process model, message passing, service layers, internals → ARCHITECTURE.md |
| Layout shell, responsive patterns, elevation (shadows), layering (z-index), motion, state patterns, accessibility, page recipe | — |
This doc restates the DEVELOP.md hard rules only where needed, then links back — it does not duplicate them.
Every UI change must satisfy all of these. They are the bar for "friendly, consistent UI/UX" in this codebase.
- Use tokens, not literal colors — one value, one place. Never write a hex (
#1296db), anrgb(), or a palette class (text-blue-500). Always use a semantic token —bg-background,text-foreground,border-border,text-primary,bg-primary-background,text-muted-foreground, … (§3). All color values live in exactly one place — the token definitions insrc/index.css— so the palette stays unified and a single edit re-skins everything. One semantic concept maps to one token: before adding a color, check §3 for an existing token and reuse it; don't introduce a near-duplicate (a second slightly-different gray or blue). Only add a new token when the concept is genuinely new — with both light and dark values — and document it in §3. - Both themes, always. Light and dark are first-class. Because every color comes from a token that has a
:rootand a.darkvalue, using tokens makes a component theme-correct for free. Verify on real light and dark before considering anything done (§4). - Design for mobile too. The UI is responsive around a single
768pxbreakpoint (useIsMobile). Mobile is a different shell, not a shrunk desktop — side nav becomes bottom tabs + drawer, tables become cards, rows stack, details/code collapse, actions move into a sticky bar (§7). A feature isn't finished until it works on a narrow viewport. - No inline
style={{}}for what Tailwind can express. Compose utility classes viacn()(clsx+tailwind-merge); build variants withclass-variance-authority(CVA). Inline styles only for genuinely dynamic values (e.g. a computed width). - Hover/focus are CSS, not state. Express interactive visuals with pseudo-classes (
hover:bg-primary-background/90,focus-visible:ring-ring/50). React state is for data/logic, not styling. - Reuse components before building new ones. Default to the shadcn primitives in
src/pages/components/ui/(§6); icons come fromlucide-reactonly — don't hand-roll a control that already exists. Beyond primitives, search the existing pages for a composed block (card row, identity header, permission card, state screen…) that already does what you need and reuse it. When the same block appears in two or more places, extract one shared component instead of copy-pasting — keep one implementation per concept so behavior and styling stay consistent and a fix lands everywhere at once. - Keep motion restrained. Enter/leave in
150–250ms,ease-out; reuse the existing@utilityanimations rather than inlining@keyframes; prefertransition-colorsovertransition-all(§8). - No silent operations. Every async flow surfaces loading / empty / error / success (and progress for long-running work). The user must always know whether their action worked (§9).
- Don't introduce new colors or fonts ad hoc. New color → add a token in
src/index.css(with both light and dark values) and document it here. New font → add a--font-*token; don't reference an unconfigured family.
The "why" behind the constraints — apply these when shaping a screen.
- Trust-first, clear hierarchy. Let the most important information win the visual weight. For decision screens (install / permission / import), order content as identity → permissions → code, with code demoted to a height-capped scroll region.
- System state is always visible. No silent work. Each async flow shows progress (a top indeterminate bar) → process (skeletons / per-row status) → result (toast or result screen).
- Color is semantic, never decorative. Blue = interactive / primary; green = safe / enabled / success; amber = caution / sensitive; red = danger / error / blocked. Color carries meaning.
- Mobile is a different shell. Don't scale the desktop down — re-shell it: bottom tabs + drawer instead of a side rail, cards instead of tables, vertical stacks, collapsed detail.
- Consistent shell. Major pages share one skeleton: sticky TopBar + single scroll container + sticky ActionBar. Swap the content, not the frame.
- High cohesion, low coupling. Each UI unit has a single purpose, a clear interface, and is understandable and testable on its own. A file growing large is usually a signal to split it.
Single source: src/index.css. :root defines light, .dark overrides for dark, and @theme inline exposes every --token as a Tailwind color (--color-*), so bg-<token> / text-<token> / border-<token> all work and switch with the theme automatically.
Usage:
- Background
bg-<token>, texttext-<token>, borderborder-<token>, focus ringring-ring. - Opacity modifiers compose directly:
bg-primary-background/90(solid primary hover),ring-destructive/20,bg-input/30. - Never hard-code a color value — see Constraint 1 and
DEVELOP.md§ UI. For dark-only tweaks use thedark:variant.
| Token / class | Light | Dark | Use |
|---|---|---|---|
background |
#fafafa |
#1e1e1e |
Page background |
foreground |
#1a1a1a |
#e5e5e5 |
Primary text |
card |
#ffffff |
#151515 |
Card / surface |
card-foreground |
#1a1a1a |
#e5e5e5 |
Text on cards |
popover |
#ffffff |
#151515 |
Floating layers (dropdown/tooltip/toast) surface |
popover-foreground |
#1a1a1a |
#e5e5e5 |
Text in floating layers |
overlay |
rgb(0 0 0 / 0.5) |
rgb(0 0 0 / 0.6) |
Modal scrim — Dialog / Sheet / AlertDialog backdrop (bg-overlay; never hard-code bg-black/50) |
fg-secondary |
#666666 |
#b5b5b5 |
Secondary text (slightly stronger than muted-foreground) |
| Token / class | Light | Dark | Use |
|---|---|---|---|
primary |
#1296db |
#3aacef |
Brand text, icons, borders, indicators, and active-state emphasis; not a solid control fill |
primary-background |
#1296db |
#0b84d8 |
Solid primary control/surface fill paired with primary-foreground; dark is deeper and hue-aligned with primary for balanced hierarchy |
primary-foreground |
#ffffff |
#ffffff |
Text/icons on primary-background |
primary-hover |
#0a7db8 |
#1296db |
Solid primary gradient/hover endpoint (or use bg-primary-background/90) |
primary-light |
#d6ecfa |
#1e3040 |
Soft brand wash — icon backgrounds, chip fills |
Per the shadcn convention,
secondary/muted/accentshare the same gray value here — different semantics, one fill color.
| Token / class | Light | Dark | Use |
|---|---|---|---|
secondary |
#f0f0f0 |
#2a2a2a |
Secondary buttons / fills |
secondary-foreground |
#1a1a1a |
#e5e5e5 |
Text on secondary |
muted |
#f0f0f0 |
#2a2a2a |
Muted background (group fills, placeholders) |
muted-foreground |
#767676 |
#8a8a8a |
De-emphasized / descriptive text. AA-tuned (≥4.5:1 on card/background) — reserve for secondary/large text, not dense body copy (§10 contrast) |
accent |
#f0f0f0 |
#2a2a2a |
Hover / selected background (menu items, etc.) |
accent-foreground |
#1a1a1a |
#e5e5e5 |
Text on accent |
| Token / class | Light | Dark | Use |
|---|---|---|---|
border |
#e5e5e5 |
#2a2a2a |
Global borders (the @layer base reset gives every element border-border) |
input |
#e5e5e5 |
#2a2a2a |
Form control borders |
ring |
#1296db |
#3aacef |
Focus ring (focus-visible:ring-ring/50) |
switch-off |
#d0d0d0 |
#3a3a3a |
Switch off-state track |
thumb |
#ffffff |
#eeeeee |
Switch/Checkbox thumb (stays light even in dark) |
| Token / class | Light | Dark | Use |
|---|---|---|---|
destructive |
#e7000b |
#ff6669e6 |
Dangerous / delete / error actions |
destructive-foreground |
#ffffff |
#ffffff |
Text on destructive |
success |
#34c759 |
#34c759 |
Success / enabled / running (solid) |
success-bg / success-fg |
#e8f9ec / #0c8833 |
#1e3520 / #6fdd8a |
Success badge (soft bg, deep fg) |
warning |
#ff9500 |
#ff9500 |
Caution / sensitive (solid) |
warning-bg / warning-fg |
#fff4e6 / #c46c00 |
#352c1e / #ffb84d |
Warning badge (soft bg, deep fg) |
Use the solid status colors (
success/warning) for icons and dots; use the*-bg/*-fgpairs for badges (see theBadgesuccess/warningvariants).
Skill / purple accent. Skills carry a purple brand identity across the install flow and the Agent management pages. Use the skill family — never raw violet-* palette classes.
| Token / class | Light | Dark | Use |
|---|---|---|---|
skill |
#9333ea |
#a855f7 |
Skill accent (solid) — install button, section icons (dark is brightened for contrast) |
skill-foreground |
#ffffff |
#ffffff |
Text on the solid skill color |
skill-bg / skill-fg |
#f3e8ff / #7e22ce |
#2a1e3a / #c084fc |
Skill badge / chip (soft bg, deep fg) — also the CapabilityTag violet tone |
For the storage table's "type" column — soft bg, deep fg; in dark the bg darkens and the fg brightens:
| Type | bg (Light → Dark) | fg (Light → Dark) |
|---|---|---|
type-string (green) |
#e4f7ea → #1e3520 |
#2ba24e → #4ade80 |
type-number (blue) |
#d6ecfa → #1e3040 |
#1296db → #3aacef |
type-boolean (amber) |
#fceedb → #352c1e |
#c2710c → #fb923c |
type-object (purple) |
#f3e8ff → #2a1e3a |
#9333ea → #c084fc |
Categorical label chips (--label-*). A name is hashed to one of 8 fixed hues and rendered as a soft-bg / deep-fg chip (bg-label-<hue>-bg text-label-<hue>-fg). Use this family for categorical tag/label chips and name-seeded avatars — never raw green-50 / blue-700 palette classes. The hashing lives in one place: getNameAvatarTone(seed) returns the { bg, text } tone, and <NameAvatar seed size> wraps it as the rounded icon used by script icons, subscribe icons, and provider badges; the script-list tag chips call the same helper via getTagColor. Light bg = each hue's -50, light fg = -700; dark bg = -900 @ 40% resolved opaque over the #151515 card, dark fg = -300.
| Hue | bg (Light → Dark) | fg (Light → Dark) |
|---|---|---|
label-green |
#f0fdf4 → #122e1e |
#008236 → #7bf1a8 |
label-blue |
#eff6ff → #182345 |
#1447e6 → #8ec5ff |
label-purple |
#faf5ff → #301644 |
#8200db → #dab2ff |
label-orange |
#fff7ed → #3f1d11 |
#ca3500 → #ffb86a |
label-rose |
#fff1f2 → #441022 |
#c70036 → #ffa1ad |
label-teal |
#f0fdfa → #112c2a |
#00786f → #46ecd5 |
label-amber |
#fffbeb → #3e210f |
#bb4d00 → #ffd230 |
label-indigo |
#eef2ff → #201e42 |
#432dd7 → #a3b3ff |
| Token / class | Light | Dark | Use |
|---|---|---|---|
sidebar |
#ffffff |
#1a1a1a |
Sidebar background |
sidebar-foreground |
#1a1a1a |
#e5e5e5 |
Sidebar text |
sidebar-primary |
#1296db |
#3aacef |
Sidebar emphasis |
sidebar-accent |
#edf5fc |
#2a2a30 |
Sidebar selected background |
sidebar-border |
#e5e5e5 |
#2a2a2a |
Sidebar border |
sidebar-ring |
#1296db |
#3aacef |
Sidebar focus ring |
(Also sidebar-primary-foreground / sidebar-accent-foreground, equal to #ffffff / the primary text color.)
| Token | Light | Dark |
|---|---|---|
--scrollbar-thumb |
rgba(0,0,0,.18) |
rgba(255,255,255,.16) |
--scrollbar-thumb-hover |
rgba(0,0,0,.32) |
rgba(255,255,255,.30) |
Add the .scrollbar-custom class to any scroll container to get a thin, rounded, semi-transparent, theme-aware scrollbar (covers both the Firefox scrollbar-* properties and the WebKit pseudo-elements).
Shadows signal how high a surface floats. There are no --shadow-* tokens — use the Tailwind utilities, but pick from this fixed ladder so elevation maps to meaning instead of drifting (the codebase currently mixes shadow-xs … shadow-2xl ad hoc — converge on these three):
| Level | Class | Use |
|---|---|---|
| Resting | (none) / shadow-sm |
Flat cards and list rows that sit on the page. Prefer a border over a shadow at rest; add shadow-sm only for a subtle lift (e.g. a sticky bar over scrolling content). |
| Raised | shadow-md |
Anchored floating layers tied to a trigger — DropdownMenu, Popover, Select, hover cards. |
| Overlay | shadow-lg |
Detached overlays that own the screen — Dialog, Sheet, AlertDialog. |
- Don't reach past
shadow-lg.shadow-xl/shadow-2xlread as heavy and inconsistent; if something needs more separation it usually needs a scrim/backdrop, not a bigger shadow. - Shadows barely render in dark mode. On
#151515cards a black shadow is nearly invisible, so depth in dark relies on theborder+ the surface step (background #1e1e1e→card #151515). Don't lean on shadow alone to separate layers in dark — keep the border. (If dark-specific depth becomes necessary, introduce--shadow-*tokens with separate.darkvalues and document them here — don't hand-tune per component.) - Pair elevation with the matching radius (§5): raised →
rounded-lg, overlay →rounded-xl.
Mechanism: the theme switches by adding/removing .dark on document.documentElement (@custom-variant dark (&:is(.dark *)) is what makes the dark: variant work). Every token is defined under both :root and .dark, so toggling the class re-skins the whole app — no per-component color changes needed.
Provider: src/pages/components/theme-provider.tsx
import { useTheme } from "@App/pages/components/theme-provider";
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: "light" | "dark" | "auto" (user choice, persisted to localStorage key "lightMode")
// resolvedTheme: "light" | "dark" (in "auto", resolved live from prefers-color-scheme)
setTheme("auto"); // "auto" follows the system theme and updates on changeFlash prevention: src/pages/common.ts reads lightMode and sets .dark before React mounts, so non-auto users don't see a wrong-theme frame on refresh. New page entry points should reuse the existing main.tsx pattern rather than rolling their own theme logic.
Correct usage (do / don't):
// ✅ Tokens — adapt to light/dark automatically
<div className="bg-card text-foreground border-border">…</div>
<button className="bg-primary-background text-primary-foreground hover:bg-primary-background/90">…</button>
// ✅ dark: variant only for a dark-specific tweak
<div className="bg-input/30 dark:bg-input/50">…</div>
// ❌ Hard-coded colors — break in dark and violate the DEVELOP.md rule
<div className="bg-white text-[#1a1a1a] border-[#e5e5e5]">…</div>Every UI change must hold up in both themes. Verify on real light and dark — don't ship after checking only one.
System-font-only, zero webfonts. A browser extension must work offline, must not phone home to a font CDN (privacy + CSP), and pays for every byte it ships — so the type system is the platform's own fonts, declared as two tokens in the @theme inline block of src/index.css. Both stacks end with an explicit CJK fallback (PingFang SC / Microsoft YaHei / Noto Sans SC) because ScriptCat is Chinese-first and CJK coverage must be controlled, not left to whatever system-ui happens to resolve to on a non-Chinese OS.
| Token | Value | Use |
|---|---|---|
font-sans (--font-sans) |
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif, "Apple Color Emoji", "Segoe UI Emoji" |
Body / UI text. Applied on body via @apply font-sans, so everything inherits it by default; this is the default — you rarely write font-sans explicitly |
font-mono (--font-mono) |
ui-monospace, SFMono-Regular, Menlo, "Cascadia Code", Consolas, "Liberation Mono", "PingFang SC", "Microsoft YaHei", monospace |
Code, version numbers, @match/permission rules, stored values — anything monospaced (font-mono) |
No webfont, no
@font-face. Don't reference a family that isn't actually packaged (it would silently fall back and mislead — Constraint 9). If a brand font is genuinely required, self-host it (woff2, local@font-face, never a CDN), keep the CJK fallback, and update this table.
--radius: 0.5rem (8px) is the base; four steps are derived via calc in @theme inline:
| class | Value | Typical use |
|---|---|---|
rounded-sm |
4px | Small tags, compact controls |
rounded-md |
6px | Buttons, inputs (Button defaults to rounded-md) |
rounded-lg |
8px | Cards, panels |
rounded-xl |
12px | Large cards, dialogs, emphasized containers |
- Desktop centered content width: ~
864pxfor narrow decision pages, ~1120pxfor wide list pages, ~1280pxas a general cap. - Sticky bars: TopBar ≈
52px, ActionBar ≈68px. - Block spacing: start sections at
gap-4(16px); card paddingp-6/p-7. - Mobile: single column,
100vw, narrower horizontal padding (e.g.px-4vs desktoppx-8).
The shadcn primitives live in src/pages/components/ui/ — new-york style, CSS variables enabled, no class prefix (components.json). Icons are always lucide-react; class merging is always cn() (src/pkg/utils/cn.ts); variants are always CVA — these are the DEVELOP.md § UI hard rules, not repeated here. This section is "what exists and how to choose."
Primitives — the shadcn building blocks in src/pages/components/ui/:
| File | Use |
|---|---|
button.tsx |
Buttons (variants/sizes below) |
badge.tsx |
Status / label badges |
card.tsx |
Card container (base for Surface / DataPanel) |
alert-dialog.tsx |
Blocking confirmation dialog (dangerous actions) |
dialog.tsx |
General modal dialog |
sheet.tsx |
Drawer (left/right/top/bottom; mobile nav, side panels) |
popover.tsx / popconfirm.tsx |
Floating layer / lightweight inline confirm (custom wrapper) |
dropdown-menu.tsx |
Dropdown menu |
tooltip.tsx |
Hover tooltip |
tabs.tsx |
Tabs |
accordion.tsx / collapsible.tsx |
Accordion / collapsible region (common for mobile collapsed detail) |
select.tsx |
Select |
input.tsx / textarea.tsx |
Text input |
checkbox.tsx / switch.tsx |
Checkbox / switch |
toggle.tsx / toggle-group.tsx |
Toggle button / toggle group (base of SegmentedControl) |
label.tsx |
Form label |
progress.tsx |
Progress bar — default / top / indeterminate variants |
skeleton.tsx |
Loading-placeholder block |
sonner.tsx |
Global toast container |
use-hover-menu.ts |
Helper hook for hover-triggered menus |
The palette is pruned to what's actually imported — unused shadcn primitives are deleted, not parked. Need one back (alert / avatar / radio-group / scroll-area / separator)? Re-add it from shadcn rather than hand-rolling. Name-seeded avatars (script / subscribe icons, provider badges) use the shared
NameAvatar/getNameAvatarToneinsrc/pages/components/NameAvatar.tsx— see §3.6.
Composites — project blocks built on the primitives. Reuse these before hand-rolling (Principle §2: one implementation per concept):
| Component | File | Use |
|---|---|---|
Surface |
ui/surface.tsx |
Padded card surface (padding / interactive / disabled variants) for cards & tiles |
DataPanel (+ Header / Row / Empty) |
ui/data-panel.tsx |
Bordered key/value or compact-list panel |
StateScreen |
ui/state-screen.tsx |
Full-area loading / empty / error / success screen with tone, monospace detail box & progress slot (§9) |
EmptyState |
ui/empty-state.tsx |
Inline empty state (icon + title + description + action) |
LoadingState |
ui/loading-state.tsx |
Inline centered Loader2 + label |
SearchInput |
ui/search-input.tsx |
Search box with leading icon over a muted field |
SegmentedControl |
ui/segmented-control.tsx |
Single-select segmented switch for a few options (e.g. task mode, permission duration) |
FormField / SwitchField |
ui/form-field.tsx |
Labeled field wrapper (label + description + error + required) / switch row (§9 forms) |
TooltipIconButton |
ui/tooltip-icon-button.tsx |
Icon button with tooltip + loading state |
NameAvatar |
components/NameAvatar.tsx |
Name-seeded rounded icon (script / subscribe / provider) — see §3.6 |
No form library (react-hook-form / zod) is used; forms are plain
useState+ controlled components. Keep new forms on this pattern — don't pull in a library unprompted.
Source: button.tsx.
- variant:
default(brand solid),destructive,outline,secondary,ghost,link - size:
default,xs,sm,lg,icon,icon-xs,icon-sm,icon-lg
The default variant uses bg-primary-background text-primary-foreground. Keep text-primary / border-primary
for accent semantics; do not use bg-primary as a solid button fill.
import { Button } from "@App/pages/components/ui/button";
import { Plus } from "lucide-react";
<Button>Install</Button> {/* primary action */}
<Button variant="outline">Cancel</Button> {/* secondary action */}
<Button variant="destructive">Delete</Button> {/* dangerous action */}
<Button variant="ghost" size="icon-sm"><Plus /></Button> {/* icon button; svg auto-sizes to size-4 */}Source: badge.tsx. Variants: default, secondary, destructive, outline, success, warning.
import { Badge } from "@App/pages/components/ui/badge";
<Badge variant="success">Enabled</Badge> {/* success-bg / success-fg */}
<Badge variant="warning">Sensitive</Badge> {/* warning-bg / warning-fg */}
<Badge variant="destructive">Parse failed</Badge>The container sonner.tsx is theme-aware, bottom-right on desktop / top-center on mobile (switched by useIsMobile()), with a neutral popover surface, a semantic-colored icon + left accent bar, a close button, and at most 3 stacked; mount it once per page entry.
Business code always uses notify (toast.ts) — never import { toast } from "sonner" directly (an eslint rule enforces this):
import { notify } from "@App/pages/components/ui/toast";
notify.success("Script installed"); // 3s
notify.error("Update failed: network error"); // 4s
notify.promise(p, { loading, success, error }); // ∞ until resolve/rejectDurations by level: success/info/warning 3s / error 4s / with action 5s / loading·promise ∞.
- Confirmation: dangerous / irreversible →
AlertDialog; lightweight inline confirm (e.g. row delete) →popconfirm. - Confirm vs. act-immediately: a modal confirm interrupts every time, so reserve it for the genuinely irreversible or wide-blast (delete N scripts + their stored values, reset settings). For easily reversible actions, prefer acting immediately over a blocking dialog — fewer interruptions. (
notifyexposes anactionbutton if a one-tap undo/retry is genuinely worth offering, but don't add it reflexively.) State the blast radius in the confirm copy ("Delete 3 scripts and their stored values? This cannot be undone."). - Transient panels: mobile nav / side detail →
Sheet; small anchored layer →Popover/DropdownMenu. - Feedback: transient →
toast; persistent / in-page → see §9 state patterns.
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 (§9) 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 7 locales and German/Russian run ~30% longer than English. 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 a long-locale on the tightest screens (mobile cards, the ActionBar). RTL is not a target for the current locale set.
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 §6.1.
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 7).
- 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 (§8).
The rule: no silent operations — after any action the user can see success / failure / in-progress.
Forms are plain useState + controlled components (§6 — 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 (§9 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(§9 single-action loading). - 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 (§9 Error), not the headline. - Don't blame the user, don't over-apologize. State the fact and the next step.
§1 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 (§10).
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 (§6). - 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 (§9). - 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 (§8) 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.
When building a new page or dialog, run this checklist to stay consistent:
- Entry reuses the existing
main.tsxpattern — mountThemeProvider,Toaster(andTooltipProviderif needed); don't roll your own theme logic. - Shell: sticky TopBar +
.scrollbar-customscroll container + sticky ActionBar (§7). - Responsive: branch on
useIsMobile(); re-shell on mobile (bottom bar/drawer, cards, collapse) rather than scaling down (Constraint 3, §7). - Color entirely from tokens (
bg-card/text-foreground/border-border/text-primary/bg-primary-background…), no literals, verified on both themes (Constraint 1–2, §3–4). - Components reuse first — search existing pages for a composed block before building; use
src/pages/components/ui/primitives; extract a shared component when a block repeats; variants via CVA, classes viacn(), icons vialucide-react(Constraint 6, §6). - Hierarchy orders the most important info first; decision pages go identity → permissions → code (Principle 1).
- State: loading / empty / error / success / in-progress all covered, never silent (§9).
- Motion restrained (
150–250ms,ease-out), hover/focus via pseudo-classes, enter/leave viadata-state, reuse existing utilities (§8). - Depth uses the elevation ladder (resting/raised/overlay, §3.9) and the z-index ladder (
z-10chrome /z-50floating, §7) — noshadow-2xl, no magicz-[…]. - Accessibility: AA contrast on both themes; meaning never color-only; custom controls keyboard-reachable with a visible focus ring;
aria-labelon icon buttons; ≥ ~44px mobile tap targets; reduced-motion-safe (§10). - Copy defaults to sentence-case English + i18n; verbs on buttons; specific errors (§9 writing), and flexes for long locales (§7); see
DEVELOP.mdandtranslation/README.md.
Page skeleton (tokens + existing primitives + the shell pattern):
import { useIsMobile } from "@App/pages/components/use-is-mobile";
import { Button } from "@App/pages/components/ui/button";
export default function ExamplePage() {
const isMobile = useIsMobile();
return (
<div className="flex h-dvh flex-col bg-background text-foreground">
{/* sticky TopBar */}
<header className="flex h-13 shrink-0 items-center border-b border-border px-4 md:px-8">
<h1 className="text-base font-semibold">Title</h1>
</header>
{/* single scroll container */}
<main className="scrollbar-custom flex-1 overflow-y-auto px-4 py-4 md:px-8 md:py-6">
<section className="mx-auto w-full max-w-[864px] space-y-4">
<div className="rounded-lg border border-border bg-card p-6">…</div>
</section>
</main>
{/* sticky ActionBar */}
<footer className={`flex shrink-0 gap-2.5 border-t border-border px-4 py-3 md:px-8
${isMobile ? "flex-col" : "justify-end"}`}>
<Button variant="outline">Cancel</Button>
<Button>Confirm</Button>
</footer>
</div>
);
}Implementation source of truth (read/edit these when changing the design):
- Color / motion / scrollbar tokens →
src/index.css - Theming →
src/pages/components/theme-provider.tsx+src/pages/common.ts - Component primitives →
src/pages/components/ui/; shadcn config →components.json cn()→src/pkg/utils/cn.ts; breakpoint →src/pages/components/use-is-mobile.ts
Related docs: UI hard rules and commit flow → DEVELOP.md; internals → ARCHITECTURE.md; doc maintenance and fact-checking → DOC-MAINTENANCE.md.
When editing this doc, follow
DOC-MAINTENANCE.md: token values, component names, and variant names track the current branch'ssrc/code (if you can'tgit grepit, don't claim it); enumerate counts and lists rather than trusting memory.