A screen system for a timed phone game. Three routes, ten states, four owned components, roughly ninety tokens. This describes what ships on finishtheline.cc as of 9 August 2026, not what it should be — where a claim differs from the running app, it says so.
The product in one line: three lines of dialogue, two of them fakes, and sixty seconds to keep picking the real one.
Authored by reading the running code, in order of authority: the app's own stylesheet (which wins every disagreement), its markup and states, its runtime-built pieces (option rows, result cells, the share label), then the design system's token files, copied verbatim.
Verified. Every token value here is copied from the source, not retyped from memory. The contrast table below computes its ratios from rendered colour at view time, rather than printing numbers written into a comment — that distinction has already caught a token shipping at 3.17:1 under a comment claiming it passed, and a contrast figure recorded elsewhere at 2.4× the truth.
Four state hues, one job each — sage means correct and only correct, brick means wrong, gold means the clock is short, slate means the clock ran out. Azure is at-rest and focus. Every hue is one value in both modes, with a single white ink over all of them.
⛔ A state hue is always a fill or a border, never text. All four sit at roughly 0.13–0.15 luminance, which is why one white ink works over every one of them in both modes — and why they cannot be text.
⚠ The cost, stated plainly. Adjacent hues separate by only 1.05–1.40:1, told apart by hue alone — which a colour-blind player cannot rely on. That is acceptable here only because every state also carries a ✓ / ✗ / – glyph, shown in the swatches above. The glyph is the accessibility mechanism, not decoration. An answered option or a result cell must never render without one.
⛔ Green is never the brand. The mark used to share the correct-answer green with the wordmark — one colour meaning both "Finish the Line" and "you got it right", thirty seconds apart. It was moved to ink instead. Both cleared contrast, so this was a meaning fix, not a contrast fix.
The two page surfaces are almost the same colour: raised clears the page by only 1.06:1 in light and 1.09:1 in dark — far below the 3:1 a non-text boundary needs. A fill can never be what makes something a distinct, tappable object here; the border is.
The three tint tokens (--ftl-tint-hit / -miss / -idle) measure about
1.1:1 against the page by design — they carry the hue quietly. The full-strength border
carries the contrast, at 3.44–3.45:1. They are opaque mixes with the page, never translucent
overlays, so the value measured is the value painted.
Every ratio below is read back out of the rendered page, not typed into this file. Pairs are enumerated from the components, not the token list — a token-list audit passes every pair it thinks of and misses the ones components actually compose.
| Pair | Where it appears | Ratio | AA |
|---|
The one row that fails is deliberate, and it is here to be looked at.
ink-subtle on surface-sunken measures 4.35:1 in light and passes at 4.65 in
dark — a pairing that is fine in the mode the product ships and fails in the mode it also
supports. The row below it is the same badge on ink-muted — this system's component
CSS already uses that fix; the running app does not yet.
One content rule decides the family, every time: the serif is worn by anything a character actually said; the sans by everything the app says about them. Under a clock, a player comparing three options is comparing quotes with quotes, and the serif says which text is in the game before a word of it is read.
The lines below are placeholder text written for this page. No dialogue from the show appears anywhere in this design system.
Every numeral above is tabular — the clock counts down in tenths, so with proportional figures the whole line reflows ten times a second while the player is trying to read three sentences against it. 15px and 22px exist in the token file and no shipped page uses either; they are not deprecated, just unclaimed.
⛔ Never set a UI string in the serif to make it feel special. Every serif string on screen is a claim that a character said it.
⛔ Only three faces ship — Inter 400, Inter 600, Newsreader 400. There is no Newsreader italic and no Inter 700. Asking for one renders a synthesised face — the actual bug behind one option row painting faux-bold, because Jelly's own vendored radio sets weight 600 and only a 400 serif was loaded.
On the round screen a player must see the clock, the cue and all three options
without scrolling. Telegram's in-app browser on a 375×667 phone leaves roughly 560px.
That is the whole budget, and two tokens spend it: --ftl-budget-timer (≤96px) and
--ftl-budget-question (≤440px).
⛔ The rule this forces: anything that appears AFTER an answer is anchored to the viewport, never appended to the flow. Appending pushes the explanation off the bottom of the screen on exactly the long questions where the player most needs it — which is why the reveal is a fixed sheet, capped at 42% of the dynamic viewport.
Measured at 375px: an option row is 56px to about 39 characters, 79px to about 77, and 108px beyond that — so three rows alone can cost 180 to 336px before a word of scene text. That is the real argument for keeping candidate lines short.
The measurement that settled this: a vendored radio control paints 41.8px tall and about 210px wide inside a 335px column — under the floor on one axis and, on the other, narrower than the row it sits in, so taps at the right-hand end land on nothing.
Hatched = looks tappable, is not.
Assert 44, set 52. A min-height floor that lands exactly on 44 leaves nothing for a rounding error or a font swap. FTL cannot go fully square either: the vendored controls paint canvas-based soft-body surfaces, and a 0px corner beside one reads as a rendering bug — so elements FTL owns go sharp (4px chips, 8px rows, pill tracks) and the vendored controls keep their soft geometry.
In a 60-second game, animation time is round time. A staggered per-element reveal of the kind daily word games use runs about 1.7 seconds per tile; at 8–12 questions that is 14 to 20 seconds of a 60-second round spent watching an animation. So the reveal is one state change, 120ms, simultaneous across all three options.
Three durations exist and no more: 120ms (state changes), 200ms (the sheet
sliding in, screen fades), 400ms (unclaimed). All three collapse to 1ms under
prefers-reduced-motion — collapse, not disable, so nothing waiting on a transition
event stops firing.
Four things the app owns outright, in plain CSS on plain elements. The candidate lines below are placeholder text; no dialogue from the show appears anywhere in this system.
Fill decorative, border carries the state, glyph carries the meaning. The player's own pick is the filled row; the correct row (when they picked wrong) is outline-only, so it does not compete with their own filled pick.
41.8
At rest
12.4
Short — under 15s
3.1
Critical — grows too
The bar grows as well as reddens at critical, because it is read peripherally — the player is looking at the option rows, not the clock. Frozen (reading an answer) dims the number rather than hiding it, so the player keeps a continuity cue that this is the same question.
⚠ Known gap: eight cells orphan one on its own line. A 335px column holds 7 cells at 40px with an 8px gap; an eight-answer round wraps to two rows with a single cell alone on the second — which reads as a mistake rather than a wrap. Nine or ten answers look fine; eight is the ugly case. Not fixed — flagged for a decision between accepting the wrap, shrinking the cell, or capping the row so it always breaks evenly.
The score is a plain count of lines, not a named tier — tiers used to draw on show-specific in-jokes that read as nonsense to a first-time player, on the one screen most likely to be screenshotted and sent to someone who has never played.
Everything else on screen is a vendored web component (a button, a radio, a progress bar, a spinner) that paints to a canvas inside a shadow root. That single fact decides everything below.
Custom properties cross the shadow boundary. Selectors do not. The only way to
restyle one of these is to set the property its painter actually reads, on the host — not
::part(), not a descendant selector, not !important on a class.
⛔ Never bind one of these tokens to color-mix() or oklch().
These components hand the raw computed colour string straight to canvas with no resolver. An
invalid fill value is silently ignored and the previous colour simply persists — the
control keeps painting, in the wrong colour, with no error anywhere.
⛔ Every one of these controls bleeds about 44px past its own host, on every side,
unconditionally. It is a soft-body wobble margin, not a stale-paint bug — the bleed has to
be contained with overflow-x on both html and
body, since an HTML document propagates whichever one of them declares a value to
the other.
Anything the app owns outright — the option row, the result cell, the reveal sheet, the type roles — is plain CSS on plain elements, and none of the above applies to it. The vendored controls are a boundary, not a foundation.
These frames are drawn with plain CSS throughout this page and the pages kit — loading the real vendored bundle here would make every specimen depend on a remote script.
Dry sitcom beat. No emoji, no exclamation marks, no hype — in the interface. The game is already funny; the interface does not need to be. Its job is to be out of the way in under a second.
The score tiers were deleted. They named results in show-specific in-jokes that read as nonsense to anyone not deep in the series — on the one screen most likely to be screenshotted and sent to someone who has never played. The standing test for any new string: would a first-time player know what this means?
The reveal never restates the answer — the correct row already outlines and pops; saying it again in words is the same answer twice.
Three lines of plain text. No stylesheet, no fonts, no tokens — whatever the receiving chat client decides. It travels further than the site and is harder to withdraw than anything else here, so it gets its own rules.
✅ ❌ ⬜, never 🟩 🟥. Green-versus-red is the one pair red-green colourblindness cannot separate — and because emoji hue is decided by the renderer, there is no contrast fix to reach for. Shape has to carry it.
No episode names, ever. A result posted into a group chat must not spoil a line for whoever reads it — the grid is abstract rather than a list of what you got, and that is a content rule, not a space saving.
Emoji are allowed here, and nowhere else. The interface bans them because the app controls its own rendering and has real glyphs, colour and type to work with. This message has none of that — the rule is about capability, not taste.
This project's posture is fan-scale, private and takedown-compliant. A logo is the single most legally exposed thing it can ship, because a mark is exactly what a trademark protects. Evoking an era is fine; quoting the show is not.
⚠ One pattern above is currently live on the product and is under review by the owner for exactly this reason — it is not reproduced here, on purpose, since showing it would defeat the point of flagging it. See the logo concepts page for the replacement under consideration.
The share card is the sharpest edge. An og:image is scraped,
cached and re-hosted by every chat client that touches the link, so it travels further than the
site and is harder to withdraw than anything else the project publishes. Nothing from the
question corpus may appear in it.
Four logo concepts are still on the table — see the logo concepts page for all four at real 16px in a real browser tab strip, in dark and light. No mark has been chosen yet.