DESIGN.md
Token architecture: The normative v2 contract for variable layers, Shadcn/Tailwind mappings, compatibility, and migration metadata is
packages/ui/docs/design-token-system.md. Official Shadcn semantics and approved Cherry Studio product semantics share one unprefixed public namespace. The operational variable inventory and selection rules are inpackages/ui/docs/variable-catalog.md.
Semantic DOM contract: For Custom CSS, tests, inspectors, and automation that need structural selectors, see
docs/references/ui-semantic-contract.md.
Usage notation: Tailwind examples use semantic utilities such as
bg-backgroundandtext-foreground. Authored CSS examples use the unprefixed public runtime contract directly, whether the role is official Shadcn (var(--background)) or a Cherry Studio product extension (var(--success)). Shared--cs-*variables are internal providers, while--color-*belongs to the generated Tailwind adapter and is not an authored CSS API.
Source of truth: foundation values live in
packages/ui/src/styles/tokens/, controlled host-written inputs live inpackages/ui/src/styles/theme-input.css, the official Shadcn contract lives inpackages/ui/src/styles/shadcn.css, and Cherry Studio product semantics live inpackages/ui/src/styles/product.css.contract.csscomposes those layers in order; Tailwind-facing aliases are generated intheme.css. Component, page, and App Shell implementation variables stay in their owning stylesheets and are not public theme roles. For actual values and stability, inspect the source pluspackages/ui/scripts/theme-contract.ts.
Cherry Studio is a shadcn/ui-based design system built for an AI conversation application. The design language follows a neutral-first approach — a restrained, systematic palette rooted in pure neutral grays where the interface itself recedes to let content take center stage. The aesthetic is utilitarian-modern: clean surfaces, subtle borders, and restrained use of the exported primary color for true primary actions, creating a tool that feels professional, focused, and endlessly customizable through its robust light/dark mode support.
The typography system is single-track: var(--font-family-body) and var(--font-family-heading) currently resolve to the same primary UI font token. Code-rendering components own their mono font stack locally. This single-family approach reflects a product with a unified voice — coherent in conversation, precise in code.
What makes Cherry Studio distinctive is its commitment to a calm UI foundation. Primary actions use var(--primary) as the strongest action color in the chrome, while neutral strong fills are used by shared buttons where that component defines the action hierarchy. New UI should avoid introducing a page-local chromatic brand hue. Other chromatic departures are reserved for semantic feedback: var(--destructive) for dangerous actions, var(--success) for positive states, var(--warning) for caution, var(--info) for informational surfaces. This creates an interface that feels like a high-quality writing tool — think iA Writer meets VS Code — where the user's content is usually the most colorful thing on screen.
Key Characteristics:
var(--primary) is reserved for true primary actions and selected states, while semantic accents carry feedbackvar(--primary); do not introduce a separate page-local brand huevar(--destructive) (red), var(--success) (green), var(--warning) (amber), var(--info) (blue)--radius input; use rounded-none for square corners and rounded-full for pillsvar(--border) (semi-transparent neutral) for structure, not decorationvar(--background) → var(--card) → var(--popover)--shadow-2xs through --shadow-2xl)var(--sidebar), var(--sidebar-primary), var(--sidebar-accent), var(--sidebar-border)Internal token values are defined in
packages/ui/src/styles/tokens/colors/{primitive,status-legacy,providers}.css; public semantic mappings live inpackages/ui/src/styles/shadcn.cssandpackages/ui/src/styles/product.css. This section names what each token is for; refer to those source files for resolved values.
The color system separates contrast-bearing content from surface tinting:
oklch values. Their resolved color and contrast must stay predictable across cards, popovers, translucent surfaces, vibrancy, and user wallpapers.--primary, --destructive, --success, --warning, --info, and primitive scales) use solid oklch color steps — never alpha — because their identity must stay constant on any background.When you reach for a value:
--foreground, --muted-foreground, --foreground-tertiary, or --foreground-disabled).--border, --secondary, --accent, --background-subtle, --border-subtle, --border-strong, --border-selected). A one-off visual treatment stays private to its owner. Do not invent shared oklch(0 0 0 / 0.x) aliases.--primary, --destructive, or the corresponding --{success,warning,info,error} product role. For feedback surfaces, use the stable surface/foreground pair and border. Primitive scales are reserved for reviewed visualization palettes beyond the default chart contract, not ordinary component state.var(--primary) — public semantic output for true page actions, selected states, and component accents. It is currently fed by the registered runtime primary input, but components depend on this semantic role rather than that host input. Shared Button default / emphasis currently define their own neutral strong fills.var(--primary-foreground) — contrast text on bg-primary surfaceshover:* utility); do not consume a compatibility adapter variable from authored CSSvar(--foreground) — primary body textvar(--muted-foreground) — secondary readable text, descriptions, labels, and placeholdersvar(--foreground-tertiary) — timestamps, counts, empty-state copy, and icons that intentionally sit one level below readable secondary textvar(--foreground-disabled) — disabled or unavailable foreground content; initially matches tertiary but may evolve independentlyvar(--card-foreground) / var(--popover-foreground) / var(--accent-foreground) / var(--secondary-foreground) — contrast text on each surfaceAll shared foreground roles resolve to solid colors. Do not recreate foreground-secondary or foreground-muted,
and do not weaken foreground semantics with Tailwind color-opacity modifiers.
var(--background) — primary page backgroundvar(--background-subtle) — slightly tinted background variantvar(--card) — elevated card surfacesvar(--popover) — floating panel surfaces (dropdowns, menus, tooltips)var(--muted) — subdued backgrounds, disabled statesvar(--accent) — hover/active backgrounds for transparent buttonsvar(--secondary) — secondary action backgrounds--accent for the shared hover fill; keep any additional active treatment component-localvar(--sidebar) — sidebar surfacevar(--sidebar-foreground) — text on sidebarvar(--sidebar-accent) / var(--sidebar-accent-foreground) — hover/active state in sidebar; do not substitute generic secondary roles even when current values look similarvar(--sidebar-border) — sidebar dividersvar(--sidebar-ring) — color source for focus feedback contained inside sidebar controlsvar(--border) — component borders, dividersvar(--border-subtle) — very quiet outlines on cards, nested panels, and non-interactive containersvar(--border-strong) — higher-emphasis structural bordersvar(--border-selected) — selected-state component bordersvar(--input) — input field bordersvar(--ring) — color source for focus feedback contained inside component boundsborder-border, border-border-subtle, border-border-strong, border-border-selected, border-input, border-sidebar-border) instead of hard-coded colors.border-border-selected only for selected state. Keyboard focus always uses border-ring / ring-ring.border, border-t, border-r, border-b, and border-l are acceptable only when the global theme base provides the color fallback; reusable components should still name a semantic border color when the role is known.[border-bottom:0.5px_solid_var(--border)] or [border-right:0.5px_solid_var(--border-subtle)].border-border/10 through border-border/80, plus hover/focus/active variants) continue to resolve through Tailwind v4's native color-opacity modifier support; theme.css does not enumerate separate compatibility mappings for them.border-border/60, border-border/40, border-border/30, or border-border/15. Use the semantic border utilities above so the visual role is explicit.var(--destructive) — dangerous user actions; use the --error* family for error feedbackhover:bg-destructive-hover state; do not consume its compatibility adapter variable in authored CSSvar(--destructive-foreground)var(--success) — positive states, confirmationsvar(--warning) — caution states, pending actionsvar(--info) — informational states, neutral highlightsUse the stable subtle surface pair plus its border for alerts, toast bodies, tags, and validation feedback. The
base --{intent} token is an accent for icons, text, or markers rather than a shared filled surface. All four
runtime families expose --{intent}, --{intent}-subtle, --{intent}-subtle-foreground, and
--{intent}-border; Tailwind exposes matching semantic utility names.
The older *-base, *-text, *-bg, hover, and active names remain internal frozen providers or migration
sources only; they are no longer Tailwind utilities. Do not introduce them in component APIs.
Do not use a page-local chromatic brand color for new UI chrome. --cs-brand-* is a foundation scale, not a component-facing semantic contract; new component styling should express action hierarchy through var(--primary) and status through the stable product roles.
text-link / var(--link). Link color is independent from primary; reserve primary for
primary actions, selected states, and component accents.link-hover token.No dedicated public glass or overlay product role is exported today. --color-* is reserved for generated Tailwind mappings; a future shared runtime role would require an approved unprefixed semantic contract. Use the shared primitive defaults first:
Dialog overlay (bg-black/50) and customize only through overlayClassName when needed.bg-popover, border-border, and the appropriate shadow utility (shadow-md to shadow-xl) rather than a page-local glass token.--card while retaining --card-foreground, keeping it independent from generic floating panels.
It exposes data-slot="quick-panel-content" so Custom CSS themes can override the QuickPanel background and
foreground together. Keep this treatment local to QuickPanel; it is not a reusable
glass token or a precedent for other floating panels.Use --chart-1 through --chart-5 in authored CSS (or bg-chart-1 through bg-chart-5 utilities) for default categorical series. Primitive scales remain building
blocks for visualizations that require a reviewed palette beyond five series; do not use primitives for ordinary
component state.
Available primitive scales in tokens/colors/primitive.css (each has 11 shades, *-50 through *-950): neutral / stone / zinc / slate / gray / red / orange / amber / yellow / lime / green / emerald / teal / cyan / sky / blue / indigo / violet / purple / fuchsia / pink / rose. Use these as raw building blocks; prefer semantic tokens for UI surfaces.
Token values defined in
packages/ui/src/styles/tokens/typography.css. The technical contract is the CSS variable; family-name strings appear here for human readability.
var(--font-family-body) / var(--font-family-heading) → primary UI font with system-ui fallbacks. Handles functional UI text.| Role | Token | Approx. value |
|---|---|---|
| Body XS | var(--font-size-body-xs) | 12px — tags, badges, timestamps, metadata |
| Body SM | var(--font-size-body-sm) | 14px — navigation, secondary labels, captions |
| Body MD | var(--font-size-body-md) | 16px — standard body text, form inputs, descriptions |
| Body LG | var(--font-size-body-lg) | 18px — emphasized body, sub-headings |
| Heading XS | var(--font-size-heading-xs) | 20px — minor section titles |
| Heading SM | var(--font-size-heading-sm) | 24px — sub-section headings |
| Heading MD | var(--font-size-heading-md) | 32px — section headings |
| Heading LG | var(--font-size-heading-lg) | 40px — page titles |
| Heading XL | var(--font-size-heading-xl) | 48px — hero headlines |
| Heading 2XL | var(--font-size-heading-2xl) | 60px — display / landing |
The full Tailwind text scale is also exposed: --text-xs through --text-9xl (12px → 128px) for large display contexts.
Three weights are exposed as semantic tokens; the rest of the Tailwind weight utility scale (font-thin → font-black, including font-semibold) is available but not part of the token contract.
| Weight | Token | Usage |
|---|---|---|
| Regular | var(--font-weight-regular) (400) | Body text, descriptions, secondary labels |
| Medium | var(--font-weight-medium) (500) | Navigation, emphasized body, form labels |
| Bold | var(--font-weight-bold) (700) | Page titles, strong emphasis, hero headlines |
| Token | Approx. value | Usage |
|---|---|---|
var(--line-height-body-xs) | 20px | Body XS / tight labels |
var(--line-height-body-sm) | 24px | Body SM (14px) |
var(--line-height-body-md) | 24px | Body MD (16px) |
var(--line-height-body-lg) | 28px | Body LG (18px) |
var(--line-height-heading-xs) | 32px | Heading XS (20px) |
var(--line-height-heading-sm) | 40px | Heading SM (24px) |
var(--line-height-heading-md) | 48px | Heading MD (32px) |
var(--line-height-heading-lg) | 60px | Heading LG (40px) |
var(--line-height-heading-xl) | 80px | Heading XL (48px) |
Heading 2XL (60px) currently has no matching
--line-height-heading-2xltoken. For display contexts usingvar(--font-size-heading-2xl), set a one-off Tailwind line-height utility (e.g.leading-[72px]) until a canonical token is added.
var(--paragraph-spacing-body-{xs|sm|md|lg}) and var(--paragraph-spacing-heading-{xs|sm|md|lg|xl|2xl}) set vertical rhythm between paragraphs and headings.
Use Tailwind's numeric spacing utilities (
px-4 py-2) in component code. In raw CSS, derive values from Tailwind's--spacingbase unit.
Source: Button from @cherrystudio/ui (packages/ui/src/components/primitives/button.tsx).
Base
gap-2, no wrappingrounded-md, font-normal, transition-allopacity-40data-loading=true, cursor-progress, opacity-40, spinner before contentbackground, text, or underline); do not draw an outer ring or outlineDefault
bg-neutral-900 light / bg-neutral-100 dark)shadow-xshover:bg-neutral-800 light / dark:hover:bg-neutral-200)Outline
var(--foreground)var(--border)var(--accent)Secondary
var(--secondary)var(--secondary-foreground)var(--radius-lg)hover:bg-secondary-hover stateEmphasis
bg-neutral-900 light / bg-neutral-100 dark)var(--radius-lg)hover:bg-neutral-800 light / dark:hover:bg-neutral-200)Ghost
var(--accent), text var(--accent-foreground)Destructive
var(--destructive)shadow-xshover:bg-destructive-hover stateLink
Sizes
| Size | Classes | Use |
|---|---|---|
default | min-h-7.5 gap-1.5 px-2.5 text-[13px] | Standard buttons |
sm | min-h-7 gap-1.5 px-2.5 text-xs | Dense controls |
lg | min-h-9 px-4 text-sm | Higher-emphasis actions |
icon | size-9 | Standard icon button |
icon-sm | size-7 | Dense icon button |
icon-lg | size-10 | Large icon button |
Pill — shape modifier, not a color variant
rounded-fullIcon-only buttons and low-emphasis actions
Public icon-only buttons should use the shared Button primitive first: variant="ghost" with size="icon" or size="icon-sm". They must provide an aria-label; add Tooltip / NormalTooltip when the icon meaning is not obvious.
Color hierarchy — ask one question first: is this icon the user's primary reason to be on this page?
text-* override). The icon is the action. (The ghost variant currently renders text-neutral-900 dark:text-neutral-100.)text-muted-foreground hover:text-foreground so it recedes at rest and surfaces on hover.| Case | Color | Example |
|---|---|---|
| Page-primary action in chrome | (Button ghost variant default, no override) | Mini-apps page top-right + and menu — the page exists to launch apps; these icons are the action. |
| Secondary utility entry | text-muted-foreground hover:text-foreground | Translate page top-right history / settings — user came to translate, not to manage history. |
| Toggle while active | text-foreground when active; muted otherwise | Panel-toggle icon while its panel is open. |
| Destructive row action | text-muted-foreground hover:text-destructive | Delete X next to a custom language row. |
Rule of thumb: if an area shows 3+ icon buttons, at most one should sit at the ghost default. The rest are utilities — mute them. Otherwise the eye has no anchor.
Do not:
text-foreground override to every icon button by reflex — the ghost default is for one action per cluster, not all of them.text-primary as a "more emphasis" replacement for the ghost default; text-primary is reserved for selected / branded states, not for raising icon weight.Row-level patterns
text-muted-foreground, no static fill or shadow) and only gain emphasis on hover, focus, active, or pressed state.ConfirmDialog plus a destructive confirm button for the actual destructive decision.IconButton wrapper for this row-level low-emphasis behavior (xs / sm / md, ghost / destructive / star, active, built-in tooltip). Treat that as a pattern to promote into a shared IconButton if another page needs the same behavior; do not create more page-local copies.Button hover behavior is variant-specific:
| Variant | Hover Fill | Hover Border | Hover Shadow | Text Change |
|---|---|---|---|---|
| Default | neutral hover fill | — | keeps shadow-xs | — |
| Outline | var(--accent) | existing border | none | — |
| Secondary | shared hover:bg-secondary-hover state | — | none | — |
| Emphasis | neutral hover fill | — | none | — |
| Ghost | var(--accent) | — | none | var(--accent-foreground) |
| Destructive | shared hover:bg-destructive-hover state | — | keeps shadow-xs | — |
| Link | — | — | none | muted text + underline |
Hover rules:
shadow-xs.shadow-none) at rest and on hover.Source: DialogContent and related primitives from @cherrystudio/ui (packages/ui/src/components/primitives/dialog.tsx).
Shell
bg-cardtext-card-foregroundrounded-3xlborder-0)p-6, gap-4shadow-xlprefers-reduced-motion requests it.ConfirmDialog and the popup.confirm/error/info/warning acknowledgement family use motion="fade-scale":
they keep the shared fade and 99% scale but omit vertical movement.Layout
z-[80], default bg-black/50top-[50%] left-[50%], translated by -50%max-w-[calc(100%-2rem)] (narrow-window fallback, all sizes). Desktop width is set by the size prop on DialogContent:
size="sm" → sm:max-w-sm (24rem ≈ 384px) — single-field inputs, rename, short confirmations. Use this whenever the body is one label + one input or a one-line confirmation; the default size feels empty for that amount of content.size="default" (current default) → sm:max-w-lg (32rem ≈ 512px) — standard forms with a few fields.size="lg" → sm:max-w-xl (36rem ≈ 576px) — multi-field forms, scrollable bodies, rich configuration panels.className="sm:max-w-*" or similar. Pick a size instead; if no size fits, propose a new size in @cherrystudio/ui rather than patching at the call site. className on DialogContent is reserved for non-width layout concerns (e.g. max-h-[70vh], flex flex-col overflow-hidden for scrollable bodies).overlayClassName; do not rewrite a page-local Dialog shell.Structure
gap-2, centered on mobile and left-aligned from smtext-lg leading-none font-semiboldtext-muted-foreground text-smflex-col-reverse, desktop row with sm:justify-endtop-4 right-4, low opacity, higher opacity on hover; hide with showCloseButton={false} when the surrounding UI supplies its own close affordanceActions
Button variant="outline" for cancel/secondary actions.Button variant="emphasis" for new neutral Dialog primary actions; existing dialogs using default are acceptable during migration, but new work should not introduce a page-local primary style.Button variant="destructive" for dangerous confirmation actions.ConfirmDialog currently uses default for non-destructive confirms and destructive for dangerous confirms. Treat that as a migration-compatible composite, not as a reason to invent page-local Dialog button styles.Use Dialog for
There are two different drawer patterns. Do not collapse them into one generic "side drawer" rule.
PageSidePanel — in-page side panel
Source: PageSidePanel from @cherrystudio/ui (packages/ui/src/components/composites/page-side-panel/index.tsx).
Use PageSidePanel for page-owned management surfaces such as mini-app display settings, translate settings, and translate history. By default, the panel and backdrop portal to document.body so page scroll containers, transformed ancestors, and nested layout shells cannot clip or re-base the drawer. In app shell route tabs, the tab content root is provided via PortalContainerProvider on every platform; PageSidePanel reads it with usePortalContainer(), portals into its owning tab root, and switches to absolute positioning, so a still-open panel stays hidden with its owning tab instead of surfacing over the active tab. The same provider scopes tab-owned Radix floating overlays (popovers, dropdown menus, selects, tooltips, hover cards, context menus) to that root, so the panel and those overlays share one portal container per tab.
inset-0, scoped absolute inset-0, z-60, bg-black/50, fades over 0.15stop-3 bottom-3, scoped absolute top-3 bottom-3, right-3 or left-3, z-70w-100, rounded-3xl, bg-card, text-card-foreground, shadow-xl, overflow-hiddendamping: 30, stiffness: 350)px-6 pt-6 pb-3, optional header content plus ghost close buttonScrollbar, space-y-4 px-6 py-4px-6 pt-3 pb-6, for sticky action groupsdialog, aria-modal=true, focus moves into the panel on open and returns to the trigger on closeFor standard settings panels, pass title instead of custom header. This renders the shared title style (font-semibold text-base text-foreground). Use custom header only when the title area needs richer layout.
Use PageSidePanelSection and PageSidePanelItem as optional content primitives for settings-style panels. The structure is intentionally three-layered:
PageSidePanel owns only the floating drawer shell: placement, backdrop, title/close chrome, body scroll, and footer.PageSidePanelSection owns a settings group: section title, optional right-aligned low-emphasis actions, and group spacing.PageSidePanelItem owns a single setting row: title/description stack, trailing control, and optional expanded content below the row.Use this full shell → section → item stack for settings drawers such as mini-app display settings and translate settings:
flex flex-col gap-3 — this gap-3 is the rhythm between the section title row, its actions, and the preference row group below; it is not the spacing between preference rows themselves.<div className="flex flex-col gap-5"> so individual preference rows breathe more than the title-to-rows gap. Existing callers (TranslateSettings, MiniAppDisplaySettings) follow this convention.action; the trailing control may also expand into an optional children slot below the row.gap-8.Do not force PageSidePanelSection / PageSidePanelItem onto non-settings content. List, history, detail, or picker drawers should still use the shared PageSidePanel shell, but their body layout should match the task. For example, translate history uses PageSidePanel for the drawer chrome and a custom list/detail/empty-state layout inside the body.
Drawer primitive — modal edge drawer
Source: Drawer primitives from @cherrystudio/ui (packages/ui/src/components/primitives/drawer.tsx, Vaul-based).
Use Drawer for modal edge/bottom sheets, especially mobile-oriented or full-viewport overlays that are not visually nested inside a page workspace.
inset-0, z-50, bg-black/50z-50, flex column, bg-backgroundmax-h-[80vh], border on the attached edge, rounded-b-lg or rounded-t-lgh-2 w-25 rounded-full bg-muted)inset-y-0, w-3/4, sm:max-w-sm, border on the attached edgep-4, gap-0.5, centered for top/bottom and left-aligned from mdmt-auto flex flex-col gap-2 p-4font-semibold text-foreground; text-sm text-muted-foregroundDrawer uses bg-background and edge attachment, not the floating bg-card rounded-3xl shadow-xl shell of PageSidePanel. New drawer work should use PageSidePanel or this shared Drawer primitive.
Standard Card
var(--card)var(--card-foreground)var(--border)var(--radius-lg) to var(--radius-xl)p-4 to p-6 (16–24px)Popover / Floating
var(--popover)var(--popover-foreground)var(--border)var(--radius-lg)var(--shadow-lg)Source: Popover, PopoverTrigger, PopoverAnchor, and PopoverContent from @cherrystudio/ui (packages/ui/src/components/primitives/popover.tsx). Use this as the default floating container for dropdowns, compact action menus, filters, and other trigger-bound transient panels.
Default PopoverContent:
var(--popover)var(--popover-foreground)var(--border) (border-[0.5px])var(--radius-lg)p-4)w-72)var(--shadow-lg) (shadow-lg)Compact menu popovers:
PopoverContent from @cherrystudio/ui; override layout density with w-fit min-w-32 rounded-xl p-1.5.w-fit), floored at 128px (min-w-32) so short menus stay legible. This matches ContextMenu's min-w-[8rem] baseline in packages/ui/src/components/primitives/context-menu.tsx. Do not hard-code widths like w-40 / w-44 — they trap trailing whitespace when labels are shorter than the slot.MenuList and MenuItem from @cherrystudio/ui.h-8), rounded-lg, px-2.5, and text-sm.Glass Panel (floating chrome with backdrop blur)
bg-popover unless a real translucent token is introducedvar(--border)var(--radius-lg) to var(--radius-xl)These patterns reflect the current v2 pages and should be treated as valid design-system usage, not exceptions.
Tool Gallery / Code Tools
bg-background with a constrained width (max-w-5xl style scale) and responsive card grid.bg-card, border-border, p-4, and var(--radius-2xl) to create a launchpad feel without adding shadows.border-border-selected, ring-ring) rather than a new chromatic accent.rounded-full) and use shadow-lg only when they behave as a visual anchor, not as repeated card elevation.Mini App Launchpad / Settings Drawer
PageSidePanel with grouped sections and dense list rows. Use the shared Drawer primitive only for modal edge/bottom sheets.rounded-md, subtle hover fills, and compact icons; avoid converting every row into a card.Translation Workspace
bg-background panes separated by structure and controls.bg-primary text-primary-foreground; target-language chips and selected language states may use bg-primary/10 or text-primary.border-border-subtle / hover border-border-strong) and muted foreground text.var(--background)var(--input)var(--radius-md) (8px)border-primary while editing; do not add an outer ring,
outline, or focus shadowvar(--font-family-body) between var(--font-size-body-sm) and var(--font-size-body-md), var(--font-weight-regular)var(--muted-foreground)Keyboard focus must remain visible without adding a second frame outside the component. Pointer interaction should not add a theme-colored border merely because a control was clicked or a popup was opened.
border-primary while editing so it follows the selected theme.open, expanded, or pressed state. Use focus-visible:border-primary or
has-[:focus-visible]:border-primary only when a border is the appropriate keyboard-focus treatment.selected, checked, active, and invalid may use a border when the border
communicates that lasting state rather than a transient click.focus-visible:underline plus the link's focused text color.focus-visible:ring-inset or an inset box-shadow.ring utilities without ring-inset, positive outline-offset, or focus
box-shadows that render beyond the component bounds. Do not remove focus feedback without replacing it with one
of the contained treatments above.Search field with trailing action:
When a search field needs an inline trailing button (e.g. add provider in ProviderList), embed a 24×24 icon button inside the search wrap, after the input:
size-6)rounded-[8px])var(--muted) (bg-muted)var(--accent) (bg-accent)var(--foreground) at full opacitypointer-events-none opacity-30Canonical implementation: providerListClasses.searchInlineAddButton in src/renderer/pages/settings/ProviderSettings/primitives/classNames.ts. The search wrap itself stays the standard input surface (bg-background, hairline border, rounded-xl).
Sidebar primitives currently live in src/renderer/components/Sidebar, not in @cherrystudio/ui. Treat this section as renderer sidebar guidance until a shared @cherrystudio/ui sidebar API exists.
The page owns the outer wrapper (width / Scrollbar / padding). Reusable sidebar internals should own spacing, sizing, and active state so individual pages do not hand-roll divergent menus.
Colors:
var(--sidebar)var(--sidebar-foreground) for body; var(--muted-foreground) for SectionTitle0.5px solid var(--border)var(--sidebar-accent) background, var(--sidebar-accent-foreground) text — icon color stays var(--sidebar-accent-foreground) on active (no color change)var(--sidebar-accent) backgroundvar(--sidebar-accent) background and var(--sidebar-accent-foreground) text; no outer ringType:
var(--font-size-body-sm) / var(--font-weight-medium)var(--font-size-body-xs) / var(--font-weight-regular)var(--font-size-body-sm) / var(--font-weight-regular)Spacing & sizing (canonical, baked into the components):
| Relationship | Value | Token |
|---|---|---|
| Header / section label / menu item own height | 32px | var(--spacing-8) |
| Horizontal inset on all rows (left/right padding) | 12px | var(--spacing-3) |
| Gap between section blocks (Header → first Section, Section → next Section) | 12px | var(--spacing-3) |
| Gap inside a section (section label → item, item → item) | 4px | var(--spacing-1) |
| MenuItem corner radius | 10px | rounded-[10px] |
| MenuItem icon size | 16px | [&_svg]:size-4 |
| MenuItem icon ↔ label gap | 12px | gap-3 |
Page-level wrapper guidance (set on the container, NOT on the components):
px-2 py-3)If a sidebar elsewhere needs different spacing, propose a shared renderer variant before hard-coding page-local overrides.
Target rule: once the
SidebarHeader / SidebarSection / SidebarSectionTitle / SidebarMenuItemfamily lands in@cherrystudio/ui, hand-rolled sidebar menus will not be allowed. Until that family ships, compose withMenuList+MenuItem+ project-level className tokens (seesrc/renderer/pages/settings/index.tsxfor the canonical token pattern:settingsSubmenuItemClassName,settingsSubmenuItemLabelClassName,settingsSubmenuSectionTitleClassName,settingsSubmenuDividerClassName).
Source: PageHeader from @cherrystudio/ui. The single component for any page or side-panel top title. All settings pages, sidebars, drawers, and content panels that need a heading row must use this — never hand-roll <h2> with manual padding.
Anatomy:
title (required) — heading text, rendered inside an <h2> with truncate for overflow safety.action (optional) — right-aligned slot for icon-buttons (filter, add, etc.).bordered (optional) — adds a border-b border-border divider underneath the header row. Default false. Use on right-pane detail headers to visually separate the title from the body; omit on left sidebar headers (which sit above a MenuList and don't need a divider).Type:
var(--font-size-body-sm) (14px) · var(--font-weight-medium) · leading-4 · text-foregroundSpacing & sizing (baked in — must not be overridden per-page):
| Relationship | Value | Token |
|---|---|---|
| Bar height | 32px | h-8 |
| Margin top (gap above) | 12px | mt-3 |
| Margin bottom (gap below) | 8px | mb-2 |
| Left padding (title aligns with menu item icon column) | 20px | pl-5 |
| Right padding (action sits 12px from the column edge) | 12px | pr-3 |
| Title ↔ action gap | 8px | gap-2 |
Bottom border (when bordered) | 1px | border-b border-border |
Rules:
size-6); they sit centered inside the 32px bar.pl-5 (20px) aligns the title's left edge with the icon column of menu items below — wrapper px-2.5 (10px) + item px-2.5 (10px) = 20px. Do not change to symmetric padding.PageHeader instances (left nav + right panel) are guaranteed to be vertically aligned because spacing tokens are identical; the title line box starts 20px from the column top.bordered; left sidebar headers must not (the menu list below them already provides visual structure). A right-pane header rendered by a non-PageHeader component (e.g. ProviderHeader, which carries a <Switch> plus multiple icons) must wrap itself in a container that draws an equivalent border-b border-border divider — see providerDetailColumnClasses.headerContentMaxWidth in ProviderSettings/primitives/classNames.ts.text-foreground rather than reduced opacity. The right pane already has dense secondary helper text, badges, and inline controls; fully opaque section labels preserve scan hierarchy without introducing another local color rule.Source: Switch and DescriptionSwitch from @cherrystudio/ui (packages/ui/src/components/primitives/switch.tsx). Current implementation uses a quiet gray off state and a brand/primary on state, matching the settings screenshots.
Anatomy & sizing:
| Size | Track | Thumb | Travel | Use |
|---|---|---|---|---|
xs | 32 × 18 | 16 × 16 | 14px | Dense inline controls |
sm | 36 × 20 | 18 × 18 | 16px | Slightly larger settings rows |
md (default) | 44 × 22 | 19 × 19 | 21px | Standard switch |
lg | 44 × 24 | 20 × 20 | 18px | Hero / marketing surfaces |
Colors:
| State | Light | Dark |
|---|---|---|
| Track — off | bg-gray-500/20 | bg-gray-500/20 |
| Track — on | bg-brand-600 | bg-brand-600 |
| Loading | bg-brand-300! | bg-brand-300! |
| Thumb glyph | white internal SVG | white internal SVG |
Other rules:
shadow-xs; do not add extra page-local shadow.loading state switches root/thumb coloring to bg-brand-300! and animates the thumb SVG.var(--ring) keyline inside the track; do not change its outer dimensions or add an outer ring.Don't:
bg-success, bg-warning, etc.) to the track. The component owns its brand on state.style={{ ... }} overrides for switch dimensions. If a new size is needed, add a variant to switchRootVariants/switchThumbVariants and document it here.<DescriptionSwitch label="..." description="..."> for reusable standalone preference rows. In dense PageSidePanel layouts, composing a row label plus a bare <Switch> is acceptable when the surrounding row owns spacing and helper text.var(--app-top-chrome-height) = 44px. Use this for the main window tab bar and any standalone macOS window top drag area that should visually align with the main app chrome.var(--navbar-height) defaults to var(--app-top-chrome-height) for the fixed top-menu layout. Only override it for inner content calculations that intentionally do not include a top navbar.var(--app-top-chrome-height) instead of hard-coded pixel classes such as h-11 or h-[50px].Settings pages use the same two-column shape:
| Column | Width | Composition |
|---|---|---|
| Left submenu | var(--settings-width) (250px default in responsive.css) | PageHeader (title) → Scrollbar → MenuList of grouped MenuItem rows |
| Right detail | flex-1 | Page-owned content |
Submenu composition rules:
PageHeader from @cherrystudio/ui at the top — do not hand-roll a header.PageHeader and pass titleClassName="font-normal text-muted-foreground text-xs leading-4" so the heading swaps to section-title typography while preserving the same 16px line box. The PageHeader's mt-3 + h-8 + mb-2 outer geometry is preserved, so the label baseline still aligns with the right column's PageHeader heading. See page-header.stories.tsx › SectionTitleStyle for the canonical example.MenuList with gap-1; group with MenuDivider + a section title <div> carrying settingsSubmenuSectionTitleClassName.MenuItem styled by the canonical settings token pair: settingsSubmenuItemClassName on className (height / hover / active surface) and settingsSubmenuItemLabelClassName on labelClassName (group-data-[active=true]:font-medium for the bold-on-active label). Both tokens live in src/renderer/pages/settings/index.tsx.ProviderList) follow the same shape: PageHeader + search field with trailing action + scroll body. They use their own scoped tokens in ProviderSettings/primitives/classNames.ts but keep the 200px column convention.Right-detail content container (mandatory):
The right pane of every "simple right-content" settings page (i.e. pages whose right column is one big content area, not its own further-split layout) must use a two-layer wrapper:
| Layer | Class | Purpose |
|---|---|---|
| Outer (full-width, scrolling) | p-6 | Page edge padding — keeps 24px between the content card and every column edge |
| Inner (constrained, centered) | mx-auto w-full max-w-3xl | Caps content at 768px and centers it on wide screens |
Use the canonical components in src/renderer/pages/settings/index.tsx:
SettingsContentColumn — full-page container that owns its own native scroll (replaces the legacy SettingContainer for "simple right-content" pages).SettingsContentBody — the same two-layer wrap, but for pages that mount their own Scrollbar externally (e.g. CommonSettings, ShortcutSettings).This mirrors the model service (Provider Settings) detail column (providerDetailColumnClasses in ProviderSettings/primitives/classNames.ts), which is the reference implementation.
Do not:
p-4 or px-5 py-4 on a settings page's outermost content container — they were the old, divergent paddings and are banned for new pages.max-w-3xl directly on a child component to "fix" centering on one page — fix the page container so every page is consistent.SettingContainer to add max-width: it intentionally stays a plain padded scroller for nested-split pages (Data, Integration, MCP, Channels, Skills) whose right pane is further subdivided.When embedded in a PageSidePanel drawer or onboarding context (e.g. ModelSettings compact), the page must NOT add max-w-3xl — the drawer width is already constrained and the centered cap would visually mis-align. Branch on the embedding flag and fall back to a plain padded container.
Use Tailwind's numeric spacing scale, based on the --spacing 4px unit. Component code should use utilities such as p-4, gap-6, and py-12; raw CSS should use calc(var(--spacing) * <multiplier>). The UI package does not define a parallel semantic spacing alias scale.
| Context | Tailwind |
|---|---|
| Inline spacing (icon to text) | gap-1 to gap-2 |
| Component internal padding | p-2 to p-4 |
| Card padding | p-4 to p-6 |
| Section gaps | gap-6 to gap-12 |
| Page section spacing | py-12 to py-24 |
max-w-sm through max-w-7xl)sm 640px, md 768px, lg 1024px, xl 1280px, 2xl 1536px)⚠️ Cherry remaps the Tailwind default radius scale.
rounded-mdresolves to 8px (Tailwind default: 6px),rounded-lgto 10px (default: 8px),rounded-xlto 14px (default: 12px), androunded-3xlto 22px (default: 24px). When copying components from shadcn examples, Tailwind tutorials, or any third-party Tailwind library, expect a 2–4px visual difference until the radius is consciously chosen against the table below.
Defined in
tokens/radius.css. Shadcn consumes--radius; generated Tailwind radius variables derive from it.
| Token | Approx. value | Usage |
|---|---|---|
rounded-none | 0 | Square corners; Tailwind utility, not a CSS variable |
var(--radius-xs) | 2px | Badges, tags |
var(--radius-sm) | 6px | Chips, small buttons |
var(--radius-md) | 8px | Default — buttons, inputs, dropdowns |
var(--radius-lg) | 10px | Cards, panels, secondary/emphasis buttons |
var(--radius-xl) | 14px | Large cards, hero sections |
var(--radius-2xl) | 18px | Feature cards, prominent containers |
var(--radius-3xl) | 22px | Dialogs, PageSidePanel, marketing cards, large modals |
var(--radius-4xl) | 26px | Extra-large feature and presentation surfaces |
rounded-full | 9999px | Pills, avatars, circular buttons |
Cherry Studio uses a dual depth system: surface color layering for structural hierarchy and box-shadows for interactive feedback (hover states, floating elements).
| Level | Token | Use |
|---|---|---|
| Ground (Level 0) | var(--background) | Page background |
| Surface (Level 1) | var(--card) | Cards, main panels |
| Raised (Level 2) | var(--popover) | Popovers, menus, dropdowns |
| Accent (Level 3) | var(--accent) | Accent/hover backgrounds, tooltips |
| Sidebar (Ambient) | var(--sidebar) | Sidebar — distinct from main surface |
| Floating panel | var(--popover) + border/shadow utilities | Dropdowns, popovers, transient chrome |
| Modal scrim | shared Dialog / Drawer / PageSidePanel overlay (bg-black/50) | Behind modals, dimmed backdrops |
Depth Philosophy: Surface color layering is the primary depth mechanism — var(--border) separates same-tone surfaces, and in dark mode progressively lighter neutrals create natural stacking. Shadows are reserved for interactive feedback (hover states add a small lift) and floating elements (popovers, centered Dialogs, and PageSidePanel use medium-to-heavy lift). The Vaul Drawer primitive relies on edge attachment and borders rather than the floating card shell. This keeps the interface feeling flat at rest and responsive on interaction.
Shadow utilities are exposed through the Tailwind theme. Treat them as utility-level design tokens.
Box shadows (7 levels):
| Token | Use |
|---|---|
var(--shadow-2xs) | Subtle dividers, pressed states |
var(--shadow-xs) | Button hover — primary interactive feedback |
var(--shadow-sm) | Cards, small floating elements |
var(--shadow-md) | Dropdowns, tooltips |
var(--shadow-lg) | Large floating panels |
var(--shadow-xl) | Dialogs, PageSidePanel, full-screen overlays |
var(--shadow-2xl) | Hero cards, peak emphasis |
Use Tailwind blur/backdrop-blur utilities directly when a component intentionally needs blur. There are currently no public --blur-* design-token aliases in @cherrystudio/ui.
Do not use opacity to encode foreground semantics.
Foreground variables are solid and must not be weakened with color-opacity modifiers such as
text-foreground-tertiary/50. Choose the correct foreground role instead. Element-level opacity may still be
used for animation and for existing component-owned disabled treatments that intentionally fade the entire
control; migrate those controls only when text, icon, border, and background disabled states can be designed
together. There are no public --opacity-* design-token aliases in @cherrystudio/ui.
Use Tailwind border-width utilities and semantic border color tokens.
Use Tailwind border-width utilities (border, border-0, border-2, etc.) with semantic border colors. There are currently no public --border-width-* design-token aliases in @cherrystudio/ui.
Use icon-library defaults unless a component has a documented reason to override SVG stroke-width.
var(--primary) for true primary actions/selected states and semantic colors for feedbackvar(--radius-md) as the base button radius, var(--radius-lg) where the Button variant explicitly rounds itself, and var(--radius-md) for inputsvar(--primary) / neutral strong fills for main CTAs; do not introduce page-local brand huesvar(--muted-foreground) for secondary readable textvar(--shadow-xs) only on button variants that already carry the base shadow (default, destructive)*-hover tokens or neutral hover classes according to the Button variant definitionvar(--accent) fill for outline and ghost button hover statesvar(--success), var(--warning), var(--info), and var(--error) for status feedback, toasts, and badges; reserve var(--destructive) for dangerous actionsbg-error-subtle text-error-subtle-foreground border-error-border for richer status surfacesvar(--border), var(--border-subtle), var(--border-strong), and var(--border-selected) for neutral structure and selection instead of opacity-modified border utilitiesvar(--font-weight-regular)/var(--font-weight-medium) for body and labels, var(--font-weight-bold) for page-level emphasisvar(--sidebar) vs var(--background) vs var(--popover)--chart-1 through --chart-5 (or their Tailwind utilities) for default categorical data visualizationrounded-full specifically for pills, avatars, and circular buttonsvar(--shadow-md) to var(--shadow-lg) for floating elements (popovers, dropdowns, large panels), and var(--shadow-xl) for Dialogs or PageSidePanel surfaces that need stronger separation from the dimmed pagefocus-visible:ring-* without ring-inset, positive focus outline offsets, or
focus shadows outside component bounds); use the contained focus treatments defined abovevar(--radius-xs) or var(--radius-sm) for buttons or cards — var(--radius-md)/var(--radius-lg) are the button radii in the shared primitivevar(--font-weight-regular) for functional UI text — thin/light/extralight weights are display-onlyvar(--destructive) to non-dangerous actions or error feedback — it is reserved for dangerous user actions such as delete and resetvar(--success) / var(--warning) / var(--info) for decorative purposes — they carry semantic meaning--chart-1 through --chart-5, and use primitives only for a reviewed palette beyond five seriesvar(--sidebar) and dedicated palette creates spatial separationvar(--popover) background for cards or vice versa — each elevation level has its specific tokenborder-border/60, border-border/40, border-border/30, or border-border/15 — choose a semantic border token insteadvar(--shadow-xl) or var(--shadow-2xl) to standard UI elements — reserve var(--shadow-xl) for Dialogs, PageSidePanel, and full-screen overlays, and var(--shadow-2xl) for peak display emphasis--color-* variables; that namespace belongs to the generated Tailwind adapter. Do not invent other token-looking aliases such as --cs-glass, --blur-md, --opacity-50, or --border-width-2 without adding a reviewed shared contract in the same change| Name | Width | Key Changes |
|---|---|---|
| Mobile | <640px | Sidebar hidden, single-column chat, bottom action bar |
| Tablet | 640–1024px | Collapsible sidebar overlay, condensed spacing |
| Desktop | 1024–1280px | Persistent sidebar + main content area |
| Wide | >1280px | Sidebar + main + optional right panel (settings/info) |
| Role | Token | Notes |
|---|---|---|
| Page background | var(--background) | Mode-aware page surface |
| Primary text | var(--foreground) | Primary body text |
| Secondary / muted text | var(--muted-foreground) | Descriptions, labels, placeholders, readable secondary content |
| Tertiary / disabled text | var(--foreground-tertiary) / var(--foreground-disabled) | Metadata and quiet icons / disabled content |
| Primary accent | var(--primary) | Page-level primary actions, selected states, component accents |
| Link | var(--link) | Clickable text |
| Destructive action | var(--destructive) | Hover: shared variant state; Text: var(--destructive-foreground) |
| Success / Warning / Info | var(--success) / var(--warning) / var(--info) | Single-token semantic accents |
| Borders | var(--border) | Neutral hairline |
| Quiet / strong / selected borders | var(--border-subtle) / var(--border-strong) / var(--border-selected) | Nested panels / higher-emphasis structure / selection |
| Card surface | var(--card) (text: --card-foreground) | Layer above background |
| Popover / floating | var(--popover) (text: --popover-foreground) | Layer above card |
| Overlay / floating chrome | shared Dialog overlay, bg-popover, border-border, shadow utilities | Modal scrims, popovers, transient panels |
| Sidebar surface | var(--sidebar) | Distinct spatial zone with full sub-palette |
| Hover backgrounds | hover:bg-accent (outline/ghost), shared variant state for secondary/destructive | Choose by variant |
| Status surfaces | --{error,success,warning,info}-subtle with paired foreground and border | Stable feedback contract |
| Charts | var(--chart-1) through var(--chart-5) | Default categorical palette |
| Shadow | var(--shadow-xs) for hover, var(--shadow-md) for floating | 7-level scale |
var(--background). Messages use var(--font-size-body-md) var(--font-weight-regular), var(--line-height-body-md), var(--foreground) text. User messages in cards with var(--secondary) background and var(--radius-lg) border-radius. Primary send button uses the Button default variant."var(--sidebar) background, 1px right border var(--sidebar-border). Nav items use var(--font-size-body-sm) var(--font-weight-medium), var(--sidebar-foreground) text. Active and hover items use var(--sidebar-accent) with var(--sidebar-accent-foreground) text."var(--card) background, 1px var(--border), var(--radius-lg). Title in var(--font-size-heading-sm) with the matching heading line-height. Description in var(--font-size-body-sm) var(--font-weight-regular), var(--muted-foreground). Toggles and inputs at var(--radius-md)."var(--background) page. Message cards on var(--card). Assistant code blocks use the code-rendering component's mono font stack at var(--font-size-body-sm) on var(--popover) with var(--radius-md). Borders at var(--border)."bg-card, text-card-foreground, rounded-3xl, border-0, p-6, gap-4, shadow-xl, default overlay. Footer uses outline cancel + destructive delete."PageSidePanel: it reads usePortalContainer() to scope into the owning route tab/page root when a PortalContainerProvider is present, otherwise falls back to the body portal; default fixed and scoped absolute bg-black/50 backdrop, top-3 bottom-3 right-3, w-100, bg-card, rounded-3xl, shadow-xl, title for the shared text-base heading, body px-6 py-4, PageSidePanelSection groups separated by gap-8, and PageSidePanelItem rows separated by gap-5 inside each group. Use only PageSidePanel for non-settings history/list/detail drawers, with a task-specific body layout."Drawer primitive: bg-background, edge-attached bottom content, max-h-[80vh], rounded-t-lg, border-t, built-in drag handle, header/footer p-4. Do not use the floating PageSidePanel shell for this."bg-popover, 1px var(--border), var(--radius-xl), var(--shadow-md). Icon buttons inside use the shared Button with variant=\"ghost\" and size=\"icon-sm\"."aria-label, hover-only emphasis, and active tint only when the action has persistent state. Promote this pattern into a shared IconButton before reusing it across pages."var(--background) → var(--card) → var(--popover)); use var(--shadow-xs) on hover and var(--shadow-md)+ for floating elements.default and destructive keep the base shadow-xs, while outline/secondary/emphasis/ghost remain flat.Button ghost icon sizes first. For dense row-level low-emphasis actions with tone/active/tooltip behavior, promote a shared IconButton before duplicating page-local wrappers.var(--font-weight-regular) / var(--font-weight-medium) for UI and var(--font-weight-bold) for page-level emphasis.var(--radius-md) for the base Button and inputs, var(--radius-lg) where a Button variant explicitly rounds itself, larger (14px+) for cards, rounded-full for pills.var(--destructive) for danger, var(--success) for positive, var(--warning) for caution, var(--info) for informational.bg-error-subtle text-error-subtle-foreground border-error-border).var(--chart-1) through var(--chart-5) for the default categorical palette.bg-popover + semantic border + shadow utilities. Add real exported tokens before introducing reusable glass/scrim aliases.var(--font-size-heading-*) size tokens with the matching var(--line-height-heading-*).