Visual design principles for the Vescape app. Follow these when building or modifying UI.
Every color in the app must come from the
themeobject insrc/constants/theme.ts. Never hardcode a hex value (#...), rgba literal, or any color string directly in a component file. If you need a new color, add a token totheme.tsfirst — then use it everywhere viatheme.*.
No large solid bright fills — anywhere in the app. Bright accent colours (
theme.*.color) are for thin borders, icons, and text, not for filling large areas. Avoidweight="fill"glyphs, bright filled discs/badges/blocks, and bright-coloured backgrounds behind content. State and emphasis come from thin borders + coloured icons/text on the dark surface. Permitted fills: neutral surfaces (theme.neutral.surface/surfaceDeep), tinted pill backgrounds (theme.*.bg), and the primaryButton. Small bright accents (a thin underline, a dot, a 1–2px border) are fine; large bright planes are not.
The app has adaptive light and dark appearances. The durable themeMode setting supports:
system(default) — follows the phone appearance.light— always light.dark— always dark.sun— light between local sunrise and sunset, dark otherwise, using the current or last known GPS location. It falls back to the system appearance when no location is available.
Selecting One Dark or Satellite persists dark themeMode; selecting Outdoors or Mapy.cz persists light themeMode. The configured appearance also keeps explicit day/night basemaps paired in the other direction: light uses Outdoors and dark uses One Dark. Satellite and Mapy.cz remain unchanged when appearance is changed separately. This also resolves a persisted One Dark/Outdoors mismatch on the next app start.
Neutral UI colors come from theme.neutral, while accent UI colors come from theme.palette.<hue>. Both are backed by iOS dynamic colors and Android day/night resources, so values captured by StyleSheet.create still update when the active appearance changes. theme.palette.slate remains a raw dark swatch for fixed dark map styles; do not use it for app surfaces or text.
The light appearance uses a pure-white canvas. Read-only content sits directly on that canvas and is separated with spacing, typography, or thin neutral rules rather than raised cards. Interactive controls use the dark navy theme.control family in both appearances — the same #0f172a base in light and dark — making affordances obvious without shadows and keeping the accent-colored actions legible. Dark-contrasted chrome must use the adaptive theme.control.* family (or theme.neutral.* for content surfaces) — never the fixed theme.palette.slate.* raw swatches, which stay near-black in both appearances and are reserved for on-map/chart graphics.
Segmented controls that use the lightTabs variant flip their contrast in light mode: the track stays navy and the active segment becomes a white pill with an accent border and accent text/icon; inactive segments are transparent with muted control text. Colored actions — Button accent/tune/success/destructive/groupRide, IconButton destructive/accent, tonal CircleButtons, the map-sheet primary Ride it / Navigate and Cancel, and map-sheet delete/save/vote buttons — carry their identity in the accent: on dark the accent tints the surface beneath (coloredAction.darkTint, dev's tinted pill); on light the accent washes over the navy control surface (coloredAction.tint) so a colored action keeps the weight of a filled control without a new palette color. They always use colored text and border, never white-on-navy or a bright translucent fill alone.
Telemetry colors are appearance-specific. The light variants are darker than their dark-appearance counterparts so gauges, charts, routes, and small labels retain contrast against white. Use theme.telemetry for React Native styles and useResolvedTelemetryColors() for renderers.
Non-React-Native renderers and worklets use the plain-string palettes from useResolvedNeutralColors() and useResolvedAccentColors(); native adaptive color objects must not cross into Mapbox, Skia, Reanimated worklets, or string-valued state.
Android native Switch color props also receive resolved string colors. Its native color converter does not reliably resolve the adaptive resource-path value used by the rest of the React Native style system.
Selecting Satellite switches the app to dark appearance for its subdued nighttime treatment. It does not inherit the previously selected light appearance.
| Role | Token |
|---|---|
| Background | theme.neutral.bg |
| Card / surface | theme.neutral.surface |
| Deep surface | theme.neutral.surfaceDeep |
| Border | theme.neutral.border |
| Primary text | theme.neutral.textPrimary |
| Secondary text | theme.neutral.textSecondary |
| Muted text | theme.neutral.textMuted |
| Dim text | theme.neutral.textDim |
Interactive surfaces use a separate semantic family:
| Role | Token |
|---|---|
| Control background | theme.control.background |
| Pressed background | theme.control.backgroundPressed |
| Disabled background | theme.control.backgroundDisabled |
| Control border | theme.control.border |
| Control divider | theme.control.divider |
| Control text/icon | theme.control.text / .icon |
| Muted control text | theme.control.textMuted |
- No decorative boxes or elevation. Cards wrap only interactive groups (rows with inputs, switches, buttons). Do not wrap static info or labels in bordered containers, and do not use shadows to make hierarchy.
- Flat rows. Settings-style rows are icon + label + control, no background box around the icon.
- Breathing room. Use padding and gap, not borders, to separate content sections.
- Section titles are uppercase, small (
12–13px), muted (theme.neutral.textMuted), with letter-spacing.
Use src/constants/theme.ts for all accent colors. Never hardcode a hex value, rgba(...) literal, or any color string directly in a component.
theme.tune aliases the purple palette for Tune Profile actions and entry points.
The theme is organized into domains:
Each accent hue has two appearance-specific palettes. Use color for icons and thin emphasis, text for foreground text, bg/border for tinted controls, and the solid/onSolid pair for filled actions such as primary buttons. Never place a guessed black or white label over an accent fill.
Named hue swatches. Every hue exposes .color, .alt (alias of .light), .light, .text, .bg, and .border.
| Hue | Purpose |
|---|---|
cyan |
Brand / primary accents |
sky |
Board data, version, distance, speed |
green |
GPS, Android platform, success, battery |
purple |
Time, iOS platform, profiles |
amber |
Weather sun, diagnostic indicators |
orange |
Warnings, motor and controller temperatures |
red |
Destructive actions, errors |
yellow |
Stars, achievements, gauges |
blue |
Currents, info states |
fuchsia |
Roll telemetry |
pink |
Balance pitch telemetry |
violet |
Map trail / marker accents |
slate |
Neutral surfaces, text, borders, map buildings |
mono |
Pure black and white |
Appearance-specific tokens for every metric. Use these for charts, sparklines, gauges, and live readouts so the same metric keeps its identity while meeting the contrast needs of dark and white canvases.
| Token | Source hue |
|---|---|
speed |
Speed / distance blue |
duty |
Duty-cycle teal |
motorCurrent |
Motor-current blue |
battCurrent |
Battery-current blue |
motorTemp |
Motor-temperature red |
controllerTemp |
Controller-temp orange |
battVoltage |
Battery green |
footpad1 |
Footpad neutral 1 |
footpad2 |
Footpad neutral 2 |
pitch |
Pitch purple |
roll |
Roll fuchsia |
balancePitch |
Balance-pitch pink |
| Token | Purpose |
|---|---|
user |
Current GPS position |
target |
Destination / target |
buildingDark |
Dark map buildings |
buildingLight |
Light map buildings |
Semantic UI-state tokens. Each exposes .color, .text, .bg, and .border.
| Token | Meaning |
|---|---|
info |
Informational callouts |
success |
Success / connected |
warning |
Warnings |
error |
Errors / destructive |
favorite |
Favorites / stars |
Every translucent value (overlays, backdrops, zone tints, glow gradients, vignettes) must be created with theme.alpha(color, level) using one of the typed levels:
type AlphaLevel = 0 | 0.12 | 0.3 | 0.4 | 0.6 | 0.7 | 0.8 | 0.85 | 1Neutral row icons use theme.neutral.textSecondary.
Use phosphor-react-native with weight="duotone" as default weight. Each icon gets a distinct accent color from theme — do not reuse the same color for adjacent icons.
Board Warnings use EngineIcon everywhere. VESC faults use WarningDiamondIcon with
theme.status.caution, the yellow status palette. The Edit Board battery entry uses
theme.settingsIcon.battery, green. Yellow in these controls is reserved for warnings and alerts.
Empty-state placeholders, including "No faults" and "No warnings", use the default gray
theme.neutral.textMuted icon, not the feature's warning accent.
Icon sizing:
14— inline metadata, header stats16–18— row icons in settings/lists20— row icons inside icon boxes (legacy card rows)
A specific application of the no-bright-fills rule. Status and selection states (checklist steps, radios, progress milestones) use thin-bordered outline circles:
- Wrap the indicator in a generous circle (
40–44px,borderWidth: 1.5, transparent background). State is carried by the thin border colour + the icon colour, both fromtheme.*— done ingps, active inwheel, error inerror, idle intheme.neutral.border/textMuted. - Never a
weight="fill"disc or filled dot — a bright filled glyph reads as a heavy blob on the dark surface. - Bigger is calmer. Prefer large outline circles with breathing room over small dense glyphs.
Use cards (backgroundColor: theme.neutral.surface, borderRadius: 12, borderColor: theme.neutral.border) only for grouping interactive elements (switches, steppers, pressable rows). A card groups related controls — not labels or read-only info.
Inside cards, separate rows with a thin theme.neutral.border line indented past the icon (marginLeft: 58).
Corner sheets (EdgeDrawer) in light theme use a translucent white body — free-floating fields on that surface read as unfinished. Group a sheet's interactive content in the same card boxes used on the settings screens (SettingsCard), with the sheet's mode switch (e.g. tab pills) and primary action sitting outside the card.
For screen headers showing metadata (version, OS, DB size), use centered text without card wrappers:
- App name large and bold
- Stats in a horizontal row with colored icons + small muted text
- No background, no border — sits directly on screen background
The app's UI font is Raleway, shipped as official static per-weight files (assets/fonts/Raleway-300.ttf … Raleway-900.ttf) and loaded in src/app/_layout.tsx via expo-font's useFonts before the Stack mounts. The splash stays visible until the fonts are ready on cold start. Static files with correct embedded family, style, and PostScript names are required: Android does not move a custom variable font's wght axis, while iOS relies on the embedded names to distinguish registered faces. Raleway defaults to old-style figures, so the shared Text wrapper enforces the OpenType lining-nums variant.
Every Text instance renders through the wrapper at src/components/base/Text.tsx, which reads fontWeight from the style and resolves it to the matching family via theme.font(weight) (default '500' — Raleway 400 reads too thin on the dark surface). Import Text from @/components/base/Text — never import Text from react-native directly for UI text.
theme.font(weight)insrc/constants/theme.tsis the single source of truth for per-file aliases ('Raleway-500'etc.). Components keep writing plainfontWeight: '600'and rely on the wrapper; never inline'Raleway…'in a component or style.- Numeric readouts opt out of Raleway and use JetBrains Mono (
assets/fonts/JetBrainsMono-500.ttf…-800.ttf, weights 500/600/700/800), viatheme.mono(weight). The platformfontFamily: 'monospace'alias is still used for developer-facing text (event log, raw settings) where the exact face does not matter; anything a rider reads at speed uses the bundled family so digit advance is identical on both platforms. - Live values (anything driven by a shared value rather than a React render) go through
MonoValue/TickTextinsrc/components/base/, which draw on Skia. Never render a live value into a non-editableTextInputthroughanimatedProps— a test insrc/components/base/liveReadouts.test.tsfails if that pattern comes back. - Every canvas is a separate native surface, so do not mount one per readout. When the parent already draws on Skia, put a
MonoTextnode in that canvas instead of aMonoValue— that is how the gauges draw their value and unit, and howBmsCellVoltagesfits every cell row onto one canvas.MonoValueisMonoTextplus a canvas, for readouts that sit on plain views. - Bars and indicators driven by live values belong on the same canvas as the numbers. An animated percentage
widthis a layout prop: it runs a Yoga pass per frame per row, which is what the cell rows used to do. - A Skia canvas does not grow to fit its text the way a
Text/TextInputbox does. GiveMonoValueawidth, or a parent with a definite width plusalignSelf: 'stretch', or the readout collapses or clips. Canvases drawn at a measured size useuseCanvasSizeon a host view (onLayoutis unsupported on a Fabric canvas). - Stack header titles (and any style fed to a native component that bypasses the wrapper) must set
fontFamily: theme.font('600')explicitly — seesrc/app/_layout.tsxheaderTitleStyle— and must not setfontWeight. fontVariant: ['tabular-nums']still aligns numeric columns on Raleway.
Typography roles:
| Role | Size | Weight | Token |
|---|---|---|---|
| Screen title | 20 | 700 | theme.neutral.textPrimary |
| Row label | 15 | 600 | theme.neutral.textPrimary |
| Row hint | 12 | 500 | theme.neutral.textMuted |
| Section title | 12–13 | 700 | theme.neutral.textMuted |
| Metadata | 12 | 500 | theme.neutral.textSecondary |
| Stepper value | 15 | 700 | theme.neutral.textPrimary |
Preview every role live under Settings → Components → Typography (src/app/settings/components/typography.tsx).
Raleway reads thinner than the platform default font, so the design system starts body text at
500(Medium). AnyTextwithout an explicitfontWeightresolves to500— see the wrapper atsrc/components/base/Text.tsx. Use'400'only when a deliberately thin label is intended (e.g. quiet chart axis ticks). Screens that need older behavior can passfontWeight: '400'explicitly.
- Wrapping non-interactive content in cards or bordered boxes
- Using the same icon color for adjacent items
- Solid bright fills for status/selection (filled check discs,
weight="fill"dots) — use thin-bordered outline circles instead Alert.alert— useConfirmModalinstead- Ad-hoc
Pressable+Text— useButtonorIconButton - Emoji or unicode as icon substitutes