Files
python-tdd/src/static_src/scss/_base.scss
Disco DeDisco 1e37fe1475
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
My Sea iter 6b: navbar GATE VIEW swap on page-my-sea + landing PAID DRAW state + seat-1 server-render + auto-token IT trap in gatekeeper FT — Sprint 5 iter 6b of My Sea roadmap — TDD
Second of three Sprint 6 commits per [[sprint-my-sea-iter-6-plan]]. Wires the always-reachable navbar gate-entry, completes the landing center-btn 3-way state machine (FREE DRAW / GATE VIEW / PAID DRAW), and lifts seat-1's `.seated` state from JS-only to server-rendered (reload-stable).

## Navbar GATE VIEW swap

`templates/core/_partials/_navbar.html` — when `'page-my-sea' in page_class`, CONT GAME swaps for `#id_navbar_gate_view_btn` (`.btn-primary`, plain `<button>` w. inline onclick navigation). Reaches the gatekeeper at any quota state — no confirm guard (non-destructive nav).

**Typeface trap caught (user 2026-05-20 visual report)**: first cut used `<a>` for GATE VIEW, which UA-renders serif while `<button>` stays sans-serif (`.btn` doesn't reset `font-family`). Same fix pattern as iter-4c's in-hex GATE VIEW: always use `<button>`. Second cut used a form-wrapped `<button>` w. `display:contents`; the form was correctly invisible in layout but broke the landscape `> #id_cont_game { order: -1 }` direct-child SCSS pin (form became the direct child, not the button). Final cut: plain `<button>` w. `onclick="window.location.href=..."`, no form, no anchor — direct flex child of `.container-fluid` so the SCSS pin matches.

`_base.scss` — paired `> #id_navbar_gate_view_btn` alongside `> #id_cont_game` in both portrait (line 93) + landscape (line 309) rules so GATE VIEW occupies the same top-center navbar slot CONT GAME does (above brand, `order: -1`).

## Landing center-btn 3-way state machine

`my_sea` view gains `deposit_reserved` (active_draw has deposit_token_id) + `hand_non_empty` context vars.

`my_sea.html` landing branches:
- `deposit_reserved` → **PAID DRAW** form (POSTs to `my_sea_paid_draw`); fastest path back to picker w. one click — no gatekeeper round-trip.
- `quota_spent and not deposit_reserved` → **GATE VIEW** (existing iter-4c btn, navigates to gatekeeper).
- else → **FREE DRAW** (existing iter-1 btn).

Three branches are mutually exclusive — FT asserts only one of `#id_my_sea_paid_draw_btn` / `#id_my_sea_gate_view_btn` / `#id_draw_sea_btn` renders at a time.

## Seat-1 server-render

`my_sea.html` table-seat 1 now picks up `.seated` + `.fa-circle-check` (instead of `.fa-ban`) when `hand_non_empty`. Other 5 seats stay banned (placeholders for the future friend-invite feature; only owner ever occupies seat 1 in solo my-sea). Reloads no longer lose the chair-styling state — existing JS animation (FREE DRAW click → flip seat to seated) still fires on first draw.

In practice today the landing only renders when hand IS empty (show_picker hides landing once hand has cards), so the `.seated` branch isn't actually visible in iter 6b. Defensive code for future surfaces (any hex render w. hand non-empty) per [[sprint-my-sea-iter-6-plan]] §Seat-1 persistence.

## FT delta

**Replaced** `MySeaGatekeeperPageTest.test_gatekeeper_renders_six_chair_seats_with_seat1_seated` w. `test_gatekeeper_renders_no_hex_modal_only`. The iter-6a FT skeleton was written before the user's "no hex on gatekeeper" spec (2026-05-20) — seats now live ONLY on the my-sea picker page; the gatekeeper is a transient `.gate-modal` overlay w. no hex / chair-seats.

**Trap caught**: `MySeaGatekeeperPageTest.test_paid_draw_commits_token_and_redirects_to_picker` was passing in iter 6a only because it didn't actually exist in CI then; running it locally exposed the IT-trap pattern: User post_save signal auto-creates COIN + FREE tokens (`apps.lyric.models:309`), so `_select_my_sea_token` picks the auto-COIN (PASS > **COIN** > FREE > TITHE) instead of the manually-seeded FREE. Test asserted FREE count drops by 1 → fails because COIN was actually debited (sets cooldown, doesn't delete the token). Same trap as the iter-6a IT memo; fix is identical: `self.gamer.tokens.all().delete()` after User.create + then seed only the token the test cares about.

## Tests

- 4 MySeaGatekeeperPageTest (iter 6a, now passing) + 1 MySeaLandingPaidDrawTest + 1 MySeaNavbarGateViewTest + 2 MySeaSeatOnePersistenceTest = 8 FTs green in 84s.
- All 7 `test_core_navbar` FTs (NavbarByeTest + NavbarContGameTest) still green — landscape order rule extension is additive; CONT GAME path unchanged.
- 153/153 gameboard ITs green.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 02:50:54 -04:00

