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).
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.
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: is with (or a stable ).
Header and footer are . The body is
— that element is the scroll container. Do not use with
for this: its viewport is 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 's default
to when the dialog is . Same list-scroll
pattern without a footer: .
See . For What's new, also set
on the title () and reset the body
on open — default focus would otherwise land on the first
markdown link deep in an older note and the list to the
middle. Screenshot thumbs () open a full-size
overlay in the same dialog; Escape closes the overlay before the dialog.
Do not add on — / drops the
primitive's , so no longer centers
in the viewport. already contains an inner overlay.
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.
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).
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 + .
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 is active).
Timezone uses
(search + browser zone on top). Palette is a card picker with mini bars, not
a segmented control. Unit options show a sample value ( / ).
Integrations: MCP live; Slack/Telegram/PagerDuty/Email/Discord shown disabled.
First-run workspace is Custom + the Essential hide list
(: 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 (, …). Applied by
(): units →
module snapshot in ; palette/density →
/ on . For 2–3 choices use ; 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:
.
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.
DialogContent
flex flex-col overflow-hidden p-0
max-h-[min(36rem,85vh)]
h-[…]
shrink-0
min-h-0 flex-1 overflow-y-auto
ScrollArea
flex-1
size-full
DialogFooter
-mx-4 -mb-4
mx-0 mb-0
p-0
components/agents/advisor-query-picker.tsx
components/whats-new/whats-new-dialog.tsx
initialFocus
tabIndex={-1}
scrollTop
scrollIntoView
WhatsNewScreenshotGallery
relative
DialogContent
cn()
twMerge
fixed
top-1/2 left-1/2 -translate-*
fixed
absolute
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-contentpre/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.permissionsonchange + 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.
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.