Apply the Lightdash frontend style guide when working on React components, migrating Mantine v6 to v8, or styling frontend code. Use when editing TSX files, fixing styling issues, or when user mentions Mantine, styling, or CSS modules.
Apply these rules when working on any frontend component in packages/frontend/.
The app runs on Mantine 8 (@mantine/core), with the theme in src/theme/.
The theme is neutral and quiet, in the spirit of shadcn, Radix and Kumo: one ink accent, flat surfaces, soft borders, a tight type scale. Most of what "looks right" comes from using the defaults. Check each rule before you add a colour, a shadow or a size.
blue, dark, indigo or ldDark for emphasis. Colour on a control means state: red destructive, yellow warning, teal "copied", orange favourite, green only for the existing verify/merge actions. Indigo belongs to AI surfaces. Links are the only blue text.filled is the one primary action per card, header or modal footer. default (bordered) is secondary. light is a tertiary or toggle-like action ("Add filter"). subtle is for icon buttons and inline actions; it is the ActionIcon default. If a surface has two filled buttons, one of them is wrong.md, Modal lg). Do not pass withBorder, shadow or radius to restate that. Empty or placeholder sections use <Paper variant="dotted">.--mantine-color-body (surface), --ld-color-page (canvas), default-border, default-hover, text, dimmed, placeholder. ldGray.N already resolves per scheme (0 canvas, 1 muted fill, 2 border, 3 strong border, 5 tertiary text, 6 = dimmed, 7 label, 9 text), so light-dark(ldGray-x, ldDark-y) and @mixin dark blocks for neutrals are always a smell. Secondary text is c="dimmed".Title orders 1 to 6 (28 to 14px); labels and table headers are 500; everything else 400. Use fz="xs|sm|md", never fz={13}. Card and section titles are Title order={5}; page tops use PageHeader.md, group gaps xs/sm, section gaps lg, page gutters lg/xl. No pixel margins; if a layout needs margin-top: 20px it is mt="lg".MantineIcon at stroke 1.5, 16px next to text and 14px in xs controls, coloured dimmed when they are secondary. Every icon-only button has an aria-label and a Tooltip.size="xs" controls carry compact secondary labels automatically. Selects mark the selected option with a check; filter value pickers are the standard combobox, not a custom list.useMantineColorScheme to pick a colour; use a token. Editors take useEditorTheme(); only JS consumers with no CSS (ECharts, Leaflet) read useComputedColorScheme.EmptyStateLoader for loading, InlineErrorState for a failed section, SuboptimalState for a failed page, <Paper variant="dotted"> for "nothing here".ld-* utility class, or a shared control (CopyActionIcon, FavoriteActionIcon, ConfirmDeleteButton, TruncatedText, FilterFacet, NumberInput, MantineModal).Self-review before you hand a screen over: open it in light and dark; count filled buttons per surface (max one); look for blue that is not a link; look for a shadow on a card; look for a font size or grey that is not a token; look for an icon button without a tooltip. The Explorer page is the reference surface when in doubt.
When creating/updating components:
@mantine/core importsstyle or styles props--mantine-color-${color}-text: for text on filled background--mantine-color-${color}-filled: for filled background (strong color)--mantine-color-${color}-filled-hover: for filled background on hover--mantine-color-${color}-light: for light background--mantine-color-${color}-light-hover: for light background on hover (light color)--mantine-color-${color}-light-color: for text on light background--mantine-color-${color}-outline: for outlines--mantine-color-${color}-outline-hover: for outlines on hoverThe goal is to use theme defaults whenever possible. Style overrides should be the exception, not the rule.
src/theme/components/<Component>.module.css (registered in src/theme/components/index.ts)mt="xl" w={240})ld-shrink-0, ld-grow, ld-self-center, ld-pointer, ld-nowrap, ld-pre-wrap, ld-overflow-hidden, ld-scroll-y in src/styles/global.css)styles prop (always use CSS modules instead)style prop (inline styles)If you find yourself applying the same style override multiple times, put it in the theme. Each component has a CSS module in src/theme/components/ and an entry in src/theme/components/index.ts:
/* src/theme/components/Badge.module.css */
.root[data-variant='light'] {
text-transform: none;
font-weight: 500;
}
// src/theme/components/index.ts
Badge: Badge.extend({
defaultProps: { variant: 'light', color: 'gray' },
classNames: badgeClasses,
}),
Reach for the vars callback only when Mantine writes the value inline (button and badge colours, NavLink fill, input font size), because CSS cannot override an inline custom property.
// ✅ Good
<Button mt="xl" w={240} c="blue.6">Submit</Button>
// ❌ Bad - Too many props, use CSS modules instead
<Button mt={20} mb={20} ml={10} mr={10} w={240} c="blue.6" bg="white">Submit</Button>
Common inline-style props:
mt, mb, ml, mr, m, p, pt, pb, pl, prw, h, maw, mah, miw, mihc (color), bg (background)ff, fs, fwta, lhCreate a .module.css file in the same folder as the component:
/* Component.module.css */
.customCard {
transition: transform 0.2s ease;
cursor: pointer;
}
.customCard:hover {
transform: translateY(-2px);
box-shadow: var(--mantine-shadow-lg);
}
import styles from './Component.module.css';
<Card className={styles.customCard}>{/* content */}</Card>;
Do NOT include .css.d.ts files - Vite handles this automatically.
Prefer default component colors. Buttons, ActionIcons and Badges get the right neutral or ink from the theme; a color prop on a control should only ever name a state (see Design principles).
// ❌ Bad - restates the theme, and is a near-black button in dark mode
<Button color="dark">Apply</Button>
<ActionIcon color="ldGray.6" variant="subtle" />
// ✅ Good - the theme already renders these
<Button>Apply</Button>
<ActionIcon />
// ❌ Bad - hand-picked grey
<Text c="ldGray.6">Secondary text</Text>
// ✅ Good - semantic token
<Text c="dimmed">Secondary text</Text>
| Token | Purpose |
| ----- | ------- |
| --mantine-color-body | Surface (cards, inputs, menus) |
| --ld-color-page | Page canvas behind surfaces |
| --mantine-color-default-border / default-hover | Borders and hover fills of neutral controls |
| --mantine-color-text / dimmed / placeholder | Primary, secondary and tertiary text |
| ldGray.0-9 | Same role in both schemes: 0 canvas, 1 muted fill, 2 border, 3 strong border, 4 faint icon, 5 tertiary text, 6 dimmed, 7 label, 9 text |
Neutrals need no dark-mode branch: the tokens and ldGray.N already resolve per scheme.
/* ❌ Bad */
.row:hover {
background-color: var(--mantine-color-ldGray-0);
@mixin dark {
background-color: var(--mantine-color-ldDark-5);
}
}
/* ✅ Good */
.row:hover {
background-color: var(--mantine-color-default-hover);
}
Use light-dark() only for a non-neutral pair that has no token, such as an accent tint:
.highlight {
background-color: light-dark(
var(--mantine-color-indigo-0),
var(--mantine-color-indigo-9)
);
}
// ❌ Bad - Magic numbers
<Box p={16} mt={24}>
// ✅ Good - Theme tokens
<Box p="md" mt="lg">
Before moving styles to CSS modules, check if they're actually needed:
// ❌ Unnecessary - display: block has no effect on flex children
<Flex justify="flex-end">
<Button style={{display: 'block'}}>Submit</Button>
</Flex>
// ✅ Better - Remove the style entirely
<Flex justify="flex-end">
<Button>Submit</Button>
</Flex>
Cross-cutting layout constants (navbar/header/banner/footer heights, page content widths, sidebar dimensions, dashboard header/tab heights and z-indexes) are exposed as global CSS variables so CSS modules can use them directly:
/* ✅ Reference the global var — resolves on :root everywhere */
.myPanel {
top: var(--dashboard-header-height);
max-width: var(--page-content-max-width-large);
}
/* ❌ Don't hardcode the literal — drifts from the source of truth */
.myPanel {
top: 50px;
}
// ❌ Don't bridge a constant into CSS via an inline style object
<div style={{ '--dashboard-header-height': `${DASHBOARD_HEADER_HEIGHT}px` }}>
Source of truth: the numeric values live in their */constants.ts files
(e.g. components/common/Page/constants.ts,
components/common/Dashboard/dashboard.constants.ts) and are registered as CSS
variables in src/theme/cssVariablesResolver.ts (wired into the Mantine provider
via Mantine's cssVariablesResolver). Read that file for the full list of available
var(--...) names before defining your own.
To add a new shared layout constant: add the number to the relevant
constants.ts, register it in src/theme/cssVariablesResolver.ts, then reference
var(--your-name) in CSS. Don't re-declare the literal in a .module.css file and
don't pass it through an inline style. Keep using the numeric constant directly in
TS where you need it as a JS value (e.g. a Mantine h= prop).
Do not read the colour scheme to pick a colour; pass a token and let CSS resolve it. The two legitimate readers are code editors and chart libraries that consume plain JS values:
// Monaco / Ace theme names
const { monaco, ace } = useEditorTheme();
// ECharts, Leaflet and other non-CSS consumers
const isDark = useComputedColorScheme('light') === 'dark';
import { clsx } from '@mantine/core';
const MyComponent = () => {
return (
<div className={clsx('my-class', 'my-other-class')}>My Component</div>
);
};
<Select
label="Your favorite library"
placeholder="Pick value"
data={[
{ group: 'Frontend', items: ['React', 'Angular'] },
{ group: 'Backend', items: ['Express', 'Django'] },
]}
/>
MantineModal from components/common/MantineModal - never use Mantine's Modal directlystories/Modal.stories.tsx for usage examplesid on the form and form="form-id" on the submit buttonCallout with variants danger, warning, infoCopyActionIcon from components/common/CopyActionIcon (value, optional copyLabel/copiedLabel/icon/tooltipPosition, plus ActionIcon props). Never hand-roll CopyButton + ActionIcon + icon swap.FavoriteActionIcon from components/common/FavoriteActionIcon (isFavorite, onToggle, optional name for the label).ConfirmDeleteButton from components/common/ConfirmDeleteButton (onConfirm, aria-label, optional tooltip); arms on first click, fires on the second, disarms on blur or timeout.FilterFacet from components/common/FilterFacet.Callout from components/common/Calloutdanger, warning, info<Paper variant="dotted"> (also Card) renders a dashed border with a transparent background — the house style for empty, placeholder, or unavailable sections. Defined in src/theme/components/Paper.module.css; used by e.g. FavoritesPanel and AiAgentKnowledgeFilesSection.InlineErrorState from components/common/InlineErrorState — a dotted Paper with a muted message and optional onRetry button. Keep it quiet; a failing secondary panel shouldn't shout.—) in ldGray.5 inside a dotted container rather than fake zeros or endless skeletons. Skeletons mean "loading", dotted means "nothing here".ErrorState / SuboptimalState for whole-page failures.Use these when you need a layout container that is also clickable — avoids the native <button> background/border reset problem.
PolymorphicGroupButton from components/common/PolymorphicGroupButton — a Group (flex row) that is polymorphic and sets cursor: pointer. Use for horizontal groups of elements that act as a single button.PolymorphicPaperButton from components/common/PolymorphicPaperButton — a Paper (card surface) that is polymorphic and sets cursor: pointer. Use for card-like clickable surfaces.Both accept all props of their base component (GroupProps / PaperProps) plus a component prop for the underlying element.
// ✅ Clickable row without native button style bleed
<PolymorphicGroupButton component="div" gap="sm" onClick={handleClick}>
<MantineIcon icon={IconFolder} />
<Text>Label</Text>
</PolymorphicGroupButton>
// ✅ Clickable card surface
<PolymorphicPaperButton component="div" p="md" onClick={handleClick}>
Card content
</PolymorphicPaperButton>
// ❌ Avoid - native <button> brings unwanted background/border in menus and panels
<UnstyledButton>
<Group>...</Group>
</UnstyledButton>
NumberInput from components/common/NumberInput — never Mantine's NumberInput directlynumber | string (empty field, half-typed values like -/12., unsafe-large integers). The wrapper's onNumberChange shields you: it fires with a number, or undefined when the field is cleared — transient strings never firedecimalScale={0} (most fields are ports, counts, timeouts). Decimal fields opt in with decimalScale={2} etc., or decimalScale="unlimited" to remove the caponChange prop remains available only for form.getInputProps() spreads, where the form library owns parsing// ✅ Good - cleared field maps to a domain decision at the call site
<NumberInput onNumberChange={(v) => setLimit(v ?? DEFAULT)} />
// ✅ Good - number-or-undefined sinks take the callback directly
<NumberInput decimalScale={2} onNumberChange={setThreshold} />
// ✅ OK - form spread owns value/onChange
<NumberInput {...form.getInputProps('warehouse.port')} />
// ❌ Avoid - hand-rolled typeof guards on the raw Mantine component
<MantineNumberInput onChange={(v) => { if (typeof v === 'number') setX(v); }} />
EmptyStateLoader from components/common/EmptyStateLoader for any centered loading state: page-level guards, panels, tables, empty containersSuboptimalState (Mantine v8) — renders a spinner with an optional title, fully centered in its parentTruncatedText from components/common/TruncatedText whenever text may overflow a constrained widthmaxWidth (number or string) to control the truncation boundaryfz="sm"; override via standard Text props// ✅ Good - truncates long names, tooltip only appears when needed
<TruncatedText maxWidth={200}>{item.name}</TruncatedText>
// ✅ Accepts any Text prop
<TruncatedText maxWidth="100%" fw={500}>{space.name}</TruncatedText>
Use the ContentTable component from components/common/ContentTable for tables with search, pagination, and sorting.
If you need filters, use FilterFacet
List of all components and links to their documentation in LLM-friendly format: https://mantine.dev/llms.txt
npx skills add lightdash/frontend-style-guide下载完整 Skill 目录,包含 SKILL.md 及所有相关文件
Best practices for using the oracle CLI (prompt + file bundling, engines, sessions, and file attachment patterns).
ElevenLabs text-to-speech with mac-style say UX.
Notion API for creating and managing pages, databases, and blocks.
Delegate coding tasks to Codex, Claude Code, or Pi agents via background process. Use when: (1) building/creating new features or apps, (2) reviewing PRs (spawn in temp dir), (3) refactoring large codebases, (4) iterative coding that needs file exploration. NOT for: simple one-liner fixes (just edit), reading code (use read tool), thread-bound ACP harness requests in chat (for example spawn/run Codex or Claude Code in a Discord thread; use sessions_spawn with runtime:"acp"), or any work in ~/clawd workspace (never spawn agents here). Claude Code: use --print --permission-mode bypassPermissions (no PTY). Codex/Pi/OpenCode: pty:true required.
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Transcribe audio via OpenAI Audio Transcriptions API (Whisper).
Category:developer