649 lines
21 KiB
SCSS
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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;
}
.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: <span>BILL</span><span>POST</span>. 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 <span>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;
}
}
}
}
}
}
@media (min-width: 1200px) {
body .container {
max-width: 1200px;
}
}
@media (orientation: landscape) {
// ── Sidebar layout: navbar ← left, footer → right ────────────────────────────
body {
flex-direction: row;
}
// Navbar → fixed left sidebar (width derives from --sidebar-w which is
// fluid via the rem-redefine above; no per-breakpoint width jumps).
body .container .navbar {
position: fixed;
left: 0;
top: 0;
height: 100vh;
width: var(--sidebar-w);
padding: 0.5rem 0;
border-bottom: none;
border-right: 0.1rem solid rgba(var(--secUser), 0.4);
background-color: rgba(var(--priUser), 1);
z-index: 100;
overflow: hidden;
.container-fluid {
flex-direction: column;
height: 100%;
max-height: 700px;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0 0.25rem;
margin: 0; // reset portrait margin-right: 0.5rem so container fills full sidebar width
> #id_cont_game,
> #id_navbar_gate_view_btn { flex-shrink: 0; order: -1; } // cont-game / GATE VIEW above brand
.navbar-user {
flex-direction: column;
align-items: center;
gap: 0.25rem;
.navbar-text { margin: 0; } // cancel landscape margin:auto so BYE abuts
> form { order: 0; .btn { margin-top: 0; } } // abut spans
}
}
.navbar-brand h1 {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 1.2rem;
line-height: 1.2;
white-space: nowrap;
// margin-right: 3.25rem;
}
.navbar-brand {
order: 1; // brand at bottom
width: 100%;
margin-left: 0; // reset portrait margin-left: 1rem
display: flex;
justify-content: center;
}
.navbar-link { display: none; }
.navbar-text {
writing-mode: vertical-rl;
transform: rotate(180deg);
font-size: 0.65rem;
white-space: nowrap;
margin: auto 0;
.navbar-label { opacity: 0.7; }
}
// .btn-primary {
// width: 4rem;
// height: 4rem;
// font-size: 0.875rem;
// border-width: 0.21rem;
// }
// Login form: offset from fixed sidebars in landscape
.input-group {
left: var(--sidebar-w);
right: var(--sidebar-w);
.navbar-text {
writing-mode: horizontal-tb;
transform: none;
font-size: 0.75rem;
white-space: normal;
margin: 0 0 0.25rem;
text-align: center;
}
}
}
// Container: fill center, compensate for fixed sidebars on both sides
// AND for the rotated-h2 column on the left (so applets can't bleed
// under the wordmark — true aperture clipping).
// max-width: none overrides the @media (min-width: 1200px) rule above
// so the container fills all available space between the sidebars.
body .container {
flex: 1;
min-width: 0;
max-width: none;
margin-left: calc(var(--sidebar-w) + var(--h2-col-w));
margin-right: var(--sidebar-w);
padding: 0 0.5rem;
}
// Header row: h2 rotates into the dedicated --h2-col-w slot just right
// of the navbar. position:fixed takes h2 out of flow; .row collapses
// to zero height automatically. Resets portrait flex so the rotated
// wordmark renders as one continuous title (not split 45/55 here).
body .container .row {
padding: 0;
margin: 0;
}
body .container .row .col-lg-6 h2 {
position: fixed;
left: var(--sidebar-w);
width: var(--h2-col-w);
top: 50%;
height: 80vh; // explicit height so the flex 45/55 % basis resolves
transform: translateY(-50%) rotate(180deg);
writing-mode: vertical-rl; // inline axis becomes top-to-bottom; flex stacks on it
// Per-letter flex spread (justify-content: space-between on each word
// span) fills the slot regardless of font-size, so we only need to
// cap font-size by vh so each letter glyph stays smaller than slot /
// letter-count. Worst case: HOWDY STRANGER (8ch second word) in 55%
// of 80vh on a 375-tall iPhone SE landscape → 165px slot ÷ 8 ≈ 20px
// max glyph height; clamp's 4.4vh + 1.2rem floor gives 16.516.8px
// at that viewport, well under 20.
font-size: clamp(1.2rem, 4.4vh, 2.75rem);
margin: 0;
z-index: 85;
pointer-events: none;
// Inherits display: flex + the per-span flex 45/55 + padding-inline
// boundary from the portrait base. With writing-mode: vertical-rl the
// flex axis runs vertically, so first-span (BILL) takes 45% of the
// height (becomes bottom 45% after rotate(180deg)) and second-span
// (POST) takes the upper 55%. padding-inline-end resolves to the
// bottom edge of the first span — natural break between words.
}
// Footer → fixed right sidebar (mirrors navbar approach — explicit right boundary)
// Use body #id_footer (specificity 0,1,0,1) to beat base #id_footer (0,1,0,0)
// which compiles later in the output and would otherwise override height: 100vh.
body #id_footer {
position: fixed;
right: 0;
top: 0;
width: var(--sidebar-w);
height: 100vh;
flex-direction: column;
justify-content: center;
align-items: center;
border-top: none;
border-left: 0.1rem solid rgba(var(--secUser), 0.3);
background-color: rgba(var(--priUser), 1); // opaque: masks tray sliding behind it
padding: 1rem 0;
gap: 0;
z-index: 100;
#id_footer_nav {
flex-direction: column-reverse;
width: auto;
max-width: none;
gap: 1.5rem !important;
margin-bottom: 4rem;
a {
font-size: 1.75rem;
display: flex;
justify-content: center;
align-items: center;
}
}
// ©2026 Dis Co. — single-line vertical strip at the right edge of
// the right sidebar (= the very right edge of the viewport in
// landscape). Reads bottom-to-top via writing-mode: vertical-rl +
// rotate(180deg) — same pattern as the navbar's rotated brand
// wordmark. Tucks into the empty 0.875rem gutter between the
// viewport edge and the centred icon/btn column, no overlap.
.footer-container {
position: absolute;
right: 0.125rem;
top: auto;
line-height: 1 !important;
color: rgba(var(--secUser), 1);
writing-mode: vertical-rl;
transform: rotate(180deg);
white-space: nowrap;
br { display: none; }
small {
font-size: 0.75rem !important;
}
}
}
}
// Footer typography refinements that only kick in once the viewport is
// wide enough to clear the cramped phone-landscape regime. Sidebar
// dimensions themselves are now fluid via rem and don't need a per-
// breakpoint width override (the old ≥1800px doubling block is gone).
@media (orientation: landscape) and (min-width: 700px) {
body #id_footer {
#id_footer_nav {
gap: 3rem !important;
a {
font-size: 1.75rem;
display: flex;
justify-content: center;
align-items: center;
}
}
.footer-container {
line-height: 1;
// margin-top vestige of the absolute-top-anchored layout —
// dropped now that the rotated text is bottom-anchored.
small {
font-size: 1rem;
}
}
}
}
@media (orientation: portrait) and (max-width: 500px) {
body .container {
.navbar {
padding: 0 0 0.25rem 0;
.navbar-brand h1 {
font-size: 1.2rem;
}
}
// Per-letter flex spread fills each 45/55 slot regardless of font-size,
// so we just need to cap the glyph size by viewport width to keep the
// worst-case title (HOWDY STRANGER, 8ch second word) from clipping at
// tiny mobile widths. clamp picks max(1.3rem, 5vw) capped at 2rem —
// at 320w → 18.2px, 390w → 19.5px, 430w → 21.5px.
// padding-inline boundary bumped from 0.4em → 0.6em (each side) so the
// last letter of word 1 (H) and first letter of word 2 (B) don't run
// together at the cramped portrait font-size.
.row .col-lg-6 h2 {
margin: 0;
font-size: clamp(1.3rem, 5vw, 2rem);
> span:first-child { padding-inline-end: 0.6em; }
> span:last-child { padding-inline-start: 0.6em; }
}
}
}
#id_footer {
flex-shrink: 0;
height: 6rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: center;
padding: 1rem 1rem;
border-top: 0.1rem solid rgba(var(--secUser), 0.3);
// background: linear-gradient(
// to top,
// rgba(var(--priUser), 1) 25%,
// transparent 100%
// );
#id_footer_nav {
display: flex;
justify-content: space-evenly;
width: 80%;
max-width: 500px;
a {
font-size: 1.75rem;
color: rgba(var(--secUser), 0.6);
text-shadow:
0 0 0.25rem rgba(0, 0, 0, 0.25),
;
&.active {
color: rgba(var(--quaUser), 1);
text-shadow:
0 0 0.5rem rgba(0, 0, 0, 0.5),
;
}
&:hover {
color: rgba(var(--quaUser), 1);
text-shadow:
0 0 1rem rgba(0, 0, 0, 0.25),
0 0 0.5rem rgba(var(--ninUser), 0.25)
;
}
}
}
.footer-container {
br { display: none; }
small {
font-size: 0.75rem;
opacity: 1;
}
}
}
.forthcoming {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
font-style: italic;
opacity: 0.6;
}
// Ordinal superscript: 21st, 2nd, 3rd etc. — matches .tt-ord but globally available.
.ord {
font-size: 0.6em;
vertical-align: 0.25em;
line-height: 0;
margin-left: -0.1em;
letter-spacing: 0;
}
#id_guard_portal {
display: none;
position: fixed;
z-index: 10000;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
background-color: rgba(var(--tooltip-bg), 0.75);
backdrop-filter: blur(6px);
border: 0.1rem solid rgba(var(--secUser), 0.4);
box-shadow: 0 0.25rem 1rem rgba(0, 0, 0, 0.4);
&.active {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.guard-message {
font-size: 0.85rem;
color: rgba(var(--secUser), 0.9);
text-align: center;
white-space: nowrap;
}
.guard-actions {
display: flex;
gap: 0.5rem;
}
}
.card-ref {
color: rgba(var(--terUser), 1) !important;
font-weight: 600 !important;
}