Compare commits

...

4 Commits

Author SHA1 Message Date
Disco DeDisco
79706e817a iOS focus-zoom prevention — input font-size floor @ 16px ; JS fallback strengthened
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Belt-and-suspenders for the iOS Safari auto-zoom-on-input quirk: Mobile Safari zooms the viewport when an `<input>`/`<textarea>`/`<select>` is focused & its computed font-size < 16px, and never zooms back out on blur. Two layers ; PRIMARY — SCSS prevention: new `input, textarea, select, [contenteditable] { font-size: unquote("max(16px, 1em)") }` in core.scss (Sass can't reconcile px/em units in compile-time max() so unquote() passes the CSS max() through verbatim — modern browsers handle natively). 1em inherits parent, max() floors at 16. ALSO floored `.form-control-lg` in _base.scss — was `font-size: 1.125rem`, which at rem=14 (small portrait, clamp(14px, 2.4vmin, 22px) hits its floor) computes to 15.75px → **0.25px** under iOS's 16px threshold → the "ever so slightly" zoom on New Game + New Post applets the user reported (both use `.form-control.form-control-lg`, specificity 0,2,0 beats my element-level 0,0,1 rule). Floor: `unquote("max(16px, 1.125rem)")` ; SECONDARY — JS fallback in base.html: rewritten from `setAttribute('content', ...)` toggle to full meta-element remove+re-add, which modern iOS handles more reliably than attribute mutations on the existing meta. Triggers on document-level `focusout` (bubbles natively, no capture-phase needed) for `input/textarea/select`; injects fresh viewport meta w. `maximum-scale=1.0, user-scalable=no` for 300ms (iOS reads as zoom violation → snaps to 1:1), then swaps back to the cached base content so pinch-zoom remains available elsewhere ; user observed horizontal scrollbar appearing when the page zoomed — that's the symptom the user actually cared about (broken layout, not aesthetic zoom). w. SCSS floor in place the zoom shouldn't trigger to begin with; the JS is purely for inputs that slip through (future custom controls, shadow DOM, etc.) ; iOS-specific behavior — Selenium+Firefox doesn't replicate the auto-zoom so no FT layer added. Verified by user manual iPhone test (post-fix retest pending after force-refresh)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:32:45 -04:00
Disco DeDisco
8066ac289f iOS viewport zoom-reset on form-field exit — global IIFE in base.html
iOS Safari auto-zooms when a user taps an `<input>`/`<textarea>`/`<select>` whose font-size is < 16px, and does NOT auto-zoom back out on blur — the page stays zoomed even after the field loses focus. New ~10-line IIFE at base.html slots next to the existing h2-letter-splitter at the bottom of <body>: caches the page's `<meta name="viewport">` content, listens (document-level, bubbling `focusout`) for inputs leaving focus, then briefly appends `, maximum-scale=1.0` before reverting 100ms later — iOS reads the tightened constraint as a "zoom violation" and snaps the viewport back to 1:1, after which the revert frees the user to pinch-zoom manually anywhere else on the page ; chose `focusout` over `blur`+capture-phase since focusout bubbles natively (cleaner); skips if `.matches` isn't available (defensive for older browsers); skips silently if no viewport meta is present (defensive) ; no test layer — iOS-specific behavior that's awkward to FT (would need a real iOS Safari runner; Selenium+Firefox doesn't replicate the auto-zoom). Verified no conflict w. other focusout listeners (grep: only vendor JS — d3 / htmx / jquery / select2 — none of which listen at document scope on inputs/textareas/selects). Side-track addition between Sprint 1 (table hex layout fbe6c12... well, 7165974) and Sprint 2 (My Sea applet kickoff) per user ask

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:22:08 -04:00
Disco DeDisco
7165974905 table hex layout: fill aperture + enlarge hex from 160×185 → 200×231 ; chair clearance preserved
User report: hex felt smaller than the aperture even at portrait (off-centered, room to spare on top + bottom), and chair labels overlapped the hex edges at landscape — progressively worse as the hex grew at larger viewports. Three contributors stacked: (1) `.room-shell { max-height: 80vh }` capped the shell at 80% of viewport height even when the .room-page aperture had more room — at 1789×1031 this donated 228px (1053→825) of aperture height to dead margin; (2) scene design 360×300 was wider than tall (1.2 aspect) but landscape aperture is narrower than tall (~1.4), so the height cap bottlenecked scene-scale at min(aperture_w/360, aperture_h/300) instead of letting the hex grow; (3) chair font-size scales w. rem (clamp(14,2.4vmin,22)) but chair position scales w. --table-scale — at large viewports rem maxes at 22 so labels widen and push chair icons further from box-center toward the hex (visual "creep") ; fix: remove the 80vh cap (`max-height: 80vh` → `height: 100%` on .room-shell L340) so the shell stretches to fill the .room-page aperture; bump hex from 160×231 to 200×231 (regular pointy-top w. width = height × √3/2 = 200 * 1.1547 — comment in _room.scss updated); apothem of 200-wide pointy-top regular hex is 100px exact (200/√3 × √3/2), so `$pos-d` 110px → 140px gives 40px design-units of radial chair clearance (was 30); derived `$pos-d-x: round(140*0.5) = 70`, `$pos-d-y: round(140*0.866) = 121` for slot 2/3/5/6 diagonal anchors at 60° from horizontal (matches existing geometry approach); scene design height 300 → 320 to leave enough vertical headroom at large landscape that the rem-driven (font-size 1.6rem × scale) chair icons + labels don't clip the aperture top/bottom edges — at 1789×1111 w. scene_H=300 the AC/BC label tops sat AT aperture top (y=-21 vs aperture y=-22), bumping to 320 drops scale from 4.05 → 3.54 and leaves 76px of headroom; SCENE_H in room.js bumped to 320 to match (Math.min(w/SCENE_W, h/SCENE_H) sets --table-scale CSS var via transform: scale on .room-table-scene) ; visual verification via Claudezilla across three viewports (no test layer per user preference — layout regression coverage via spot-check on next room render) — iPhone-14 portrait 566×875: hex 243×281 → 314×363 (+29% wider, fills 55% of aperture width vs 44% before); mid landscape 1149×781: hex 333×385 → 493×569 (+48% wider, 56% vs 38% before); large landscape 1789×1111: hex 440×509 → 708×818 (+61% wider, 48% vs 30% before — the most dramatic improvement, matching user's "progressively worse the larger the hex grows" observation). Chair clearance now uniform 40 design-units radially across all scales; AC/BC labels stay 76px inside aperture top at the largest viewport ; dead `$seat-r`/`$seat-r-x`/`$seat-r-y` consts at L357-359 left in place (unused elsewhere in codebase but out of scope for this layout fix) ; full IT/UT 999 green in 46s — no regressions; .table-hex / .table-hex-border / .room-table-scene / .table-seat positioning consts are the only refs to these dimensions across SCSS & JS so no cascade beyond room layout. Unblocks Sprint 2+ (My Sea applet will share the same hex CSS, parameterized, per user's intent for future friend-invite up-to-6-person rooms)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:19:11 -04:00
Disco DeDisco
fbe6c12ded fix CAST SKY click opening tray instead of Sky Select — TDD
CAST SKY btn click handler in sig-select.js init() was bound to `Tray.open()` — wrong on two counts: (1) the tray was already played during the `polarity_room_done` → `Tray.placeSig` sequence (sig stage card slides into the tray cell before the overlay dismisses), so re-opening it on CAST SKY click pops the tray a second time; (2) Sky Select never opens — `_sky_overlay.html` is only `{% include %}`d server-side when `room.table_status == "SKY_SELECT"`, so during SIG_SELECT the partial + its `openSky` handler aren't in the DOM and `Tray.open()` is the only thing the click does. Bug surfaced symmetrically in both polarity rooms regardless of which finished first ; fix: replace `Tray.open()` w. `window.location.reload()` so the server re-renders the room w. table_status=SKY_SELECT — which surfaces the sky overlay partial + the `openSky` handler bound at _sky_overlay.html:192-193. Same pattern as `_onSkyConfirmed` in the sky partial (location.reload after sky save) ; testability hook mirrors `RoleSelect.setReload` (role-select.js:236): `var _reload = function () { window.location.reload(); };` at module scope, listener calls `_reload()` (closure looks up the var at click time so reassignment works), `setReload(fn)` exposed on the module's test API. SigSelectSpec.js adds `describe("CAST SKY click (post pick_sky_available)")` w. 2 specs — reload spy hit on click + `Tray.open` spy NOT hit on click; the negative assertion catches the original bug, the positive verifies the fix's intent. Existing 363 specs untouched ; Jasmine FT green in 8.6s; full IT/UT 999 green in 44s ; collectstatic mirror at src/static/apps/epic/sig-select.js refreshed in same commit so the served JS carries the fix (Django serves from STATIC_ROOT, not from app static dirs, in StaticLiveServerTestCase)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 17:21:32 -04:00
8 changed files with 155 additions and 25 deletions

View File

@@ -1,5 +1,5 @@
(function () { (function () {
var SCENE_W = 360, SCENE_H = 300; var SCENE_W = 360, SCENE_H = 320;
function scaleTable() { function scaleTable() {
var scene = document.querySelector('.room-table-scene'); var scene = document.querySelector('.room-table-scene');

View File

@@ -29,6 +29,10 @@ var SigSelect = (function () {
var _reservedFloats = {}; // key: role → portal <i> element (thumbs-up, frozen) var _reservedFloats = {}; // key: role → portal <i> element (thumbs-up, frozen)
var _cursorPortal = null; var _cursorPortal = null;
// CAST SKY click → page reload. Reassignable via setReload for tests so
// they can spy without actually reloading the Jasmine runner.
var _reload = function () { window.location.reload(); };
function getCsrf() { function getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/); var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : ''; return m ? m[1] : '';
@@ -625,12 +629,16 @@ var SigSelect = (function () {
userRole = overlay.dataset.userRole; userRole = overlay.dataset.userRole;
userPolarity= overlay.dataset.polarity; userPolarity= overlay.dataset.polarity;
// CAST SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available // CAST SKY btn is rendered hidden during SIG_SELECT; revealed by
// room:pick_sky_available once both polarity rooms are done. Clicking
// it must reload the page — the _sky_overlay.html partial is only
// included server-side once room.table_status == "SKY_SELECT", so
// reloading is the only path that brings the modal + its openSky
// handler into the DOM. (Tray.placeSig already played during the
// polarity_room_done sequence; do NOT re-open the tray here.)
var pickSkyBtn = document.getElementById('id_pick_sky_btn'); var pickSkyBtn = document.getElementById('id_pick_sky_btn');
if (pickSkyBtn) { if (pickSkyBtn) {
pickSkyBtn.addEventListener('click', function () { pickSkyBtn.addEventListener('click', function () { _reload(); });
if (typeof Tray !== 'undefined') Tray.open();
});
} }
// Restore reservations from server-rendered JSON (page-load state). // Restore reservations from server-rendered JSON (page-load state).
@@ -738,5 +746,6 @@ var SigSelect = (function () {
}, },
_setFrozen: function (v) { _stageFrozen = v; }, _setFrozen: function (v) { _stageFrozen = v; },
_setReservedCardId: function (id) { _reservedCardId = id; }, _setReservedCardId: function (id) { _reservedCardId = id; },
setReload: function (fn) { _reload = fn; },
}; };
}()); }());

View File

@@ -894,4 +894,44 @@ describe("SigSelect", () => {
expect(document.getElementById("id_hex_waiting_msg")).toBe(null); expect(document.getElementById("id_hex_waiting_msg")).toBe(null);
}); });
}); });
// ── CAST SKY click (post pick_sky_available reveal) ──────────────────── //
//
// After room:pick_sky_available reveals the hidden #id_pick_sky_btn, a
// click on CAST SKY must reload the page — the _sky_overlay.html partial
// is only rendered server-side once room.table_status == "SKY_SELECT", so
// reloading is the only way to bring its modal + openSky handler into the
// DOM. The handler must NOT call Tray.open: the tray was already played
// during the polarity_room_done sequence (Tray.placeSig) and re-opening it
// here would swap Sky Select for the tray.
describe("CAST SKY click (post pick_sky_available)", () => {
let pickSkyBtn, reloadSpy;
beforeEach(() => {
pickSkyBtn = document.createElement("button");
pickSkyBtn.id = "id_pick_sky_btn";
pickSkyBtn.style.display = "none";
document.body.appendChild(pickSkyBtn);
makeFixture({ polarity: "levity", userRole: "PC" });
reloadSpy = jasmine.createSpy("reload");
SigSelect.setReload(reloadSpy);
spyOn(Tray, "open");
});
afterEach(() => {
if (pickSkyBtn) pickSkyBtn.remove();
SigSelect.setReload(function () { window.location.reload(); });
});
it("reloads the page so the sky overlay partial renders", () => {
pickSkyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(reloadSpy).toHaveBeenCalled();
});
it("does NOT call Tray.open (tray was already played by Tray.placeSig)", () => {
pickSkyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(Tray.open).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -159,7 +159,11 @@ body {
&.form-control-lg { &.form-control-lg {
--_pad-v: 0.75rem; --_pad-v: 0.75rem;
padding: var(--_pad-v) 1rem; padding: var(--_pad-v) 1rem;
font-size: 1.125rem; // 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 { &.is-invalid ~ .invalid-feedback {

View File

@@ -337,7 +337,7 @@ html.sea-open #id_aperture_fill {
align-items: stretch; align-items: stretch;
gap: 2rem; gap: 2rem;
width: 100%; width: 100%;
max-height: 80vh; height: 100%;
align-self: stretch; align-self: stretch;
} }
@@ -345,24 +345,22 @@ html.sea-open #id_aperture_fill {
// //
// .table-hex: regular pointy-top hexagon. // .table-hex: regular pointy-top hexagon.
// clip-path polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%) // clip-path polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%)
// on a 160×185 container gives equal-length sides (height = width × 2/√3). // on a 200×231 container gives equal-length sides (height = width × 2/√3).
// //
// Seats use absolute positioning from the .room-table centre. // Seats use absolute positioning from the .room-table centre.
// $seat-r = 130px — radius to seat centroid
// $seat-r-x = round(130px × sin60°) = 113px — horizontal component
// $seat-r-y = round(130px × cos60°) = 65px — vertical component
//
// Clockwise from top: slots 1→2→3→4→5→6. // Clockwise from top: slots 1→2→3→4→5→6.
$seat-r: 130px; $seat-r: 140px;
$seat-r-x: round($seat-r * 0.866); // 113px $seat-r-x: round($seat-r * 0.866); // 121px
$seat-r-y: round($seat-r * 0.5); // 65px $seat-r-y: round($seat-r * 0.5); // 70px
// Seat edge-midpoint geometry (pointy-top hex). // Seat edge-midpoint geometry (pointy-top hex).
// Apothem ≈ 80px + 30px clearance = 110px total push from centre. // 200×231 hex → apothem = 100px; $pos-d = 140 leaves 40px design-units of
$pos-d: 110px; // chair clearance radially. $pos-d-x / $pos-d-y are the x/y components for
$pos-d-x: round($pos-d * 0.5); // 55px // diagonal seats (cos/sin of 60° from horizontal).
$pos-d-y: round($pos-d * 0.866); // 95px $pos-d: 140px;
$pos-d-x: round($pos-d * 0.5); // 70px
$pos-d-y: round($pos-d * 0.866); // 121px
// ─── Position strip ──────────────────────────────────────────────────────── // ─── Position strip ────────────────────────────────────────────────────────
// Numbered gate-slot circles sit above the gate backdrop/overlay (z 130 > 120 // Numbered gate-slot circles sit above the gate backdrop/overlay (z 130 > 120
@@ -482,10 +480,12 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
} }
// Fixed design-size scene; JS scales it to fill .room-table via transform: scale(). // Fixed design-size scene; JS scales it to fill .room-table via transform: scale().
// Design dims: seat reach is ±110px H / ±95px V from centre + seat element size. // Design dims: seat reach is ±140px H / ±121px V from centre + seat element size.
// scene H of 320 leaves vertical headroom at large landscape so the rem-scaled
// chair icons + labels don't clip the aperture top/bottom edges.
.room-table-scene { .room-table-scene {
width: 360px; width: 360px;
height: 300px; height: 320px;
position: relative; position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -496,8 +496,8 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
// Hex border: clip-path clips CSS borders, so the ring is a wrapper with the // Hex border: clip-path clips CSS borders, so the ring is a wrapper with the
// same hex polygon at a slightly larger size. 0.25rem each side — subtle only. // same hex polygon at a slightly larger size. 0.25rem each side — subtle only.
.table-hex-border { .table-hex-border {
width: calc(160px + 0.5rem); width: calc(200px + 0.5rem);
height: calc(185px + 0.5rem); height: calc(231px + 0.5rem);
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
background: rgba(var(--quaUser), 1); background: rgba(var(--quaUser), 1);
filter: drop-shadow(0 0 6px rgba(var(--quaUser), 0.5)); filter: drop-shadow(0 0 6px rgba(var(--quaUser), 0.5));
@@ -507,8 +507,8 @@ html:has(.gate-backdrop) .position-strip .gate-slot button { pointer-events: aut
} }
.table-hex { .table-hex {
width: 160px; width: 200px;
height: 185px; height: 231px;
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
// Six gradients — one per hex face — each perpendicular to that face so the // Six gradients — one per hex face — each perpendicular to that face so the
// shadows follow the hex geometry rather than the rectangular bounding box. // shadows follow the hex geometry rather than the rectangular bounding box.

View File

@@ -23,4 +23,11 @@ select,
[contenteditable] { [contenteditable] {
user-select: text; user-select: text;
touch-action: auto; touch-action: auto;
// iOS Safari auto-zooms when focusing a form field whose computed font-size
// is < 16px. At rem=14 (small portrait viewports w. clamp(14,2.4vmin,22))
// a 1rem input is 14px → triggers zoom. `max(16px, 1em)` enforces the 16px
// floor while still inheriting larger sizes from parent contexts when set.
// unquote() keeps Sass from trying to evaluate the px/em compare at compile
// time (it can't reconcile units); CSS max() handles it natively at runtime.
font-size: unquote("max(16px, 1em)");
} }

View File

@@ -894,4 +894,44 @@ describe("SigSelect", () => {
expect(document.getElementById("id_hex_waiting_msg")).toBe(null); expect(document.getElementById("id_hex_waiting_msg")).toBe(null);
}); });
}); });
// ── CAST SKY click (post pick_sky_available reveal) ──────────────────── //
//
// After room:pick_sky_available reveals the hidden #id_pick_sky_btn, a
// click on CAST SKY must reload the page — the _sky_overlay.html partial
// is only rendered server-side once room.table_status == "SKY_SELECT", so
// reloading is the only way to bring its modal + openSky handler into the
// DOM. The handler must NOT call Tray.open: the tray was already played
// during the polarity_room_done sequence (Tray.placeSig) and re-opening it
// here would swap Sky Select for the tray.
describe("CAST SKY click (post pick_sky_available)", () => {
let pickSkyBtn, reloadSpy;
beforeEach(() => {
pickSkyBtn = document.createElement("button");
pickSkyBtn.id = "id_pick_sky_btn";
pickSkyBtn.style.display = "none";
document.body.appendChild(pickSkyBtn);
makeFixture({ polarity: "levity", userRole: "PC" });
reloadSpy = jasmine.createSpy("reload");
SigSelect.setReload(reloadSpy);
spyOn(Tray, "open");
});
afterEach(() => {
if (pickSkyBtn) pickSkyBtn.remove();
SigSelect.setReload(function () { window.location.reload(); });
});
it("reloads the page so the sky overlay partial renders", () => {
pickSkyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(reloadSpy).toHaveBeenCalled();
});
it("does NOT call Tray.open (tray was already played by Tray.placeSig)", () => {
pickSkyBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
expect(Tray.open).not.toHaveBeenCalled();
});
});
}); });

