ui/titlescreen.razor.scss
// Designed in 1080-reference space (ScreenPanel AutoScreenScale = ConsistentHeight).
@import "Theme.scss";
// Selected-QUIT accent colour. Muted salmon-red (OKLCH ~L 0.82 / C 0.09 / h 27) — enough hue to
// read as "this one exits" but low-chroma so it sits in the palette rather than alarming.
// Lightness is deliberately lifted well above the rolled backgrounds' mid range (an earlier
// L≈0.68 pick matched the teal roll's intensity and blended in): selection pop comes from the
// LIGHTNESS gap, like the selection white, with hue only carrying the exit meaning.
$quit-red: #ffaaa0;
// The daily calendar's "today" mark (see .i-daily .img .today). Same hue as $quit-red
// (OKLCH h 27) but deeper — L 0.60 / C 0.15 — because this red sits ON the icon's white
// date dot: the pale quit salmon vanished against it. Deliberately the most saturated
// red in the menu; at an 8px square that's an accent, not an alarm.
$daily-mark-red: #c9564d;
TitleScreen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
font-family: $font;
font-smooth: never;
.title {
flex-direction: column;
align-items: center;
margin-bottom: 80px;
// One black drop-shadow over the whole line (the glyphs are white-on-transparent),
// reproducing the original MenuTitle swatch (WHITE body, BLACK shadow).
.line {
flex-direction: row;
filter-drop-shadow: 8px 8px 0 $black;
.glyph {
width: 150px;
height: 150px;
background-image: url( "fonts/font_big.png" );
background-repeat: no-repeat;
background-tint: $white;
image-rendering: pixelated;
}
}
}
// Bottom-aligned "mountain" of square icon buttons — blocks resting on a floor, sizes peaking
// at the centred PLAY (order comes from TitleStage.DisplayOrder, sides width-mirrored around
// the hero: DAILY and LEADERBOARDS share the primary tier, OPTIONS and QUIT the tertiary one).
// Same recipe as the shared .square-btn (Theme.scss): dark body, WHITE border, hard offset
// shadow, press-in where the shadow's outer edge stays put — but the title row is its own
// family because of the size tiers and the keyboard-driven selected state.
.options {
flex-direction: row;
align-items: flex-end;
gap: 16px;
.option {
justify-content: center;
align-items: center;
pointer-events: all;
cursor: blank-hand;
> * {
pointer-events: none;
}
background-color: rgba( $black, 0.6 );
border: 4px solid $white;
border-radius: 0;
// Unselected buttons recede (same trick as the old text rows' 0.55 dim): opacity, not a
// fixed blend colour, so they fade toward whatever the day's rolled background is.
opacity: 0.6;
transition: background-color 0.1s ease, opacity 0.1s ease;
.img {
background-size: 100% 100%;
background-repeat: no-repeat;
image-rendering: pixelated;
background-tint: $white;
}
// Group gaps between the wings and the central play cluster (adds to the 16px row gap).
// The extra margin is dead space to pointer events — deliberate padding around QUIT so
// overshoot clicks miss it. One break per side, both 24px, so the two wing buttons sit
// the same 40px from their neighbour and PLAY stays centred: the left cluster
// (options64+16 +24+ workshop96+16+daily128+16 = 360) equals the right
// (16+lb128+16+replays96+16 +24+ quit64 = 360). Re-derive when any tier size or the
// order changes.
&.section-start {
margin-left: 24px;
}
// Selected (keyboard or hover, driven from the stage): the one fully-opaque, fully-dark
// button in the row — the same "only solid thing on screen" rule as the old selection white.
&.selected {
opacity: 1;
background-color: rgba( $black, 0.9 );
}
// Size tiers. Icon display size == icon image resolution on EVERY tier (40/60/80/120),
// so 1 image px = 1 ref px and the art's pixels read the same size on all buttons.
// Faces shrank one notch when WORKSHOP joined the row (216/144/108/72 → 192/128/96/64),
// keeping the icon art at its native resolutions, so the icon inset is now a little
// tighter than the letterbox buttons' 40-in-72 ratio. Shadow depth scales with the tier
// (like the old per-tier text shadows). Keep tier sizes in sync with TierClass in
// TitleScreen.razor.
&.hero {
width: 192px;
height: 192px;
box-shadow: 10px 10px 0 $square-btn-shadow;
.img { width: 120px; height: 120px; }
}
&.primary {
width: 128px;
height: 128px;
box-shadow: 9px 9px 0 $square-btn-shadow;
.img { width: 80px; height: 80px; }
}
&.secondary {
width: 96px;
height: 96px;
box-shadow: 7px 7px 0 $square-btn-shadow;
.img { width: 60px; height: 60px; }
}
&.tertiary {
width: 64px;
height: 64px;
box-shadow: 5px 5px 0 $square-btn-shadow;
.img { width: 40px; height: 40px; }
}
// Pressed: the face sinks INTO the shadow — the translate + the remaining shadow offset
// always sum to the tier's resting depth, so the shadow's outer edge stays put and only
// the face moves (same trick as .square-btn:active). Press depth scales with the tier.
// These sit AFTER the tier rules: equal specificity, so order breaks the tie.
&.hero:active {
transform: translate( 3px, 3px );
box-shadow: 7px 7px 0 $square-btn-shadow;
}
&.primary:active {
transform: translate( 3px, 3px );
box-shadow: 6px 6px 0 $square-btn-shadow;
}
&.secondary:active {
transform: translate( 2px, 2px );
box-shadow: 5px 5px 0 $square-btn-shadow;
}
&.tertiary:active {
transform: translate( 2px, 2px );
box-shadow: 3px 3px 0 $square-btn-shadow;
}
// QUIT selected: the one button that gets a hue — border and icon shift to the muted
// exit red. Deliberately the ONLY coloured selection in the row.
&.i-quit.selected {
border-color: $quit-red;
.img { background-tint: $quit-red; }
}
// "Today" marker while the daily is unattempted (div gated in the razor): one cell of
// the calendar icon's date grid blinks red, like the calendar marking today's date.
// An 8px square centred on a 4px date dot — the extra 2px ring makes the mark read
// at menu distance and hides the subpixel fringe where the div's edges and the scaled
// icon pixels don't land on the same screen pixel. WHICH dot is per-day (walks the
// grid, see TitleStage.DailyMarkCell) so left/top are inline in the razor; icon art is
// 1 image px = 1 ref px, so those are PNG pixel coords (dots are 4px chunks at
// x 20/32/44/56, y 32/42/52/62). The .img MUST be position:relative: s&box's Yoga
// anchors absolute panels to the nearest POSITIONED ancestor (web rule, root
// fallback), not the parent — without it the mark lands in the screen corner.
// The mark renders at FULL strength even on the resting button — see .marked below,
// which lifts the panel-opacity dim off this button so the child can escape it.
&.i-daily .img {
position: relative;
.today {
position: absolute;
width: 8px;
height: 8px;
background-color: $daily-mark-red;
animation: daily-today-blink 0.6s infinite;
}
}
// While the daily is unattempted (razor-gated .marked), the resting 0.6 dim moves off
// panel opacity onto each drawn part: bg 0.6→0.36 alpha, border/icon-tint whites at
// 0.6, shadow 0.45→0.27 (primary-tier 10px depth — DAILY is always primary). The
// engine multiplies panel opacity into every drawn element rather than compositing a
// flattened layer, so this renders IDENTICALLY to the neighbours' opacity dim — but
// panel opacity caps all descendants, and this frees the .today mark to blink at full
// strength on the resting button. Reverts to the plain opacity dim once attempted.
&.i-daily.marked {
opacity: 1;
background-color: rgba( $black, 0.36 );
border-color: rgba( $white, 0.6 );
box-shadow: 10px 10px 0 rgba( 0, 0, 0, 0.27 );
.img { background-tint: rgba( $white, 0.6 ); }
}
// Selected while marked: back to the normal solid treatment (these outrank the plain
// .selected rules on class count, so every dimmed property is restated).
&.i-daily.marked.selected {
background-color: rgba( $black, 0.9 );
border-color: $white;
box-shadow: 10px 10px 0 $square-btn-shadow;
.img { background-tint: $white; }
}
// ...and pressed while marked: keep the primary tier's press-sink shadow (the
// .marked.selected 10px shadow outranks .primary:active's 7px, which would pin the
// shadow's outer edge wrong while the face translates).
&.i-daily.marked.selected:active {
box-shadow: 7px 7px 0 $square-btn-shadow;
}
// Icon images: white-on-transparent pixel-art PNGs in Assets/ui/icons, drawn on a
// 2px-chunk grid like the letterbox icons. EDITOR/OPTIONS reuse the letterbox art.
&.i-play .img { background-image: url( "ui/icons/icon_title_play.png" ); }
&.i-daily .img { background-image: url( "ui/icons/icon_title_daily.png" ); }
&.i-trophy .img { background-image: url( "ui/icons/icon_title_trophy.png" ); }
&.i-replay .img { background-image: url( "ui/icons/icon_title_replay.png" ); }
// Generated placeholder (crate + up-arrow) at the secondary tier's native 60px — hand-edit
// the PNG freely, the pixel grid is 3px cells.
&.i-workshop .img { background-image: url( "ui/icons/icon_title_workshop.png" ); }
&.i-edit .img { background-image: url( "ui/icons/icon_edit.png" ); }
&.i-gear .img { background-image: url( "ui/icons/icon_gear.png" ); }
&.i-quit .img { background-image: url( "ui/icons/icon_quit.png" ); }
}
}
// Names the selected (icon-only) button: the same WHITE-on-BLACK selection swatch as the old
// selected text row, centred under the whole row so it doesn't jump around with the selection.
.option-label {
margin-top: 40px;
font-size: 64px;
// Hug the pixel font's glyphs (it reserves a tall line box with empty space below).
line-height: 0.8;
color: $white;
text-shadow: 4px 4px 0 $black;
&.quit {
color: $quit-red;
}
// Daily-unattempted shimmer while DAILY is selected: WHITE↔gold, full opacity throughout,
// so the selection stays solid and the pulse reads as "there's a prize here".
&.flash {
animation: daily-label-pulse 0.4s infinite;
}
}
// Editor-only dev shortcut into the level editor. Bottom-left letterbox, square — keep the
// geometry in sync with the level select screen's .editor-button (72px box, 40px inset).
// Look comes from the shared .square-btn (Theme.scss).
.editor-button {
position: absolute;
bottom: 40px;
left: 40px;
}
}
// "Today" cursor blink on the daily calendar's marked date cell (see .i-daily .img .today).
// Hard on/off like a terminal cursor — a stationary blink reads far calmer than the old
// whole-button pulse it replaces. 60% duty cycle; the 1% ramp is only there to avoid relying
// on steps() support. While OFF the icon's white date dot shows through, so the cell ticks
// white↔red rather than appearing/vanishing.
@keyframes daily-today-blink {
0%, 60% {
opacity: 1;
}
61%, 100% {
opacity: 0;
}
}
// DAILY CHALLENGE unattempted pulse on the label while DAILY is selected. Colour-only shimmer
// between the selection WHITE and the score-victory gold ($score-victory) — full opacity
// throughout, so the selection stays solid and the pulse reads as "there's a prize here".
@keyframes daily-label-pulse {
0%, 100% {
color: #f8f5e6;
}
50% {
color: #f0df80;
}
}