ui/docs/THEMING.md
This document is the contributor-facing guide to styling Meshery UI components.
The short version:
styled() factory, the
useTheme() hook) come from @sistent/sistent, the Meshery design system.@/theme. Import everything theme-related
from there, not from @sistent/sistent directly and not from the legacy
@/themes* modules.rgb(), rgba()) and inline style={{}}
props for static values are banned in component code. Use theme.palette.*
via styled() instead.This guide is part of the Phase 1 UI restructure tracked in the parent epic
meshery/meshery#18656. For
the full migration plan, see ../restructure-plan.md,
especially §4 ("Theme & Color Consolidation") and §8 ("Lint rules").
@/theme entry pointui/theme/index.ts is a thin wrapper around @sistent/sistent. It exists so
every consumer in the Meshery UI imports theme primitives from a single,
project-local path. The wrapper is deliberately small: re-exports plus a
handful of named accessors that read directly from theme.palette.*.
From @sistent/sistent:
useThemestyledalpha, lightenSistentThemeProvider,
SistentThemeProviderWithoutBaseLine, CssBaseline, NoSsrThemeBridged from MUI (until Sistent re-exports them) so callers can still go through the project-local front door:
darkenGlobalStyles (used for one-off escape hatches
like cross-portal z-index overrides; routed through @/theme so app code
stays off @mui/material directly)A palette object with named accessors for the palette paths the app reaches
for most often:
palette.status.error, palette.status.warning, palette.status.success,
palette.status.info — return theme.palette.<status>.main.palette.surface.page, palette.surface.elevated, palette.surface.card
— return the corresponding theme.palette.background.* token.Each accessor takes a Theme argument:
import { styled, palette } from '@/theme';
const ErrorBadge = styled('span')(({ theme }) => ({
color: palette.status.error(theme),
background: palette.surface.card(theme),
}));
Prefer @/theme as the front door. Never deep-import @/theme/index, and
avoid importing from @sistent/sistent for primitives that @/theme
already re-exports — @/theme is the project-local entry point that future
phases will extend with additional palette accessors and helpers.
// Correct
import { useTheme, styled, alpha, palette } from '@/theme';
// Incorrect — deep import of the local entry point (lint-blocked)
import { styled } from '@/theme/index';
// Discouraged — bypasses the project-local front door (convention, not
// lint-blocked today)
import { styled } from '@sistent/sistent';
Today's no-restricted-imports rule in
ui/eslint.config.js blocks the legacy
@/themes* / @/constants/colors paths, the @/theme/index deep import,
and direct @mui/* / @material-ui/* / @rjsf/mui imports. Routing
theme primitives through @/theme rather than @sistent/sistent is
project convention; a later phase may tighten the rule.
All static styling values should come from the theme:
theme.palette.* (e.g. theme.palette.text.primary,
theme.palette.primary.main, theme.palette.error.main).theme.spacing(n) where n is a multiplier of the design
system's base unit. theme.spacing(2) is preferred over '16px'.theme.breakpoints.up('md'), theme.breakpoints.down('sm'),
etc.import { styled } from '@/theme';
const StyledCard = styled('div')(({ theme }) => ({
color: theme.palette.text.primary,
background: theme.palette.background.card,
padding: theme.spacing(2),
[theme.breakpoints.up('md')]: {
padding: theme.spacing(4),
},
}));
import { styled, alpha } from '@/theme';
const HoverOverlay = styled('div')(({ theme }) => ({
background: alpha(theme.palette.primary.main, 0.08),
borderColor: theme.palette.divider,
}));
// Don't: hex literals, hard-coded pixels, no theme awareness
<div style={{ color: '#1E1E1E', padding: '16px', background: '#FFFFFF' }}>
The wrong example will:
#FFFFFF doesn't react to theme switches).no-restricted-syntax lint rule (hex literals are forbidden
outside ui/theme/).react/forbid-dom-props rule on style (see below).Hex literals (#RRGGBB, #RGB, with optional alpha) and rgb()/rgba()
function-call strings are forbidden in component code. The rule is enforced
by no-restricted-syntax in
ui/eslint.config.js.
Literal colors are only allowed in modules that define the theme or ship non-themed assets:
ui/theme/** — the theme module itself.ui/themes/** — the legacy theme module, scheduled for deletion.ui/assets/** — SVG icons encoded as React components.ui/constants/** — legacy color constants, scheduled for deletion.ui/lib/** — third-party integration helpers.ui/public/** — static assets.Everywhere else, use theme.palette.* (composed if needed with alpha or
lighten from @/theme).
errorThe rule ships in warn mode with a file-level allowlist for legacy
offenders that haven't been migrated yet (the legacyLiteralColorOffenders
list in eslint.config.js). The plan is to:
warn to error once the list is empty.When you touch a file on the allowlist, please remove it from the list as part of your change.
style propsreact/forbid-dom-props forbids the style prop in components for the
same reason: static styling belongs in styled(), not on the element.
style is reserved forTruly dynamic geometry that can't be expressed in CSS-in-JS at definition time. Examples:
// Draggable element: x/y change at 60fps, can't go through styled().
<div style={{ transform: `translate(${x}px, ${y}px)` }} />
// Resize observer driven layout.
<div style={{ width: measuredWidth }} />
// CSS variable driving an animation.
<div style={{ '--progress': progress } as React.CSSProperties} />
style is not forStatic colors, paddings, dimensions, borders, font weights. All of these
go in styled().
Before:
function StatusPill({ status, label }: Props) {
return (
<span
style={{
color: status === 'error' ? '#F91313' : '#1E1E1E',
backgroundColor: '#F5F5F5',
padding: '4px 8px',
borderRadius: '12px',
}}
>
{label}
</span>
);
}
After:
import { styled, palette } from '@/theme';
const Pill = styled('span', {
shouldForwardProp: (prop) => prop !== 'isError',
})<{ isError: boolean }>(({ theme, isError }) => ({
color: isError ? palette.status.error(theme) : theme.palette.text.primary,
backgroundColor: palette.surface.elevated(theme),
padding: theme.spacing(0.5, 1),
borderRadius: theme.shape.borderRadius,
}));
function StatusPill({ status, label }: Props) {
return <Pill isError={status === 'error'}>{label}</Pill>;
}
The palette.status.* and palette.surface.* accessors are the
project-local helpers from @/theme; they read directly from
theme.palette.* but document the supported palette paths in one place.
Like the hex-literal rule, this rule ships in warn mode with a file-level
allowlist; future phases drain the allowlist and promote it to error.
styled() vs useTheme() vs inline styleUse this decision rule when writing or refactoring a component:
styled() — the default for new components and for any component
whose style does not depend on a value computed at render time. Static,
theme-aware, and the resulting components are reusable.useTheme() — reach for this only when you need to read a theme
value at render time and there's no way to express the dependency through
styled() props. This is rare; if you find yourself here, double-check
whether a prop on a styled() component would do the job.style={{}} — only for truly dynamic geometry (the transform,
width, CSS-variable cases listed above). Never for colors or static
spacing.In practice, 90%+ of components in this codebase should be styled().
A migration table for the legacy modules that the lint rules and the restructure plan call out for deletion:
| Legacy import | Replace with |
|---|---|
import { Colors } from '@/themes/app' | theme.palette.* (e.g. theme.palette.error.main) |
import { notificationColors, darkNotificationColors } from '@/themes/app' | theme.palette.* (the theme handles the dark variant) |
import { NOTIFICATIONCOLORS } from '@/themes' | theme.palette.* |
import { PRIMARY_COLOR } from '@/constants/colors' | theme.palette.primary.main |
import { lightenOrDarkenColor } from '@/utils/lightenOrDarkenColor' | import { lighten, darken } from '@/theme'. The legacy helper accepts percent (-100..100); the MUI utilities take a coefficient (0..1). Convert by dividing by 100 and using darken for negative percents, lighten for positive. |
import { styled } from '@/theme/index' | import { styled } from '@/theme' |
import { ... } from '@mui/material' | import { ... } from '@sistent/sistent' |
For the underlying ESLint configuration that enforces these mappings, see
the no-restricted-imports block in
ui/eslint.config.js.
Once a file is migrated off a legacy import, it should also be removed from
the legacyRestrictedImportOffenders allowlist in the same file.
If a color, spacing scale, typography setting, or other token you need is missing from Sistent's palette, open an upstream PR to Sistent rather than overriding locally.
Local overrides are how the sprawl regrew last time. The whole point of consolidating on Sistent is to make the design system the single source of truth; that only works if missing tokens go back upstream.
Examples of "open it upstream":
Examples of "no upstream PR needed":
transform value driven by component state.When in doubt, ask in #meshery-ui whether the token is reusable enough to
belong upstream.
ui/themes/rjsf.ts currently uses Material UI's createTheme to configure
the react-jsonschema-form
adapter. A later phase (see §4.4 of ../restructure-plan.md)
migrates it to a Sistent-backed theme via extendSistentTheme.
Until then:
@rjsf/mui remains as a transitive dependency only. The shared
RJSFProvider wrapper is the only place in the codebase that's allowed
to import from @rjsf/mui directly — the lint rule
(no-restricted-imports) blocks the import everywhere else.@rjsf/mui and not
ui/themes/rjsf.ts directly.../restructure-plan.md — the full Phase 1 plan.
Sections §4 ("Theme & Color Consolidation") and §8 ("Lint rules") are the
most relevant to this document../ARCHITECTURE.md — companion architecture doc
describing target file layout, component conventions, and the broader
Sistent migration. Created alongside this doc in Phase 1.ui/eslint.config.js
(no-restricted-imports, no-restricted-syntax, and the
react/forbid-dom-props configuration).