chmonitor product design system
The rulebook for keeping new features visually + behaviourally consistent. When
in doubt, COPY the closest existing component rather than inventing. Full token
values and file paths: docs/knowledge/product-design.md.
Non-negotiables
- Never edit
src/components/ui/* (shadcn primitives). Customise via the
className prop at the call site, or a wrapper in src/components/ — never
in ui/. Always merge classes with cn() (src/lib/utils.ts), never
template-literal concatenation.
- Tailwind v4, CSS-first. Tokens live in
src/styles.css @theme blocks —
there is no tailwind.config.ts. Use semantic tokens (bg-card,
text-muted-foreground, border-border), not raw colors. Theme is OKLCH.
- Dark mode is
class-based (next-themes, .dark). Every surface must
read correctly in both themes — only use semantic tokens, which flip
automatically. Don't hardcode bg-white / text-black.
- Hooks at the deepest consumer. A component that needs data calls
useHostId() / useChartData() itself — do NOT prop-drill hostId.
?host=N routing, never dynamic /N/... segments. Preserve other search
params with buildUrl(pathname, { host }, searchParams). Data shown under
a host name must come from that host: host-scoped hooks use
keepPreviousDataForHost (or opt out of placeholderData), and per-host
component state remounts with key={hostId} (see
docs/knowledge/static-site-architecture.md, "Host-scoped cache").
- No "AI slop" decoration — one clear signal per state, not several
redundant ones. No full-saturation accent bars/rails stacked on an
already-colored border; no gradient blobs/glow orbs behind icons. See
"Anti-patterns" in
docs/knowledge/product-design.md.
Tokens (semantic — use these names, not hex/oklch literals)
background foreground card card-foreground popover muted muted-foreground primary secondary accent destructive border input ring. Charts:
--chart-1..13 for series, plus named accents --chart-red (error series),
--chart-blue (info), --chart-green (success), --chart-yellow (warning) —
only pass tokens that exist in styles.css to a chart's colors prop; an
undefined var() renders the series black. Radius: rounded-md (9px) default,
rounded-lg (10px), rounded-xl (14px) for cards. Brand accents: orange
(metrics) + emerald (health/live) — see components/icons/chmonitor-logo.tsx.
Canonical idioms (match these class strings)
- Card surface:
rounded-xl border bg-card shadow-sm (premium variant adds
bg-gradient-to-b from-card/80 to-card/40 dark:from-card/60 dark:to-card/30 backdrop-blur-xl). See components/charts/chart-card-styles.ts.
- Icons:
lucide-react, size-4 standard / size-3.5 compact / strokeWidth={1.5}.
- Dense text:
text-[13px] controls, text-sm body, text-xs text-muted-foreground meta, text-xl font-semibold tracking-tight page/hero titles.
- Spacing:
gap-1.5 compact, gap-2 standard, gap-4 generous; card content p-4 pt-0.
- Pill/secondary button link:
inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-3 text-[13px] font-medium hover:bg-muted.
- Clickable card → detail dialog: whole card is the target (
role="button" +
tabIndex={0} + onKeyDown={activateOnEnterOrSpace(open)}, never a nested
<button>); inner links e.stopPropagation() (not preventDefault) so they
still navigate. Drive drill-down from a per-item field, rendered via
ResultTable. See components/health/{health-card-shell,health-detail-rows}.tsx.
- One severity signal per card: severity reads from a single tinted icon —
never icon tile + colored border + severity pill + header count badge on the
same finding.
components/insights/severity-meta.ts is the source of truth
(label / icon / iconColor / neutral badge); card surface, border and count
badges stay neutral. Body line-clamp-2 + title tooltip; the breakdown goes
in a detail dialog.
- A dialog opened from a popover lives OUTSIDE the popover subtree. Rendered
inside
PopoverContent, closing the popover unmounts the dialog and nothing
appears. Keep the selected item + dialog in the parent, as a sibling of
<Popover> — see components/insights/insights-popover.tsx.
- Dialog with sticky header/footer:
DialogContent is flex flex-col overflow-hidden p-0 with max-h-[min(36rem,85vh)] (or a stable h-[…]).
Header and footer are shrink-0. The body is min-h-0 flex-1 overflow-y-auto
— that element is the scroll container. Do not use ScrollArea with flex-1
for this: its viewport is size-full and does not constrain unless the root
has an explicit height, so notes paint under the footer and EmptyState can
vanish inside a blank scrollbar box. Reset DialogFooter's default
-mx-4 -mb-4 to mx-0 mb-0 when the dialog is p-0. Same list-scroll
pattern without a footer: components/agents/advisor-query-picker.tsx.
See components/whats-new/whats-new-dialog.tsx. For What's new, also set
initialFocus on the title (tabIndex={-1}) and reset the body
scrollTop on open — default focus would otherwise land on the first
markdown link deep in an older note and scrollIntoView the list to the
middle. Screenshot thumbs (WhatsNewScreenshotGallery) open a full-size
overlay in the same dialog; Escape closes the overlay before the dialog.
Do not add relative on DialogContent — cn() drops the
primitive's fixed, so top-1/2 left-1/2 -translate-* no longer centers
in the viewport. fixed already contains an inner absolute overlay.
- Base UI Select labels: pass
items={{ value: 'Human label' }} on
Select (the Root) so SelectValue shows the label, not the raw value.
placeholder only appears when nothing is selected — a selected 24 /
__all__ otherwise renders as those strings. Same map on compare
Source/Target (ComparePeerSelect — id → h.name, never a raw 0). See
components/agents/advisor-query-picker.tsx and
components/compare/compare-peer-select.tsx.
- Schema Compare (
/schema-diff): one static PageHeader ("Schema
Compare" + recommend-only description). Pair identity lives in the
Source/Target comboboxes — no second "Comparing X → Y — N tables differ"
line. Compare tools wrap filters in compact CompareToolbar
(rounded-xl border bg-card p-3): tabs on top (Connections /
Replica nodes, SegmentedControl size="sm"), then Source /
Target searchable comboboxes (ComparePeerSelect: Command +
Popover, sorted by name, ChmonitorLogo plus version/uptime/status
like HostSwitcher — never a native Select). Copy recommended SQL
sits on that row (all safe statements, or only tables checked in
the catalog). Differing tables can be checked to build a sync
script — recommend-only, never applied. Each table's plan card
copies that table only. The table catalog is a collapsible left sidebar
grouped database → table (folder row + nested table name).
Search, Differences / All (icon-only GitCompareArrows /
List), and sort (icon-only ArrowDownAZ menu: A–Z, Z–A,
differences first) live on that sidebar — not the host toolbar.
When Differences is on and there are no diffs, still list identical
tables with a green CheckCircle2 (--chart-green) — click a row
to select it on the right. Switching Connections / Replica nodes
keeps the toolbar and shows a listing loading state (not a full-page
empty load). A matching selection is All matched / This
table matches (MatchOk), never EmptyState "no data" or
"Select a table". Keep "No tables match" only for a name-filter
miss.
Settings Diff (/settings-diff) uses the same host toolbar
(Connections / Replica nodes + Source/Target only). The listing is
the shared DataTable in embedded mode — one rounded-xl border bg-card like Running Queries, no second page title. Search,
Differences / All, Changed from default, Filters, Display options,
density, column visibility, and CSV live on that table toolbar.
Diffs-only with zero deltas still lists matching settings. The
Match column uses the shared boolean check (green) / cross (rose);
column headers carry lucide icons (CheckCircle2,
SlidersHorizontal, Table2, Pencil, Undo2, Server).
"Changed from default" is a pressable chip, not a second switch.
Empty catalog copy is DataTable's "No settings found" / "No
settings match your filters" — only a name or changed-from-default
miss.
- Sidebar favorites: the row is a link (
cursor-pointer). Pin is
hover-only. Favorites also reveal a grip handle on hover — drag it to
reorder (nav-favorites.tsx).
- Overflow strip (one row, no wrap):
scrollbar-hide overflow-x-auto + py-*
(so shadows/accents/focus rings aren't clipped) with a chevron button + a
from-background→transparent edge fade per scrollable side, paging via
scrollBy. Re-measure on scroll / ResizeObserver / content-count change.
Copy components/insights/insights-strip.tsx.
- Agent chat machinery stays ghost-weight. Reasoning/tool-group triggers
(
components/assistant-ui/{reasoning,tool-group}.tsx) are plain
icon + label + chevron text rows — no bg-muted/50 slab — so the
assistant's prose stays the loudest element, not two stacked grey cards.
Tool-call headers show a family icon + short capped summary
(summarizeToolOutput when done, else summarizeToolInput), never a raw
key=value param dump; long params (e.g. sql) render as a
syntax-highlighted CodeBlock in the "Parameters" disclosure, not inline.
Running rows use a muted label + tiny spinner. Tool errors render via
summarizeToolError as a compact border-destructive/30 row with an
expandable "Details" disclosure — never a raw {"error":...} blob. Don't
add a .markdown-content
pre/code background rule — Streamdown's own code: renderer already
owns that styling with token-based Tailwind classes; a sitewide override
paints a second box INSIDE its already-bordered fenced-block card. See
docs/knowledge/product-design.md § "Agent chat: reasoning / tool-call
rendering".
- Settings channel grid (configured-first): a settings surface with many
optional integrations renders a responsive
grid gap-3 sm:grid-cols-2 of
collapsible ChannelCards (summary row = icon + name + status + badges +
enable switch; expanded = the config form) for the channels that are ALREADY
configured, and compact dashed AddChannelTiles (icon + one-line description
- an example target value) for the rest, which expand into the same card on
click. Nothing configured →
EmptyState, never a wall of blank forms. Pin a
card open once the user edits it so clearing its value can't unmount the input
mid-keystroke. Unconfigured channels live behind a ChannelPickerDialog
opened from an "Add channel" button in the section header — not a permanent
inline tile grid. See components/health/channel-card.tsx +
alert-channels-panel.tsx (/alert-settings).
- Compact rail sidebar (primary block + collapsible groups): a narrow
(~320px) settings rail (e.g.
/agents right-hand sidebar) keeps its 1-3 most
important controls (host, model) as a static, never-collapsing "primary"
block of LabeledRows (small uppercase tag left of the control, one shared
header, no chevron); everything else is a CollapsibleSidebarSection
(chevron + icon + uppercase label + optional count badge, ui/collapsible,
defaults OPEN so nothing is hidden on first visit). A read-only status row
explains itself via an info-icon Tooltip, not a standing paragraph. See
agent-settings-sidebar.tsx.
- Settings page shape — few tabs, dialogs for the rest: at most FOUR tabs;
rarely-visited panels become a
grid gap-2 sm:grid-cols-2 of launcher cards
(icon tile + title + one-line description + ChevronRight) that open the
unchanged panel in a Dialog. /alert-settings went from ten tabs to
Alerts · Thresholds · Activity · Advanced this way. Nothing may become
unreachable, and every retired ?tab= id must still resolve — keep a
LEGACY_TAB_MAP to { tab, advancedSection? } so an old link opens the right
dialog (advanced-settings-panel.tsx + health-settings-panel.tsx).
- Presets before forms: when a surface would render N identical input pairs,
lead with a named
SegmentedControl preset covering all of them and show only
the items tuned away from that baseline; the rest come from a searchable
picker dialog. Presets scale each item's OWN defaults by a factor
(lib/health/threshold-presets.ts), and "overridden" compares VALUES to the
defaults, never key presence. A quick-start template
(lib/health/alert-templates.ts) may set several at once, but must write only
into existing stored shapes (whitelist parsers drop unknown fields) and never
overwrite a target the user typed.
- Numeric threshold input:
components/health/threshold-field.tsx — severity
dot + label, −/+ steppers around a centered tabular-nums input, step
derived from magnitude. Clamp critical ≥ warning on change, not at save.
- Permission-backed toggles: a switch gated on a browser permission must
reflect the LIVE permission, not just the stored preference. Use
lib/health/use-notification-permission.ts (effect-only — the app prerenders;
navigator.permissions onchange + a visibilitychange/focus Safari
fallback). Four states: unsupported / needs-grant / granted / blocked. On
denied disable the switch and explain the unblock — never write false into
storage. Gate "Send test" on the live permission.
- ⌘K command palette: category tabs All / Pages / Databases / Tables /
Actions under the search input (
CommandPaletteTabs: h-9 px-3, strip
px-3 pt-1.5). Pages nest under sidebar group headings with a continuous
left rail on cmdk-group-items (not a per-row border-l). Query
tokens highlight in the visible title/description (HighlightText). Titles
stay one line (whitespace-nowrap) so TTL & Partitions does not wrap
on &. All still caps explorer rows; the Databases and Tables tabs show
the full fetch. Reset tab + query on close. Filter in userland
(shouldFilter={false} + filterPaletteRows): group headings and Hidden
badges are not navigable, so Enter opens the highlighted href.
- Tab strips: define the tabs as one array and map it — an icon per tab with
ONE size (
size-3.5) and NO margin utility; TabsTrigger already supplies
items-center gap-1.5. Adding mr-* on top of that reads as misalignment.
Wrap the list in scrollbar-hide min-w-0 w-full overflow-x-auto +
TabsList w-max min-w-full flex-nowrap so labels like "Memory & CPU"
scroll inside the strip (min-w-0 is required on the flex child). Selected
styles use Base UI data-active: (trigger border-b-2, not Radix
data-[state=active]:). Do not use TabsList variant="line" on an
overflow-x-auto strip — the hanging after underline is clipped.
- Responsive chrome: overview KPI strip is 1-col → 2×2 from
sm → 4-col
from xl (never four-across at md/lg: at lg the sidebar docks and the
content pane is still ~768px). Titles wrap from sm (truncate is max-sm:
only). App sidebar overlays below lg (not a docked rail at 768). Mobile
sidebar sheet is opaque — no heatmap-through-frost. Phone sidebar rows /
toggle / header utility icons (refresh, search, theme) are min 44×44.
The header day switcher (1h…30d) stays compact and flex-1 below sm so
chips + those utilities fit one 375 row. Header page title (breadcrumb
current page) stays fully readable at 768 — do not ellipsize it; the title
cluster is shrink-0, parent crumbs wait until lg, Search is icon-only
below lg (the Search… field is desktop), and the refresh countdown label
plus header action gap stay compact until lg. Docs article Copy Markdown / Open
are 44px below md (not header search/menu). Agent FAB must not cover
heatmap "Avg / active day" (pb-16 + last-card pr-16; landscape FAB at
top-16).
- Paired page sections (e.g. AI-generated vs. plain-statistics content):
identical-weight header on both —
icon (size-4, muted-foreground) + <h2 className="text-sm font-medium text-foreground">. A genuinely empty section
still gets the header, with an EmptyState variant="no-data" compact
placeholder card, rather than being omitted. See /insights and
/insights-settings (AI Insights vs Statistics Insights — the latter is
now a real StatsInsightsSettingsForm).
- Preview / "Example" surfaces: render from deterministic mock data
parameterized by settings (seed-rotated, SSR-safe), never a live query/LLM
call — no "Couldn't generate" error for anon/read-only visitors. Label it
"Sample". See
components/insights/insights-preview.tsx. Schema Compare with
one saved host uses a real EmptyState (Add host via AddHostDialog, same
as HostSwitcher) plus a faded TableList + DdlPair example. Settings Diff
with one host keeps the live vs-default table and a banner (AddHostButton,
data-testid="add-host"). Pair ids include user connections. Diffs-only
with zero deltas still lists matching settings with a green Match check.
- Base UI primitives (
components/ui/* = shadcn Base UI, not Radix): style
overlays off data-open/data-closed/data-orientation (needs the
@custom-variant data-horizontal|vertical in styles.css) and Base UI CSS
vars (--anchor-width, --available-height, --collapsible-panel-height),
NOT data-[state=…] / --radix-*. Use the render prop, not asChild. Full
detail in the knowledge doc's "Base UI backing" section.
Illustrations (bespoke, brand-warm SVGs)
Prefer inline React SVG illustration components in
components/illustrations/ over a lone lucide glyph for high-impact "moments"
and for differentiating states. Rules:
- Token-driven only — colours come from
currentColor (set by a caller
text-* class), the OKLCH chart palette via Tailwind utilities
(fill-chart-1, text-chart-red, …), and brand fill-orange-500 /
fill-emerald-500. NEVER a raw hex/oklch or hsl(var(--…)) literal (that
breaks on the OKLCH tokens — see docs/knowledge/cluster-topology.md).
- Motion-safe only — gate any animation on
motion-safe: (e.g.
motion-safe:animate-flow-stream, motion-safe:animate-pulse), never SMIL;
add motion-reduce:animate-none on pulses.
- Theme-aware for free because everything is
currentColor + semantic
tokens; verify in both light and dark.
- Structural template:
FlowConnector in
components/connections/connection-help-panel.tsx.
Current components: WelcomeIllustration (first-run welcome hero),
AgentGreetingIllustration (agent greeting hero), EmptyStateIllustration
(one bespoke ~40×40 mini per EmptyStateVariant — wired into EmptyState, so
ChartError gets a cause-appropriate illustration automatically via
toEmptyStateVariant), BrokenWireIllustration (connection-error panel: a
browser→chmonitor→source flow with the failed hop severed). Static art for the
marketing/docs sites (which can't import React components) goes in repo-root
assets/illustrations/ (synced like screenshots//backgrounds/).
Charts — always wrap state, never hand-roll it
Use ChartContainer (renders skeleton / ChartError / empty) + ChartCard
(title, SQL, metadata, stale indicator, retry). Fetch with useChartData({ chartName, hostId, interval }). Header icon order:
[StaleIndicator] [DateRange] [LogScaleToggle] [CardToolbar]. Copy an existing
chart in components/charts/ as the template — don't reinvent the wiring.
Loading / empty / error
- Loading: a
Skeleton that matches the final layout (components/skeletons/)
to avoid layout shift; gate with Suspense where used.
- Empty:
EmptyState (components/ui/empty-state.tsx) with the right
variant (no-data | no-results | error | table-missing | timeout | filtered-empty | offline | loading), icon, title, description, optional
action/onRefresh. Table query failures use the full (non-compact)
EmptyState so timeout / missing-column copy is visible — never hourglass +
Retry with no description.
- Interactive tool pages (Explain, Advisor): before the first run, a
dashed-border
EmptyState variant="no-data" ("Nothing to analyze/explain
yet"). User-input issues — table-less SQL like SELECT 1, missing
query_id — use the same EmptyState with next steps, never ErrorAlert
titled "Analysis failed". ErrorAlert is for host/schema/fetch failures.
Picking a query from the picker auto-runs, same as /explain.
Advisor (/advisor) defaults to Schema & Settings (Query Advisor is
the second tab; ?query= / ?queryId= still open it). The schema surface
reuses Explorer DatabaseTree in a left sidebar (search + icon-only All /
Needs attention / Hide suggested, group, sort — same catalog chrome as
Schema Compare). Care tables get a single amber chart-yellow dot, not a
second border or pill. Selecting a table shows findings + copyable
recommend-only DDL (TuningFindingsPanel / RecommendDdlBlocks) and MV
relations; never an Apply/Run control. When nothing needs fixing, a healthy
empty state includes tips for creating new tables.
- Recommend-only DDL pairs: when cluster topology is known (Distributed
engine or cluster metadata), Advisor findings and schema-diff plan items
show the local table name plus a copyable
ON CLUSTER variant of the same
statement via RecommendDdlBlocks (components/ddl/recommend-ddl-blocks.tsx).
Single-node / no topology stays a single statement. Never a Run/apply
button — lib/ddl/on-cluster.ts is a pure transform.
- Error (graceful): initial error (
error && !hasData) → full ChartError
with retry. Revalidation error (staleError) → KEEP showing data + subtle
amber ChartStaleIndicator (hover-revealed), auto-clears on next success.
Never blank out good data on a refresh failure.
- First run (zero hosts):
FirstRunGate → FirstRunEmptyState (3 modes:
cloud signed-in / cloud anon / self-hosted). See the cloud-saas-mode skill.
Data tables
Use the components/data-table/ system (resizing, wrap toggle, sorting via
sorting-fns.ts, pagination, faceted filters, row actions, SQL display).
Synthetic column ids __expand, select, action are non-data — skip them in
filter/search/sort/card wiring.
Favorites in the sidebar can be drag-reordered; order is the localStorage pin
list (chm-pinned-favorites). Leaf sidebar rows also reveal Hide (EyeOff)
beside the pin; that writes hiddenMenuHrefs and toasts Undo + Open
Navigation (Settings → Workspace → Navigation). Hover + lists hidden
siblings in that group (showMenuHref on click). Group headings show a
hover + / Customize control that opens a per-category dialog
(group-customize-dialog.tsx) listing every catalog child of that group:
visible rows have Remove (hideMenuHref), hidden rows are muted with Add
(showMenuHref). Toggle updates the rail immediately; Open is an explicit
arrow. Footer Done, optional All pages… into Settings → Navigation (not
the default path). Overview (no children) has no heading dialog. Footer
About is never hideable. More is a flyout of hidden pages (not Settings).
Below lg (touch overlay) leaf rows show only the pin; Hide / Add are
max-lg:hidden and live in the heading dialog. Overlay-visible row actions
use overlayActionClasses (nav-main/overlay-action.ts), not
showOnHover (its md:opacity-0 hides the + on 768 tablets). Group
Collapsible is controlled: it opens when the active child href changes so
⌘K / breadcrumb navigation never lands on a collapsed parent.
Essential is grouped (not flattened): Overview; AI Agent → Chat; Insights
→ Insights; Health → Health; Queries → Running + History; Tables →
Overview + Explorer; Tools → SQL. Do not flatten those groups to Chat /
SQL leaves.
User appearance settings
Entry: sidebar footer chrome beside Sign In / the avatar —
[what's new] [gear] [Sign In / avatar] with flex items-center gap-1.5.
What's new is WhatsNewButton (components/whats-new/, lucide Newspaper,
aria-label="What's new", data-testid="whats-new-button", 44px mobile hit
min-h-11 min-w-11 lg:min-h-8). A primary dot badge appears when
APP_VERSION is newer than persisted lastSeenChangelogVersion (on
UserSettings). The dialog (WhatsNewDialog, sibling of the menu via
WhatsNewProvider in dashboard-shell.tsx) lists vX.Y.Z GitHub Releases
newest first; auto-opens once per upgrade (sessionStorage), never on every
navigation. Header and footer stay put; only the notes body
(min-h-0 flex-1 overflow-y-auto) scrolls — see the sticky header/footer
dialog idiom above. Manual open always works. Extra entry points: WhatsNewMenuItem
next to About in the user dropdown, and a What's new action on /about. Do
not add a Settings tab for changelog — Settings stays browser-local prefs.
GET /api/v1/releases loads notes server-side (no browser GitHub calls).
Copy comes from docs/whats-new/vX.Y.Z.md when present (same files as landing
/changelog); otherwise the GitHub body is stripped to Features. Airgap
fallback is the committed airgap-snapshot.json (friendly notes overlaid on
latest v* Features), never a runtime fetch of CHANGELOG.md. Use NavSettingsButton /
NavUserFooterRow (components/nav-user/nav-settings-button.tsx): lucide
Settings, size-4, strokeWidth={1.5}, aria-label="Open settings",
data-testid="nav-settings-button", tooltip "Settings". Hide when
canUseSettings is false. Gear must work signed-out (these are local settings).
Keep the dialog a sibling of the menu (not inside DropdownMenu /
SignInButton). ⌘, still opens it (useSettingsShortcut). Do not invent a
second settings store — always SettingsDialog + useUserSettings.
The Settings dialog (components/settings/settings-form.tsx) exposes units
(byteUnit, numberFormat), chart palette (chartPalette), table density
(tableDensity), default time range, and workspace preset
(workspacePreset, hiddenMenuHrefs) on UserSettings. Header is Settings
icon + title + one-line "Local to this browser"; surface is
rounded-xl border bg-card p-0 with a stable height (h-[min(42rem,90vh)], sm:max-w-4xl)
and select-text so labels copy. Left rail is a flat column (no boxed
tab well): section labels + icon rows, selected = muted pill, border-r
divider. Below sm the rail stacks above the pane so Navigation fits
375. Content pane shows the active tab title. Theme (Light / Dark /
System) is a settings row (label left, three window thumbnails right)
on Appearance only. Navigation leads with a SegmentedControl workspace
preset (Full / DBA / Engineer / SRE / Custom) plus an in-page sidebar-like
menu tree (same groups, icons, nested children as nav-main). Groups
default collapsed; picking a role remounts them closed. Parent rows are
chevron-only. Nested children use SidebarMenuSubButton (text-left,
same as the parent button) so a <button> row is not UA-centered; Hide
stays shrink-0 on the right. Click a leaf to hide or show it — hidden
rows stay visible but muted, like Dim unavailable pages.
Expand/collapse does not write settings. Hide of an already-hidden-by-
preset leaf stays on the role; Custom only when the hide list leaves
hideListForPreset. Search filters the tree. When the preset is not
Full, a Show all control applies Full. The sidebar More row is a
searchable flyout of hidden leaves (click navigates; Customize… opens
this pane). Group headings are the default customize surface (one group
at a time — never a 40-checkbox wall of the whole catalog). Then the Dim /
Hide unavailable-page
demos.
Hidden pages stay routable. Sidebar visibility is the hide list
(getVisibleMenuItems + Alerts). ⌘K uses
usePaletteMenuItems / getAllowedMenuItems so hidden pages stay
indexed with a Hidden hint and do not auto-unhide. Landing on a hidden
page shows Keep in sidebar. Settings > Navigation
tree match the active host engine (useActiveHostEngine —
default source engine, Postgres pages when ?pg= is active).
Timezone uses timezone-combobox.tsx
(search + browser zone on top). Palette is a card picker with mini bars, not
a segmented control. Unit options show a sample value (1.5 GiB / 1.6 GB).
Integrations: MCP live; Slack/Telegram/PagerDuty/Email/Discord shown disabled.
First-run workspace is Custom + the Essential hide list
(DEFAULT_HIDDEN_MENU_HREFS: grouped Overview; AI Agent → Chat; Insights
→ Insights; Health → Health; Queries → Running + History; Tables →
Overview + Explorer; Tools → SQL). Full still restores every page. Other
DEFAULTs reproduce the prior look (byteUnit: 'binary', …). Applied by
AppearanceSettingsProvider (lib/context/appearance-settings.tsx): units →
module snapshot in lib/format-settings.ts; palette/density →
data-chart-palette / data-density on <html>. For 2–3 choices use components/settings/segmented-control.tsx; 4+
options wrap so the Navigation presets fit a 375 Settings pane. Show all
is a full-width control under the hide-count line. Full detail:
docs/knowledge/product-design.md.
Adding a page
src/routes/(dashboard)/my-page.tsx ('use client', uses useHostId()).
- Add a
QueryConfig in src/lib/query-config/ if it needs data.
- Register in
src/menu/ (with feature gate / tableCheck if optional).
Add the tab title in lib/page-title.ts (ROUTE_TITLE_MAP) when it
differs from title-casing the last URL segment — ⌘K searches that
<title> as well as the sidebar label.
Interactive utilities (SQL, explorer, explain, compare, builder, advisor)
go in menu/tools.ts. Data Explorer (/explorer) is also listed under
Tables. TTL & Partitions (/ttl-partition-health) is a system-table
inventory — it lives under Tables, not Tools or System. The listing
includes a stacked in-range vs past-TTL bar (part max_date vs the
parsed TTL interval). The same recommend-only rules also power the
TTL & Partition Health card on /health (flagged-table count +
detail dialog). Do not invent a third TTL surface; never ALTER TTL or
DROP PARTITION from these pages. Other
system-table views stay in their domain file. Tools is the last Main group, composed
after Logs and before the About footer in menu/index.ts — do not put
it after Overview / before AI Agent. The Tools parent must not set
permission; copy the child's existing feature onto the leaf. Leave
engines absent so Postgres hosts hide the whole Tools group — do not
add engines: ['postgres']. DBA / Engineer / SRE presets include
Tools. Webhook ingest (Inbound Events) lives under Health after
Alert Settings — not as a top-level Others item; leave engines
absent so Postgres hosts inherit Health (default source-engine family).
Day-to-day pages belong in DEFAULT_VISIBLE_MENU_HREFS
(lib/menu/slim-default.ts); omit specialist pages so the first-run
sidebar stays Essential plus Insights, Explorer, and Query History
(grouped: Overview; AI Agent → Chat; Insights → Insights; Health →
Health; Queries → Running + History; Tables → Overview + Explorer;
Tools → SQL) — they remain restorable from the group heading dialog,
hover +, More, in-page More / Customize, or Settings → Navigation. Do
not add Merges / Metrics / Clusters / Explain / Advisor, or Keeper /
PeerDB / Security / Logs / System / Operations, to the keep list.
- Compose
ChartContainer + ChartCard; reuse skeletons + empty/error states.
File & naming conventions
kebab-case files; PascalCase components; use* camelCase hooks; props as
interface XProps colocated; client components declare 'use client', server
components don't; shared types in src/types/ or src/lib/api/types.ts. Details
in docs/knowledge/conventions.md.
Keep this skill current
This skill is the source of truth for design consistency. When you introduce or
change a durable UI pattern (a new token, a reusable component, an
empty/error/onboarding convention), UPDATE this file + docs/knowledge/ product-design.md in the SAME change. See the "Auto-improve project skills"
note in the root CLAUDE.md.
1---2name: product-design3description: chmonitor's product design system + UX conventions, so every new feature looks and behaves consistent with the rest of the dashboard. Use BEFORE building or reviewing any UI: new pages, charts, cards, dialogs, empty/error/loading states, badges, onboarding, settings. Covers design tokens (OKLCH theme, dark mode, radius, chart colors), shadcn/ui rules, the ChartCard/ChartContainer pattern, data-table system, EmptyState variants, graceful error handling, ?host routing + hooks-at-deepest-consumer, file/route organization, and brand. Triggers: "new page", "add a chart", "build UI", "design", "component", "empty state", "loading", "consistent", "follow-up feature", "match the design", "what's new", "changelog", "dialog scroll", "settings gear", "schema compare", "settings diff", "add host", "pick a query", "query picker", "select labels", "ON CLUSTER", "advisor DDL", "advisor schema", "schema advisor", "command palette", "cmd k", "search dialog", "ttl partitions", "header title", "768", "truncate Overview", "essen4---56# chmonitor product design system78The rulebook for keeping new features visually + behaviourally consistent. When9in doubt, COPY the closest existing component rather than inventing. Full token10values and file paths: `docs/knowledge/product-design.md`.1112## Non-negotiables13141. **Never edit `src/components/ui/*`** (shadcn primitives). Customise via the15 `className` prop at the call site, or a wrapper in `src/components/` — never16 in `ui/`. Always merge classes with `cn()` (`src/lib/utils.ts`), never17 template-literal concatenation.182. **Tailwind v4, CSS-first.** Tokens live in `src/styles.css` `@theme` blocks —19 there is no `tailwind.config.ts`. Use semantic tokens (`bg-card`,20 `text-muted-foreground`, `border-border`), not raw colors. Theme is OKLCH.213. **Dark mode is `class`-based** (`next-themes`, `.dark`). Every surface must22 read correctly in both themes — only use semantic tokens, which flip23 automatically. Don't hardcode `bg-white` / `text-black`.244. **Hooks at the deepest consumer.** A component that needs data calls25 `useHostId()` / `useChartData()` itself — do NOT prop-drill `hostId`.265. **`?host=N` routing**, never dynamic `/N/...` segments. Preserve other search27 params with `buildUrl(pathname, { host }, searchParams)`. Data shown under28 a host name must come from that host: host-scoped hooks use29 `keepPreviousDataForHost` (or opt out of `placeholderData`), and per-host30 component state remounts with `key={hostId}` (see31 `docs/knowledge/static-site-architecture.md`, "Host-scoped cache").326. **No "AI slop" decoration** — one clear signal per state, not several33 redundant ones. No full-saturation accent bars/rails stacked on an34 already-colored border; no gradient blobs/glow orbs behind icons. See35 "Anti-patterns" in `docs/knowledge/product-design.md`.3637## Tokens (semantic — use these names, not hex/oklch literals)3839`background foreground card card-foreground popover muted muted-foreground40primary secondary accent destructive border input ring`. Charts:41`--chart-1..13` for series, plus named accents `--chart-red` (error series),42`--chart-blue` (info), `--chart-green` (success), `--chart-yellow` (warning) —43only pass tokens that exist in `styles.css` to a chart's `colors` prop; an44undefined `var()` renders the series black. Radius: `rounded-md` (9px) default,45`rounded-lg` (10px), `rounded-xl` (14px) for cards. Brand accents: **orange**46(metrics) + **emerald** (health/live) — see `components/icons/chmonitor-logo.tsx`.4748## Canonical idioms (match these class strings)4950- **Card surface:** `rounded-xl border bg-card shadow-sm` (premium variant adds51 `bg-gradient-to-b from-card/80 to-card/40 dark:from-card/60 dark:to-card/3052 backdrop-blur-xl`). See `components/charts/chart-card-styles.ts`.53- **Icons:** `lucide-react`, `size-4` standard / `size-3.5` compact / `strokeWidth={1.5}`.54- **Dense text:** `text-[13px]` controls, `text-sm` body, `text-xs55 text-muted-foreground` meta, `text-xl font-semibold tracking-tight` page/hero titles.56- **Spacing:** `gap-1.5` compact, `gap-2` standard, `gap-4` generous; card content `p-4 pt-0`.57- **Pill/secondary button link:** `inline-flex h-8 items-center gap-1.5 rounded-md58 border border-border px-3 text-[13px] font-medium hover:bg-muted`.59- **Clickable card → detail dialog:** whole card is the target (`role="button"` +60 `tabIndex={0}` + `onKeyDown={activateOnEnterOrSpace(open)}`, never a nested61 `<button>`); inner links `e.stopPropagation()` (not `preventDefault`) so they62 still navigate. Drive drill-down from a per-item field, rendered via63 `ResultTable`. See `components/health/{health-card-shell,health-detail-rows}.tsx`.64- **One severity signal per card:** severity reads from a single tinted icon —65 never icon tile + colored border + severity pill + header count badge on the66 same finding. `components/insights/severity-meta.ts` is the source of truth67 (label / icon / `iconColor` / neutral `badge`); card surface, border and count68 badges stay neutral. Body `line-clamp-2` + `title` tooltip; the breakdown goes69 in a detail dialog.70- **A dialog opened from a popover lives OUTSIDE the popover subtree.** Rendered71 inside `PopoverContent`, closing the popover unmounts the dialog and nothing72 appears. Keep the selected item + dialog in the parent, as a sibling of73 `<Popover>` — see `components/insights/insights-popover.tsx`.74- **Dialog with sticky header/footer:** `DialogContent` is `flex flex-col75 overflow-hidden p-0` with `max-h-[min(36rem,85vh)]` (or a stable `h-[…]`).76 Header and footer are `shrink-0`. The body is `min-h-0 flex-1 overflow-y-auto`77 — that element is the scroll container. Do not use `ScrollArea` with `flex-1`78 for this: its viewport is `size-full` and does not constrain unless the root79 has an explicit height, so notes paint under the footer and EmptyState can80 vanish inside a blank scrollbar box. Reset `DialogFooter`'s default81 `-mx-4 -mb-4` to `mx-0 mb-0` when the dialog is `p-0`. Same list-scroll82 pattern without a footer: `components/agents/advisor-query-picker.tsx`.83 See `components/whats-new/whats-new-dialog.tsx`. For What's new, also set84 `initialFocus` on the title (`tabIndex={-1}`) and reset the body85 `scrollTop` on open — default focus would otherwise land on the first86 markdown link deep in an older note and `scrollIntoView` the list to the87 middle. Screenshot thumbs (`WhatsNewScreenshotGallery`) open a full-size88 overlay in the same dialog; Escape closes the overlay before the dialog.89 Do not add `relative` on `DialogContent` — `cn()` drops the90 primitive's `fixed`, so `top-1/2 left-1/2 -translate-*` no longer centers91 in the viewport. `fixed` already contains an inner `absolute` overlay.92- **Base UI Select labels:** pass `items={{ value: 'Human label' }}` on93 `Select` (the Root) so `SelectValue` shows the label, not the raw value.94 `placeholder` only appears when nothing is selected — a selected `24` /95 `__all__` otherwise renders as those strings. Same map on compare96 Source/Target (`ComparePeerSelect` — id → `h.name`, never a raw `0`). See97 `components/agents/advisor-query-picker.tsx` and98 `components/compare/compare-peer-select.tsx`.99- **Schema Compare (`/schema-diff`):** one static `PageHeader` ("Schema100 Compare" + recommend-only description). Pair identity lives in the101 Source/Target comboboxes — no second "Comparing X → Y — N tables differ"102 line. Compare tools wrap filters in compact `CompareToolbar`103 (`rounded-xl border bg-card p-3`): tabs on top (**Connections** /104 **Replica nodes**, `SegmentedControl size="sm"`), then Source /105 Target searchable comboboxes (`ComparePeerSelect`: Command +106 Popover, sorted by name, `ChmonitorLogo` plus version/uptime/status107 like HostSwitcher — never a native Select). Copy recommended SQL108 sits on that row (all safe statements, or only tables checked in109 the catalog). Differing tables can be checked to build a sync110 script — recommend-only, never applied. Each table's plan card111 copies that table only. The table catalog is a collapsible left sidebar112 grouped **database → table** (folder row + nested table name).113 Search, **Differences / All** (icon-only `GitCompareArrows` /114 `List`), and sort (icon-only `ArrowDownAZ` menu: A–Z, Z–A,115 differences first) live on that sidebar — not the host toolbar.116 When Differences is on and there are no diffs, still list identical117 tables with a green `CheckCircle2` (`--chart-green`) — click a row118 to select it on the right. Switching Connections / Replica nodes119 keeps the toolbar and shows a listing loading state (not a full-page120 empty load). A matching selection is **All matched** / **This121 table matches** (`MatchOk`), never EmptyState "no data" or122 "Select a table". Keep "No tables match" only for a name-filter123 miss.124 Settings Diff (`/settings-diff`) uses the same host toolbar125 (Connections / Replica nodes + Source/Target only). The listing is126 the shared `DataTable` in **embedded** mode — one `rounded-xl127 border bg-card` like Running Queries, no second page title. Search,128 Differences / All, Changed from default, Filters, Display options,129 density, column visibility, and CSV live on that table toolbar.130 Diffs-only with zero deltas still lists matching settings. The131 Match column uses the shared boolean check (green) / cross (rose);132 column headers carry lucide icons (`CheckCircle2`,133 `SlidersHorizontal`, `Table2`, `Pencil`, `Undo2`, `Server`).134 "Changed from default" is a pressable chip, not a second switch.135 Empty catalog copy is DataTable's "No settings found" / "No136 settings match your filters" — only a name or changed-from-default137 miss.138- **Sidebar favorites:** the row is a link (`cursor-pointer`). Pin is139 hover-only. Favorites also reveal a grip handle on hover — drag it to140 reorder (`nav-favorites.tsx`).141- **Overflow strip (one row, no wrap):** `scrollbar-hide overflow-x-auto` + `py-*`142 (so shadows/accents/focus rings aren't clipped) with a chevron button + a143 `from-background`→`transparent` edge fade per scrollable side, paging via144 `scrollBy`. Re-measure on scroll / `ResizeObserver` / content-count change.145 Copy `components/insights/insights-strip.tsx`.146- **Agent chat machinery stays ghost-weight.** Reasoning/tool-group triggers147 (`components/assistant-ui/{reasoning,tool-group}.tsx`) are plain148 `icon + label + chevron` text rows — no `bg-muted/50` slab — so the149 assistant's prose stays the loudest element, not two stacked grey cards.150 Tool-call headers show a family icon + short capped summary151 (`summarizeToolOutput` when done, else `summarizeToolInput`), never a raw152 `key=value` param dump; long params (e.g. `sql`) render as a153 syntax-highlighted `CodeBlock` in the "Parameters" disclosure, not inline.154 Running rows use a muted label + tiny spinner. Tool errors render via155 `summarizeToolError` as a compact `border-destructive/30` row with an156 expandable "Details" disclosure — never a raw `{"error":...}` blob. Don't157 add a `.markdown-content`158 `pre`/`code` background rule — Streamdown's own `code:` renderer already159 owns that styling with token-based Tailwind classes; a sitewide override160 paints a second box INSIDE its already-bordered fenced-block card. See161 `docs/knowledge/product-design.md` § "Agent chat: reasoning / tool-call162 rendering".163- **Settings channel grid (configured-first):** a settings surface with many164 optional integrations renders a responsive `grid gap-3 sm:grid-cols-2` of165 collapsible `ChannelCard`s (summary row = icon + name + status + badges +166 enable switch; expanded = the config form) for the channels that are ALREADY167 configured, and compact dashed `AddChannelTile`s (icon + one-line description168 + an example target value) for the rest, which expand into the same card on169 click. Nothing configured → `EmptyState`, never a wall of blank forms. Pin a170 card open once the user edits it so clearing its value can't unmount the input171 mid-keystroke. Unconfigured channels live behind a `ChannelPickerDialog`172 opened from an "Add channel" button in the section header — not a permanent173 inline tile grid. See `components/health/channel-card.tsx` +174 `alert-channels-panel.tsx` (`/alert-settings`).175- **Compact rail sidebar (primary block + collapsible groups):** a narrow176 (~320px) settings rail (e.g. `/agents` right-hand sidebar) keeps its 1-3 most177 important controls (host, model) as a static, never-collapsing "primary"178 block of `LabeledRow`s (small uppercase tag left of the control, one shared179 header, no chevron); everything else is a `CollapsibleSidebarSection`180 (chevron + icon + uppercase label + optional count badge, `ui/collapsible`,181 defaults OPEN so nothing is hidden on first visit). A read-only status row182 explains itself via an info-icon `Tooltip`, not a standing paragraph. See183 `agent-settings-sidebar.tsx`.184- **Settings page shape — few tabs, dialogs for the rest:** at most FOUR tabs;185 rarely-visited panels become a `grid gap-2 sm:grid-cols-2` of launcher cards186 (icon tile + title + one-line description + `ChevronRight`) that open the187 unchanged panel in a `Dialog`. `/alert-settings` went from ten tabs to188 `Alerts · Thresholds · Activity · Advanced` this way. Nothing may become189 unreachable, and every retired `?tab=` id must still resolve — keep a190 `LEGACY_TAB_MAP` to `{ tab, advancedSection? }` so an old link opens the right191 dialog (`advanced-settings-panel.tsx` + `health-settings-panel.tsx`).192- **Presets before forms:** when a surface would render N identical input pairs,193 lead with a named `SegmentedControl` preset covering all of them and show only194 the items tuned away from that baseline; the rest come from a searchable195 picker dialog. Presets scale each item's OWN defaults by a factor196 (`lib/health/threshold-presets.ts`), and "overridden" compares VALUES to the197 defaults, never key presence. A quick-start **template**198 (`lib/health/alert-templates.ts`) may set several at once, but must write only199 into existing stored shapes (whitelist parsers drop unknown fields) and never200 overwrite a target the user typed.201- **Numeric threshold input:** `components/health/threshold-field.tsx` — severity202 dot + label, `−`/`+` steppers around a centered `tabular-nums` input, step203 derived from magnitude. Clamp `critical ≥ warning` on change, not at save.204- **Permission-backed toggles:** a switch gated on a browser permission must205 reflect the LIVE permission, not just the stored preference. Use206 `lib/health/use-notification-permission.ts` (effect-only — the app prerenders;207 `navigator.permissions` `onchange` + a `visibilitychange`/`focus` Safari208 fallback). Four states: unsupported / needs-grant / granted / blocked. On209 `denied` disable the switch and explain the unblock — never write `false` into210 storage. Gate "Send test" on the live permission.211- **⌘K command palette:** category tabs All / Pages / Databases / Tables /212 Actions under the search input (`CommandPaletteTabs`: `h-9 px-3`, strip213 `px-3 pt-1.5`). Pages nest under sidebar group headings with a continuous214 left rail on `cmdk-group-items` (not a per-row `border-l`). Query215 tokens highlight in the visible title/description (`HighlightText`). Titles216 stay one line (`whitespace-nowrap`) so `TTL & Partitions` does not wrap217 on `&`. All still caps explorer rows; the Databases and Tables tabs show218 the full fetch. Reset tab + query on close. Filter in userland219 (`shouldFilter={false}` + `filterPaletteRows`): group headings and Hidden220 badges are not navigable, so Enter opens the highlighted href.221- **Tab strips:** define the tabs as one array and map it — an icon per tab with222 ONE size (`size-3.5`) and NO margin utility; `TabsTrigger` already supplies223 `items-center gap-1.5`. Adding `mr-*` on top of that reads as misalignment.224 Wrap the list in `scrollbar-hide min-w-0 w-full overflow-x-auto` +225 `TabsList w-max min-w-full flex-nowrap` so labels like "Memory & CPU"226 scroll inside the strip (`min-w-0` is required on the flex child). Selected227 styles use Base UI `data-active:` (trigger `border-b-2`, not Radix228 `data-[state=active]:`). Do not use `TabsList variant="line"` on an229 `overflow-x-auto` strip — the hanging `after` underline is clipped.230- **Responsive chrome:** overview KPI strip is 1-col → 2×2 from `sm` → 4-col231 from `xl` (never four-across at `md`/`lg`: at `lg` the sidebar docks and the232 content pane is still ~768px). Titles wrap from `sm` (truncate is `max-sm:`233 only). App sidebar overlays below `lg` (not a docked rail at 768). Mobile234 sidebar sheet is opaque — no heatmap-through-frost. Phone sidebar rows /235 toggle / header utility icons (refresh, search, theme) are `min 44×44`.236 The header day switcher (1h…30d) stays compact and `flex-1` below `sm` so237 chips + those utilities fit one 375 row. Header page title (breadcrumb238 current page) stays fully readable at 768 — do not ellipsize it; the title239 cluster is `shrink-0`, parent crumbs wait until `lg`, Search is icon-only240 below `lg` (the Search… field is desktop), and the refresh countdown label241 plus header action gap stay compact until `lg`. Docs article Copy Markdown / Open242 are 44px below `md` (not header search/menu). Agent FAB must not cover243 heatmap "Avg / active day" (`pb-16` + last-card `pr-16`; landscape FAB at244 `top-16`).245- **Paired page sections** (e.g. AI-generated vs. plain-statistics content):246 identical-weight header on both — `icon (size-4, muted-foreground) + <h2247 className="text-sm font-medium text-foreground">`. A *genuinely* empty section248 still gets the header, with an `EmptyState variant="no-data" compact`249 placeholder card, rather than being omitted. See `/insights` and250 `/insights-settings` (`AI Insights` vs `Statistics Insights` — the latter is251 now a real `StatsInsightsSettingsForm`).252- **Preview / "Example" surfaces**: render from deterministic mock data253 parameterized by settings (seed-rotated, SSR-safe), never a live query/LLM254 call — no "Couldn't generate" error for anon/read-only visitors. Label it255 "Sample". See `components/insights/insights-preview.tsx`. Schema Compare with256 one saved host uses a real `EmptyState` (Add host via `AddHostDialog`, same257 as HostSwitcher) plus a faded `TableList` + `DdlPair` example. Settings Diff258 with one host keeps the live vs-default table and a banner (`AddHostButton`,259 `data-testid="add-host"`). Pair ids include user connections. Diffs-only260 with zero deltas still lists matching settings with a green Match check.261- **Base UI primitives** (`components/ui/*` = shadcn Base UI, not Radix): style262 overlays off `data-open`/`data-closed`/`data-orientation` (needs the263 `@custom-variant data-horizontal|vertical` in `styles.css`) and Base UI CSS264 vars (`--anchor-width`, `--available-height`, `--collapsible-panel-height`),265 NOT `data-[state=…]` / `--radix-*`. Use the `render` prop, not `asChild`. Full266 detail in the knowledge doc's "Base UI backing" section.267268## Illustrations (bespoke, brand-warm SVGs)269270Prefer inline **React SVG** illustration components in271`components/illustrations/` over a lone lucide glyph for high-impact "moments"272and for differentiating states. Rules:273274- **Token-driven only** — colours come from `currentColor` (set by a caller275 `text-*` class), the OKLCH chart palette via Tailwind utilities276 (`fill-chart-1`, `text-chart-red`, …), and brand `fill-orange-500` /277 `fill-emerald-500`. NEVER a raw hex/oklch or `hsl(var(--…))` literal (that278 breaks on the OKLCH tokens — see `docs/knowledge/cluster-topology.md`).279- **Motion-safe only** — gate any animation on `motion-safe:` (e.g.280 `motion-safe:animate-flow-stream`, `motion-safe:animate-pulse`), never SMIL;281 add `motion-reduce:animate-none` on pulses.282- **Theme-aware for free** because everything is `currentColor` + semantic283 tokens; verify in both light and dark.284- Structural template: `FlowConnector` in285 `components/connections/connection-help-panel.tsx`.286287Current components: `WelcomeIllustration` (first-run welcome hero),288`AgentGreetingIllustration` (agent greeting hero), `EmptyStateIllustration`289(one bespoke ~40×40 mini per `EmptyStateVariant` — wired into `EmptyState`, so290`ChartError` gets a cause-appropriate illustration automatically via291`toEmptyStateVariant`), `BrokenWireIllustration` (connection-error panel: a292browser→chmonitor→source flow with the failed hop severed). Static art for the293marketing/docs sites (which can't import React components) goes in repo-root294`assets/illustrations/` (synced like `screenshots/`/`backgrounds/`).295296## Charts — always wrap state, never hand-roll it297298Use `ChartContainer` (renders skeleton / `ChartError` / empty) + `ChartCard`299(title, SQL, metadata, stale indicator, retry). Fetch with `useChartData({300chartName, hostId, interval })`. Header icon order:301`[StaleIndicator] [DateRange] [LogScaleToggle] [CardToolbar]`. Copy an existing302chart in `components/charts/` as the template — don't reinvent the wiring.303304## Loading / empty / error305306- **Loading:** a `Skeleton` that matches the final layout (`components/skeletons/`)307 to avoid layout shift; gate with `Suspense` where used.308- **Empty:** `EmptyState` (`components/ui/empty-state.tsx`) with the right309 `variant` (`no-data | no-results | error | table-missing | timeout |310 filtered-empty | offline | loading`), `icon`, `title`, `description`, optional311 `action`/`onRefresh`. Table query failures use the full (non-compact)312 EmptyState so timeout / missing-column copy is visible — never hourglass +313 Retry with no description.314- **Interactive tool pages** (Explain, Advisor): before the first run, a315 dashed-border `EmptyState variant="no-data"` ("Nothing to analyze/explain316 yet"). User-input issues — table-less SQL like `SELECT 1`, missing317 `query_id` — use the same EmptyState with next steps, never `ErrorAlert`318 titled "Analysis failed". `ErrorAlert` is for host/schema/fetch failures.319 Picking a query from the picker auto-runs, same as `/explain`.320 **Advisor (`/advisor`)** defaults to **Schema & Settings** (Query Advisor is321 the second tab; `?query=` / `?queryId=` still open it). The schema surface322 reuses Explorer `DatabaseTree` in a left sidebar (search + icon-only All /323 Needs attention / Hide suggested, group, sort — same catalog chrome as324 Schema Compare). Care tables get a single amber `chart-yellow` dot, not a325 second border or pill. Selecting a table shows findings + copyable326 recommend-only DDL (`TuningFindingsPanel` / `RecommendDdlBlocks`) and MV327 relations; never an Apply/Run control. When nothing needs fixing, a healthy328 empty state includes **tips for creating new tables**.329- **Recommend-only DDL pairs:** when cluster topology is known (Distributed330 engine or cluster metadata), Advisor findings and schema-diff plan items331 show the local table name plus a copyable `ON CLUSTER` variant of the same332 statement via `RecommendDdlBlocks` (`components/ddl/recommend-ddl-blocks.tsx`).333 Single-node / no topology stays a single statement. Never a Run/apply334 button — `lib/ddl/on-cluster.ts` is a pure transform.335- **Error (graceful):** initial error (`error && !hasData`) → full `ChartError`336 with retry. Revalidation error (`staleError`) → KEEP showing data + subtle337 amber `ChartStaleIndicator` (hover-revealed), auto-clears on next success.338 Never blank out good data on a refresh failure.339- **First run (zero hosts):** `FirstRunGate` → `FirstRunEmptyState` (3 modes:340 cloud signed-in / cloud anon / self-hosted). See the `cloud-saas-mode` skill.341342## Data tables343344Use the `components/data-table/` system (resizing, wrap toggle, sorting via345`sorting-fns.ts`, pagination, faceted filters, row actions, SQL display).346Synthetic column ids `__expand`, `select`, `action` are non-data — skip them in347filter/search/sort/card wiring.348349Favorites in the sidebar can be drag-reordered; order is the localStorage pin350list (`chm-pinned-favorites`). Leaf sidebar rows also reveal Hide (EyeOff)351beside the pin; that writes `hiddenMenuHrefs` and toasts Undo + Open352Navigation (Settings → Workspace → Navigation). Hover **+** lists hidden353siblings in that group (`showMenuHref` on click). Group headings show a354hover **+** / Customize control that opens a **per-category dialog**355(`group-customize-dialog.tsx`) listing every catalog child of that group:356visible rows have Remove (`hideMenuHref`), hidden rows are muted with Add357(`showMenuHref`). Toggle updates the rail immediately; Open is an explicit358arrow. Footer Done, optional All pages… into Settings → Navigation (not359the default path). Overview (no children) has no heading dialog. Footer360About is never hideable. More is a flyout of hidden pages (not Settings).361Below `lg` (touch overlay) leaf rows show only the pin; Hide / Add are362`max-lg:hidden` and live in the heading dialog. Overlay-visible row actions363use `overlayActionClasses` (`nav-main/overlay-action.ts`), not364`showOnHover` (its `md:opacity-0` hides the `+` on 768 tablets). Group365`Collapsible` is controlled: it opens when the active child href changes so366⌘K / breadcrumb navigation never lands on a collapsed parent.367Essential is grouped (not flattened): Overview; AI Agent → Chat; Insights368→ Insights; Health → Health; Queries → Running + History; Tables →369Overview + Explorer; Tools → SQL. Do not flatten those groups to Chat /370SQL leaves.371372## User appearance settings373374**Entry:** sidebar footer chrome beside Sign In / the avatar —375`[what's new] [gear] [Sign In / avatar]` with `flex items-center gap-1.5`.376What's new is `WhatsNewButton` (`components/whats-new/`, lucide `Newspaper`,377`aria-label="What's new"`, `data-testid="whats-new-button"`, 44px mobile hit378`min-h-11 min-w-11 lg:min-h-8`). A primary **dot** badge appears when379`APP_VERSION` is newer than persisted `lastSeenChangelogVersion` (on380`UserSettings`). The dialog (`WhatsNewDialog`, sibling of the menu via381`WhatsNewProvider` in `dashboard-shell.tsx`) lists `vX.Y.Z` GitHub Releases382newest first; auto-opens **once** per upgrade (sessionStorage), never on every383navigation. Header and footer stay put; only the notes body384(`min-h-0 flex-1 overflow-y-auto`) scrolls — see the sticky header/footer385dialog idiom above. Manual open always works. Extra entry points: `WhatsNewMenuItem`386next to About in the user dropdown, and a What's new action on `/about`. Do387**not** add a Settings tab for changelog — Settings stays browser-local prefs.388`GET /api/v1/releases` loads notes server-side (no browser GitHub calls).389Copy comes from `docs/whats-new/vX.Y.Z.md` when present (same files as landing390`/changelog`); otherwise the GitHub body is stripped to Features. Airgap391fallback is the committed `airgap-snapshot.json` (friendly notes overlaid on392latest `v*` Features), never a runtime fetch of CHANGELOG.md. Use `NavSettingsButton` /393`NavUserFooterRow` (`components/nav-user/nav-settings-button.tsx`): lucide394`Settings`, `size-4`, `strokeWidth={1.5}`, `aria-label="Open settings"`,395`data-testid="nav-settings-button"`, tooltip "Settings". Hide when396`canUseSettings` is false. Gear must work signed-out (these are local settings).397Keep the dialog a **sibling** of the menu (not inside `DropdownMenu` /398`SignInButton`). ⌘, still opens it (`useSettingsShortcut`). Do **not** invent a399second settings store — always `SettingsDialog` + `useUserSettings`.400401The Settings dialog (`components/settings/settings-form.tsx`) exposes units402(`byteUnit`, `numberFormat`), chart palette (`chartPalette`), table density403(`tableDensity`), default time range, and workspace preset404(`workspacePreset`, `hiddenMenuHrefs`) on `UserSettings`. Header is Settings405icon + title + one-line "Local to this browser"; surface is406`rounded-xl border bg-card p-0` with a **stable height** (`h-[min(42rem,90vh)]`, `sm:max-w-4xl`)407and `select-text` so labels copy. Left rail is a flat column (no boxed408tab well): section labels + icon rows, selected = muted pill, `border-r`409divider. Below `sm` the rail stacks above the pane so Navigation fits410375. Content pane shows the active tab title. Theme (Light / Dark /411System) is a settings row (label left, three window thumbnails right)412on Appearance only. Navigation leads with a `SegmentedControl` workspace413preset (Full / DBA / Engineer / SRE / Custom) plus an in-page sidebar-like414menu tree (same groups, icons, nested children as `nav-main`). Groups415default collapsed; picking a role remounts them closed. Parent rows are416chevron-only. Nested children use `SidebarMenuSubButton` (`text-left`,417same as the parent button) so a `<button>` row is not UA-centered; Hide418stays `shrink-0` on the right. Click a leaf to hide or show it — hidden419rows stay visible but muted, like Dim unavailable pages.420Expand/collapse does not write settings. Hide of an already-hidden-by-421preset leaf stays on the role; Custom only when the hide list leaves422 `hideListForPreset`. Search filters the tree. When the preset is not423 Full, a **Show all** control applies Full. The sidebar **More** row is a424 searchable flyout of hidden leaves (click navigates; Customize… opens425 this pane). Group headings are the default customize surface (one group426 at a time — never a 40-checkbox wall of the whole catalog). Then the Dim /427 Hide unavailable-page428 demos.429Hidden pages stay routable. Sidebar visibility is the hide list430(`getVisibleMenuItems` + Alerts). ⌘K uses431`usePaletteMenuItems` / `getAllowedMenuItems` so hidden pages stay432indexed with a Hidden hint and do not auto-unhide. Landing on a hidden433page shows Keep in sidebar. Settings > Navigation434tree match the **active host engine** (`useActiveHostEngine` —435default source engine, Postgres pages when `?pg=` is active).436Timezone uses `timezone-combobox.tsx`437(search + browser zone on top). Palette is a card picker with mini bars, not438a segmented control. Unit options show a sample value (`1.5 GiB` / `1.6 GB`).439Integrations: MCP live; Slack/Telegram/PagerDuty/Email/Discord shown disabled.440First-run workspace is Custom + the Essential hide list441(`DEFAULT_HIDDEN_MENU_HREFS`: grouped Overview; AI Agent → Chat; Insights442→ Insights; Health → Health; Queries → Running + History; Tables →443Overview + Explorer; Tools → SQL). Full still restores every page. Other444DEFAULTs reproduce the prior look (`byteUnit: 'binary'`, …). Applied by445`AppearanceSettingsProvider` (`lib/context/appearance-settings.tsx`): units →446module snapshot in `lib/format-settings.ts`; palette/density →447`data-chart-palette` / `data-density` on `<html>`. For 2–3 choices use `components/settings/segmented-control.tsx`; 4+448options wrap so the Navigation presets fit a 375 Settings pane. Show all449is a full-width control under the hide-count line. Full detail:450`docs/knowledge/product-design.md`.451452## Adding a page4534541. `src/routes/(dashboard)/my-page.tsx` (`'use client'`, uses `useHostId()`).4552. Add a `QueryConfig` in `src/lib/query-config/` if it needs data.4563. Register in `src/menu/` (with feature gate / `tableCheck` if optional).457 Add the tab title in `lib/page-title.ts` (`ROUTE_TITLE_MAP`) when it458 differs from title-casing the last URL segment — ⌘K searches that459 `<title>` as well as the sidebar label.460 Interactive utilities (SQL, explorer, explain, compare, builder, advisor)461 go in `menu/tools.ts`. Data Explorer (`/explorer`) is also listed under462 Tables. TTL & Partitions (`/ttl-partition-health`) is a system-table463 inventory — it lives under Tables, not Tools or System. The listing464 includes a stacked in-range vs past-TTL bar (part `max_date` vs the465 parsed TTL interval). The same recommend-only rules also power the466 **TTL & Partition Health** card on `/health` (flagged-table count +467 detail dialog). Do not invent a third TTL surface; never ALTER TTL or468 DROP PARTITION from these pages. Other469 system-table views stay in their domain file. Tools is the last Main group, composed470 after Logs and before the About footer in `menu/index.ts` — do not put471 it after Overview / before AI Agent. The Tools parent must not set472 `permission`; copy the child's existing feature onto the leaf. Leave473 `engines` absent so Postgres hosts hide the whole Tools group — do not474 add `engines: ['postgres']`. DBA / Engineer / SRE presets include475 `Tools`. Webhook ingest (Inbound Events) lives under Health after476 Alert Settings — not as a top-level Others item; leave `engines`477 absent so Postgres hosts inherit Health (default source-engine family).478 Day-to-day pages belong in `DEFAULT_VISIBLE_MENU_HREFS`479 (`lib/menu/slim-default.ts`); omit specialist pages so the first-run480 sidebar stays Essential plus Insights, Explorer, and Query History481 (grouped: Overview; AI Agent → Chat; Insights → Insights; Health →482 Health; Queries → Running + History; Tables → Overview + Explorer;483 Tools → SQL) — they remain restorable from the group heading dialog,484 hover +, More, in-page More / Customize, or Settings → Navigation. Do485 not add Merges / Metrics / Clusters / Explain / Advisor, or Keeper /486 PeerDB / Security / Logs / System / Operations, to the keep list.4874. Compose `ChartContainer` + `ChartCard`; reuse skeletons + empty/error states.488489## File & naming conventions490491kebab-case files; PascalCase components; `use*` camelCase hooks; props as492`interface XProps` colocated; client components declare `'use client'`, server493components don't; shared types in `src/types/` or `src/lib/api/types.ts`. Details494in `docs/knowledge/conventions.md`.495496## Keep this skill current497498This skill is the source of truth for design consistency. When you introduce or499change a durable UI pattern (a new token, a reusable component, an500empty/error/onboarding convention), UPDATE this file + `docs/knowledge/501product-design.md` in the SAME change. See the "Auto-improve project skills"502note in the root `CLAUDE.md`.