View File

@@ -98,6 +98,36 @@
} }
} }
}()); }());
// iOS Safari auto-zooms when focusing an <input>/<textarea>/<select>
// whose font-size is < 16px, and does NOT auto-zoom back out on blur.
// Belt-and-suspenders: primary prevention is the global `font-size:
// max(16px, 1em)` rule in core.scss; this JS is a fallback for inputs
// that slip through (custom controls, shadow DOM, etc.). Modern iOS
// doesn't reliably react to a simple `setAttribute('content', ...)`
// tweak on the existing meta — removing and re-appending the meta
// element entirely is more dependable.
(function () {
var origMeta = document.querySelector('meta[name="viewport"]');
if (!origMeta) return;
var baseContent = origMeta.getAttribute('content');
document.addEventListener('focusout', function (e) {
if (!e.target.matches || !e.target.matches('input, textarea, select')) return;
var oldMeta = document.querySelector('meta[name="viewport"]');
if (oldMeta) oldMeta.remove();
var snapMeta = document.createElement('meta');
snapMeta.setAttribute('name', 'viewport');
snapMeta.setAttribute('content', baseContent + ', maximum-scale=1.0, user-scalable=no');
document.head.appendChild(snapMeta);
setTimeout(function () {
snapMeta.remove();
var revertMeta = document.createElement('meta');
revertMeta.setAttribute('name', 'viewport');
revertMeta.setAttribute('content', baseContent);
document.head.appendChild(revertMeta);
}, 300);
});
}());
</script> </script>
<script> <script>
(function () { (function () {