Files
python-tdd/src/templates/core/base.html
Disco DeDisco c1a8133345 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>
2026-05-20 00:12:52 -04:00

248 lines
12 KiB
HTML

{% load compress %}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Disco DeDisco">
<meta name="robots" content="noindex, nofollow">
<title>Earthman RPG | {% block title_text %}{% endblock title_text %}</title>
{% compress css %}
<link type="text/x-scss" rel="stylesheet" href="{% static 'scss/core.scss' %}">
{% endcompress %}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
</head>
<body class="{{ user_palette }} {{ page_class|default:'' }}">
<div class="container">
{% include "core/_partials/_navbar.html" %}
<div class="row">
<div class="col-lg-6">
<h2 >{% block header_text %}{% endblock header_text %}</h2>
{% block extra_header %}
{% endblock extra_header %}
</div>
</div>
{% if messages %}
{% for message in messages %}
{# Transient Brief-styled banner — no DB row, no FYI/square. #}
{# Slides in under the navbar h2 w. the same Gaussian-glass #}
{# look as the Brief notification banner; NVM dismisses. #}
<div class="note-banner note-banner--message note-banner--{{ message.level_tag }}">
<div class="note-banner__body">
<p class="note-banner__description">{{ message }}</p>
</div>
<button type="button" class="btn btn-cancel note-banner__nvm"
onclick="this.parentElement.remove()">NVM</button>
</div>
{% endfor %}
{% endif %}
{# Anchor for Brief.showBanner — banner inserts as nextSibling so #}
{# it lands at the top of page content on every base.html-extending #}
{# page, regardless of where (or whether) <h2> is positioned. #}
<div id="id_brief_banner_anchor"></div>
{% block content %}
{% endblock content %}
</div>
{% include "core/_partials/_footer.html" %}
{% if user.is_authenticated %}
<button id="id_kit_btn" data-kit-url="{% url 'kit_bag' %}" aria-label="Open Kit Bag">
<i class="fa-solid fa-briefcase"></i>
</button>
{% endif %}
<dialog id="id_kit_bag_dialog"></dialog>
<div id="id_guard_portal">
<span class="guard-message"></span>
<div class="guard-actions">
<button class="btn btn-confirm guard-yes" type="button">OK</button>
<button class="btn btn-cancel guard-no" type="button">NVM</button>
</div>
</div>
{% block scripts %}
{% endblock scripts %}
<script>
// h2 letter splitter — wrap each character of every .row .col-lg-6 h2
// word-span in its own <span> so .scss can use justify-content:
// space-between to fill the 45/55 slot. text-justify: inter-character
// would do this in pure CSS but iOS Safari + Firefox silently fall
// back to inter-word for Latin text, leaving letters clustered at the
// slot's start.
(function () {
var spans = document.querySelectorAll('.row .col-lg-6 h2 > span');
for (var i = 0; i < spans.length; i++) {
var span = spans[i];
if (span.dataset.lettersSplit === '1') continue;
var text = (span.textContent || '').trim();
if (!text) continue;
span.dataset.lettersSplit = '1';
span.setAttribute('aria-label', text);
span.textContent = '';
for (var j = 0; j < text.length; j++) {
var letter = document.createElement('span');
letter.setAttribute('aria-hidden', 'true');
letter.textContent = text[j];
span.appendChild(letter);
}
}
}());
// 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>
(function () {
var portal = null;
var _cb = null;
var _onDismiss = null;
function show(anchor, message, callback, onDismiss, options) {
if (!portal) return;
options = options || {};
_cb = callback;
_onDismiss = onDismiss || null;
portal.querySelector('.guard-message').innerHTML = message;
// Optional override for the YES-button label (e.g., "DEL" for
// a destructive-named action). Resets to "OK" inside dismiss/
// doConfirm so the next show() starts from the default.
portal.querySelector('.guard-yes').textContent = options.yesLabel || 'OK';
portal.classList.add('active');
var rect = anchor.getBoundingClientRect();
var pw = portal.offsetWidth;
var rawLeft = rect.left + rect.width / 2;
var cleft = Math.max(pw / 2 + 8, Math.min(rawLeft, window.innerWidth - pw / 2 - 8));
portal.style.left = Math.round(cleft) + 'px';
var cardCenterY = rect.top + rect.height / 2;
// Default: upper half → below (avoids viewport top edge for navbar/fixed buttons).
// invertY: upper half → above (for modal grids where tooltip should fly away from centre).
var showBelow = (cardCenterY < window.innerHeight / 2);
if (options.invertY) showBelow = !showBelow;
if (showBelow) {
portal.style.top = Math.round(rect.bottom) + 'px';
portal.style.transform = 'translate(-50%, 0.5rem)';
} else {
portal.style.top = Math.round(rect.top) + 'px';
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
}
}
function dismiss() {
if (!portal) return;
var od = _onDismiss;
portal.classList.remove('active');
portal.querySelector('.guard-yes').textContent = 'OK';
_cb = null;
_onDismiss = null;
if (od) od();
}
function doConfirm() {
var cb = _cb;
portal.classList.remove('active');
portal.querySelector('.guard-yes').textContent = 'OK';
_cb = null;
_onDismiss = null;
if (cb) cb();
}
document.addEventListener('DOMContentLoaded', function () {
portal = document.getElementById('id_guard_portal');
if (!portal) return;
portal.querySelector('.guard-yes').addEventListener('click', doConfirm);
portal.querySelector('.guard-no').addEventListener('click', dismiss);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') dismiss();
});
// Outside-click to dismiss — capture phase + stopPropagation
// prevents the click from cascading to backdrop listeners (e.g. closeFan)
document.addEventListener('click', function (e) {
if (!portal.classList.contains('active')) return;
if (portal.contains(e.target)) return;
// If clicking a card, let the event through so the card's
// own handler immediately opens the guard on the new target.
// For any other outside click, stop propagation to prevent
// the backdrop from also closing the fan.
if (!e.target.closest('.card')) e.stopPropagation();
dismiss();
}, true);
// Intercept [data-confirm] buttons (capture phase, before form submits)
document.addEventListener('click', function (e) {
var btn = e.target.closest('[data-confirm]');
if (!btn) return;
e.preventDefault();
e.stopImmediatePropagation();
var form = btn.closest('form');
show(btn, btn.dataset.confirm, function () {
if (form) form.submit();
else if (btn.dataset.href) window.location.href = btn.dataset.href;
});
}, true);
});
window.showGuard = show;
}());
</script>
<script src="{% static "vendor/htmx.min.js" %}"></script>
<script src="{% static "apps/applets/applets.js" %}"></script>
<script src="{% static "apps/applets/row-lock.js" %}"></script>
<script src="{% static "apps/dashboard/game-kit.js" %}"></script>
<script>
document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone + '; path=/; SameSite=Lax';
document.body.addEventListener('htmx:configRequest', function(evt) {
evt.detail.headers['X-CSRFToken'] = getCookie('csrftoken');
});
function getCookie(name) {
let val = null;
if (document.cookie) {
for (let c of document.cookie.split(';')) {
c = c.trim();
if (c.startsWith(name + '=')) {
val = decodeURIComponent(c.substring(name.length + 1));
break;
}
}
}
return val;
}
</script>
</body>
</html>