Foundations · 01
Colour
Seven core colours, four accent ramps and a nine-step neutral scale. The site lives in the neutral scale — the accents are events, not surfaces. The colour names stay in German: they are the brand's own names for them.
Core palette
Click to copy the hex value.
Seven, and no eighth
The dark surface of the site — footer, logo plate, consent banner, nav bar — is
Schwarz, one of the seven. It used to be an eighth token, an
anthracite #1B2022 sampled out of the mockups and declared as
--cf-anthrazit because it could not be mixed from the palette. That was
the reason to keep it and it was not a good enough one: anthracite and black differ by
1.2:1, which is a difference nobody can see except as a mistake when
the two land next to each other — and they did, on the solid button, whose rest state
was anthracite and whose hover was black.
So the eighth colour is gone. --surface-inverse resolves to
--cf-schwarz, every dark plate in the system is the same black, and the
palette is exactly the seven the manual names. Where a dark surface needs a second
value — the solid button's hover, a raised panel inside a dark section — it comes from
the neutral ramp, which is where every other surface in the system already comes from.
--surface-raised is
grey-800 and --surface-sunken is a 6 % white veil, which
renders #0F0F0F over Schwarz and keeps their old order and separation.
The light theme used to climb toward white as well, and no longer does. It cannot:
the wash it sits on ends at white, so a lighter step has nowhere left to go
and dies at the bottom of every screen. See
A step away from the page is a ratio.
A step away from the page is a ratio, not a grey
--surface-sunken was grey-100, #E7E7E7. That
number is only meaningful against --surface-base, CF-Grau — and
no page in this system is painted CF-Grau. Every page carries
.page-wash, and the wash is background-attachment: fixed at
background-size: cover. Fixed and cover together mean it spans the
viewport rather than the document, so it does not scroll with the page: what
is behind any element is a function of where that element currently sits on screen,
and it runs the full CF-Grau-to-white every single time. The panel steps were
calibrated against a colour that exists in the top row of pixels and nowhere else.
Measured on patterns/landing-page.html at 1280 × 900,
walking the process card's note block down the viewport and sampling the wash beside
it out of the same screenshot row:
| Note at | Wash beside it | #E7E7E7 reads | 6 % veil reads |
|---|---|---|---|
| 14 % of viewport height | 210 | +21 — raised | −13 |
| 44 % | 222 | +9 | −13 |
| 65 % | 231 | 0 — invisible | −14 |
| 86 % | 241 | −10 — sunken | −14 |
An absolute grey inside the wash's own range is raised at the top of the screen, gone at two thirds of it, and sunken below that: one block that changes which way it steps as the reader scrolls past. Nothing renders wrong — the material simply is not one. A veil is the same declaration made relative. Six per cent black removes six per cent of whatever light is there, which is a constant 0.858 ratio in linear light at both ends of the wash, so the step is the same step everywhere.
Contrast moves with the surface and the floor is the CF-Grau end:
--text-secondary measures 5.19:1 there, 6.48:1 in the
middle and 8.03:1 at the white end; --text-primary never drops below
11.91:1. --text-muted still may not sit on it, for exactly the reason it
may not sit on CF-Grau itself.
--surface-card is retired. It was
grey-050 — the same value as --surface-raised, so wherever
the two were stacked one of them painted nothing — and its only users were the process
card and the stacked form of the Expertise step. Both are contour on the wash now,
which is what Materials already claimed they were.
A panel that needs a surface takes the veil; a panel that needs a boundary takes a
contour. There is no third thing to reintroduce.
Ramps
Each accent is mixed with white (100–300) and with black (700–900). Step 500 is always
the brand colour. UI states — hover, disabled, backgrounds — use these steps only,
never opacity on the 500.
Neutral scale
The actual working palette. CF-Grau is step 300 — the anchor, not white.
--grey-400 (#919191) does not come from the manual but from
the Figma redesign: it is the colour of every mono label and meta line. That is why it
sits deliberately next to --grey-500 in the scale.
#F1F1F1 but
labelled #D9D9D9. The painted value is the arithmetically correct
one — mixing CF-Grau with 70 % white gives 0.7·255 + 0.3·207 = 240.6, i.e.
#F1. Sampling every one of the 35 swatches on that plate turns up exactly
this one mismatch; all four accent columns agree with their labels.
#D9D9D9 is kept in the scale as --grey-200 because the
mockups do use it as a UI grey — it simply is not a step on the white ramp.
The plate is on Source Material if you want to
check it yourself.
Semantic tokens
Components use these only — never a ramp value directly.
| Token | Value | Used for |
|---|---|---|
--surface-base | grey-300 | the base surface of the page |
--surface-sunken | black 6 % | inset blocks, note panels — a veil, not a grey, so the step is the same step wherever the block sits on the wash |
--surface-raised | grey-050 | figure plates that already carry a contour, and the hero still, which sits on video rather than on the wash |
--surface-inverse | black | footer, logo plate, nav bar, dark sections |
--surface-glass | white 46 % | frosted layer that carries text — the tint at which black type clears 4.5:1 over any backdrop |
--surface-glass-veil | white 4 % | frosted layer that carries only opaque objects; the blur does the work |
--surface-glass-solid | grey-100 | the opaque stand-in when the browser cannot blur, or the reader asked for less transparency |
--text-primary | black | headings, body copy |
--text-secondary | grey-700 | descriptions, secondary text |
--text-muted | grey-400 | mono labels, counters, meta |
--border-strong | black | the 1 px contour — the default frame |
--accent | lime | one lit element per object, and — where objects are sequential — one per screen. See below. |
--gradient-light | lime → glas → grey, 132.36° | isometric surfaces, primary button |
.cf-iso__light elements that are both inside the viewport and above
fill-opacity 0.15 — three pages carry four at once, by construction:
| Page | Lights | Most on one screen |
|---|---|---|
patterns/landing-page.html at 1440 × 900 | 4 | 4 — the pinned stage assembles all four cards in place |
| the same page at 768 and 375 | 4 | 1 — stacked, so they pass one at a time |
patterns/expertise.html | 4 | 4 — four fields sharing a row |
prototypes/expertise-scroll.html | 4 | 4 |
foundations/illustration.html | 3 | 2 — showing the vocabulary, not using it |
patterns/404.html, blog-artikel.html | 1 | 1 |
Every one of those fours is a layout fact rather than a timing one: objects that share a row, or a pinned stage, enter and leave together and no animation range can separate them. So either three pages are wrong or the sentence is, and the practice the system actually follows — everywhere, without exception — is one light per object. The per-screen reading is a consequence of that wherever objects are sequential, and only there.
animation-fill-mode: both — so a screen held every
light it had ever passed. Dimming each light on the exit phase of the
timeline that raised it took that 2 back to 1. Then .cf-pin arrived and
made the four cards simultaneous instead of sequential, the 2 became a 4, and the same
mechanism measured 4 before and 4 after — no change on any page in the tree. It was
removed rather than shipped. A mechanism whose justification you cannot measure is
one more thing for the next person to explain.
Gradients — one ramp, three rakes
There is only one gradient in this brand: lime falling away to CF-Grau, with Glas where the two meet. Lime is the light source, CF-Grau is the surface, and the single thing that varies is where Glas sits — how steeply the light rakes across. Three positions are sanctioned, in the same way four angles and four dash ratios are, and all three are measured off the designer's own files rather than chosen. Anything else is drift.
| Token | Glas at | Measured from |
|---|---|---|
--rake-near | 32 % | Figma 132.36° linear · 01-discovery.svg (0.32) |
--rake-mid | 51 % | Figma 239.25° linear · 03-weniger-ausfaelle.svg (0.51) |
--rake-far | 64 % | Figma 45.72 %/95 % radial · 04-mehr-leistung.svg |
The three swatches above are .material-rake, the shipping class, not a
drawing of it — --mid and --far move the rake and
--mirror reads the band from the other end, for a face turning away.
It is the light layer with its one free parameter exposed;
.material-light is the same ramp at the designer's 132.36° with his
inset stops kept verbatim. → Materials
These swatches were three inline style attributes, and an inline style
cannot be reached by the @supports (in oklab) block that carries the
rest of the family. So the drawing that teaches the ramp was the one place in the
system still painting it on the raw sRGB path: at the crossover of the lime leg,
sRGB gives #D4F66A where oklab gives #D3F798 — a
ΔEok of 0.045, the family's second-worst leg, and plainly visible at
7 rem square. Three more of the same literals sat on
Illustration. Composing the image on a class puts
all six back on the family's own path.
Figma states its stops across the whole fill box, with flat colour either side of the ramp. Renormalise to the band and they land on the rakes exactly: (40.45 − 24.97) ÷ (73.05 − 24.97) = 32.2 %, and (43.85 − 4.82) ÷ (81.19 − 4.82) = 51.1 %. The two numbers that had looked like two unrelated gradients are the same ramp at two rakes.
The two halves — sweep and bloom
Four of the six gradients in the Figma dump are radial. The ramp is the same one; what changes is whether the light arrives as a sweep across a face or as a bloom out of a point. Both halves are classes for the same reason — the ramp is written once and the rake, the angle and the shape are parameters.
| Class | Shape | Rake | The light is |
|---|---|---|---|
.material-rake--grazing | linear 239.25° | any; the dump's own is --mid | grazing a long surface |
.material-bloom | radial circle | --mid, mirrored | on the rim, surface at the centre |
.material-bloom--iso | radial 2:1 ellipse | --mid, mirrored | the same, on an isometric plate |
.material-bloom--top | radial 45.72 % × 95 % from 50 % 0 % | --far, inset 26.66 % | entering a surface from its top edge |
The two mirrored blooms put lime at the rim, so painted edge to edge on a rectangle
the outer half of the box is lime and the thing spends the screen's one lime moment
on a swatch. In use it is clipped — to a rhombus, a plate, a body of revolution —
and what the reader sees is the lit edge of an object with CF-Grau in the middle of
it. Both of the designer's own live inside a <path>. These four
are drawn full-bleed because this is the page that shows the ramp, the same licence
the three rakes above take. Do not copy the swatch; clip the bloom.
Its stops are lime 26.66 %, Glas 73.62 %, CF-Grau 100 %, so the band runs 26.66 → 100 and Glas sits at 26.66 + (100 − 26.66) × 0.64 = 73.60 % against the designer's 73.62. Two hundredths of a point — his exporter rounding 64.03 to the sanctioned 64. The inset is kept verbatim, because a lime core in the middle of a light source is part of the drawing; the rake under it is derived like every other.
The retired --gradient-bloom-iso cited
02-datenfundament.svg, scale(120 59.88), as its authority
for a 2:1 wide ellipse. The full transform is
translate(…) rotate(90) scale(120 59.88), and the rotate swaps the axes:
59.88 wide by 120 tall. The designer's ellipse is 1:2 tall, and so is
the Figma dump's (50 % 100 %).
It is tall because the thing it lights is. The rhombus under it in card 02 is
120 × 240 and the bloom is exactly half of it on both axes — a light fitted to an
object, not a projection rule. A bloom fitted to its object needs no token, because it
is drawn with the object in SVG, which is where both of the designer's live. What the
system needs a token for is the other thing: the circle seen in 2:1 isometry, which is
2:1 wide — the shape --field-bloom's pool of light already is.
So the shape is right and the citation was wrong, and the two were never in contact
until someone read the transform.
The CSS gradients interpolate in oklab where the browser supports it. SVG cannot:
its color-interpolation attribute offers only sRGB and linearRGB, so an
inline <linearGradient> is always sRGB. That split matters on the
legs that leave lime, and on no others. Lime crosses a wide arc of hue on its way to
anything, and on both of its legs the two paths pull apart far past the
just-noticeable threshold for a smooth ramp, with sRGB taking the darker, more
saturated route through chartreuse:
| Leg | ΔEok | Where |
|---|---|---|
| lime → Weiss | 0.04651 | the spectrum's first leg |
| lime → Glas | 0.04512 | every rake |
| Glas → Sky | 0.00148 | the glass edge |
| Glas → CF-Grau | 0.00073 | the tail of every rake |
| the page wash | 0.00071 | end to end |
| Weiss → Glas | 0.00045 | the panel sheen |
Everything under a thousandth stays on the sRGB path untouched. The two lime legs do not, and the difference between them is worth stating plainly, because a comment in the dark wallpaper once granted lime → Weiss the exemption that belongs to Weiss → Glas and left the mark running through the dip on the worst leg in the family. They are different legs. Only the second is exempt.
Every leg that leaves lime in an inline SVG therefore carries one waypoint,
#DBFC60 at 19 % of the leg, which is the colour the oklab
path passes through there — exactly, not approximately. The path at t = 0.19 comes back
as R 219.05, G 252.05, B 96.16, which is #DBFC60 at 8 bits, every channel
well inside its rounding margin. The hex is re-derivable rather than hand-tuned: recompute
the ramp and you get it back. One stop is enough: it takes the divergence to ΔE 0.0116,
below the threshold. Evenly spaced stops do not work — the curve bends hardest near
the lime end, so three even stops still leave ΔE 0.0119 while one placed at 19 % matches
it. The waypoint is scaled into each gradient's own offset space:
0.061 on the near rake, 0.097 on the mid rake,
0.905 and 0.822 on the two radials that run Glas → lime.
The lime → Weiss leg takes the same treatment at the same 19 %: #E6FF66,
at offset 0.0342 in the dark wallpaper's own space, which drops that leg
from ΔE 0.0465 to 0.0125. 19 % is the family's convention rather than that leg's
optimum — the best single waypoint for lime → Weiss sits at 21 % and buys 0.0013 —
and one number across every drawing is worth more than a thousandth per drawing.
A second waypoint reaches 0.0092 and no further on either leg, because what remains
is the short segment before the first stop, which nothing placed after it can
straighten. One stop, and no more.
Every ΔE above compares the two interpolation paths continuously, before the 8-bit quantization both are subject to. Rounding each path to 8 bits first adds a few thousandths of noise — invisible against the 0.044 on the lime leg, but several times larger than the real difference on the two near-neutral legs, where it makes 0.0007 read as 0.0037. Measure continuously, or the noise floor is what you measure. Sampled off rendered 8-bit pixels in Chromium the lime leg reads 0.045 and the waypointed leg 0.0124, which is the same few thousandths arriving as a floor.
The correction is not unconditional. The oklab gradients sit behind
@supports; the base CSS declarations are sRGB. A browser that fails that query
therefore takes the sRGB route in CSS while the SVGs carry the corrected path, which puts the
two back out of step by ΔE 0.034 — in the opposite direction, where before
they at least matched. Not the full 0.044: that is the gap between the sRGB path and the
oklab one, and what these two browsers disagree about is only the part the waypoint
corrected. That is the accepted cost: oklab gradient interpolation is supported
across all mainline browsers, so the affected population is small and shrinking, and the
alternative is holding the whole system to the worse curve for it.
The convention is now run rather than read. It held across twenty
gradients and broke on the twenty-first: all four lights on
Expertise were drawn on the near rake, Glas at
0.32, and carried the mid rake's waypoint offset of
0.097 — so the colour that belongs at 19 % of the leg was painted at
30.3 % of it. Nothing rendered wrong and no reference broke; the ramp simply held
lime-yellow half again as long as it should and then hurried into Glas, at
ΔE 0.0265 against the family's 0.0116, which is 60 % of the way
back to carrying no waypoint at all. scripts/check-gradient-family.py reads
every gradient the site ships and derives the answer instead of trusting the literal:
it recomputes both the waypoint's position, from the leg's own two ends, and its
colour, from the oklab path, so #DBFC60 and #E6FF66 are
re-derived on every run rather than checked against a list. It also catches the other
way this drifts — a stop that is almost a chromatic brand colour, which is what
#E0FF02 is. It runs in CI on every pull request.
The waypoint above puts an sRGB leg back on the oklab path. This is the same move one space further out, and it exists because oklab is not the right path for a leg that turns. The foil's whole claim is hue travel inside a narrow luminance band. Two stops of similar chroma and different hue sit at roughly the same distance from the neutral axis, so the straight line oklab draws between them is a chord, and the middle of a chord falls inside the circle. Chroma sags where no stop asked it to:
| Leg | chord | arc | midpoint | waypoint |
|---|---|---|---|---|
| Glas → Sky | 0.0383 | 0.0452 | +17.9 % | #B9E3EB |
| Sky → Violett | 0.0553 | 0.0588 | +6.3 % | #B8CCF3 |
| Glas 800 → Sky 800 | 0.0254 | 0.0289 | +13.6 % | #33494E |
| Sky 800 → Violett 800 | 0.0484 | 0.0513 | +6.0 % | #273650 |
A ramp that loses a sixth of its chroma halfway through its longest leg is greying out exactly where it is supposed to be turning, which reads as a fade with a colour cast at each end rather than as one material seen at an angle. This is not an argument for more saturation — that is the one thing the brand's definition of iridescence forbids, and this does not do it. Peak chroma on both ramps is unchanged: 0.0686 lit and 0.0620 ink, both still the endpoints' own values, and no point on either ramp is now more chromatic than the palette colour it is travelling between. The sag is removed; nothing is added.
The correct path is polar — and in oklch is not used.
OKLCH interpolates L, C and h each linearly, so it holds chroma across the turn, and it
was the obvious answer. It fails on a measurement rather than on a principle:
Chromium discards the hue of a low-chroma stop. Rendered and sampled,
linear-gradient(90deg in oklch shorter hue, #45524F, #283E4F) — Glas 800 to
Sky 800, the ink foil's first leg — comes out with hue pinned flat at Sky's 242.5° for
its entire length. Glas 800's own 179.95° never appears, at t = 0 or anywhere
else. Sweeping the start stop's chroma in steps of 0.004 at fixed hue puts the threshold
between 0.016 and 0.020: at or above about 0.018 the declared hue renders, below it the
neighbour's is carried forward. Glas 800 is C 0.0171. The one ramp with
the most to gain from a polar path is the one a polar path silently flattens — it would
ship 39.7° of its 102.2° of hue travel. Writing the stops as oklch()
literals renders correctly and was rejected for a different reason: it takes all three
ramps off the palette tokens.
So the arc goes into the ramp, as one waypoint per chromatic leg at that leg's
midpoint — exact where it sits, and engine-independent, which in oklch is
not. Max deviation from the true polar arc over 2,001 samples falls from
0.00722 to 0.00212 on the lit foil's chromatic legs and from
0.00741 to 0.00257 on the ink foil.
Every measured figure on this page survives, by construction rather than by luck. Polar interpolation moves C and h and leaves L alone, so each waypoint's lightness is the mean of its leg's endpoints and lands on the linear L path that was already there — within 9.2 × 10−4, which is the 8-bit grid and not the arithmetic. Band depths (0.090, 0.115, 0.180 counting the specular), hue travel (104.5° / 102.2°) and every endpoint are untouched. Re-measured over the whole ramp anyway: 11.900:1 on black, 5.239:1 on CF-Grau, 8.162:1 on white — the same three numbers to three decimals.
No waypoint on a leg that ends in grey, and that is where this stops.
--gradient-light, --gradient-light-90, the rakes, the blooms
and the page wash all run into CF-Grau or out of Weiss, and the lit foil's own
Weiss → Glas leg is the same case. An achromatic stop has no hue, so there is no arc for
a chord to fall short of: oklab's straight line from the neutral axis to a chromatic stop
already is the constant-hue radial. Only a leg chromatic at both ends turns, and
only a leg that turns can cut a corner.
And no waypoint on a leg carrying lime, which is the second half of the
same sentence and was left unwritten because it is invisible on the four legs above —
every one of them sits inside the foil's narrow band. The chord argument has two premises,
not one: the two stops must differ in hue and sit at roughly the same
distance from the neutral axis. Lime is C 0.2201 and Glas is C 0.0414, so
the move between them is radial rather than tangential — the polar path through it bows
out to #A8FFB6, a green in no palette at C 0.1310, three times the
chroma of the stop it is travelling to. That is the achromatic case again and it takes the
same answer: oklab's straight line is already correct. It is exactly why the light family
is corrected with in oklab and the SVG waypoint while the spectrum and the
two foils are corrected with an arc. Lime is the light source and CF-Grau and Weiss are
what it falls away to; a leg touching any of the three is a falloff, and only
what happens in the band between them turns.
The four hexes are literals, like #DBFC60 — and they are now derived
on every run rather than trusted. They carried the same trap the SVG waypoint did
before it was checked: they do not follow the palette, so moving an endpoint left the ramp
quietly back on the chord.
scripts/check-gradient-family.py reads the shipping stylesheets as well as
the drawings now. It resolves each var() down to a colour, recognises a
waypoint by its being the polar midpoint of the two stops either side of it, and
then compares that value exactly — so #B9E3EB, #B8CCF3,
#33494E and #273650 are recomputed from OKLCh on every run and
never checked against a list. A leg that turns and carries nothing is reported as a chord.
It checks one more thing, and that one had already slipped. A ramp
carrying a lime leg has to appear somewhere on the oklab path, because lime → Glas is the
single leg where the two paths actually part — ΔEok 0.0443, against 0.0015 and below
for every other leg in the family, which is why --glass-edge
and --sheen-panel are left in sRGB by name. Every lime ramp in the system was
on that path except one: .cf-btn--glass drew Glas into lime with no
@supports branch at all, measuring ΔEok 0.03866 composited
over CF-Grau at its own alphas — seventy-nine times the divergence of the layer one step
above it that the family deliberately leaves alone. Its stops are a custom property now
and only the path changes, the way .material-rake and .text-foil
already did it. Nothing about the drawing moved: the rim's 62 % window is measured off
where this face's lime becomes visible and is unaffected by an interpolation path.
| Token | Shape | Used for |
|---|---|---|
--gradient-light | linear 132.36°, near | the signature — isometric surfaces, .material-light |
--gradient-light-90 | linear 90°, near mirrored | primary button, horizontal edges |
.material-rake | linear, any rake, --mirror, --grazing (239.25°) | the linear half of the family, stated once — see The two halves |
.material-bloom | radial circle, mid, mirrored; --iso 2:1; --top far, from above | the radial half, same ramp — same section |
--gradient-spectrum | linear 132.36°, five stops plus two arc waypoints | .material-foil — surfaces only |
--spectrum-stops | the spectrum's stops, without an angle | the same split the two foils make, so the oklab branch restates nothing |
--gradient-foil | linear --foil-rake, the spectrum minus lime, renormalised | .text-foil — the lit half, inverse surfaces; and every .cf-btn--solid label |
--gradient-foil-ink | linear --foil-rake, the same three hues in the 800 band | .text-foil--ink — the shadow half, light surfaces |
--foil-rake | 116.57° = 90° + 26.57° | the angle both foils are drawn at head-on |
--foil-stops | the lit half's four stops plus two arc waypoints, without an angle | the material, so the rake can change without restating it |
--foil-ink-stops | the shadow half's three stops plus two arc waypoints, without an angle | the same, for the 800 band |
--foil-glas-n, --foil-sky-n, --foil-ink-sky-n | the renormalised stop positions as bare numbers | so each waypoint's position is the average of its leg's ends rather than a third picked figure — see The arc |
--spectrum-hot | 18 % — the white hot spot | shared by both foil gradients; not used alone |
--spectrum-cool | 64 % — where Sky takes over | shared by both foil gradients; not used alone |
--surface-page-wash | linear 180°, the foil reversed at chroma 0.005 | the page itself — .page-wash. See The page wash |
--wash-stops | the wash's four stops, without an angle | the same split as the two foils, so the oklab branch restates nothing |
--grain | SVG fractal noise tile | the second half of the wash's dithering, at 8 %; the same filter is inlined in all three wallpapers, at 4 % on the two dark ones. See The page wash |
--hero-dissolve | mask, linear 180°, an (1 − t)^1.45 falloff over the last 25.6 % | how full-bleed artwork ends — see Where artwork meets the page |
--hero-scrim | linear 63.43°, CF-Grau at --scrim-depth to nothing | the light layer under hero type; same section |
--scrim-reach | min(39rem, 62 %) plus the content column's own displacement, projected onto the axis | how far the scrim holds full before falling off. The second term is zero up to a viewport of 1438.2 and sin(63.43°) of --column-inset − --gutter above it: past --container-max the column centres and slides right, and a constant plateau leaves the headline behind. Held by scripts/check-hero-scrim.py |
--scrim-depth | 0.42, and 0.5 at or below 56.25 rem | how deep the scrim is across the plateau. The reach answers the width axis; this answers the aspect one, because cover on a square artwork puts a different part of it under the type as the box changes shape. Below the breakpoint the hero's media box is 567 px tall and the kicker sits on the square's dark band — 4.03:1 at 375 × 812 against a 4.5:1 floor. Recomputed by scripts/check-hero-contrast.py |
Where artwork meets the page
Two gradients that carry no colour of their own. Both are the light family doing the two jobs a wash has to do when a full-bleed image is dropped into a page made of CF-Grau: end, and be read over.
The dissolve
The landing-page hero was cut off with a knife. Measured on the rendered page at
1280 × 1400, the last row of artwork is rgb(42,41,55) at the left and
rgb(198,237,167) at the middle, and the next row down is the page wash at
rgb(233,237,238) — a step of about 190 levels, in three channels at once,
across one pixel.
The designer's mockup has no edge at all. The artwork decays continuously into CF-Grau and
the page simply carries on. Measured off
assets/source/mockups/landing-page.jpg as the mean absolute channel deviation
from #CFCFCF over each full row — which is what alpha is, when the thing
composites onto CF-Grau — the decay runs from y 598 to y 804 in an 804 px
hero: the last 25.6 %. Fitting a = (1 − t)^k across the
band gives k = 1.31, 1.43, 1.46, 1.46 at the four quartiles, so the token is that power law
at k = 1.45, sampled at the quartiles.
It is a mask, not an overlay, and that is why it can be one token. An
overlay has to be painted the colour of whatever is behind it, so it would have to know
that the hero sits on the page wash and at what point of the ramp. A mask hands the
question back to the page: the artwork stops being there, and whatever the page is at that
height is what you see. .cf-hero therefore carries no background of its own —
an opaque one would only have swapped the artwork's seam for a second, flatter one, since
the wash is fixed-attachment and no colour declared on the hero can match it at that height.
The reading scrim
The hero sets black display type, a black hairline and a 12 px mono line over a loop
nobody controls the frames of. Sampled off the rendered page in the text column, the
artwork behind the headline reached rgb(48,55,59) and behind the kicker
rgb(70,70,81). Both failed their floors, on the page as shipped.
| Element | Floor | Before | After |
|---|---|---|---|
| Headline — display, large text | 3:1 | 1.74:1 | 4.13 – 4.46:1 |
| Kicker — 12 px mono, body | 4.5:1 | 2.26:1 | 8.50 – 14.81:1 |
| Rule — UI contour | 3:1 | 2.26:1 | 7.93 – 14.08:1 |
Worst backdrop pixel anywhere inside each element's box, with the type hidden, at 375 / 768 / 1280 / 1920. The still frame the designer composed does not fail: in the mockup the left column is pale grey-blue the whole way down and the colour is all on the right. The loop drifts a dark violet plane under the type instead. The scrim restores the composition rather than correcting it, and how much of it there is was set by what the mockup already looks like, not by the contrast target.
CF-Grau and nothing else — the page's own surface, bleeding up into the artwork exactly
where the type sits. White would frost it and black would bruise it; both would read as a
plate laid on top, which is the failure mode this has to avoid. It runs at
63.43°, --angle-a, so it rises from the bottom-left corner
where the text block is and is gone by the top right, where the artwork is at its best and
the glass button needs something complicated behind it to be worth being glass.
Its stops are lengths, not percentages, and that is the one thing here
that had to be got right rather than merely chosen. A percentage stop is a percentage of
the gradient line, whose length is 0.894·W + 0.447·H — so the same
38 % is 587 px in a 1280 × 896 hero and 402 px in a
768 × 828 one. But the scrim has to cover a text column, and a text column is
44ch wide and four display lines tall whatever the viewport is. Projecting its far corner
onto the 63.43° axis puts it at 578 px at 768, 603 px at 1280 and 603 px at
1920 — one number, because the column is one size. With percentage stops the headline read
4.46:1 at 1280 but only 3.58:1 at 768; with 39rem it reads 4.13 – 4.46:1 at
every width. The min(39rem, 62 %) is for phones, where the whole gradient line
is 623 px and a flat 39rem plateau would sheet the entire picture.
The foil
Carry the same light far enough and it splits. Lime → Weiss → Glas → Sky → Violett is the brand's own spectrum: it is the order of the column on Farben > Dosierung, and it is what the hero artwork does across a single isometric plane — lime at the lit edge, a white hot spot, then cyan, blue and violet in the falloff.
Iridescence comes from hue travel inside a narrow luminance band, not from more saturation. Sky and Violett are therefore taken at their 300 tints, which keeps the four chromatic stops between OKLab L 0.820 and 0.945 — 166° of hue (118° → 284°) across 0.125 of lightness. At their 500 values the band opens to 0.31 and the ramp reads as a fade rather than a foil.
Weiss is the deliberate exception. At L 1.000 it sits above that band, and that is precisely why it reads as a specular hot spot rather than as one more step in the ramp. Counting it, the full span is 0.180. Both numbers matter: the band is what keeps the ramp iridescent, the hot spot is what makes it catch.
Gradient in the letters
.text-foil is the opt-in way to put a gradient inside letterforms. It
uses the cool half of the spectrum — no lime — so a screen that has already spent its
one lime moment can still carry it. Every sample along it clears 11.9:1
on black, which is past AA at any size; on a light surface it fails, so it is an
inverse-surface material only. Its counterpart for light surfaces is
.text-foil--ink, below.
One foil moment per screen — the brand's own unit for the lime rule, and the right unit here too, because the constraint is what the eye takes in at once rather than what a URL contains. A page whose light-surface headline and whose footer never share a screen may carry one of each, which is the case on both designed pages. Two on one screen is one too many.
Three places the foil lands, governed differently
| Where | How | Budget |
|---|---|---|
| A display headline on dark | .text-foil, opt-in per element |
Together, one foil moment per screen. |
| A display headline on light | .text-foil--ink, opt-in per element |
|
| The black button's label | .cf-btn--solid, automatic — no class, no span |
Unbudgeted. It is what a black button is, not an emphasis on one. |
The exemption is not a loophole. A headline in foil is a decision about that headline; a black button in foil is the component's own colour, the same way the primary button's colour is the light ramp. A 12 px mono label does not compete with a display headline, and three black buttons on a page are three buttons rather than three emphases — none of which has spent the page's lime, which is the entire reason the foil exists. The precedent is the manual's app icon (Farben > Anwendung): a black plate whose mark carries the ramp while the plate stays black.
What the budget does still forbid is a foil headline and a black button in one eyeful. At that point the headline is no longer the only iridescent thing on screen, which is the condition the rule exists to protect. Neither designed page hits this today — the footer's headline is paired with the primary button, not the solid one. → Buttons
width: fit-content, and that is
deliberate. Sizing the clip box to the text is the right fix for a headline in
a block, where the box is the whole column and the ramp dies a third of the way in. On
the button the gradient box is the whole button on purpose: the label takes the
Weiss → Glas → Sky leg and the arrow at the far right sits in Violett, so light enters
at the first letter and arrives coloured at the arrow. Shrink that box to the label and
every button ramps the full spectrum inside ten characters, which reads as a rainbow
rather than as light crossing a plate. Do not propagate the headline's fix to it.
Its stops are not chosen separately. They are the spectrum's own, with lime removed and the remaining band renormalised onto 0–100 %: Weiss (18−18)/82 = 0 %, Glas (32−18)/82 = 17 %, Sky (64−18)/82 = 56 %, Violett (100−18)/82 = 100 %. Keeping the white hot spot is the point — it gives the letterform an edge for the light to land on. Started at Glas instead, the same ramp reads as a flat mint tint rather than as foil.
That arithmetic is written into the token as calc(), not pasted in as
finished numbers, so the two gradients cannot drift apart: move
--spectrum-cool or --rake-near and the foil follows. This is
why the rakes and the spectrum's stops are each declared twice — once as a bare number
(--rake-near-n), once as the percentage built from it —
because calc() cannot divide a percentage by a percentage, and the
renormalisation needs to. Weiss and Violett are 0 % and 100 % by construction, being
the ends of the band itself.
The angle is 116.57° (90° + 26.57°, the shallow isometric rake) and not the spectrum's 132.36°. A headline is a wide, short box: at the steeper angle the ramp completes inside the first two or three letters. The shallow rake spreads it across the whole line.
Jetzt Projekt starten!
<p class="cf-footer__title text-foil">Jetzt Projekt starten!</p>
The shadow half — .text-foil--ink
Every stop in the foil above sits above OKLab L 0.82, so on the page's own CF-Grau it
lands between 1.1:1 and 1.5:1. That left the two designed pages — a CF-Grau-to-white
wash almost end to end — with no gradient type at all, and the only foil moment on
either of them down in the black footer. --gradient-foil-ink is the
same material seen from the other side. Not a second gradient: the other half of the
one that already exists.
It is built by the rule the foil is built by. Iridescence is hue travel inside a narrow luminance band, so the band moves and the hue travel does not. The ramps are white-mixes and black-mixes of one palette, so the dark mirror falls straight out of them:
| Stop | Lit half | L | Shadow half | L | Hue |
|---|---|---|---|---|---|
| 1 | Glas 500 | 0.910 | Glas 800 | 0.426 | 179.4° / 180.0° |
| 2 | Sky 300 | 0.865 | Sky 800 | 0.353 | 243.6° / 242.5° |
| 3 | Violett 300 | 0.820 | Violett 800 | 0.311 | 283.9° / 282.2° |
| band depth | 0.090 | 0.115 | 104.5° / 102.2° | ||
Two decimal places of hue apart across three stops, and a band 0.025 wider. It reads as the same material because it is. The stops are the spectrum's own once again — the lit half drops Weiss and rebases off the hot spot, so the shadow half drops the hot spot too and rebases off the near rake: Glas (32−32)/(100−32) = 0 %, Sky (64−32)/(100−32) = 47 %, Violett 100 %. Same 116.57° rake, same reason.
It has no hot spot, and that is the one place the mirror is not symmetric. The foil's Weiss sits deliberately above its band as a specular. The ink mirror of that would be a stop below L 0.311, and there is no room for its opposite either — the lightest ink that still clears 4.5:1 on CF-Grau is about L 0.44, which is where Glas 800 already sits. So the shadow half is the falloff without the highlight, which is what the shadow side of a foil actually looks like. Completing it with Schwarz at 0 % would put a 0.426 span on a ramp whose whole claim is a 0.115 one, and it would read as a fade to black rather than as foil.
The 800s are not a taste choice. They are the lightest rung of the ramp that clears AA on the page's own grey: Glas 700, the obvious pick for a lighter and prettier ink, is 1.5:1 on CF-Grau. Measured on the shipped page header at 375 / 768 / 1280 / 1920, worst sample against the real backdrop: 5.45 / 5.51 / 5.56 / 5.56:1. Against flat CF-Grau it is 5.24:1 and against white 8.16:1, so the worst case is the top of the page wash — this ink darkens as it travels, so unlike the lit half it gets safer further down the page.
Über uns
<h1 class="cf-page-header__title text-foil text-foil--ink">Über uns</h1>
background-clip: text clips a background painted across the whole
element box, and a display headline in a block is as wide as its column however
short the words are. Measured on the footer title before this was fixed: at a 1920
viewport the box was 1000 px against 378 px of inked text, so the letterforms
sampled only the first 36.5 % of a four-stop ramp. Weiss and Glas
rendered, Sky barely arrived, and Violett never appeared on the page at
all at any viewport above roughly 400 px. The claim above that the shallow
rake "spreads it across the whole line" was describing the intent rather than the
paint.
.text-foil now takes width: fit-content inside the same
@supports block. fit-content and not
max-content: it resolves to min(max-content, available), so a short
headline shrink-wraps and a long one still wraps and fills its column exactly as
before. It sits inside @supports because outside it nothing is clipped,
and shrinking the box there would be a layout change for no reason.
The clip lives inside @supports, so the unenhanced state is simply the
inherited solid colour — nothing is transparent until the browser has proved it can
clip. Print and forced-colors both reset to solid type. Both variants
share every one of those rules: --ink is a modifier that swaps one
custom property, --foil-ramp, so it cannot drift out of the safety net.
-webkit-text-fill-color is what draws the clip, and it is
inherited — where it is set it beats the color of
every descendant. So text under a foil headline or inside a
.cf-btn--solid label arrives at any highlight already filled
transparent, and a highlight that sets only color paints
its plate and no word.
Measured before this was fixed: .cf-mark--current — the current match,
the rung this brand is named after — rendered as an empty lime block
inside every foil headline and every solid button on the site, at every size, on
both designed pages. ::selection had been caught earlier and answered
the expensive way, with .text-foil and .cf-btn--solid each
carrying a private ::selection rule restating the global one plus the
missing property. Two clipping contexts, two copies, and the found state never got
its own.
The rule is now stated once on the highlight rather than once per clipping context:
::selection, .cf-mark--current, ::target-text
and ::highlight(cf-found-current) each restate their ink as a fill, and
both private copies are gone. The non-current rung deliberately does not:
:where(mark) takes color: inherit because a match the
reader has not arrived at is the running text with a ground line under it — inside a
foil, the running text is the foil. Only a rung that supplies its own ink
has an ink to lose. Held by
scripts/check-highlight-fill.py.
The rake swings
Holographic stock is not a picture of a spectrum. It is a surface that hands you a different part of one from every angle, which is why a foil sample book is photographed tilting and a printed one looks dead. Static, the foil is the photograph. The swing is the tilt: the angle the light crosses the letterforms at opens and closes by exactly one brand angle as the type travels up the viewport, scrubbed from scroll position so it tracks the reader's hand and runs backwards when they scroll back.
The contrast floor is unchanged by construction, and that is the reason it is an angle and not a slide. Turning a linear gradient changes where each stop lands, never which stops are present — the whole ramp is inside the box at every frame. So the worst sample of the swinging foil is the worst sample of the static one: 11.9:1 on black, 5.24:1 on CF-Grau. Nothing needs re-measuring per frame, which is a property of the mechanism rather than a lucky result. The two alternatives both would have needed it: a specular band composited over the type raises contrast on the lit half and lowers it on the ink half, and a ramp painted wider than its clip box and slid across leaves the glyphs standing in two thirds of the ramp at any instant, which reads as changing colour rather than as catching light.
90° to 116.57°, and the swing is itself a brand angle. Both endpoints are sanctioned — 90° is the flat rake and 116.57° is 90° + 26.57° — and the distance between them is 26.57°, the isometric angle. The swing cannot drift off the four permitted angles wherever it is stopped, because it is bounded by two of them. It stays shallow throughout on purpose: a headline is a wide short box, and the foil took the shallow rake in the first place because at 132.36° the ramp completes inside the first two or three letters.
116.57° is what you see head-on, which is why the swing turns round at
the halfway mark instead of running straight through. You see a material as drawn when
you are square to it, so timeline 50 % — the type at the middle of the viewport — is
where the rake is the designer's value, and either edge of the screen is an oblique
line of sight where it flattens to 90°. That is also the value everything falls back to
when the swing cannot run: no @property, no scroll timeline, reduced
motion, print, forced colours.
That model has two axes and this is one of them. A
view() timeline can say how far the type is above or below the middle of
the screen; it cannot say how far to the side of the reader it is, because it measures
the element and the model is about the reader. The second axis is a second squareness,
multiplied into the same rake so the band cannot be left, and it is measured rather
than assumed. → The Line of Sight
Both sit at a document extreme — a page header at the top, a footer at the bottom —
where a view() timeline only has part of its range to work with.
Measured at 1280 × 900: the Über uns title runs 74.6 % → 100 % of the timeline, so
103.5° → 90°; the footer title runs 0 % → 34 %, so 90° → 108.2°. Both therefore read
between 90° and 108°, opening towards the designed rake as they approach the middle
of the screen and never quite arriving. That is the honest behaviour of the model
rather than a shortfall in it — a headline the reader cannot bring to the centre of
the screen is a headline they never look at square on. A foil moment placed mid-page,
such as a blog article title, gets the whole journey.
Those are the vertical figures. The horizontal axis multiplies into them and can only take the rake further from head-on, never closer, so 108.2° remains the ceiling on both pages and is now reached only by a reader standing in front of the type as well as level with it.
.cf-btn--solid does not swing. Its foil is the component's
own colour rather than an emphasis, its clip box is the whole button on purpose, and a
label whose light shifts while the reader is aiming at it is a different thing from a
headline catching it. It reads --gradient-foil from :root,
which is static, so it is untouched.
The foil, moving
Everything above is light that has settled. There is one member of the family that has not: the band that crosses a placeholder while the thing it stands for is still on its way. It is the same three chromatic stops as the foil, taken down to 18 % alpha and swept across the object at 26.57° on a 2.4-second loop, and it is documented here rather than only on its component because every decision in it is a colour decision.
Beiträge werden geladen
Three stops, and they are the cool half on purpose
Weiss, Glas and Sky 300 — the foil's own chromatic run with lime removed. That is
not a shortening for convenience, it is the same reason --gradient-foil
drops lime: this band loops for as long as the wait lasts, and lime is a moment.
A lit stop that returns every 2.4 seconds until the network answers is the one thing
the dosage rule cannot survive — it would be the longest-running lime on the site, on the
one element that is not even content yet.
The three are mixed with transparent in oklab, and the band
itself interpolates in oklab behind an @supports test, the same way every
other gradient in the family does. This is also the only member of the family that is
transparent rather than opaque: the foil, the wash and the three rakes all paint
a surface, and this one passes over one. That is what puts it above the contour it
crosses — light is layer 5 and a contour is layer 4 — and it is why the alpha is the
parameter that had to be argued.
18 %, and why the ceiling is 52 %
A band that lightens a contour can only take contrast away from it, so the cap is worth being exact about. Worst case is the whitest stop over the blackest contour on the page's own CF-Grau, composited the way CSS actually composites it — in sRGB, not in linear light:
| Alpha | Contour composites to | Ground composites to | Contrast |
|---|---|---|---|
| 0.18 — shipped | 46 | 216 | 9.51:1 |
| 0.28 | 71 | 241 | 6.76:1 |
| 0.50 | 128 | 231 | 3.22:1 |
| 0.52 | — | — | 3.03:1 — the floor for a non-text contour |
Measured off rendered pixels across eight frames of one period, the worst inked pixel on the shipped ghost is 9.57:1. So contrast did not choose 18 %: anything under 52 % clears the floor. What the arithmetic gives is the ceiling, not the value — 18 % is chosen for restraint, because lime is not the only light in this system that has to stay a moment rather than become a surface. Raise the token past 0.52 and the ghost fails quietly, which is exactly the kind of failure a written-down ceiling exists to prevent.
The movement is part of the colour
A holographic surface hands you a different part of the spectrum from every angle, and the foil's rake swing is that tilt scrubbed from the reader's scroll. This band is the other way a foil moves: the surface holds still and the light travels across it. Three numbers carry it, and each is a decision:
| Value | Is | Because |
|---|---|---|
| 26.57° | the angle the band crosses at | --angle-b, the isometric rake. The light on a placeholder arrives on the same axis as the light on everything else in the brand. |
| −30 % → 130 % | the travel | the band leaves the object completely at both ends, so a period reads as one pass and a rest rather than as a strobe. |
| 2400 ms, linear | the period | twice --duration-scene, the slowest thing the system otherwise animates. A fast loop reads as impatience, and easing would imply the wait has a shape. |
This is the one place in the system where a continuous loop is honest. Everything else that moves here is answering the reader — a hover, a scroll, a page changing. A loop with no input behind it is usually decoration pretending to be feedback. Here something genuinely is still happening, and it stops when it stops being true.
@property, reduced
motion, print, forced colours. It is the same rest value the swinging foil and the
field's pool land on — a system that agrees with itself about where light sits when
nothing is moving. The ghost itself stays in all of them, because the thing is still not
here yet. A browser with no color-mix() loses the light and keeps the ghost,
which is the correct half to lose.
The component, its markup and the rest of the presence ladder are on Arrival. Everything above is the part that belongs to colour.
The page wash
The page is not flat grey. It is a very slow vertical gradient from CF-Grau at the top to white at the bottom — in the original across the full page height of roughly 5,800 px. It lets the content come into the light as you scroll, and it is why individual sections never need a background colour of their own.
It is the foil, reversed, at the amplitude of atmosphere
The wash used to be the one gradient in the system with no hue in it at all — two
neutral stops, zero chroma, a member of no family. It is now the foil's own ramp run
backwards, because the page descends from shadow into light: CF-Grau at the top,
Weiss at the bottom, so Violett → Sky → Glas → Weiss. The stop
positions are --foil-stops' own, subtracted from 100 % — that is what
“reversed” means arithmetically, and it is why nothing here is picked by hand. Weiss
lands at 100 % by construction, so the bottom of the page is the same
#FFFFFF it has always been.
The lightness path is unchanged, exactly and by construction. Each chromatic stop is the neutral wash's own OKLab L at that position, so the interpolated lightness of the four-stop ramp is identical to the two-stop one it replaces. Only the hue moves. That is this brand's definition of iridescence taken to its limit — hue travel inside a narrow luminance band, with the band held at zero width relative to the ramp that was already there.
| Stop | Position | L | C | h | Renders |
|---|---|---|---|---|---|
| Violett | 0.00 % | 0.8545 | 0.005 | 283.86° | #CFCFD2 |
| Sky | 43.90 % | 0.9184 | 0.005 | 243.65° | #E1E4E7 |
| Glas | 82.93 % | 0.9752 | 0.005 | 179.38° | #F3F8F7 |
| Weiss | 100.00 % | 1.0000 | 0 | — | #FFFFFF |
The three hues are --foil-stops' three, to the digit. Hue travel across
them is 104.5°, which is the lit foil's own figure; the band they
span is 0.120, against the foil's 0.090 and the ink foil's 0.115. By
the family's own defining metric this is a family member.
Chroma 0.005 is the whole budget, and the ceiling is set by something
already on this surface: no channel may move further from the neutral than the grain
does. Worst channel deviation over the whole ramp is 4 of 255, inside
the roughly five levels of grain the wash already carries. Black text measures
13.51:1 at the top against 13.48:1 before, and --text-secondary 5.88:1
against 5.87:1 — both marginally better, because a stop nudged off the achromatic axis
at fixed L loses no luminance.
Banding — and why hue is the first half of the fix
Over 5,800 px, 8-bit sRGB cannot resolve a ramp between two colours this close and the wash bands into visible steps. Two layers work on that, and they are not substitutes.
The first is the chroma above. A neutral ramp is quantization-bound:
#CFCFCF to #FFFFFF is 49 greys and there is no fiftieth,
because R, G and B are locked together and cross every 8-bit boundary in the same
place. That is also why greyscale ramps are the standard diagnostic for banding —
with no hue component, nothing masks the step. Put chroma on the ramp and the three
channels cross at different places, so it has more rungs to stand on.
Measured off rendered pixels in Chromium, 4,000 px tall, oklab interpolation, with Skia's own gradient dither left on — what actually paints, rather than what the arithmetic predicts:
| Neutral wash | Iridescent wash | |
|---|---|---|
| distinct colours down the ramp | 51 | 130 |
| widest flat run | 40 px | 36 px |
| transitions moving all three channels | 99.8 % | 23.6 % |
Two and a half times the levels, and three quarters of the steps are now one or two channels moving instead of three — a partial contour rather than a full one. The neutral ramp's 51 is not a shortcoming of the renderer. It is the ceiling: 49 greys plus dither noise.
The second is --grain — one tile of static, greyscale fractal noise
generated inline in SVG — which rides on top of the ramp inside the same background and
hides the edges that are left. It sits under the content, never over it, so it cannot
touch the text. Decorrelation weakens each edge; grain hides what remains. Use the
.page-wash utility and you get both layers.
Grain is not the wash's alone, and its strength is not one number. Any member of the light family painted across a whole screen has the wash's problem, so all three wallpapers carry the same filter primitive for primitive — the two dark ones since the run that measured them, the light one since it was drawn. What does not carry over is the 8 %. The grain's own mean is a floor lift wherever it lands: on a ramp that ends at white that costs nothing and the strength can be chosen for the grain, and on a near-black ground it is the first thing anybody sees. So the rule is the least grain that clears the banding, which is 8 % on the wash and 4 % on a dark ground. Measured over the video-call frame, the share of it sitting in a flat plateau of eight pixels or more goes 53.1 % → 0.6 % at 4 % and 0.4 % at 8 %, while mean luminance goes 9.49 → 12.84 → 16.31. The second four per cent buys two tenths of a point and doubles the lift.
Nor is the first trick free to apply anywhere. It costs a hue budget, and the wash could afford one only because it had the foil's own 104.5° of travel going spare.
<!-- on <body> or the outermost wrapper --> <body class="page-wash">
The three hexes are re-derivable, not hand-tuned — recompute the ramp
and you get them back — and they carry the same trap the #DBFC60 waypoint
does: they are literals, so they do not follow --cf-grau. Move CF-Grau and
they must be recomputed, or the wash quietly stops starting where the page starts. At
this chroma the 8-bit grid is coarser than the target, so each hex lands 2.5–4.2° off
its hue and within 0.001 of the chroma. That is the grid, not the arithmetic.
Lime is never flat
Lime never appears as a flat fill. Wherever it fills an area, it fills
it with a gradient — one of the light family above, running out of lime into Glas and
away to CF-Grau. A rhombus painted #E1FF00 edge to edge is not the light
layer; it is a yellow shape. The ramp is what makes it light.
This follows from the rule the whole palette is built on and had simply never been written down as its own line. Light in this brand is a source and a falloff — that is what the three rakes are, what the bloom is, what the foil is. A flat lime plane has no source and no falloff, so it has nothing to say about where the light is coming from, and it reads as the one thing the manual never draws: a coloured surface.
Which ramp, on a small mark
The objection to the rule is always the same: a 40 px cap or an 8 px marker has no room for a ramp. It has exactly as much room as it needs, because the ramp is sized to the element and not to the page — the process cards' lit faces are 120 units in a 640 box and read as light at 352 px. Pick by what the mark is:
| The mark is | Take | Because |
|---|---|---|
| a flat face — a cap, a top face, a tile | --gradient-light, near rake | the face light actually lands on. Same choice as card 01. |
| a curved body turning away | --gradient-light-raking, mid rake | the falloff is longer because the surface leaves the light gradually. |
| a rim, a ring, a nucleus | --gradient-bloom / -iso | lime sits on the rim and the centre falls to CF-Grau — cards 02 and 04. |
| a horizontal run — an edge, a head, a bar | --gradient-light-90 | the same ramp mirrored onto a level axis. |
| a mark on a dark surface | the same ramp, stopped at Glas | CF-Grau over black is a mid grey, so the far end of the ramp reads as a grey pill rather than as light falling off. Ending at Glas keeps the whole mark inside the light half. |
The last row is the one that is easy to get wrong, because it looks like an exception and is not: it is the same ramp, run over a shorter distance, for the same reason the three rakes exist. What changes on black is not the material — it is where the falloff stops being light and starts being a surface.
Aim the axis at a vertex, not across an edge
This is the part that decides whether a small lit face reads as light or as a yellow shape, and it is pure geometry. A linear gradient's stops run perpendicular to its axis — so if the axis is perpendicular to one of the shape's own edges, that entire edge sits on a single stop. Put lime at offset 0 and the whole edge is pure lime: a band, not a highlight.
On the isometric rhombus this is easy to walk into, because the shape's edges are at 26.57° and 63.43° — the two angles a rake is most likely to be drawn on. The plot's cap hit it exactly: an axis at 63.43° put its full upper-left edge on the lime stop, and a 40 px mark with a lime edge is a yellow mark. Running the same ramp down the rhombus's vertical diagonal instead — 90°, still a brand angle — puts lime on the apex alone and lets it fall away with the shape. Same ramp, same rake, a third of the lime.
drop-shadow in lime falls off in alpha and not in hue,
which at any usable radius reads as a yellow cloud around the mark. The system's glow is
--glow-light: two shadows, lime tight to the shape and Glas spreading past
it, so the spill shifts colour as it fades the way the rakes do. --glow-r
sets the reach; lime always takes 45 % of it.
fill, so a lit face in a drawing declares
its own <linearGradient> — with #DBFC60 at 19 % of
the lime→Glas leg, because SVG interpolates in sRGB and the rest of the family
interpolates in oklab. → Illustration
Three exceptions, and each is a technical one
| Where | Why it stays flat |
|---|---|
| the palette swatch on this page | a swatch of a colour has to be the colour. It is the specimen, not an application of it. |
the found state — ::selection, ::target-text, .cf-mark--current, ::highlight() | background-image is ignored on a highlight pseudo-element, so no ramp of this family can be painted into one. There is no gradient form of the declaration to reach for. .cf-mark--current is an element and could take a ramp; it is held to the pseudo-element's means on purpose, because a match must be one drawing wherever it is drawn. |
| a stroke, an outline, a focus ring | not a fill. A line has no plane to ramp across, and the rule is about area. |
The fourth thing that looks like an exception is not one: the flat lime cube on Illustration is a labelled don't, drawn wrong on purpose.
Lime is light, not ink
No lime letterforms on a dark surface. Not a headline, not a label, not a nav marker, not a syntax colour. Type on black is white — at whatever strength the hierarchy needs — or it is the foil, which is the palette's own answer to colour inside letterforms and is built from the cool half of the spectrum precisely so that it never spends the screen's lime.
Lime keeps everything it was already doing on black, because none of it is type: the lit contour of one icon, the cap of one plot column, the focus ring, a 2 px edge along a banner. The line is between a source of light in the composition and the colour of the words. Lime is the first and never the second.
Why, given that it passes
Lime on black measures 18.5:1. It clears AA at any size with room to spare, and it is still wrong — which is the useful part of this rule, because it is a case where the contrast check has nothing to say and the type is bad anyway.
#E1FF00 sits at 87.5 % of white's luminance while
carrying the highest chroma in the palette. On a pure-black ground that is close to
the maximum luminance step and the maximum saturation step at the same time —
the exact combination that blooms. Light-on-dark type already fringes for readers with
astigmatism, which is a large share of them; adding maximum chroma to a near-white
luminance widens the halo instead of narrowing it, and the eye cannot bring all of a
saturated edge into focus at once. Dark-theme guidance elsewhere reaches the same
conclusion by another route: saturated accents visually vibrate against a dark
surface, so they get desaturated for dark UI rather than carried across from the light
one.
Desaturating lime is the move this system does not make. Lime is the brand's light — one moment per screen, the thing everything else is measured against — and a dimmed, dark-mode-safe lime would be a second lime that is neither the light nor the brand. So lime stays exactly itself and stops being used for a job it was never good at. The seven colours do not change; the rule about where the letters take their colour from does.
What replaces it
Ink and shape, which is what the system uses everywhere else. The site nav has stated this since it was written — the current page is marked by weight of ink, not by a lime ground — and the documentation sidebar you are reading was, until this change, the loudest counter-example to it. Three steps of one white carry a whole navigation column:
| Role | Ink | On black | Told apart by |
|---|---|---|---|
| Group title | white 100 % | 21.0:1 | 11 px mono, uppercase, tracked |
| Link, at rest | white 66 % | 8.8:1 | 15 px sans |
| Current page | white 100 % | 15.5:1 on its own plate | a white 14 % plate at --radius-sm |
The title and the marker share an alpha and still cannot be confused, because they are different objects: a tracked mono label and a sans link carrying a plate. That is the general shape of the replacement — where lime was doing the work of distinguishing one thing from another, the distinction moves into ink strength, type texture or a plate, all of which survive greyscale, forced colours and a monochrome print.
Dosage
Do
- Use lime as light: one edge, one dot, one button gradient.
- Keep Glas, Sky and Violett to illustrations and gradients.
- Take surfaces from the neutral scale and meaning from the contour.
- On black: white type at whatever ink strength the hierarchy needs, white 18 % contours — and the foil for one display headline.
Don't
- No body copy on lime — and no lime as a section background.
- No lime letterforms on black — lime is light, not ink. → the rule
- No Sky or Violett as UI colours (links, buttons, status).
- Don't mix new greys — nine steps is enough.
- Never let colour be the only carrier of meaning; add contrast, position or a label.
Contrast
Checked combinations. The target is WCAG 2.1 AA — 4.5:1 for text, 3:1 for large text and UI contours.
| Foreground | Background | Ratio | Use |
|---|---|---|---|
| Black | CF-Grau #CFCFCF | 13.1:1 | unrestricted |
| grey-700 #484848 | CF-Grau | 5.3:1 | body copy, 14 px and up |
| grey-400 #919191 | CF-Grau | 1.9:1 | decorative only — never for text that must be read |
| grey-400 #919191 | White | 3.0:1 | mono labels 14 px and up, counters, meta |
| Black | Lime #E1FF00 | 18.5:1 | button labels |
| White | Black #000000 | 21.0:1 | footer, nav, consent |
| Lime | Black | 18.5:1 | never as type — light only: a lit contour, a plot cap, a focus ring, an edge. → Lime is light, not ink |
| White 72 % | Black | 10.6:1 | nav links, mono meta on dark |
| White 66 % | Black | 8.8:1 | documentation sidebar links |
| Violett 300 #BEBFF0 | Black | 11.9:1 | the foil's worst sample |
#919191 on grey — that is 1.9:1 and unreadable for many
people. In this implementation those labels either sit on a light surface or switch to
--text-secondary. Purely decorative counters additionally get
aria-hidden="true".