Conventions for the React/TypeScript renderer (src/renderer). Match the
surrounding code first; this captures the defaults for when there's no local
precedent.
- React 18.3.1 on the legacy
ReactDOM.renderroot (React 17-compatible behavior) - function components and hooks only. Do NOT usecreateRoot, concurrent features, orStrictModeyet; the createRoot flip is a separate, later release. Class components, legacy context andfindDOMNoderemain functional but deprecated. Don't introduce new usages. - TypeScript (type-checked ESLint).
import type { ... }for types (consistent-type-importsis an error). - Tailwind v4 for styling, react-redux for state, react-i18next for copy, Electron renderer process.
If you change exported types in types/api.ts (for example
IMainPageOptions), run pnpm run api and commit the regenerated
etc/vortex.api.md. Add a one-line TSDoc /** ... */ so the entry isn't
(undocumented) in the report.
This is the one renderer-specific step that isn't covered by the standard build/test/format loop.
- Shared UI:
src/renderer/src/ui/components/<snake_case>/<PascalCase>.tsx. - Feature UI:
src/renderer/src/extensions/<ext>/...(components/,views/,hooks/,utils/,selectors.ts). - Colocate
<Name>.test.tsxand, for shared components,<Name>.demo.tsx. - One component per file, named exports for components. Helpers and selectors
move to
utils//hooks/rather than staying inline in a view, once reused.
- Define a type or interface in the file that consumes it. Only lift it to a
shared module (
types.ts, autils//hooks/file) once more than one file needs it. Don't create a shared type file pre-emptively. - Name interfaces with an
Iprefix (IMainPage,IHealthCheckEntry,ITabButtonProps). Union and alias types stay plain PascalCase (Severity,FileRequirementCategory). Keep new names consistent with their neighbours.
- Function components with a typed props interface.
- Prop order is auto-enforced (
perfectionist/sort-jsx-props): shorthand first, then alphabetical, callbacks (on*) last. Don't hand-order; runpnpm run format. - Prefer composition and small leaf components over large ones.
- Self-closing tags for empty elements (lint-enforced).
- Omit props equal to their default. Don't pass a prop whose value is
already the component's default (
Button/Typographyappearance="strong",Typographybrand="neutral",Pictogramsize="md"). Redundant assignments pin call sites to today's default and block a future sweeping change from applying consistently. Set a prop only when it differs.
oxfmtowns import grouping and ordering (external, then@/alias, then relative, alphabetical within each group). Don't fight it; runpnpm run format.- Unused vars and args are errors unless prefixed
_. - React imports: if a file already has
import * as React from "react", keep usingReact.*references - don't mix styles within a file. In files without it, import what you need by name (import { useCallback, type ReactNode } from "react") and never add a newimport * as Reactorimport type * as Reactline.
- Utility classes via
className. Build conditional or among-many class strings withjoinClasses([...classes], { "class": condition })- it's lint-aware, so Tailwind classes inside it get ordered and validated too. - Class order is auto-enforced (
better-tailwindcss); let--fixorpnpm run formatsort it. - Use design tokens, not raw colors:
bg-surface-*,text-neutral-*,bg-danger-strong,bg-warning-moderate,bg-info-moderate, and so on. - Shared components also expose semantic
nxm--prefixed classes (nxm-tab-button). Keep that pattern for reusable primitives.
Typography (src/renderer/src/ui/components/typography/Typography.tsx) styles
a block of text, and everything inside it inherits that style. as picks the
element for its meaning only; typographyType picks the style, body-md unless
given.
Wrap a list or a run of paragraphs in one Typography, and put plain elements
inside:
<Typography as="ul" className="list-inside list-disc space-y-2">
<li>First</li>
<li>Second</li>
</Typography>
<Typography as="div" className="space-y-2">
<p>First paragraph.</p>
<p>Second paragraph.</p>
</Typography>Not one per item:
<ul>
<li><Typography as="span">First</Typography></li>
<li><Typography as="span">Second</Typography></li>
</ul>
<Typography>First paragraph.</Typography>
<Typography>Second paragraph.</Typography>Nest another Typography only where the style changes: a heading, a subdued
line, a different size. One that changes only the colour takes
typographyType="inherit", so it keeps the parent's type style instead of
resetting to body-md:
<Typography as="div" className="space-y-2" typographyType="body-sm">
<p>
{`Some text, and `}
<Typography appearance="subdued" as="span" typographyType="inherit">
a subdued phrase
</Typography>
.
</p>
</Typography>A scrim darkens a backdrop so whatever sits on top stays readable: game art
behind a tile's title, the app behind a modal. Use bg-scrim-* (or
from-/via-/to-scrim-* for a gradient) and colour the content on it with
text-on-scrim-*, or <Typography brand="neutral-on-scrim">.
A scrim is not the same thing as tinting a surface for state or depth - a row
hover, an input fill, a pressed state. Those are overlays, and they use the
translucent-* / translucent-dark-* ramps.
The distinction matters because the two behave differently when the theme changes, and the names are the only thing carrying that:
- A scrim never flips. A photograph needs darkening whether the UI is light
or dark, and a modal backdrop stays dark in a light theme.
--color-scrim-*and--color-on-scrim-*are built from fixed black and white, and themes leave them alone - seesrc/stylesheets/ui/themes/light.css, which overrides every other family and deliberately omits these two. - An overlay must flip. A wash that lightens a dark surface has to darken a light one, or it stops reading as a state change at all.
Important
Text laid on a scrim must use on-scrim-*, not the ordinary text tokens. A
theme-relative colour like text-neutral-strong flips to dark in a light
theme, and the scrim beneath it does not - so the label disappears.
Both ramps live in src/stylesheets/ui/theme/colours.css.
src/stylesheets/ui/elements/modal.css and the gradient in
src/renderer/src/ui/components/game_tile/GameTile.tsx are the two worked
examples.
Components name this after what they paint, not what they sit on, so the two halves of the idea stay tellable apart:
- A component that is a scrim - it paints the fill - takes
appearance="scrim".Pillis the worked example, and the name matches theAppearance=Scrimvariant in Figma. - Something laid on a scrim someone else painted draws from the
on-scrimramp.Typography'sbrand="neutral-on-scrim"is the worked example.
That the two land on different props is deliberate rather than drift. Pill's
appearance is a whole treatment - fill, border, hover - and has to stay
orthogonal to brand, because a branded icon on a scrim is a real combination.
Typography's brand picks a ramp and appearance picks a step within it, so
for text the scrim really is just another ramp.
Only the achromatic ramp has a scrim counterpart: the design system has
Neutral/Scrim, but no Success/Scrim. That is why the Typography brand is
spelled neutral-on-scrim - there is deliberately no way to ask for
success-on-scrim. A branded icon on a scrim still takes its colour from the
ordinary, theme-relative brand ramp, and will shift with the theme.
Users can turn non-essential animation down: Settings → Interface → Reduce motion,
which follows the OS prefers-reduced-motion preference until they set it themselves.
The answer is published as data-reduce-motion="true" on <html>
(util/reduceMotion.ts); the rules keyed off it live in
src/stylesheets/ui/theme/motion.css.
- Reduced means instant, not gentler. Don't swap movement for a fade - a state change
lands immediately. Neutralise a transition by starting it from its final state rather
than by zeroing the duration:
enterFrom="translate-y-6 opacity-0 reduce-motion:translate-y-0 reduce-motion:opacity-100". That leaves nothing to animate while keeping the duration aTransitionreads to time itself. Drop delays too (reduce-motion:delay-0) - there is nothing left to wait for. - Prefer
transition-*without an explicitduration-*. Those resolve their duration through--default-transition-duration, which the attribute overrides, so they flatten for free. Aduration-*utility sets the duration directly and survives on purpose - a component that names its own duration often depends on it - so opt decorative ones out with thereduce-motion:variant:transition-[width] duration-300 reduce-motion:duration-0. - Custom keyframes live in
src/stylesheets/ui/theme/animations.css, registered as--animate-*theme entries. Take the duration from--default-transition-durationfor the same reason as above, so the animation flattens without needing its own rule inmotion.css. A delay is timing rather than motion, so keep it - a loader held back so it never flashes on a short wait should still be held back. - Don't use Tailwind's
motion-reduce:/motion-safe:variants. They compile toprefers-reduced-motionmedia queries, so they follow the OS and can't see the app's own setting.reduce-motion:is the app-aware equivalent. - In JavaScript, use
useReduceMotion()in components andisReduceMotionActive()elsewhere - for movement computed in JS rather than declared in CSS: a scroll tween, an autoplay interval, a staged sequence. Clear any "is animating" flag when the preference is on rather than leaving it set; an animation that never runs never firesanimationend. - Keep essential motion. Spinners (
animate-spin), and anything whose movement is the information (a countdown ring, a progress bar's width), have to keep working - a static spinner says nothing. Skeleton shimmer (animate-pulse) is not essential and is switched off centrally. - Only the Tailwind/
nxm-surface responds so far. The legacy SCSS (src/stylesheets/vortex/**, bundled Bootstrap) compiles its durations in and is not covered yet, so the classic toolbar's flashing buttons still flash.
useSelectorwith a stable selector reference: a module-level function, not an inline closure that returns a fresh value on each call.- Subscribe to the narrowest slice you need. Health-check-style features dispatch frequently, and a broad subscription re-renders on unrelated writes.
- Never return a freshly-created object, array or React element from a selector. Referential inequality forces a re-render on every dispatch. Return primitives or stable refs and map to objects or nodes in the component.
- Push a hot subscription down into a small leaf component so only it
re-renders. See
HealthCheckMenuBadgeandLastUpdated. - For writing reducers, registering them and the deprecated
storeHelperpath helpers, see state.md.
Localize with useTranslation(["ns", "common"]). For namespaces and key format,
see I18N_MIGRATION_GUIDE.md.
Never derive identity, logic, state, keys or comparisons from a translated string. It changes per language and silently breaks. Use a stable id or enum for identity and translate only for display.
This is exactly how the tab selection bug happened: identity was a slug of the label, so selecting a tab stopped working as soon as the label was translated.
- MDI paths from
@mdi/js, mapped inviews/components/iconMap.tsviagetIconPath(name). Pages can pass anmdipath directly. - Custom SVGs must use the 24x24 viewBox the
Iconcomponent renders. To port a Material Symbols icon (0 -960 960 960): scale by 0.025 and offset absolute Y by +960. Keep it a single filled path withfill="currentColor".
- Register with
context.registerMainPage(icon, title, Component, options). newLayout: trueopts into the redesigned chrome, where the page renders its own<Page>,<PageHeader>and<PageScroll>. For converting an existing page, see design-system/page-migration.md.- Optional menu extras are self-contained components (such as
menuBadge) that subscribe to their own state.
Use correct roles and attributes (role="tab"/tabpanel, aria-controls,
aria-selected) and support keyboard navigation for custom interactive UI.
vitest with @testing-library/react v16, colocated as <Name>.test.tsx. For
how to select elements and the extension-test mocking pattern, see
testing.md.
Prefer self-documenting names over comments. Comment the non-obvious why (a subscription tradeoff, a workaround), not the what. Remove narration.