My Sea iter 4b polish: Brief banner uses standard portaled .note-banner (Gaussian glass atop h2); next-free-draw datetime in dedicated <time> slot (not "Invalid Date"); DEL guard reuses shared #id_guard_portal from base.html — TDD

UX refactor on top of iter 4b (b76d3c5) per user direction:

(1) Brief banner — replaced custom `.my-sea-brief` markup + SCSS w. a call to `Brief.showBanner` from note.js. Now matches the my-notes / my-sign default-deck-warning Briefs exactly: standard `.note-banner` portaled atop the h2 w. Gaussian-glass backdrop-filter blur. Tagged `.my-sea-locked-banner` for FT disambiguation only — no visual override.

(2) Brief timestamp — fix for "Invalid Date" rendering in note.js's `<time class="note-banner__timestamp">` slot. Previously passed `created_at: ''` to `Brief.showBanner` → `new Date('')` returns Invalid Date → `toLocaleDateString` renders "Invalid Date". Now passes the next-free-draw ISO timestamp as `created_at` (server emits via `|date:'c'`). After Brief.showBanner returns, the `_showFreeDrawLockedBrief` JS overwrites the rendered text w. the more detailed `D, M j @ g:i A` format ("Wed, May 20 @ 11:57 PM") — leaves the ISO `datetime=` attribute intact for accessibility. The `line_text` no longer carries the timestamp inline (it's redundant w. the dedicated slot).

(3) DEL guard portal — replaced custom `#id_my_sea_del_portal` fullscreen modal + `.my-sea-del-portal` SCSS w. a call to `window.showGuard` from base.html, targeting the shared `#id_guard_portal`. Same Gaussian-glass tooltip the room gear-menu DEL flow uses: no backdrop, positioned above the anchor button, standard `.btn-confirm OK` + `.btn-cancel NVM` pair. Bundled a non-breaking `options.yesLabel` extension to `show()` in base.html for future destructive flows that need a custom YES label (defaults to 'OK', resets on dismiss/confirm) — my-sea doesn't use it per user direction (the `.btn-confirm` class implies "OK"; destructive intent belongs on the trigger button, which is `.btn-danger DEL`).

Tests: 30 iter-4b ITs (model + lock + delete + saved-draw view branches) + 5 iter-4b FTs all green; IT/FT assertions updated to target the shared portal markup (`#id_guard_portal.active`, `.guard-yes`, `.guard-no`, `.note-banner.my-sea-locked-banner`).

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-20 00:12:52 -04:00
parent b76d3c5dff
commit c1a8133345
5 changed files with 165 additions and 204 deletions

View File

