Skip to main content
8 min read

Type, color & stroke

Typography

The more important the data, the larger the font. Most SCROLL users view from 10–30 feet; small text presents really small at that range. Size the hierarchy by importance, and let the hero dominate.

Fonts are bitmap and UPPERCASE ONLY.upper() everything; 5x5 is the sole font with a full lowercase set. Roles, by catalog usage:

RoleWide (128+)Narrow (64)
Eyebrow / label / meta / chip4x5 (the workhorse)4x5
Body & list rows5x7 (or 4x7)4x5
Sub-head6x85x7
Hero value (one per page)16x20 / 16x2410x16
Hero on a filled background10x16_outline10x15_outline
Footnotepicopixel / 4x5picopixel

All 40 faces, at real size: Fonts reference.

Design for the worst case, not the average

The longest possible string is the design point. If the app shows MLB teams, "WASHINGTON" is the string that must fit — or there must be a plan: truncate deliberately, or switch to an abbreviation that fits the space.

Fit with a ladder, then clip by hand

c.text_fit() picks the biggest font that fits — but when even the smallest overflows it still draws (nothing in the API clips). The catalog's answer, in 80 apps, is a _fit_clip(c, text, fonts, maxw) helper: pick the largest that fits, then hard-clip. Canonical ladders:

NODATA_FONTS = ["10x16", "6x8", "5x7", "4x5"] # error titles, 40 apps verbatim
["6x8", "5x7", "4x5"] # narrow headline
["10x16", "6x8", "5x7"] # wide headline

Clip at a word boundary when the tail is a whole word, but not when that costs more than ~30% of the string. When a wrapped block drops lines, append ".." so the cut reads as deliberate.

No overlapping text, ever

Text may only sit on other drawn pixels when text_stroke separates it from what's behind. Measure before committing (c.text_width, or the measure_text tool over MCP); a right-aligned draw with no bound grows leftward into its neighbor the day a longer string appears.

Font traps (all silent)

  • 3x4 has no space glyph — "HARD FREEZE" draws as HARDFREEZE. Single words only.
  • 8x12's - glyph is a solid block — skip it in ladders for hyphenated strings.
  • Starlark has no font-metrics call, so carry a height table: FONTH = {"16x20": 20, "10x16": 16, "10x15": 15, "6x8": 8, "5x7": 7, "4x5": 5}.

The outline fonts

10x15_outline and 10x16_outline carry a border inside the glyphs — every letter arrives already outlined, in the one color you pass. They are the hero faces for a lockup on a filled or tinted background, and they need no text_stroke.

10X15 in the solid face above OUTLINE in the outline face, both sky blue on black1 USD BUYS in the outline faces, gold on a dark gold gradient
Left: 10x15 over 10x15_outline. Right: the outline faces on a tinted ground — the edge is in the glyph, so the fill can't swallow it.

Color & contrast

High contrast is a requirement, not a taste. These are LEDs viewed across a room — high-contrast text against the background is what pulls information off the screen and into the eye.

  • Limit full-colored backgrounds. Black backgrounds are very good — they are the cheapest high contrast there is. On scroll apps especially, avoid full-color background images when they aren't necessary (they also fight the apps before and after in the rotation).
  • If a full-colored background is used, stroke the text.

c.text_stroke()

c.text_stroke(s, x, y, font="5x7", color="white", stroke="black", thickness=1, align="left")

text_stroke draws a border around every glyph (it mirrors the panel's drawTextWithStroke). When the goal is contrast on a colored background, black is the stroke to reach for — and the API's default — because it restores the black-background contrast locally around each letter.

White and sky-blue text on a striped blue gradient with a gold sun — the letters blend into the stripesThe same text with a one-pixel black stroke — every letter separates cleanly from the background
Same background, same text. Left: c.text. Right: c.text_stroke(…, stroke="black").

But stroke= is a general parameter, not a contrast-only tool. A colored stroke as a design choice — white text with a blue outline, say — is fully supported and yours to call. thickness=2 doubles the border. Give pixel art a creative outline for the same separation.

BLUE OUTLINE in white with a blue stroke, and THICK in cream with a two-pixel purple stroke
stroke="#2F6FDC", and stroke="#7521F9", thickness=2. A design choice, not a fix.

The catalog agrees on the default: the only shipped apps using text_stroke are exactly the full-background ones (sun-arc, rain-radar).

What the colors mean

Semantics the catalog agrees on:

  • White is the resting color for live numbers — "which leaves amber and red free to mean something the moment they appear" (citi-bike). Gray (gray / #6E7A94) for labels.
  • Green = up / open / ok · amber = attention · red = alarm. "Red is the only state allowed to shout; everything else is built quiet enough that it can."
  • Keep brand and alarm distinct (todoist-scroll: brand #E44332, overdue #FF3B30 — "so brand and alarm never fight").
  • Thresholds are banded [label, color] pairs from one function, so the color and the word can never disagree.
  • Past / present / future = full / dim / gray — declare dim twins, or use color.dim(col, pct) for tracks and washes.

The palette is LED-tuned

The named palette is tuned for the panel (green is (0, 220, 70), not lime; brand purple is #7521F9; skyblue is #78DCFF) — see every swatch on the Drawing API page. Panels are RGB565, so subtle shades collapse: keep values punchy and separated.

Near-black tinted fills and vertical near-black gradients (#0A1220#1E3350) are the catalog's compromise between a mood and a black ground — never a bright fill. On filled chips and rows, compute contrast: flip the text to black when the fill's brightness passes ~150.

Next