// ── Fluid root rem: 1rem scales with viewport ─────────────────────────────
// All sidebar/h2/font sizes downstream are in rem, so redefining root
// font-size against `vmin` (smaller of vh/vw) gives us a single sliding
// scale that's invariant under phone rotation: rotating swaps width/height
// but vmin stays the same → 1rem stays the same → navbar/footer/h2 hold
// their size. Floor 14px on cramped viewports, ceiling 22px on huge ones.
// 2.4vmin hits 16px (browser default) at vmin=667 (iPhone SE landscape).
html {
font-size: clamp(14px, 2.4vmin, 22px);
// Aperture foundation — locks the document viewport so content lives
// between/behind the fixed navbar + footer sidebars instead of leaking
// into a page-level scroll. Was opt-in per-page via body.page-X classes
// (duplicated 5× across SCSS files); now universal. Pages that need a
// narrower override (e.g. body.page-gameboard .container { overflow:
// clip; }) live in their per-page SCSS.
overflow: hidden;
}
// Layout custom properties — single source of truth for the landscape
// sidebar width (navbar/footer) + the rotated-h2 column slot to the right
// of the navbar. Container margin-left in landscape adds these so applets
// can't bleed under the wordmark.
:root {
--sidebar-w: 5rem;
--h2-col-w: 3rem;
}
body {
display: flex;
flex-direction: column;
background-color: rgba(var(--priUser), 1);
color: rgba(var(--secUser), 1);
font-family: Georgia, serif;
height: 100vh;
overflow: hidden;
a {
text-decoration: none;
font-weight: 700;
color: rgba(var(--terUser), 1);
&:hover {
color: rgba(var(--ninUser), 1);
text-shadow: 0 0 0.5rem rgba(var(--terUser), 1);
}
}
.container {
max-width: 960px;
width: 100%;
margin: 0 auto;
// padding: 0 1rem;
flex: 1;
// Aperture container — flex-column so navbar + h2 row + page content
// stack vertically, min-height: 0 + overflow: hidden contain the
// content within the aperture so applet borders / titles can't
// leak past the navbar / footer sidebars at narrow viewports.
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
.navbar {
padding: 0.75rem 0;
border-bottom: 0.1rem solid rgba(var(--secUser), 0.4);
.navbar-brand {
margin-left: 1rem;
h1 {
font-size: 2rem;
}
}
.container-fluid {
display: flex;
align-items: center;
gap: 1rem;
margin-right: 0.5rem;
.navbar-user {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
.navbar-text { flex: none; } // prevent expansion; BYE abuts the spans
> form { flex-shrink: 0; order: -1; } // BYE left of spans
}
> #id_cont_game,
> #id_navbar_gate_view_btn { flex-shrink: 0; }
}
.navbar-text,
.navbar-link {
flex: 1;
min-width: 0;
text-align: center;
.navbar-label {
display: block;
color: rgba(var(--secUser), 0.7);
font-size: 0.75rem;
}
.navbar-identity {
display: block;
color: rgba(var(--quaUser), 1);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.input-group {
position: fixed;
left: 0;
right: 0;
top: 50%;
transform: translateY(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
z-index: 50;
// Match .sky-field label — small, gold, uppercase, tracked.
label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(var(--quaUser), 0.8);
}
.form-control {
width: 24rem;
text-align: center;
}
}
.form-control {
background-color: rgba(var(--priUser), 1);
color: rgba(var(--secUser), 1);
border: 0.1rem solid rgba(var(--secUser), 0.5);
--_pad-v: 0.5rem;
padding: var(--_pad-v) 0.75rem;
border-radius: calc((var(--_pad-v) * 2 + 1em) / 3);
width: 100%;
font-family: inherit;
&.is-invalid {
border-color: rgba(var(--priRd), 1);
}
&.form-control-lg {
--_pad-v: 0.75rem;
padding: var(--_pad-v) 1rem;
// 1.125rem at rem=14 (small portrait clamp floor) is 15.75px
// — just under iOS Safari's 16px auto-zoom threshold. Floor
// at 16px to prevent the focus-zoom; native CSS max() handles
// the unit mix Sass can't reconcile at compile time.
font-size: unquote("max(16px, 1.125rem)");
}
&.is-invalid ~ .invalid-feedback {
display: block;
}
&:focus {
border-color: rgba(var(--terUser), 0.75);
box-shadow: 0 0 0.75rem rgba(var(--terUser), 0.5);
}
}
.invalid-feedback {
display: none;
color: rgba(var(--priRd), 1);
font-size: 0.875rem;
margin-top: 0.25rem;
}
// The post composer wraps its input + OK btn in `.composer-row`, so the
// `.form-control.is-invalid` input is no longer a general SIBLING of its
// `.invalid-feedback` (they live in different parents) — the
// `&.is-invalid ~ .invalid-feedback` reveal above silently stops
// matching, so the error renders in the DOM but stays display:none and
// the user never sees post-line validation. Reveal via :has() on the row.
.composer-row:has(.form-control.is-invalid) ~ .invalid-feedback {
display: block;
}
.alert {
padding: 0.75rem 1rem;
margin: 0.75rem;
border-radius: 0.5rem;
border: 0.1rem solid rgba(var(--priYl), 0.5);
color: rgba(var(--priYl), 1);
&.alert-success {
border-color: rgba(var(--priGn), 0.5);
color: rgba(var(--priGn), 1);
}
&.alert-warning {
border-color: rgba(var(--priOr), 0.5);
color: rgba(var(--priOr), 1);
}
}
.row {
padding: 2rem 0;
// Aperture: the h2-row mustn't shrink when the page-content
// child fills the remaining vertical space. Universal — was
// duplicated in every body.page-X { .row { flex-shrink: 0 } }.
flex-shrink: 0;
.col-md-12 {
width: 100%;
justify-content: center;
}
.col-lg-6 {
max-width: inherit;
margin: 0 1rem;
// Two-span title: BILLPOST. First
// word (always 4 letters: BILL/DASH/GAME/etc.) gets 45% of
// the title width; the variable second word fills the
// remaining 55%. Letters within each span spread via
// text-align: justify + text-justify: inter-character. The
// first-span colour shifts to --quaUser so the two-tone
// heading reads "Bill | Post" / "Dash | Sky".
h2 {
display: flex;
font-size: 3rem;
color: rgba(var(--secUser), 0.75);
margin-bottom: 1rem;
text-transform: uppercase;
text-shadow:
var(--title-shadow-offset) var(--title-shadow-offset) 0 rgba(0, 0, 0, 0.8)
;
// Each word-span hosts per-letter s injected by the
// h2-letter-split script in base.html — display: flex +
// justify-content: space-between distributes those letters
// across the slot's width (or height in landscape's
// writing-mode: vertical-rl). text-justify: inter-character
// would do the same in pure CSS, but iOS Safari + Firefox
// silently fall back to inter-word for Latin scripts, which
// can't split a single word — letters end up clustered at
// the slot's start with empty space trailing. The flex
// approach works everywhere.
> span {
display: flex;
justify-content: space-between;
align-items: center;
box-sizing: border-box;
}
// Padding-inline (logical) creates the natural visual gap
// between the two words at the 45/55 boundary — works for
// both portrait (horizontal) AND the landscape rotated
// wordmark (vertical-rl writing mode).
> span:first-child {
flex: 0 0 45%;
padding-inline-end: 0.4em;
color: rgba(var(--quaUser), 0.75);
}
> span:last-child {
flex: 0 0 55%;
padding-inline-start: 0.4em;
}
// Sprint A.7.5-polish-7 — 3-letter suffix special case
// (SKY / SEA / KIT). Default `space-between` parks the
// first + last letter at the slot edges + leaves a yawning
// gap mid-span ("S E A"). `space-around` puts
// equal padding on both sides of each letter so the trio
// sits more tightly clustered inside the slot. Data attr
// `data-letters` injected by `base.html`'s splitter script
// — length-keyed so future special cases (e.g. 2-letter
// for a hypothetical AP / WR title) bolt in via the same
// selector pattern.
> span[data-letters="3"] {
justify-content: space-around;
}
// ── GAME ROOM ⇄ GAME SCROLL title reel ─────────────────
// The second word becomes a two-word vertical reel inside
// the 55% title slot. The base word (ROOM) rests in view;
// SCROLL is parked one notch below, in the slot's bottom
// fade. room-scroll.js toggles `.is-scroll` on this
// when the table-hex aperture snaps to the provenance feed:
// ROOM slides up & out under the navbar line while SCROLL
// rises out of the bottom gradient — one reel, both words
// travelling up. Reverses on scroll-up.
//
// The slide axis is vertical in BOTH orientations: portrait
// the word is a short horizontal row; landscape (writing-
// mode: vertical-rl, inherited from the rotated wordmark)
// the word is a tall letter-column, so the same translateY
// slides it ALONG the wordmark — the user-chosen landscape
// behaviour falls out for free.
> span.gr-swap { // (0,4,3) ties > span:last-child; later source order wins
// Motion knobs — TWEAK here. The reel doesn't run on a
// single cubic-bezier any more: a bezier is one curve
// with two control points, so it can overshoot at most
// ONCE and can't oscillate. To make the word feel old &
// rusty — stall against the grime, jerk free, then clunk
// past its mark and wobble into place — `--gr-ease` is a
// CSS linear() easing: a piecewise list of `