geoview

Styling & CSS Best Practices

This document collects GeoView-specific conventions for styling, CSS, and theming. See best-practices.md for general coding conventions.

Contents

1- Organizing component styles

Keep non-trivial component styles in a companion *-style.ts module. This separates the component’s rendering and interaction logic from its visual presentation and makes theme-dependent styles reusable.

Export getSxClasses(theme): SxStyles from the style module, with JSDoc describing the theme parameter and returned styles. Import SxStyles from @/ui/style/types. In the component, use:

const memoSxClasses = useMemo((): SxStyles => getSxClasses(theme), [theme]);

This standardized style memo needs neither JSDoc nor trace logging. Combine conditional styles with the MUI array syntax, for example [memoSxClasses.base, condition && memoSxClasses.extra] as SxProps.

2- Theme tokens and external-theme safety

Use geoViewColor and geoViewFontSize tokens rather than hard-coded colors and font sizes. Spacing and border radius have their own conventions in the sections below.

Reusable @/ui and core/components/ui components may receive a standard external MUI theme, which does not contain GeoView’s custom extensions. Access those extensions with optional chaining, for example theme.palette.geoViewColor?.primary.main. Apply the same protection to other non-standard extensions rather than assuming every caller supplies a GeoView theme.

3- UI wrappers and translated labels

Import UI components and icons from the @/ui barrel. Only MUI hooks, utilities, and types are imported directly from MUI. GeoView wrappers provide additional behavior: for example, IconButton requires aria-label, uses it as the fallback tooltip, and exposes additional tooltip, ref, and visibility props. Inspect the wrapper rather than assuming it has the same interface as MUI.

All user-visible text and labels use t('domain.key'); add each key to both en/fr translation files. Namespaces are case-sensitive (for example appbar, mapctrl, dataTable) and interpolation uses i18next ``. Reuse existing keys before adding new ones, and verify meaningful French text. For interaction semantics and focus behavior, see the accessibility guide.

4- Registering display themes

A display theme is more than a palette: configuration validation, UI selectors, and documentation must all recognize the same theme name. When adding or renaming a theme:

  1. Update TypeDisplayTheme, VALID_DISPLAY_THEME, the schema enum, and getThemeOptions.
  2. Keep MapViewer JSDoc, public theme documentation, sandbox help, and navigator selectors consistent.
  3. Spread defaultGeoViewColors before palette overrides. If the theme has a custom font, pass it to generateThemeOptions so base typography and heading fonts agree.
  4. Add a release-testing scenario in rt-18-global-settings.html instead of replacing an existing one. Renumber each following map’s ID, heading, and snippet target together, and update the corresponding map numbers in the release-testing plan.

5- Spacing: theme.spacing() in style modules, no sx shorthand

GeoView uses MUI’s default 8px spacing scale: theme.spacing(n) = n × 8px. There is no custom spacing scale override today; if this ever changes, update the conversion guidance in Section 6 accordingly, since its tie-break rules are derived from the 8px assumption.

In-scope properties: padding/margin and all directional variants (paddingTop, mx, etc.), gap, rowGap, columnGap.

Rule:

// ✅ Good
padding: theme.spacing(2),
gap: theme.spacing(1),
margin: theme.spacing(0.5, 1, 0.25, 0.75),
// ❌ Bad — shorthand inside a *-style.ts file
p: 2,
gap: 1,

