Internal reference · not part of the public site. This page loads
apps/pwa/src/styles.css directly — every component below is rendered with the
app's real, live CSS, not a copy. It's a reference to check new/edited screens against, not a
spec for how things should change. If a screen doesn't match what's shown here, that's drift
worth fixing; if you deliberately change a convention in styles.css, this page
updates automatically since it's the same stylesheet.
Every color in the app is a CSS custom property on :root. Two groups: fixed
brand/pillar accents, and theme-computed tokens that change per-user (page tint, panel appearance).
Defined once in presets.ts's PALETTE and mirrored as CSS vars
in styles.css's :root — except the Habit pillar's rose accent (#FF6B9D), which today
only exists as a JS value (applied inline per pillar), not a root CSS var. A pillar's color always
comes from this set; don't pick an arbitrary hex for a new preset.
background: linear-gradient(160deg, var(--bg1) 0%, var(--bg2) 100%) on
html and body — this very page's background is that exact rule, inherited from the
linked stylesheet. In Settings → Appearance the user can pick a "Solid" background instead — theme.ts
repoints both vars to the same solid color, so nothing that reads --bg1/--bg2 needs to
know which mode is active.
--panel-color is the user's customizable accent (set via the color picker in Settings)
— it drives primary buttons, active tab rings, progress fills, links. Never hardcode a hex where this
should be used; it's the one color in the app that's meant to change per user.
--panel-text / --panel-muted / --btn-ink aren't swatches so much as computed contrast
answers: theme.ts checks whether --panel-color (for --btn-ink) or the
effective panel background (for --panel-text/--panel-muted) is light or dark, and
picks black or white text accordingly. Use these vars for text on accent surfaces instead of assuming white.
Two typefaces, loaded once, with a strict division of labor.
Used sparingly; most UI text is 600–800 weight, never 400.
Rule of thumb: Baloo 2 is for things that should feel like a heading or a big number (page titles, section titles, stat values, calendar day numbers). Everything else — buttons, body text, nav — is Nunito. Don't apply Baloo 2 to a button label or body paragraph.
A named scale, not pixel values — the class tells you the gap without doing math.
Only three radius values in the whole app: var(--radius) — 12px, used
everywhere (panels, cards, inputs, stat-tiles, date-groups). 100px (fully round) for buttons, pills, tags,
tabstrip. 50% for icon buttons and avatars. Don't introduce a fourth value.
The two surface containers everything else sits inside.
Default content container — forms, detail screens, settings groups. 20px padding.
Dashboard grid tiles, list items. 18px padding, hover lift.
.notice — smaller text for inline callouts (privacy notices, hints), full contrast since it's content someone actually needs to read.
All three redefine --paper/--muted to --panel-text/--panel-muted
internally, so any text inside inherits a contrast-safe color against the panel's own background —
this is what keeps text legible whether the panel sits on a dark gradient or a light custom page color.
See Common mistakes for what breaks this.
One shared look for every input type.
No form field carries a separate visible <label> — the field's purpose is the
placeholder text (.input/.textarea) or its selected option
(.select, which has no placeholder concept). .field still wraps each
control for consistent vertical spacing; it just no longer has label text as a child.
.input, .textarea, and .select share padding, radius,
border, and background — a new field type should reuse one of these classes, not invent new
input chrome.
Small inline status/metadata chips.
.pill and .btn-sm deliberately share the same vertical
padding/font-size so they sit flush at the same height when placed side by side in a row.
Numeric summaries — budgets, streaks, time tracking.
.progress-fill uses --panel-color normally, and swaps to
.over (--danger) only for an explicit over-budget/over-limit state — not as a
general "warning" color elsewhere.
Flex-row compositions used for list items and layout — combine modifiers instead of ad hoc inline flex styles.
Modifiers: .row-between (space-between), .row-end,
.row-top (align-items: flex-start), .row-nowrap, .row-tight (6px gap).
.entry-line adds the top divider and drops it on the first child.
The recurring ways a screen drifts from the rest of the app.
Hardcode hex colors in component files. Every color that isn't a one-off
illustration value already exists as a CSS var (--teal, --panel-color,
--danger, …). A literal hex bypasses per-user theming and light/dark contrast handling.
Set text color manually inside a .panel/.card/.modal-panel/.menu-panel/.notice.
These already redefine --paper/--muted to the panel-safe versions. Setting a
fixed white (or var(--paper) from outside that container) reintroduces the exact
white-on-light-panel contrast bug this pattern exists to prevent.
Assume white text on an accent surface. A user-chosen --panel-color
can be light (e.g. pale yellow) or dark. Buttons and colored surfaces must use --btn-ink /
--panel-text, which theme.ts computes per-color, not a fixed white or black.
Apply Baloo 2 to body text, buttons, or labels. Baloo 2 is reserved for headings and big numerals. Everything interactive or body-level (buttons, fields, pills, nav) is Nunito — mixing them in is the fastest way to make a screen feel off-brand.
Use one-off margin/padding values. Use .mt-sm/md/lg and
.mb-sm/md/lg (8/12/16px) instead of inline one-offs — new arbitrary values make
vertical rhythm inconsistent screen to screen.
Invent a new radius value. Only three radius values exist:
var(--radius) (12px, panels/cards/inputs), 100px (pills/buttons/tabstrip), or 50%
(icon buttons/avatars). A fourth value reads as a mistake, not a design choice.
Pick a button color by feel. It's purely contextual, not a design choice per
button: outside any panel/card, use .btn (filled, primary). Inside a panel/card, use
.btn-ghost — except a destructive action, which is always .btn-danger.
There's no other variant to reach for.
Wrap a form control in a visible <label>. No field has
separate label text. An .input/.textarea carries its label as its
placeholder; a .select has no placeholder equivalent, so it carries none —
its selected option is descriptive enough on its own.
Apply --muted to text someone needs to actually read.
--muted (65% opacity) is for genuinely secondary micro-text — tab labels, timestamps, hex
codes — not for body copy, notices, or explanations. Low-contrast prose is a legibility bug, not a
visual-hierarchy choice; if it's more than a few words of real content, it's --paper.
Compose row/spacing modifiers instead of new flex CSS. Before writing a new
flex/justify-content block, check whether .row + a modifier (.row-between,
.row-tight, …) already does it — most list/header layouts in the app are this same
composition.