Gradients
Gradients in Nockerl are purposeful, not decorative. They appear in a few specific, codified places and never as a generic background wash. Every one is a static fill, never a colored glow, never tweened between two fills. All stops are color tokens, so a gradient re-themes light/dark by construction and carries no hardcoded hex.
Where gradients are allowed
Section titled “Where gradients are allowed”There are exactly four sanctioned gradients. Each is a fixed recipe (angle + token stops), mirrored across web, Android, and Swift.
1. The accent fill: a two-stop vertical cyan
Section titled “1. The accent fill: a two-stop vertical cyan”The primary fill that runs the whole accent ladder. A top-down (180deg) cyan gradient
whose lighter top stop reads as a catch-light, exactly like a surface lit from above:
background: linear-gradient(180deg, var(--color-accent-primary-hi), var(--color-accent-primary));| Stop | Token | Dark | Light |
|---|---|---|---|
| top (catch-light) | --color-accent-primary-hi |
#2EC9E4 |
#2BA0BD |
| bottom (brand) | --color-accent-primary |
#0CC0DF (cyan-500) |
#0891B2 (cyan-700) |
The bottom stop is the brand anchor; -hi is a lighter cyan defined solely as “the
lit-from-above top of a primary fill gradient.” This one recipe is shared verbatim by the
primary Button, and the filled/ON states of
Switch, Checkbox,
RadioGroup, SegmentedControl,
Stepper, and the ProgressTrack bar.
One fill, one place to change it. It always ships with a top surface-highlight inset and a
neutral drop shadow (see the elevation law), never a
colored glow.
2. The elevated-surface sheen: surface2 → surface1
Section titled “2. The elevated-surface sheen: surface2 → surface1”Floating surfaces (menus, popovers, and other lifted panels) fill with a subtle top-down lightening from the next surface tone down to the card tone, which reads as a gentle sheen on a raised panel:
background: linear-gradient(180deg, var(--color-card-surface2), var(--color-card-surface1));| Stop | Token | Dark |
|---|---|---|
| top | --color-card-surface2 |
#333942 |
| bottom | --color-card-surface1 |
#2C313A |
3. The ground top-light: a dark-only radial wash
Section titled “3. The ground top-light: a dark-only radial wash”The dark app ground is lit from above by a single large, off-center radial that fades to the near-black canvas. It is a page-level light, not a brand color:
background: radial-gradient(1100px 600px at 82% -8%, #12161b, transparent 60%), #0a0b0d; /* --color-canvas */Applied to body in the dark theme only; the light theme is a flat neutral ground
(#f3f4f6). The wash is a barely-lighter neutral (#12161b over canvas #0A0B0D), again a
catch-light read, not an emission.
4. The faceted field: the Nockerl signature backdrop
Section titled “4. The faceted field: the Nockerl signature backdrop”The low-poly faceted field is the Nockerl signature surface: a jittered triangle mesh
(the Nockerl mark is a triangle) whose facet luminance drifts in a slow diagonal tone-wave so
the field is “active, not busy.” It is not a CSS gradient. It is a token-colored <canvas>
that paints a flat ground, then re-tints each facet per frame. It has one codified look:
no intensity / density / speed knobs and no variants.
It lives in one primitive, FacetedBackground
(site/src/components/primitives/FacetedBackground.tsx), which is the web mirror of the two
shipped native implementations, the canonical truth being Android:
- Android (canonical):
chat/ui/ChatFeedBackground.kt(Compose Canvas) - Voice (Swift port):
UI/FacetBackground.swift(SwiftUI Canvas)
Every visual parameter is a frozen constant pulled 1:1 from those native sources. Editing the look means editing these in lock-step everywhere. There is deliberately no runtime control, so the field looks identical on every platform by construction:
| Constant | Value | Native name | Role |
|---|---|---|---|
CELL_PX |
128 |
FACET_CELL_SIZE (128.dp; Swift port 130) |
mesh cell size |
JITTER_FRACTION |
0.34 |
n/a | point stray as a fraction of the cell |
STATIC_JITTER |
0.022 |
STATIC_FACET_JITTER |
baked per-facet tonal grain |
AMPLITUDE |
0.05 |
WAVE_AMPLITUDE |
peak tone-wave luminance swing |
PERIOD_MS |
18000 |
WAVE_PERIOD_MILLIS |
one full diagonal wave cycle (~18s) |
EDGE_WIDTH |
0.75 |
n/a | facet hairline width (device px) |
EDGE_ALPHA |
0.05 |
EDGE_ALPHA |
barely-there facet hairline |
Color is token-only: the canvas reads the live computed --color-chat-bg (facet base,
#181B20 dark) and --color-canvas-edge (hairline, #2A2D33 dark) via getComputedStyle,
so a token edit re-tints the whole field and it themes light/dark. The mesh is built from a
deterministic hash (jitter01) so it never re-rolls or shimmers, and each cell’s diagonal
alternates by (col+row) parity so the field never looks like a striped lattice.
How it animates without breaking the static-fill law: the fill is never tweened. Each
frame the canvas re-tints existing facets by a cheap per-channel luminance add
(AMPLITUDE · sin(phase + facet.diagonalPos · 2π) plus the facet’s baked staticDelta),
which is hue-preserving, so facets stay neutral. This is the same trick as Compose drawPath /
SwiftUI Canvas: paint, don’t interpolate a Brush.
The static-fill law
Section titled “The static-fill law”All four gradients obey design law 7 (motion):
- Gradients are static fills. Feedback never tweens between two gradients: a gradient
swap can’t interpolate in CSS or Compose
Brush; it hard-cuts and flashes. State feedback rides on brightness, transform, and shadow over the top. (The primary Button, for example, keeps its fill fixed and animatesfilter: brightness()+translateY+ a neutral shadow on hover/active.) - A gradient is never a colored glow. Depth stays a neutral shadow + top catch-light,
never a colored gradient standing in for emission. The accent fill’s lighter top stop and
the surfaces’
surface-highlightinset are catch-lights, not neon. - The faceted field re-tints, it does not tween. Its motion is a per-frame luminance shift on fixed geometry, a static field that breathes, not an animated gradient.
Not gradients
Section titled “Not gradients”For contrast, these are not part of the gradient system and should not be reached for as one:
- Resting cards / panels are flat
--color-card-surface1; lift is shadow + catch-light. - Soft accent fills (secondary/toggle-pressed buttons, selection washes) use a single
translucent
--color-accent-primary-soft(cyan at ~16%), a flat tint, not a gradient. - Edge fades (e.g. scroll-overflow masks on Tabs) are a color-to-transparent fade used purely to hint clipped content, not a decorative wash.