Design system recovered from the app's StyleSheet.create calls, app.config,
and bundled font resources. Everything here is observed, not inferred from a
design file — the app has no theme module beyond a two-entry Colors export
(white: '#FFFFFF', black: '#000000'), so the real system lives inline in
~450 named style objects.
Aimed at building a display that matches the app. Values are raw React Native units, which map 1:1 to CSS px.
1. Foundations
The app is dark-only. app.config sets userInterfaceStyle: "dark", splash
background #000000, and Android status bar #000000. There is no light
theme and no useColorScheme branch anywhere in the app code — don't build one.
The core move is a pure black page with raised dark-grey cards. Not a layered grey-on-grey system: black background, one card colour, done.
2. Colour
Surfaces
| Token | Value | Use |
|---|---|---|
--bg |
#000000 |
Page background, splash, status bar |
--surface |
#1D1D1D |
Every card, panel, tab bar, header block |
--surface-raised |
#2A2A2A |
Nested panels inside a card (custom-tuning section), selected tiles |
--surface-sunken |
#141414 |
Disabled tile background |
--control |
#444444 |
Text inputs, value chips, slider value pills |
--control-alt |
#333333 |
Modal body, slider end-label chips |
--control-muted |
#666666 |
Reset buttons |
--control-track |
#555555 |
Battery bar track, speedometer arc track |
--icon-circle |
#3C3C3C |
Round icon buttons (disconnect) |
#1D1D1D does the overwhelming majority of the work — 12 of 23 background
declarations in app code. If in doubt, a surface is #1D1D1D.
Accent
| Token | Value | Use |
|---|---|---|
--accent |
#69D2DF |
The brand colour: logo, slider fill + thumb, battery fill, active tick marks, section dividers, selected borders, badge circles |
--accent-deep |
#2BB7CA |
Device-list connect buttons (the one darker variant) |
--accent-soft |
#7DD3E3 |
A single lighter border |
A pale cyan. Used sparingly and almost always as fill or edge, never as a large surface — the biggest accent area in the app is a 35 px battery bar.
Text
| Token | Value | Use |
|---|---|---|
--text |
#FFFFFF |
Default. A wrapper <Text> component injects Colors.white before any passed style, so white is the inherited default everywhere |
--text-secondary |
#BBBBBB |
Feature labels, feature values, tile labels |
--text-muted |
#AAAAAA |
Tab labels (inactive), disclosure arrows |
--text-dim |
#808080 |
Sub-labels beside option text |
--text-on-accent |
#000000 |
Text sitting on the cyan battery fill |
--text-on-accent-alt |
#1D1D1D |
Volts text on the battery fill |
Note the inversion on the battery bar: black and #1D1D1D text on cyan. That's
the only place dark-on-light appears.
Semantic
| Token | Value | Use |
|---|---|---|
--danger |
#FF4444 |
Overheating tab label + underline |
--danger-strong |
#AC0E0E |
Sport / Agro mode badge |
--danger-alt |
#CC3232 |
Disabled-state text |
--warning |
#FFC300 |
"Previous value" hints under changed settings |
--gauge |
#FFD700 |
Speedometer arc fill (gold, not the accent) |
--success |
#198E13 |
Eco / Float mode badge |
--info |
#0E77AC |
Cruise / Standard mode badge |
--neutral-mode |
#777777 |
Custom / unknown mode badge |
--overheat-overlay |
rgba(255,0,0,0.7) |
Full-width overheat banner |
Mode badges are a fixed four-colour set reused for both ride modes and wheelie
modes: red #AC0E0E, blue #0E77AC, green #198E13, grey #777777.
Scrims
rgba(0,0,0,0.5) for modal overlays (most common), with 0.4 and 0.7
variants also present.
3. Typography
Fonts
The app ships exactly one custom font. Everything else is the platform system face.
| Family | Source | Where |
|---|---|---|
| Sonsie One | @expo-google-fonts/sonsie-one, SonsieOne_400Regular, loaded via useFonts({ 'Sonsie One': … }) |
Two places only: a centred label with paddingRight: 2, and a 24 px white centred heading |
| System sans | RN default (Roboto on Android, SF Pro on iOS) |
Everything else — no fontFamily is set on any other text style |
Sonsie One is a display face with heavy slab-ish serifs and high contrast. On the web, load it from Google Fonts:
@import url('https://fonts.googleapis.com/css2?family=Sonsie+One&display=swap');
Do not use Inter or JetBrains Mono. Both appear in res/font/
(inter_regular/medium/semibold/bold, jetbrains_mono_light/medium/regular)
and it's tempting to treat them as the app's typefaces — they are not. They
ship with expo-dev-menu / expo-dev-launcher (confirmed: 1071 dev-menu
symbol hits in classes5.dex) and are never referenced from app code.
roboto_medium_numbers.ttf is likewise an AndroidX/Play-Services resource.
The string 'Roadgeek 2005 Series E' appears 62 times, but only inside
embedded SVG markup — it's the FHWA highway-signage face used to draw
speed-limit sign graphics, not a UI font. It isn't bundled, so those SVGs fall
back to whatever the renderer has.
Scale
Observed sizes, by frequency in app stylesheets:
| px | Count | Typical role |
|---|---|---|
| 16 | 18 | Body, tab labels, option text, slider labels |
| 22 | 12 | Section labels, toggle labels, stat labels/values, save-button text |
| 18 | 11 | Feature labels/values, stat units, battery text |
| 14 | 8 | Small print, "previous value" hints, button text |
| 20 | 4 | Mode button titles, features title |
| 24 | 3 | Section titles |
| 30 | 3 | Page titles, disclosure arrows |
| 11–12 | 3 | Slider end labels (uppercase) |
| 28, 36, 50, 60 | 1 each | Angle value (36), logo (60), sub-logo (50) |
Plus two scale-derived sizes in the speedometer: round(60 × scale) for the
speed number and round(25 × scale) for the unit.
A workable ladder: 11 / 14 / 16 / 18 / 20 / 22 / 24 / 30 / 36 / 60.
Weights
| Weight | Count | Use |
|---|---|---|
400 |
75 | Default body |
bold (700) |
26 | Values, titles, save buttons, logo |
500 |
13 | Labels, tab text, feature text |
600 |
7 | Section titles, slider labels, preset labels |
Only four weights. The jump straight from 500/600 to bold with nothing
in between is characteristic — there's no 800/900 anywhere.
Other text properties
lineHeightis set on only four styles:30(disclosure arrows, matching their 30 px size),22(20 px mode titles),20,18. Everything else uses the platform default.letterSpacing: 0.5on save-button text (4 uses);0.35once.textTransform: 'uppercase'on slider end labels only.fontStyle: 'italic'for the connect-screen subtitle and "feature status" text — used to mark secondary/placeholder states.includeFontPadding: false+textAlignVertical: 'center'on the disconnect icon glyph, for optical centring in a circular button.
4. Shape and spacing
Radii
| px | Use |
|---|---|
2 |
Battery icon nub, tab underline caps |
5 |
Inputs, scan button, small chips |
8 |
Slider value pills, option rows |
10 |
Modal content, device buttons, battery bar |
12 |
The default card radius — mode buttons, features container, custom-tuning panel |
14, 16, 18 |
Occasional; 18 = half of the 36 px circular buttons |
20 |
Tab pill interior, bottom corners of the top section |
25 |
Tab bar container |
999 |
Fully-round mode icons |
12 is the house radius for cards; 999 for anything circular.
Spacing
The app uses a loose 4 px grid: gaps of 2, 4, 5, 8, 10, 12, 14, 16, 20, 24, 30, 32.
Recurring patterns:
- Screen horizontal padding: 20
- Card interior padding: 16 or paddingHorizontal: 20
- Card-to-card gap: 12 (dataGridRow, modeContainer)
- Vertical rhythm between sections: 14 (dataGrid gap) or 16
- Negative horizontal margins of −20 / −10 to let cards bleed past the
screen padding (marginHorizontal: 4294967276 in the decompiled output
is −20 rendered unsigned)
Elevation
Barely used. Three shadows total, all on the slider thumb and similar small
controls: shadowOpacity 0.3, shadowRadius 6, elevation 6, with
shadowColor set to the accent #69D2DF so the thumb has a faint cyan glow.
Cards have no shadow — separation comes purely from the #1D1D1D-on-black
contrast.
5. Component recipes
Copied values, lightly translated to CSS.
Card
background: #1D1D1D;
border-radius: 12px;
padding: 16px 20px;
min-height: 125px;
justify-content: center;
Top section (the header block the dashboard hangs off)
background: #1D1D1D;
padding: 0 20px;
border-bottom-left-radius: 20px;
border-bottom-right-radius: 20px;
margin: 0 -20px; /* bleeds past the 20px screen padding */
z-index: 1;
Tab bar
/* container */
display: flex; background: #1D1D1D;
border-radius: 25px; padding: 4px; margin: 14px 0;
/* tab */
flex: 1; padding: 10px 0; text-align: center; border-radius: 20px;
/* label */ color: #AAA; font-size: 16px; font-weight: 500;
/* label, active */ color: #FFF; font-weight: bold;
/* label, overheat */ color: #FF4444;
/* underline */
position: absolute; bottom: -3px; height: 3px;
background: #FFF; border-radius: 2px 2px 0 0;
/* overheat underline */ background: #FF4444;
Slider
/* track */ height: 10px; background: #555; border-radius: 5px;
/* active fill */ height: 10px; background: #69D2DF;
border-radius: 5px 0 0 5px; top: 11px;
/* thumb */ width: 24px; height: 24px; border-radius: 12px;
background: #69D2DF; border: 2px solid #FFF; top: 4px;
box-shadow: 0 0 6px rgba(105,210,223,.3);
/* tick, active */ width: 3px; height: 12px; background: #69D2DF;
/* value pill */ background: #444; padding: 4px 12px;
border-radius: 8px; min-width: 36px;
/* value text */ font-size: 16px; font-weight: bold; color: #FFF;
/* label */ font-size: 16px; font-weight: 600; color: #FFF;
/* end labels */ background: #333; padding: 3px 6px; border-radius: 5px;
font-size: 11px; font-weight: 500; color: #BBB;
text-transform: uppercase;
Mode button
flex: 1; display: flex; flex-direction: column; align-items: center;
background: #1D1D1D; border-radius: 12px;
padding: 8px 4px 10px; gap: 5px; min-height: 125px;
/* icon disc */ min/max 60–80px square; aspect-ratio: 1;
border-radius: 999px; background: <mode colour>;
/* icon glyph */ font-size: 36px; font-weight: bold; padding-bottom: 4px;
/* title */ font-size: 20px; font-weight: bold; line-height: 22px;
/* arrow */ font-size: 30px; line-height: 30px; color: #AAA;
Modal
/* overlay */ position: fixed; inset: 0;
background: rgba(0,0,0,.5);
display: flex; align-items: center; justify-content: center;
/* content */ background: #333; border-radius: 10px; padding: 20px;
width: 80%; min-width: 280px; max-width: 500px;
Selected / disabled states
.selected { border-color: #69D2DF; background: #2A2A2A; }
.disabled { opacity: .5; border-color: #222; background: #141414; }
Save bar
padding: 20px; border-top: 1px solid #444;
/* text */ color: #FFF; font-size: 22px; font-weight: bold; letter-spacing: .5px;
Round icon button
width: 36px; height: 36px; border-radius: 18px;
background: #3C3C3C; /* or #666 with a 4px #1D1D1D border for resets */
display: flex; align-items: center; justify-content: center;
6. The speedometer
The signature element, worth reproducing exactly. It's an SVG arc.
viewBox 260 × 180
arc path M 27 168 A 110 110 0 1 1 233 168
radius 110
sweep 1.2333333333333334 × π rad ≈ 222°
arc length 1.2333333333333334 × π × 110 ≈ 426.2
stroke-width 14
stroke-linecap butt
fill none
track colour #555555
value colour #FFD700 (gold — deliberately not the cyan accent)
Progress is drawn with stroke-dasharray:
const filled = arcLength * speedPercentage;
strokeDasharray = `${filled} ${arcLength - filled}`;
Everything scales off a nominal 300 px width:
const scale = size / 300;
speedFontSize = Math.round(60 * scale);
unitFontSize = Math.round(25 * scale);
unitMarginTop = Math.round(-8 * scale); // pulls the unit up under the number
actualHeight = Math.round(180 * scale);
The negative unit margin is what gives the tight number/unit lockup.
Battery bar (sits under the speedometer)
position: relative; height: 35px; width: 100%;
background: #555; border-radius: 10px; overflow: hidden;
/* fill */ position: absolute; inset: 0 auto 0 0; height: 100%;
background: #69D2DF; width: <pct>;
/* text */ color: #000; font-size: 18px; font-weight: bold;
/* volts */ color: #1D1D1D; font-size: 18px; font-weight: 400;
Stat readouts
/* label */ font-size: 22px; font-weight: bold;
/* row */ display: flex; align-items: baseline; gap: 4px; margin-top: 2px;
/* value */ font-size: 22px; font-weight: 400;
/* unit */ font-size: 18px; font-weight: 400;
Baseline alignment with a 4 px gap and a smaller unit is the house pattern for every number-plus-unit pairing.
Overheat banner
Appears when a temperature crosses OVERHEAT_THRESHOLD = 75.
position: absolute; bottom: -10px; left: 0; right: 0; z-index: 100;
background: rgba(255,0,0,.7); padding: 12px 16px;
/* text */ font-size: 16px; font-weight: bold; text-align: center;
7. Icons
The app uses Ionicons (via @expo/vector-icons). Every other family —
MaterialCommunityIcons, FontAwesome 5/6, Feather, AntDesign, Entypo, Octicons,
SimpleLineIcons, Fontisto, Foundation, Zocial, EvilIcons — ships in the APK but
is only registered, never used from app code. If you're matching the look, pull
Ionicons.
Mode icons are not icons at all: they're single letters (S, C, E for
Sport/Cruise/Eco; 1, 2, 3 for Float/Standard/Agro) rendered at 36 px bold
inside the coloured disc.
8. Drop-in CSS variables
@import url('https://fonts.googleapis.com/css2?family=Sonsie+One&display=swap');
:root {
color-scheme: dark;
/* surfaces */
--bg: #000000;
--surface: #1D1D1D;
--surface-raised: #2A2A2A;
--surface-sunken: #141414;
--control: #444444;
--control-alt: #333333;
--control-muted: #666666;
--control-track: #555555;
--icon-circle: #3C3C3C;
/* accent */
--accent: #69D2DF;
--accent-deep: #2BB7CA;
--accent-soft: #7DD3E3;
/* text */
--text: #FFFFFF;
--text-secondary: #BBBBBB;
--text-muted: #AAAAAA;
--text-dim: #808080;
--text-on-accent: #000000;
/* semantic */
--danger: #FF4444;
--danger-strong: #AC0E0E;
--danger-alt: #CC3232;
--warning: #FFC300;
--gauge: #FFD700;
--success: #198E13;
--info: #0E77AC;
--neutral-mode: #777777;
--overheat: rgba(255, 0, 0, .7);
--scrim: rgba(0, 0, 0, .5);
/* type */
--font-display: 'Sonsie One', serif;
--font-ui: system-ui, -apple-system, 'Roboto', 'Helvetica Neue', sans-serif;
--fs-xs: 11px; --fs-sm: 14px; --fs-base: 16px; --fs-md: 18px;
--fs-lg: 20px; --fs-xl: 22px; --fs-2xl: 24px; --fs-3xl: 30px;
--fs-4xl: 36px; --fs-logo: 60px;
--fw-regular: 400; --fw-medium: 500; --fw-semibold: 600; --fw-bold: 700;
--ls-wide: .5px;
/* shape */
--r-xs: 2px; --r-sm: 5px; --r-md: 8px; --r-lg: 10px;
--r-card: 12px; --r-xl: 20px; --r-pill: 25px; --r-full: 999px;
/* spacing */
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px;
--sp-5: 20px; --sp-6: 24px; --sp-8: 32px;
--glow-accent: 0 0 6px rgba(105, 210, 223, .3);
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: var(--fs-base);
padding: 0 var(--sp-5);
}
9. Getting the feel right
Five things that carry most of the app's character:
- Pure black page, one card grey. Resist a layered grey hierarchy —
#000and#1D1D1Dwith nothing in between is the look. - Cards separate by contrast, not shadow. No elevation on surfaces at all.
- Cyan is an edge colour. Fills, thumbs, 2 px dividers, selected borders. The gauge is gold; the accent never fills a large area except the battery bar.
- Numbers are big and unit-paired. Value and unit on a shared baseline, 4 px apart, unit one step smaller and the same weight.
- One display font, used twice. Sonsie One for the wordmark and one heading; everything else is system sans. Resist spreading it around.
10. Caveats
- Colours were harvested from the decompiled bundle, scoped to the app's own
module range to exclude library defaults and inline SVG style blocks (the
bike illustration alone contributes ~100
#000declarations that are SVG defaults, not UI colour). - Names like
--surface-raisedare mine; the app has no token names. - The app is an
expo-updatesbuild, so a device may be running a newer JS bundle than the one in this APK.