ui/_theme.scss
// The shared visual language for Coilgarden's UI.
//
// The UI is not styled independently of the game. Every colour below is the same colour as
// something in the world (see Code/Core/Palette.cs) - the panels are the tray's terracotta,
// the text is the sand, the accent is the apple. That is what makes the interface feel like
// it belongs to the game rather than sitting on top of it.
//
// Three rules drive every value here.
//
// 1. Rank by consequence. Score is the biggest thing on screen because score is the point.
// Best is a third of its size. Labels are tiny - a player reads the word "SCORE" once and
// never again, so it exists only to explain the number the first time.
//
// 2. Nothing over the playfield during play. The arena has to stay readable, so the HUD sits
// above the tray and only a finished or paused run is allowed to cover it.
//
// 3. One rhythm. Spacing is multiples of $u, radii come from one pair of values, and there is
// a single easing curve. Consistency is most of what "polished" means.
// ---------------------------------------------------------------- palette
// Mirrors Code/Core/Palette.cs. Change one, change the other.
$backdrop: #1b171d;
$table: #2a242b;
$tray: #965f47;
$tray-light: #b47b5c;
$tray-dark: #69402e;
$sand: #c7c4a9;
$sand-dim: #bdb99d;
$snake: #2e6f43;
$snake-light: #4a9252;
$apple: #e03d31;
$leaf: #6fa145;
// Text sits on dark panels, so it is the sand tone rather than pure white - white on warm
// brown reads as clinical, and nothing in this game should.
$text: #f2ede2;
$text-soft: rgba(242, 237, 226, 0.66);
$text-faint: rgba(242, 237, 226, 0.34);
// Panels are the backdrop hue, near-opaque so a card reads as the room dimming without the
// playfield showing through what sits on it. 0.90 was not enough: the sand is the brightest
// thing in the game, and the checker was visible straight through the buttons.
$panel: rgba(27, 23, 29, 0.985);
$hairline: rgba(242, 237, 226, 0.10);
// Controls sit *on* a card, so they tint it rather than being transparent themselves - the
// card behind them is already opaque, which is what keeps the world out of them.
$plate: rgba(56, 50, 58, 1);
$plate-hover: rgba(74, 66, 76, 1);
// ---------------------------------------------------------------- rhythm
// The spacing unit, and the one rule about it: **never write `$u * n`**. s&box's SCSS does not
// evaluate arithmetic in lengths and drops the whole declaration - with a warning for `gap` and
// in complete silence for margins and padding. The entire spacing scale of this interface was
// resolving to nothing for exactly that reason, which is why every space below is written as a
// literal multiple of 4. The unit is kept as documentation of the rhythm, not as a calculator.
$u: 4px;
$radius: 14px;
$radius-lg: 26px;
// One curve for everything. A settle with a touch of overshoot reads as soft rather than
// mechanical, which is the whole target for this game's feel.
$ease: cubic-bezier(0.22, 1, 0.36, 1);
// ---------------------------------------------------------------- type
// Poppins is a geometric, round-shouldered face that ships with s&box. It is doing real work
// here: the roundness is the same idea as the sphere-based snake.
@mixin face {
font-family: "Poppins", "Roboto", sans-serif;
font-weight: 500;
}
@mixin label {
@include face;
font-size: 13px;
font-weight: 600;
letter-spacing: 3px;
text-transform: uppercase;
color: $text-faint;
}
@mixin numeral {
@include face;
font-weight: 700;
// Numbers are tabular so a score counting up does not shift its own layout. A number
// that jitters while it changes is the most avoidable kind of unpolished.
font-family: "Poppins", "Roboto", sans-serif;
}
@mixin card {
background-color: $panel;
border-radius: $radius-lg;
border: 1px solid $hairline;
flex-direction: column;
align-items: center;
// Each side written out rather than a `padding: A B` shorthand. s&box's SCSS applies the
// shorthand only partly - the card came out with a correct top and no bottom, so the last
// button on every screen sat flush against the rounded corner.
padding-top: 48px;
padding-bottom: 48px;
padding-left: 56px;
padding-right: 56px;
// Without this the centring scrim compresses the card to less than its content.
flex-shrink: 0;
}
// ---------------------------------------------------------------- screen chrome
// Shared by every full-screen state - the main menu, pause, game over and settings all sit
// in a scrim over the arena and rise in on the same card. One treatment for "a screen is up"
// is what makes the four of them read as one system rather than four separate designs.
@keyframes scrim-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes card-in {
0% {
opacity: 0;
transform: translateY(18px) scale(0.94);
}
100% {
opacity: 1;
transform: translateY(0px) scale(1);
}
}
.scrim {
position: absolute;
width: 100%;
height: 100%;
align-items: center;
justify-content: center;
pointer-events: all;
// Warm rather than neutral black, so a menu or a pause reads as the lights dimming in
// the same room rather than a grey sheet dropped over the game.
background-color: rgba(27, 23, 29, 0.62);
// Fades up rather than cutting in. A hard cut to a dimmed screen reads as a glitch;
// over a sixth of a second it reads as the room dimming.
animation: scrim-in 0.18s $ease both;
}
.card {
@include card;
min-width: 460px;
animation: card-in 0.26s $ease both;
// A record-beating run gets an apple-red edge. Restating the good news in a second
// channel, so it lands even if the words are skimmed.
&.record {
border: 2px solid $apple;
}
.wordmark {
@include face;
font-size: 62px;
font-weight: 700;
letter-spacing: -1px;
color: $snake-light;
}
.tagline {
font-size: 19px;
color: $text-soft;
margin-top: 8px;
}
.heading {
@include face;
font-size: 46px;
font-weight: 700;
letter-spacing: -0.5px;
color: $text;
}
.cause {
font-size: 19px;
color: $text-soft;
margin-top: 8px;
text-align: center;
}
.tally {
flex-direction: row;
margin-top: 28px;
gap: 40px;
.tally-item {
flex-direction: column;
align-items: center;
.tally-label { @include label; }
.tally-value {
@include numeral;
font-size: 32px;
color: $text;
}
}
}
.prompt {
@include label;
font-size: 14px;
letter-spacing: 2px;
color: $text-faint;
margin-top: 24px;
}
}
// ---------------------------------------------------------------- buttons
// One control, three intents. Ghost is the default - a low-key rectangle that only asserts
// itself on hover - primary is the one action on a screen the player most likely wants, and
// danger is reserved for anything that throws a run away.
.menu {
flex-direction: column;
align-items: stretch;
width: 320px;
margin-top: 40px;
gap: 12px;
}
.menu-row {
flex-direction: row;
align-items: stretch;
width: 320px;
// Same standoff as a stacked menu, so a screen ending in a row of buttons has the same
// rhythm as one ending in a column of them.
margin-top: 40px;
gap: 12px;
.btn { flex-grow: 1; }
}
.btn {
@include face;
// Sides written out - see the note on the card mixin about the padding shorthand.
padding-top: 14px;
padding-bottom: 14px;
padding-left: 24px;
padding-right: 24px;
border-radius: $radius;
border: 1px solid $hairline;
background-color: $plate;
color: $text;
font-size: 17px;
font-weight: 600;
text-align: center;
justify-content: center;
align-items: center;
pointer-events: all;
cursor: pointer;
transition: background-color 0.12s $ease, border-color 0.12s $ease,
color 0.12s $ease, transform 0.12s $ease;
transform: scale(1);
&:hover {
background-color: $plate-hover;
border-color: rgba(242, 237, 226, 0.24);
transform: scale(1.03);
}
// Click feedback: a quick dip below rest, so a press always reads as a press even on a
// hover state that looks similar at a glance.
&:active {
transform: scale(0.97);
transition-duration: 0.05s;
}
&.primary {
background-color: $apple;
border-color: $apple;
color: $text;
&:hover { background-color: #ff5647; transform: scale(1.03); }
}
&.danger {
&:hover { border-color: $apple; color: $apple; }
}
&.small {
padding-top: 8px;
padding-bottom: 8px;
padding-left: 16px;
padding-right: 16px;
font-size: 14px;
}
}
// ---------------------------------------------------------------- settings controls
.setting-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
padding-top: 14px;
padding-bottom: 14px;
padding-left: 20px;
padding-right: 20px;
border-radius: $radius;
background-color: $plate;
border: 1px solid transparent;
margin-bottom: 8px;
transition: border-color 0.12s $ease, background-color 0.12s $ease;
.setting-name {
@include face;
font-size: 16px;
color: $text;
// Fixed and non-shrinking, so the longest label sets the column and no row wraps its
// name onto a second line while its neighbours stay on one.
width: 120px;
flex-shrink: 0;
}
}
.stepper {
flex-direction: row;
align-items: center;
gap: 12px;
.step {
width: 30px;
height: 30px;
border-radius: 999px;
background-color: rgba(242, 237, 226, 0.08);
border: 1px solid $hairline;
color: $text;
font-size: 17px;
font-weight: 700;
text-align: center;
justify-content: center;
align-items: center;
pointer-events: all;
cursor: pointer;
transition: background-color 0.1s $ease, border-color 0.1s $ease, transform 0.1s $ease;
&:hover { background-color: rgba(242, 237, 226, 0.16); border-color: $apple; }
&:active { transform: scale(0.9); }
}
// Ten pips rather than a continuous bar. A single filled element sized from an inline
// `style` never drew - the attribute did not reach the panel at all - and pips are the
// better control anyway: one pip is exactly one tap of the stepper beside it, so the meter
// shows what a click will do rather than only where the value currently sits.
.meter {
flex-direction: row;
flex-shrink: 0;
align-items: center;
gap: 3px;
.pip {
width: 8px;
height: 10px;
flex-shrink: 0;
border-radius: 3px;
background-color: rgba(242, 237, 226, 0.14);
transition: background-color 0.12s $ease;
&.on { background-color: $apple; }
}
}
.setting-value {
@include numeral;
font-size: 14px;
color: $text-soft;
min-width: 38px;
text-align: center;
justify-content: center;
}
}
// ---------------------------------------------------------------- records readout
.records {
flex-direction: column;
align-items: stretch;
width: 320px;
margin-top: 28px;
padding-top: 18px;
padding-bottom: 18px;
padding-left: 22px;
padding-right: 22px;
background-color: $plate;
border-radius: $radius;
.records-title { @include label; margin-bottom: 12px; }
.record-row {
flex-direction: row;
justify-content: space-between;
padding-top: 4px;
padding-bottom: 4px;
font-size: 15px;
color: $text-soft;
span:last-child { @include numeral; color: $text; }
}
}