@@ -205,39 +205,12 @@
{% include "apps/gameboard/_partials/_sea_stage.html" %}
</div>
{# Iter 4b — Look!-formatted Brief banner above the picker. #}
{# Always rendered when the picker is rendered, but hidden #}
{# unless a saved draw occupies the user's free-quota slot #}
{# (server) OR LOCK HAND just fired (client un-hides on the #}
{# fetch response w. the next-free-draw timestamp). Avoiding #}
{# a full page reload on LOCK lets the iter-4a FTs keep their #}
{# picker element refs valid post-lock. #}
<div class="my-sea-brief"{% if not active_draw %} hidden{% endif %}>
<p class="my-sea-brief__line">
Look!&mdash;your free draw is locked in for the next 24 hours. Next free draw available at
<time class="my-sea-brief__timestamp"
datetime="{% if next_free_draw_at %}{{ next_free_draw_at|date:'c' }}{% endif %}">{% if next_free_draw_at %}{{ next_free_draw_at|date:'D, M j @ g:i A' }}{% endif %}</time>.
</p>
<button type="button" class="btn btn-cancel my-sea-brief__nvm">NVM</button>
</div>
{# Iter 4b — DEL guard portal. Uniform 'Are you sure?' copy #}
{# regardless of quota state (the Brief banner above carries #}
{# the quota-specific info). Always rendered (hidden by #}
{# default); DEL click un-hides when picker is `_locked`. #}
{# CONFIRM POSTs to /gameboard/my-sea/delete; NVM dismisses. #}
<div id="id_my_sea_del_portal" class="my-sea-del-portal" hidden>
<div class="my-sea-del-portal__panel">
<p class="my-sea-del-portal__line">Are you sure?</p>
<div class="my-sea-del-portal__actions">
<button type="button"
class="btn btn-cancel my-sea-del-portal__nvm">NVM</button>
<button type="button"
class="btn btn-danger my-sea-del-portal__confirm"
data-delete-url="{% url 'my_sea_delete' %}">CONFIRM</button>
</div>
</div>
</div>
{# Iter 4b — DEL guard reuses the shared `#id_guard_portal` #}
{# from base.html (the same one the room's gear-menu DEL btn #}
{# uses). Gaussian-glass tooltip positioned above the DEL btn,#}
{# no backdrop. The picker IIFE below invokes it via #}
{# `window.showGuard(delBtn, "Are you sure?", confirmFn, null,#}
{# {yesLabel: "DEL"})` when DEL is clicked post-lock. #}
{# Sprint 5 iter 4a — shuffled deck (levity + gravity halves, #}
{# sig excluded) embedded as JSON; JS reads on init and #}
{# pops from the relevant pile on each deposit. #}
@@ -492,19 +465,39 @@
// ── DEL semantics differ by lock state ──────────────────
// Pre-lock: DEL resets the in-progress hand client-side
// (iter 4a behaviour — no server round-trip).
// Post-lock: DEL opens `#id_my_sea_del_portal` guard portal
// (iter 4b). The portal CONFIRM POSTs to
// /gameboard/my-sea/delete; NVM closes the
// portal.
// Post-lock: DEL invokes the shared `#id_guard_portal`
// from base.html via `window.showGuard`, w. a
// "DEL" YES-label override (the room's gear-
// menu DEL flow uses the same portal). On YES
// we POST to /gameboard/my-sea/delete then
// navigate back to the page (server returns
// 204; we redirect manually to land on the
// FREE DRAW landing).
if (delBtn) {
delBtn.addEventListener('click', function (e) {
e.stopPropagation();
var portal = document.getElementById('id_my_sea_del_portal');
if (_locked && portal) {
portal.hidden = false;
if (!_locked) {
_resetHand();
return;
}
_resetHand();
if (!window.showGuard) return;
// Trigger btn (DEL, `.btn-danger`) opens the shared
// guard portal; the portal's confirm button is the
// standard `.btn-confirm` "OK" + `.btn-cancel` "NVM"
// pair — matches the room gear-menu DEL flow exactly.
window.showGuard(
delBtn,
'Are you sure?',
function () {
fetch('{% url "my_sea_delete" %}', {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': _csrf()},
}).then(function (r) {
if (r.ok) window.location.reload();
});
}
);
});
}
if (lockBtn) {
@@ -538,11 +531,8 @@
return m ? decodeURIComponent(m[1]) : '';
}
function _formatTimestamp(iso) {
// Mirror the server-side `D, M j @ g:i A` format used in
// the template's pre-rendered next-free-draw timestamp
// (e.g., "Thu, May 21 @ 2:41 AM"). Keeps the post-LOCK
// visual consistent with a fresh-page-load saved-draw
// render.
// Mirror the server-side `D, M j @ g:i A` format
// (e.g., "Thu, May 21 @ 2:41 AM").
var d = new Date(iso);
if (isNaN(d)) return '';
var DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
@@ -556,16 +546,40 @@
return DAYS[d.getDay()] + ', ' + MONTHS[d.getMonth()]
+ ' ' + d.getDate() + ' @ ' + h + ':' + mm + ' ' + ampm;
}
function _revealBrief(nextFreeDrawIso) {
var brief = document.querySelector('.my-sea-brief');
if (!brief) return;
var ts = brief.querySelector('.my-sea-brief__timestamp');
if (ts && nextFreeDrawIso) {
ts.setAttribute('datetime', nextFreeDrawIso);
ts.textContent = _formatTimestamp(nextFreeDrawIso);
window._showFreeDrawLockedBrief = function (iso) {
// Standard Brief banner — portaled atop the h2 w.
// Gaussian-glass bg (see [[note.js]] showBanner). The
// next-free-draw moment is passed as an ISO string +
// re-used as `created_at` so note.js's `<time
// class="note-banner__timestamp">` slot renders the
// datetime instead of "Invalid Date" (which it does
// for empty/invalid input). The `line_text` carries
// only the contextual prose now — the dedicated slot
// owns the timestamp display.
if (!window.Brief || !Brief.showBanner) return;
Brief.showBanner({
title: 'Free draw locked',
line_text:
'Look!&mdash;your free draw is locked in. ' +
'Next free draw available at:',
post_url: '{% url "gameboard" %}',
created_at: iso,
kind: 'NUDGE',
});
var banner = document.querySelector('.note-banner');
if (banner) {
banner.classList.add('my-sea-locked-banner');
// note.js renders the timestamp as `toLocaleDateString`
// (e.g., "May 20, 2026") — short-form, no time. Our
// use case wants the full `D, M j @ g:i A` shape
// (e.g., "Wed, May 20 @ 11:57 PM") so the user sees
// both the date AND the precise unlock hour. Overwrite
// the rendered text in-place (leaves the `datetime=`
// attribute intact for accessibility tooling).
var ts = banner.querySelector('.note-banner__timestamp');
if (ts && iso) ts.textContent = _formatTimestamp(iso);
}
brief.hidden = false;
}
};
function _postLock(hand) {
fetch('{% url "my_sea_lock" %}', {
method: 'POST',
@@ -582,49 +596,11 @@
return r.ok ? r.json() : null;
}).then(function (body) {
if (body && body.next_free_draw_at) {
_revealBrief(body.next_free_draw_at);
window._showFreeDrawLockedBrief(body.next_free_draw_at);
}
});
}
// ── DEL guard portal wiring ────────────────────────────
var portal = document.getElementById('id_my_sea_del_portal');
if (portal) {
var nvmBtn = portal.querySelector('.my-sea-del-portal__nvm');
var confirmBtn = portal.querySelector('.my-sea-del-portal__confirm');
if (nvmBtn) {
nvmBtn.addEventListener('click', function (e) {
e.stopPropagation();
portal.hidden = true;
});
}
if (confirmBtn) {
confirmBtn.addEventListener('click', function (e) {
e.stopPropagation();
var url = confirmBtn.dataset.deleteUrl || '';
if (!url) return;
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {'X-CSRFToken': _csrf()},
}).then(function (r) {
if (r.ok) window.location.reload();
});
});
}
}
// ── Brief banner NVM ──────────────────────────────────
var brief = document.querySelector('.my-sea-brief');
if (brief) {
var briefNvm = brief.querySelector('.my-sea-brief__nvm');
if (briefNvm) {
briefNvm.addEventListener('click', function () {
brief.hidden = true;
});
}
}
function syncLabels(spread) {
var labels = POSITION_LABELS[spread] || {};
cross.querySelectorAll('.sea-pos-label').forEach(function (el) {
@@ -731,6 +707,22 @@
{# Tagged w. .my-sea-intro-banner so FTs disambiguate from #}
{# any other Briefs on the page. #}
<script src="{% static 'apps/dashboard/note.js' %}"></script>
{% if active_draw %}
{# Iter 4b — saved-draw Brief. Standard portaled banner via #}
{# Brief.showBanner (Gaussian-glass bg, atop-h2 positioning); #}
{# the on-LOCK-success path inside the picker IIFE calls the #}
{# same `window._showFreeDrawLockedBrief` so a freshly-locked #}
{# hand gets the identical UX without a page reload. Pass an #}
{# ISO timestamp (`|date:'c'`) so note.js's `<time>` slot #}
{# parses cleanly instead of rendering "Invalid Date". #}
<script>
document.addEventListener('DOMContentLoaded', function () {
if (window._showFreeDrawLockedBrief) {
window._showFreeDrawLockedBrief("{{ next_free_draw_at|date:'c' }}");
}
});
</script>
{% endif %}
{% if show_backup_intro_banner %}
<script>
document.addEventListener('DOMContentLoaded', function () {