Note: Pre-commit hooks handle formatting automatically. Focus on implementation patterns.
- Avoid
any- use proper typing - Avoid
ascasts, usesatisfiesor type inference whenever possible - Use Zod schemas for runtime validation
- Define clear interfaces for data structures
- Implement proper error boundaries
- Define and import reusable named types instead of repeating verbose types
- Single Responsibility: One clear purpose per component/function
- File Size: Max 300 lines - refactor when approaching limit
- DRY: Grep for an existing implementation before adding a type, schema, helper, or component - see "Before you add a type, schema, or helper" below (REQUIRED)
- In-Context Learning: Explore similar files before implementing
Grep for an existing implementation before you define a new type, Zod schema, helper function, or React component. Search for the operation — splitting a list, bucketing a timestamp, validating a URL, formatting a duration — not for the name you were about to give it. Names differ; operations don't.
# Two or three words for the operation, as alternates. Runs in ~20ms.
grep -rniE "export (const|function|type|interface|enum) [a-zA-Z]*(bucket|interval|granular)" \
packages/common-utils/src packages/app/src packages/api/src \
--include='*.ts' --include='*.tsx'Search order: the file you are editing, then packages/common-utils/src/, then
the rest of your package. common-utils has no root barrel — no
src/index.ts, and its package.json declares no entry point, which is why
every import is a deep @hyperdx/common-utils/dist/... path. src/types.ts
alone is ~2,850 lines and 280+ exports. You cannot discover what exists by
reading an index; grep is the index.
Nine modules carry ~90% of all cross-package imports:
| Import path | Holds |
|---|---|
.../dist/types |
Every shared Zod schema and its inferred type: sources, alerts, dashboards, tiles, webhooks, chart configs, SQLInterval, MetricsDataType, StacktraceFrame |
.../dist/core/utils |
Time-bucket and granularity math (toStartOfInterval, timeBucketByGranularity, convertGranularityToSeconds), string splitting (splitAndTrimCSV, splitAndTrimWithBracket, escapeSqlString), hashCode, parseJSON, formatDate |
.../dist/core/metadata |
ClickHouse table/column introspection — Metadata, getMetadata, parseKeyPath, unquoteIdentifier |
.../dist/clickhouse (/node, /browser) |
Query client, ChSql/chSql/concatChSql, CH↔JS type conversion |
.../dist/core/renderChartConfig |
ChartConfig → SQL |
.../dist/filters |
Filter state, serialization, and validation (FilterState, filtersToQuery, parseQuery) |
.../dist/guards |
Chart-config and source type guards (isBuilderChartConfig, isRawSqlChartConfig, isHeatmapCompatibleSource) |
.../dist/validation |
isValidUrl, isValidSlackUrl, password validators |
.../dist/variables |
Dashboard variable resolution and template expansion |
Package-local homes, searched second: packages/app/src/utils.ts,
packages/app/src/types.ts, packages/app/src/ChartUtils.tsx,
packages/api/src/utils/.
| Situation | Do this |
|---|---|
| Canonical version exists and fits | Import it. Delete the copy you were writing. |
| Exists, but you want a local name | Alias-shim it — packages/app/src/types.ts:9, export type NumberFormat = _NumberFormat;. Never retype the definition. |
| Exists, but the shape is almost right | Derive from it (Pick, Omit, .extend(), Alert & { … }) or parameterize the canonical one. One source of truth. |
| Shared logic between two components | Factor it into a sibling module — packages/app/src/components/sourceSelectUtils.tsx:51 (useFilteredSortedSourceItems, shared by SourceSelect and SourceMultiSelect). |
| Two genuinely must coexist | Doc-comment why, and name the twin. packages/common-utils/src/types.ts:571-582 does this for the Mongoose IWebhook (packages/api/src/models/webhook.ts:17) vs the JSON-serialized WebhookSchema. |
Not acceptable: a second definition with no alias, no doc comment explaining the twin, and no test pinning the two together.
When an entire file has to be copied across packages — packages/cli does this
for the components listed under "Web Frontend Alignment" in
packages/cli/AGENTS.md — head it with an
@source tag naming the origin, one tag per origin file:
/**
* Source helper functions.
*
* @source packages/app/src/source.ts
*/scripts/ci/ratchet.mjs counts these so the total stays visible in CI. That
count is advisory, not a gate: annotating a port never fails the build, and
stripping the tag to go green hides the copy instead of removing it. Lower it by
moving the shared code into common-utils, then run yarn ratchet:update.
- Functional components with hooks (not class components)
- Write small, focused components
- Extract reusable logic into custom hooks
- Define TypeScript interfaces for props
- Use proper keys for lists, memoization for expensive computations
The project uses Mantine UI with custom variants defined in packages/app/src/theme/mantineTheme.ts.
- Prefer Mantine components over custom-styled elements
- Prefer individual Mantine style props (eg.
m='xs') over raw styles (eg.style={{ margin: '4px' }})
ONLY use these variants for Button and ActionIcon components:
| Variant | Use Case | Example |
|---|---|---|
variant="primary" |
Primary actions (Submit, Save, Create, Run) | <Button variant="primary">Save</Button> |
variant="secondary" |
Secondary actions (Cancel, Clear, auxiliary actions) | <Button variant="secondary">Cancel</Button> |
variant="danger" |
Destructive actions (Delete, Remove, Rotate API Key) | <Button variant="danger">Delete</Button> |
variant="link" |
Link-style actions with no background or border (View Details, navigation-style CTAs) | <Button variant="link">View Details</Button> |
variant="subtle" |
Transparent background with hover highlight; for toolbar/utility controls that shouldn't draw attention until hovered (collapse toggles, close buttons, auxiliary actions) | <Button variant="subtle">Filter</Button> |
<Button variant="primary">Save</Button>
<Button variant="secondary">Cancel</Button>
<Button variant="danger">Delete</Button>
<Button variant="subtle">Filter</Button>
<Button variant="link">View Details</Button>
<ActionIcon variant="primary">...</ActionIcon>
<ActionIcon variant="secondary">...</ActionIcon>
<ActionIcon variant="danger">...</ActionIcon>
<ActionIcon variant="link">...</ActionIcon>
<ActionIcon variant="subtle">...</ActionIcon><Button variant="light" color="green">Save</Button>
<Button variant="light" color="gray">Cancel</Button>
<Button variant="light" color="red">Delete</Button>
<Button variant="outline" color="green">Save</Button>
<Button variant="outline" color="gray">Cancel</Button>
<Button variant="outline" color="red">Delete</Button>
<Button variant="filled" color="gray">Cancel</Button>
<Button variant="default">Cancel</Button>
<ActionIcon variant="light" color="red">...</ActionIcon>
<ActionIcon variant="filled" color="gray">...</ActionIcon>Link variant details: Renders with no background, no border, and muted text color. On hover, text brightens to full contrast. Use for link-style CTAs that should blend into surrounding content (e.g., "View Details", "View Full Trace").
Subtle variant details: Transparent background with standard text color. On hover, a subtle background highlight appears (--color-bg-hover). This is the default ActionIcon variant. Use for toolbar icons, collapse toggles, close buttons, and utility controls that should stay unobtrusive but reveal interactivity on hover. Unlike link, subtle shows a hover background rather than changing text color.
Note: variant="filled" is still valid for form inputs (Select, TextInput, etc.), just not for Button/ActionIcon.
If a Button only contains an icon (no text), use ActionIcon instead:
// ❌ WRONG - Button with only an icon
<Button variant="secondary" px="xs">
<IconRefresh size={18} />
</Button>
// ✅ CORRECT - Use ActionIcon for icon-only buttons
<ActionIcon variant="secondary" size="input-sm">
<IconRefresh size={18} />
</ActionIcon>This pattern cannot be enforced by ESLint and requires manual code review.
We ship themed semantic variants for Alert, Text, Button, and ActionIcon so callouts and status text are token-driven and consistent across the HyperDX and ClickStack brands (and light/dark). Prefer these over raw Mantine palette colors (color="yellow", color="red", c="green", etc.).
The variant → token mapping is centralized in packages/app/src/theme/themes/semanticVariants.ts (the single source of truth, consumed by both brand themes' mantineTheme.ts). See the Storybook stories Components/Alert (interactive Playground) and Design tokens/Semantic variants for the full visual matrix.
Alert — info | success | warning | danger. Renders a tinted -subtle background with the title, icon, and body text in the semantic color token:
// ✅ token-driven, works in both brands + light/dark, meets WCAG AA
<Alert variant="warning" title="Heads up">This may take a while.</Alert>
<Alert variant="danger" title="Failed">Could not save the alert.</Alert>
// ❌ hardcoded Mantine palette — not theme-aware, inconsistent contrast
<Alert color="yellow" title="Heads up">...</Alert>
<Alert color="red" title="Failed">...</Alert>Text — danger | warning | success for inline status/validation text:
<Text variant="danger">Required field</Text>
<Text variant="success">Connection verified</Text>
// ❌ don't reach for raw palette colors for semantic status text
<Text c="red.5">Required field</Text>Button / ActionIcon variant="danger" is a soft control: a tinted --color-bg-danger-subtle background (with hover) and semantic foreground, not a solid red fill. warning/success are intentionally not exposed as control variants — use them on Text and Alert only.
Note: Existing <Alert color="..."> call sites are untouched; the semantic variants are opt-in. Prefer the variant for any new callout, and migrate nearby color="..." alerts when you touch them.
Use useConfirm (@/useConfirm) for any "are you sure?" step. Do not
hand-roll a <Modal> with Cancel/Confirm buttons. The provider is already
mounted app-wide in pages/_app.tsx, so there is no setup at the call site.
const confirm = useConfirm();
const handleDelete = async () => {
if (
await confirm(
<>
Deleting {name} is <b>not reversible</b>.
</>,
'Delete',
{ variant: 'danger' },
)
) {
await deleteThing.mutateAsync({ id });
}
};- The message is a
ReactNode, so it can carry emphasis and multiple sentences. - Pass
{ variant: 'danger' }for destructive actions; the confirm label defaults toConfirm. - It resolves exactly once, so a double click on Confirm during the modal's exit transition cannot fire the action twice. A hand-rolled modal has to guard that itself.
- Test ids are shared and already exist:
confirm-modal,confirm-confirm-button,confirm-cancel-button. Do not invent per-flow confirm/cancel test ids — E2E page objects key off the shared ones.
Known limits. It passes no title to the Modal and renders the body at
size="sm" opacity={0.7}, and CSS opacity applies to the whole subtree so a
nested <Text> cannot opt back out. If a flow genuinely needs a heading or
full-contrast body, extend useConfirm (an optional prop, applied to all
call sites) rather than forking a one-off modal.
In component tests, mock it — ConfirmProvider pulls in next/router,
which is not available in jsdom:
jest.mock('@/useConfirm', () => ({ useConfirm: jest.fn() }));Assert on the arguments (and render the message ReactNode if you need to check
the copy). Exercise the real dialog in E2E instead.
Use EmptyState (@/components/EmptyState) for all empty/no-data states. Do not create ad-hoc inline empty states.
| Prop | Type | Default | Description |
|---|---|---|---|
icon |
ReactNode |
— | Icon in the theme circle (hidden if not provided) |
title |
string |
— | Heading text (headline style — no trailing period) |
description |
ReactNode |
— | Subtext below the title |
children |
ReactNode |
— | Actions (buttons, links) below description |
variant |
"default" | "card" |
"default" |
"card" wraps in a bordered Paper |
// ❌ BAD - ad-hoc inline empty states
<div className="text-center my-4 fs-8">No data</div>
<Text ta="center" c="dimmed">Nothing here</Text>
// ✅ GOOD - use the EmptyState component
<EmptyState
icon={<IconBell size={32} />}
title="No alerts created yet"
description="Create alerts from dashboard charts or saved searches."
variant="card"
/>Title copy: Treat title as a short headline (like Title in the UI). Do not end it with a period. Use description for full sentences, which should use normal punctuation including a trailing period when appropriate. Match listing pages (e.g. dashboards and saved searches use parallel phrasing such as “No matching … yet” / “No … yet” without dots).
Do not render raw <pre>, ad-hoc Paper + monospace text, or unstyled <code>. Use the existing Mantine/product components so snippets match Terraform export, onboarding, and Storybook guidelines.
| Kind | Component | When |
|---|---|---|
| Inline code | Mantine <Code> |
Short tokens in prose (Session, a column name, a flag). |
| Fenced / multi-line / copyable | CopySnippet (@/components/ClickStackOnboarding/CopySnippet) |
Install commands, HCL, JSX examples, any block the user might copy. Wraps <Code block> plus a Copy button. |
// ✅ inline — Mantine Code
Create a source with <Code>Session</Code> type.
// ✅ fenced / copyable — CopySnippet (Code + Copy)
<CopySnippet
label="Import block"
snippet={`import { clickstack_dashboard } from "clickhouse/clickstack"`}
/>
// Label is optional when the surrounding heading already names the snippet
<CopySnippet snippet={USAGE} />
// ❌ BAD — raw pre / Paper chrome
<pre>{snippet}</pre>
<Paper bg="var(--color-bg-code)"><Text component="pre" ff="monospace">{snippet}</Text></Paper>SQL query previews (rendered ClickHouse SQL with highlighting) still use SQLPreview / ChartSQLPreview — those are editors, not snippet chrome.
Storybook: Components/Code (live InlineCode, FencedBlocks, and Usage). Guidelines markdown maps fenced blocks → CopySnippet and inline `code` → <Code> automatically. Follow the same split in app UI.
Use ChartCard (@/components/charts/ChartCard) to wrap a chart in a card.
It is a bordered surface with the same header treatment as a custom dashboard
tile (a full-bleed divider under the title). It replaces the older ChartBox;
don't hand-roll a bordered <div>/<Paper> around a chart.
ChartCard renders the card chrome only. The header divider is drawn only
when a descendant renders a ChartContainer with a title (or toolbarItems) —
ChartCard supplies the ChartContainerCardHeaderProvider that switches that
header into card mode — so put a chart that renders a ChartContainer inside it
(DBTimeChart, DBTableChart, DBHeatmapChart, DBListBarChart, …). Content
with its own heading (e.g. a bespoke table card) should still route that heading
through a titled ChartContainer rather than a bare Text, so it gets the same
card header — divider and top padding included — instead of sitting flush against
the top border. The tile-level controls (fullscreen, line/bar display switcher,
kebab menu) belong to dashboard tiles and are intentionally not part of
ChartCard.
| Prop | Type | Description |
|---|---|---|
children |
ReactNode |
The chart, usually a DB*Chart (or a titled ChartContainer) |
style |
CSSProperties |
Sizing/overflow override — pass a fixed height, or flex: 1; height: 100% to fill a flex row (paddingInline is pinned to keep the divider aligned) |
data-testid |
string |
Test hook |
// ✅ GOOD — shared card chrome, consistent with dashboard tiles
<ChartCard style={{ height: 350 }}>
<DBTimeChart title="Request Latency" config={config} />
</ChartCard>
// ❌ BAD — hand-rolled card that drifts from the dashboard look
<Box style={{ border: '1px solid var(--color-border)', borderRadius: 4 }}>
<DBTimeChart title="Request Latency" config={config} />
</Box>Give it a height. ChartCard is width: 100% and fills its parent, so the
parent (or a style={{ height }}) must define the height. For equal-width
side-by-side charts (e.g. the RED row) use
style={{ flex: 1, minWidth: 0, minHeight: 0, height: '100%' }} inside a
Flex. See the Charts/ChartCard Storybook stories for the variants.
All user-facing text uses sentence case — capitalize only the first word and any proper nouns/acronyms. Do not use Title Case (capitalizing every significant word).
This applies to every string a user reads: field labels, buttons, tab/menu items, headings, section titles, modal titles, placeholders, tooltips, table column headers, empty states, and toast/notification copy.
| Title Case (avoid) | Sentence case (use) |
|---|---|
Data Source |
Data source |
Chart Name |
Chart name |
Add Series |
Add series |
Count of Events |
Count of events |
Save Changes |
Save changes |
Delete Dashboard |
Delete dashboard |
Keep the original casing of proper nouns, product names, and acronyms
anywhere in the string — sentence case only changes the words around them:
HyperDX, ClickHouse, ClickStack, OpenTelemetry/OTel, Lucene, SQL, PromQL,
MongoDB, Kubernetes, JSON, CSV, URL, ID, API, MCP, CPU, P95. For example:
Search your events w/ Lucene, Edit SQL, Copy as cURL, View in ClickHouse.
Only sentence-case static UI chrome. Never rewrite dynamic/user data (column names, tag values, log/trace content, source or dashboard names a user typed) — render those verbatim.
The UI is built with Mantine components, but colors and surfaces should follow the semantic CSS custom properties in our themes (--color-*, etc.), not ad-hoc Mantine palette values. Those tokens are defined in packages/app/src/theme/themes/**/_tokens.scss, align with a Click UI–style system, and keep HyperDX and ClickStack visually consistent. They are the path toward a shared design system even while Mantine remains the component layer.
- Do: Use Mantine for layout, components, and spacing; use semantic tokens for themed backgrounds, text colors, borders, and states (e.g.
style={{ color: 'var(--color-text-muted)' }}orstyle={{ border: '1px solid var(--color-border)' }}). - Do not: Rely on raw Mantine color props for app chrome and content when a semantic token exists — e.g.
c="gray.5",bg="dark.7", or arbitrarycolor="blue.4"for surfaces that should match the rest of the product. - Reference:
packages/app/src/theme/semanticColorsGrouped.ts(token names), theme SCSS underpackages/app/src/theme/themes/, and Storybook (SemanticColorsand related theme stories) for a visual map.
Mantine theme overrides in packages/app/src/theme/** may map Mantine’s scale to our palette; that does not replace using var(--color-...) in new styling where you need explicit color control.
Chart and visualization colors are a separate, more specific contract — they have their own categorical, semantic, and heatmap palettes wired through packages/app/src/utils.ts helpers. Don't hard-code series colors or reuse --color-text-* for charts. See data_viz_colors.md before touching anything that renders data.
- Edit files directly - don't create
component-v2.tsxcopies - Look for duplicate code across the affected area
- Verify all callers and integrations after changes
- Refactor to improve clarity or reduce complexity, not just to change
- Clear, descriptive names following package conventions
- Avoid "temp", "refactored", "improved" in permanent filenames
- Put related components in a single directory