Skip to content

Latest commit

 

History

History
289 lines (232 loc) · 13.3 KB

frontend.md

File metadata and controls

289 lines (232 loc) · 13.3 KB

Frontend (renderer)

Conventions for the React/TypeScript renderer (src/renderer). Match the surrounding code first; this captures the defaults for when there's no local precedent.

Stack

  • React 18.3.1 on the legacy ReactDOM.render root (React 17-compatible behavior) - function components and hooks only. Do NOT use createRoot, concurrent features, or StrictMode yet; the createRoot flip is a separate, later release. Class components, legacy context and findDOMNode remain functional but deprecated. Don't introduce new usages.
  • TypeScript (type-checked ESLint). import type { ... } for types (consistent-type-imports is an error).
  • Tailwind v4 for styling, react-redux for state, react-i18next for copy, Electron renderer process.

The public extension API

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.

Files and structure

  • 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.tsx and, 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.

Types

  • Define a type or interface in the file that consumes it. Only lift it to a shared module (types.ts, a utils//hooks/ file) once more than one file needs it. Don't create a shared type file pre-emptively.
  • Name interfaces with an I prefix (IMainPage, IHealthCheckEntry, ITabButtonProps). Union and alias types stay plain PascalCase (Severity, FileRequirementCategory). Keep new names consistent with their neighbours.

Components and props

  • 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; run pnpm 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/Typography appearance="strong", Typography brand="neutral", Pictogram size="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.

Imports

  • oxfmt owns import grouping and ordering (external, then @/ alias, then relative, alphabetical within each group). Don't fight it; run pnpm run format.
  • Unused vars and args are errors unless prefixed _.
  • React imports: if a file already has import * as React from "react", keep using React.* 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 new import * as React or import type * as React line.

Styling

  • Utility classes via className. Build conditional or among-many class strings with joinClasses([...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 --fix or pnpm run format sort 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.

Text

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>

Scrims

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 - see src/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.

Naming a scrim-aware prop

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". Pill is the worked example, and the name matches the Appearance=Scrim variant in Figma.
  • Something laid on a scrim someone else painted draws from the on-scrim ramp. Typography's brand="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.

Motion

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 a Transition reads to time itself. Drop delays too (reduce-motion:delay-0) - there is nothing left to wait for.
  • Prefer transition-* without an explicit duration-*. Those resolve their duration through --default-transition-duration, which the attribute overrides, so they flatten for free. A duration-* 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 the reduce-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-duration for the same reason as above, so the animation flattens without needing its own rule in motion.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 to prefers-reduced-motion media 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 and isReduceMotionActive() 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 fires animationend.
  • 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.

State (Redux)

  • useSelector with 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 HealthCheckMenuBadge and LastUpdated.
  • For writing reducers, registering them and the deprecated storeHelper path helpers, see state.md.

i18n

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.

Icons

  • MDI paths from @mdi/js, mapped in views/components/iconMap.ts via getIconPath(name). Pages can pass an mdi path directly.
  • Custom SVGs must use the 24x24 viewBox the Icon component 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 with fill="currentColor".

Pages and extensions

  • Register with context.registerMainPage(icon, title, Component, options).
  • newLayout: true opts 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.

Accessibility

Use correct roles and attributes (role="tab"/tabpanel, aria-controls, aria-selected) and support keyboard navigation for custom interactive UI.

Testing

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.

Comments

Prefer self-documenting names over comments. Comment the non-obvious why (a subscription tradeoff, a workaround), not the what. Remove narration.