No bare-zero exception. Even a zero value must go through theme.spacing(0) rather than a bare 0/'0px'. The rendered result is identical either way, but this keeps theme.spacing( a complete, unambiguous grep target for every spacing declaration in the codebase — no exceptions to remember.

// ✅ Good
margin: theme.spacing(0),

// ❌ Bad — bare zero breaks the "always theme.spacing()" invariant
margin: 0,
margin: '0px',

Why:

Out of scope for spacing-scale conversion: border-radius (follow §7 for radius values), border-width, fixed control width/height, icon/graphic sizing, focus-indicator metrics, map-canvas/OpenLayers overlay position offsets (top/left/right/bottom used for absolute positioning).

rem/em-based spacing should be converted too. Treat an existing rem/em value on an in-scope property (padding, margin, gap, etc.) as a legacy inconsistency, not an intentional exception — convert it to theme.spacing() like any other value, using the px-equivalent at the default root font size (16px) to pick the nearest factor. Only keep a value in rem/em when it’s deliberately coupled to font-size (e.g. spacing meant to scale with adjacent text for WCAG text-resize behavior), and mark that case with a one-line comment explaining why it’s exempt.

6- Handling legacy values that don’t land on the 8px grid

Always prefer a standard scale factor. New and edited code should use values that land exactly on the 8px grid — 0.25(2px), 0.5(4px), 0.75(6px), 1(8px), 1.25(10px), 1.5(12px), 1.75(14px), 2(16px), 2.25(18px), 2.5(20px), 2.75(22px), 3(24px), 3.75(30px), 4(32px), etc. Any value expressible in exact 2px increments (a 0.25 factor step) converts exactly and requires no judgment call (e.g. 10px → theme.spacing(1.25), 20px → theme.spacing(2.5)).

Whole, half, and quarter factors: factors that are multiples of 1 or 0.5 (e.g. 1, 1.5, 2, 3) are called “whole” or “half” factors; factors that only land on a multiple of 0.25 (e.g. 1.25, 1.75, 3.25) are called “quarter” factors. This distinction matters for the tie-break rule below.

Off-scale values are the exception, not the default. They should only occur when converting a pre-existing legacy value that doesn’t divide evenly by 2px — never introduce a new off-scale value by choice. When snapping a legacy off-scale value (e.g. 5px, 15px, 25px) onto the scale:

// ✅ Good — snapped value documented
marginTop: theme.spacing(2), // snapped from 15px

Do not silently guess on a tie. If a snap decision affects a value used in more than one place (a recurring rhythm — for example, the same 15px legacy value appearing in both a card header and a panel header), confirm the direction once and apply it consistently across the affected files rather than deciding file-by-file.

7- Border radius: named tokens on theme.shape

Use one of the five border-radius tokens. Never use a literal px/% value or a bare number.

The examples below are guidance rather than fixed mappings; choose a token based on the visual context and consistency with surrounding components.

Token Value Example uses
borderRadiusNone 0 Squared-off corners, e.g. where a surface attaches to another (tabs on panels)
borderRadiusSm 4px Smaller elements, e.g. map controls, chips, thumbnails, icons, scrollbar thumbs, inline code
borderRadiusMd 6px Common surfaces, e.g. cards, panels, popovers, tooltips, inputs (same as the MUI base)
borderRadiusLg 8px Larger grouped containers, e.g. settings sections
borderRadiusFull 9999px Circles (square elements) and pills (rectangular elements)

Where each kind of file gets the token:

// ❌ Bad
borderRadius: '5px',
borderRadius: 1, // in sx this is multiplied by the base: 1 → 6px, 2 → 12px
borderRadius: theme.shape.borderRadius, // a number, so in sx it renders 6 × 6 = 36px

// ✅ Good
borderRadius: theme.shape.borderRadiusMd,

Prefer central defaults. MUI’s own components (Button, Paper, Dialog, Menu, inputs, Tooltip and others) already read the numeric base theme.shape.borderRadius (6), so don’t re-declare a radius on them. When a whole component family needs a different radius, change it once in theme.components (themeOptionsGenerator.ts) instead of at each call site.

Off-scale legacy values move to the nearest token: 5px goes to borderRadiusMd, and anything else is decided case by case. Keep theme.shape.borderRadius (the base) as a number — MUI components such as Skeleton do math on it.

4- Keyboard focus indicators

Use the focus selector that matches the element receiving focus:

Let the theme provide the standard indicator for MUI controls. For custom controls or composite wrappers that need to draw an indicator, use getFocusIndicatorStyles() from themeOptionsGenerator.ts. Keep local overrides limited to layout needs such as inset placement or suppressing a redundant halo; do not restate the standard ring’s color, width, or halo locally.

When an inset offset equals the outline width, use the existing FOCUS_OUTLINE_WIDTH constant (for example, outlineOffset: `-${FOCUS_OUTLINE_WIDTH}`) rather than repeating its pixel value. Keep distinct one-off offsets literal; do not add tokens for them. Use boxShadow: 'none' directly when the halo must be suppressed.

Form controls show their focus outline in keyboard navigation mode. Keep indicators visible when controls are clipped or inset, and preserve fallback indicators for external themes. See the accessibility guide for user-facing focus behavior.