Visual Identity

The mobile app's type scale, spacing rhythm, and two designed themes

The mobile app has one design system — a six-step type scale, a 4px spacing rhythm, and two deliberately designed colour themes — driven entirely by CSS custom properties you reuse instead of writing literals.

Diagram

Visual Identity

🔤 Type scale

📱 Rendered screen

📐 Spacing rhythm

🎨 Two themes

Quick Start

  1. Use a token

    Style a component from tokens — never from raw hex, px, or font-size literals.

    css
    .card {
      color: var(--ink);
      background: var(--chrome);
      padding: var(--space-4);
      border-radius: var(--radius);
      font-size: var(--text-base);
    }
  2. Follow the two themes

    The same tokens switch automatically under prefers-color-scheme: dark. Your component needs no dark rule of its own — style once, both themes render.

    css
    :root { --ink: #14171c; }
    
    @media (prefers-color-scheme: dark) {
      :root { --ink: #e8eaed; }
    }

How It Works

Four decisions define the identity — each one defends a specific cross-platform edge case.

Diagram

🔤 System font stack
committed to

📱 Rendered UI

🏋️ Three weights only
400 / 500 / 700

📏 Type scale in two bands
12/13/14 · 16/18/22

📐 rem spacing
not px

DecisionWhy
System font stack, committed toA webfont with only Latin subsets leaves Arabic/CJK users reading two typefaces; index.html paints the wordmark on the first frame; app.css is render-blocking under font-src 'self'.
Three weights only400/500/700 are the faces Roboto ships. A request above 500 searches upward, so 550 rendered as full-bold 700 on Android while resolving to 550 on iOS.
Type scale in two bands12/13/14 for information, 16/18/22 for hierarchy — deliberately not geometric, because a constant ratio lands on fractional pixels at the small end.
rem spacing, not pxAndroid’s font-scale reaches a WebView as a text zoom, so rem spacing grows with the type while px spacing does not.

Design Tokens

Every value below is verbatim from app.css. Reach for the token, never the literal.

Font stacks

TokenValuePurpose
--sanssystem-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serifEvery UI element
--monoui-monospace, SFMono-Regular, Menlo, "Roboto Mono", monospaceCode, tool output

Weights

Only these three exist by design.

TokenValuePurpose
--weight-normal400Prose, message text
--weight-medium500Labels, chat titles — heavier, never bold
--weight-strong700Headings, wordmark

Type scale — information band (12/13/14)

TokenValuePurpose
--text-xs.75rem (12px)Eyebrows, tool meta, dropped-event counts
--text-sm.8125rem (13px)Help notes, timestamps, secondary states
--text-md.875rem (14px)Buttons, reasoning, secondary UI

Type scale — hierarchy band (16/18/22)

TokenValuePurpose
--text-base1rem (16px)Message text, inputs
--text-lg1.125rem (18px)Screen headings, the empty-state title
--text-xl1.375rem (22px)The boot wordmark, and nothing else

Line-height & tracking

TokenValuePurpose
--leading-tight1.25Headings — short lines, few of them
--leading-snug1.4UI text, one-line rows
--leading-normal1.55Message prose, read for minutes at a time
--tracking-eyebrow.09emSettings section eyebrows

Spacing scale — 4px grid, in rem

TokenValuePixels
--space-1.25rem4
--space-2.375rem6
--space-3.5rem8
--space-4.75rem12
--space-51rem16
--space-61.5rem24
--space-72rem32
--guttervar(--space-4)12 — the number main.ts also writes inline

Radius

TokenValuePurpose
--radius-sm8pxNotch on the user bubble’s trailing-bottom corner
--radius12pxDefault component radius
--radius-lg16pxThe user bubble body
--radius-pill999px.jump-latest, .tool-status chips

Colour — both themes, side by side

Every token carries a light and a dark value in one comparison. --chrome is lighter than --ground in both themes (chrome floats above the page); --panel flips — below --ground in light, above it in dark, because a control reads as distinguished by sitting nearer the light source.

Surfaces / neutrals

TokenLightDarkRole
--ground#f7f8fa#0f1114Page background
--chrome#ffffff#16191fTopbar, composer, tool rows — lighter than --ground in both themes
--panel#eceff4#1c2027Inset surfaces (lead settings row); flips across themes
--rule#dfe3e9#262b33Hairlines
--rule-strong#c8ced7#39414cStronger dividers

Ink

TokenLightDarkRole
--ink#14171c#e8eaedPrimary text
--soft#5b636e#9aa1acSecondary text (help, timestamps)

Accent & user message

TokenLightDarkRole
--accent#1f7a63#7fd1b9Primary buttons, the focus ring, theme-color
--accent-ink#ffffff#0f1114Text on --accent
--user-bg#1f7a63#254f43User bubble background — dark steps 2.05:1 down from --accent so a column of bubbles is not the brightest object on screen
--user-ink#ffffff#e8f3eeText in the user bubble

Status

TokenLightDarkRole
--bad#a6413c#e58b86Failure text/border
--bad-bg#fbeceb#2e1c1bTinted background for .row-error, .row-tool[data-status="failed"]
--warn#b34700#f78c6cWarning text/border
--warn-bg#fdf1e7#2e2219Tinted background for .row-approval, unresolved tool state

Tone — defined once, self-updating in dark because they reference the tokens above.

TokenValueUsed for
--tone-neutralvar(--soft)Default notice colour
--tone-pendingvar(--soft)Tool status “running”
--tone-successvar(--accent)Tool status “ok”, success notice
--tone-failurevar(--bad)Tool status “failed”, failure notice
--tone-warningvar(--warn)Tool status “unresolved”, warning notice

Two behaviours you can rely on

Focus ring — never suppressed for keyboard

The base ring is :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px }. To drop the outline on programmatic focus (e.g. route navigation to .screen-heading), scope the removal so it never touches keyboard focus.

Diagram

Yes :focus-visible

No programmatic

⌨️ Element focused

Keyboard?

✅ 2px accent ring shown

🔇 outline suppressed

css
.screen-heading:focus:not(:focus-visible) { outline: none; }

A bare .screen-heading:focus { outline: none } beats :focus-visible on specificity (0,2,0 vs 0,1,0) and takes the keyboard ring with it. src/praisonai-mobile/app/src/css.test.ts pins this by rejecting any :focus outline suppression that is not scoped with :not(:focus-visible).

WCAG AA in both themes

Every text pair in the stylesheet clears WCAG AA — the worst pair measures 4.5:1 in light and 6.3:1 in dark on the merged file.

Diagram

🎨 Every text/background pair

🔍 Contrast sweep

📱 3 screens

⚠️ real error row

✅ ≥ 4.5:1

The sweep runs on the three screens and the real error row.


Chat message treatment

The user’s row keeps its bubble, its alignment, and gains one logical corner notch.

css
.row-user {
  border-radius: var(--radius-lg);
  border-end-end-radius: var(--radius-sm);
}

The notch is logical (border-end-end-radius), not physical — under dir="rtl" it follows the reader, not the LTR page. The assistant’s row loses its fill: a 100%-wide filled box is a container with prose in it, not a bubble, and stacked down a transcript it read as a column of grey slabs. Alignment, data-speaker, and the visually-hidden “You said:” span carry the speaker in the accessibility tree.


Tool-status chip — status as text and colour

A <span class="tool-status"> renders strings.toolStatus(row.status) as words (from dom.ts) and is coloured by --tone-* per data-status.

data-statusChip colourBorder
running--tone-pending (= --soft)--rule
ok--tone-success (= --accent)--accent
failed--tone-failure (= --bad)--bad
unresolved--tone-warning (= --warn)--warn

Before this, unresolved had no rule at all and rendered pixel-for-pixel identically to running.


The gutter coupling (contributor note)

--gutter: var(--space-4) (= 12px) is the one number in app.css also written elsewhere: app/src/main.ts sets the composer’s padding-inline-start/end inline as calc(<inset> + .75rem).

src/praisonai-mobile/app/src/css.test.ts has a remOf() / addendOf() helper pair that resolves --gutter through the :root token table (following the var() indirection through --space-4) and asserts the resolved rem equals the addend parsed from main.ts‘s inline padding template. The same test asserts that .screen-settings, .screen-chats, .transcript, and .topbar each build both right and left inset padding as calc(var(--inset-<side>) + var(--gutter)). Change the gutter in one place; the test catches drift.


Common Patterns

Compose a component from tokens.

css
.notice {
  color: var(--tone-neutral);
  background: var(--chrome);
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius);
  font-size: var(--text-sm);
}

Colour by meaning, not by hue. Use the tone tokens so “success/failure/warning” can be re-tuned in one place.

css
.tool-status[data-status="failed"] { color: var(--tone-failure); }

Keep the keyboard ring when you quiet an outline.

css
.screen-heading:focus:not(:focus-visible) { outline: none; }

Best Practices

Use tokens, not literals

Every colour, spacing, radius, and type step has a token; a literal skips both themes.

Never suppress :focus without :not(:focus-visible)

The css.test.ts gate will fail your PR — a bare :focus { outline: none } out-specifies :focus-visible and removes the keyboard ring.

Reach for the tone tokens, not –bad / –warn directly

When the meaning is “success/failure/warning”, use --tone-* so they can be re-tuned without touching every call site.

Pick from three weights, not five

Requests outside 400/500/700 land unpredictably across platforms (Roboto vs SF).


Related