read.markets/app/templates/base.html
Giorgio Gilestro 2b3ea33884 mobile: hamburger drawer (right-side slide-out)
≤480px gets a hamburger button in the topbar and a fixed slide-out
panel from the right edge (width min(82vw, 320px)). The topbar keeps
only brand + tone toggle + hamburger visible; nav and the
header-right widgets (theme, lang, user menu, version meta) move
into the drawer.

Markup change: nav and .header-right are now wrapped in
.mobile-drawer, which is display:contents on desktop (no layout
effect) and a fixed translateX panel on mobile. The user-menu
dropdown chip hides on mobile and its links surface flat inside the
drawer.

JS: ~50 lines of vanilla. Tap hamburger / backdrop / ESC / swipe-
right-on-drawer all close. Clicking a nav link inside the drawer
closes it after the navigation kicks off so the panel doesn't linger
on the next page.

CSS: per-file @media block at the bottom of layout.css per the
agreed-upon organisation.
2026-05-28 18:36:37 +02:00

463 lines
19 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ BRAND_NAME }}{% endblock %}</title>
{# Cross-user contamination guard.
localStorage is browser-wide; if User A uploads a portfolio and User B
logs in on the same browser, the stale `cassandra.pie` would otherwise
render as User B's holdings. We stamp the logged-in user's id in
localStorage on every authenticated page load and wipe per-user keys
if the id changed since last time. Theme stays — it's cosmetic. #}
<script>
(function() {
try {
var current = "{{ cu.user.id if cu and cu.user else '' }}";
if (!current) return;
var last = localStorage.getItem('cassandra.user_id') || '';
if (last && last !== current) {
var theme = localStorage.getItem('cassandra.theme');
localStorage.clear();
if (theme) localStorage.setItem('cassandra.theme', theme);
try { sessionStorage.clear(); } catch (e) {}
}
localStorage.setItem('cassandra.user_id', current);
} catch (e) {}
})();
</script>
{# Apply saved theme before stylesheet renders to avoid a flash. #}
<script>
(function() {
try {
var t = localStorage.getItem('cassandra.theme') || 'light';
document.documentElement.dataset.theme = t;
} catch (e) { document.documentElement.dataset.theme = 'light'; }
})();
</script>
<link rel="stylesheet" href="{{ url_for('static', path='/css/tokens.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/panels.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/dashboard.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/portfolio.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/log-chat.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/news.css') }}" />
<script src="{{ url_for('static', path='/js/htmx.min.js') }}" defer></script>
<script>
// Inject the user's preferred TONE (NOVICE | INTERMEDIATE) into every
// HTMX request so AI-generated panels resolve to the right cached
// variant. Preference persists in localStorage; see toggle in header.
document.addEventListener('DOMContentLoaded', function () {
function currentTone() {
try {
var t = localStorage.getItem('cassandra.tone');
return (t === 'NOVICE' || t === 'INTERMEDIATE') ? t : 'INTERMEDIATE';
} catch (e) { return 'INTERMEDIATE'; }
}
document.body.addEventListener('htmx:configRequest', function (evt) {
evt.detail.parameters.tone = currentTone();
// News tag filters — only attach to /api/news requests.
if ((evt.detail.path || '').indexOf('/api/news') === 0) {
var inc = newsTags('include');
var exc = newsTags('exclude');
if (inc.length) evt.detail.parameters.tags = inc.join(',');
if (exc.length) evt.detail.parameters.exclude_tags = exc.join(',');
}
});
// News tag preference: include / exclude sets persisted in
// localStorage. Click cycles include → exclude → off;
// shift-click goes straight to exclude.
function newsTags(kind) {
try {
var raw = localStorage.getItem('cassandra.news.' + kind);
var arr = raw ? JSON.parse(raw) : [];
return Array.isArray(arr) ? arr : [];
} catch (e) { return []; }
}
function setNewsTags(kind, arr) {
try { localStorage.setItem('cassandra.news.' + kind, JSON.stringify(arr)); }
catch (e) {}
}
function refreshNewsPanels() {
document.querySelectorAll('[hx-get*="/api/news"]').forEach(function (el) {
if (window.htmx) window.htmx.trigger(el, 'tags-changed');
});
}
// Event delegation so HTMX-swapped pills work without rebinding.
document.addEventListener('click', function (e) {
var el = e.target.closest && e.target.closest('.news-tag');
if (!el) return;
e.preventDefault();
var tag = el.getAttribute('data-tag') || '';
if (el.classList.contains('news-tag--clear')) {
setNewsTags('include', []);
setNewsTags('exclude', []);
refreshNewsPanels();
return;
}
var inc = newsTags('include');
var exc = newsTags('exclude');
var inInc = inc.indexOf(tag);
var inExc = exc.indexOf(tag);
if (e.shiftKey) {
// Shift-click → toggle exclude membership; remove from include.
if (inInc >= 0) inc.splice(inInc, 1);
if (inExc >= 0) exc.splice(inExc, 1);
else exc.push(tag);
} else {
// Plain click → cycle: off → include → exclude → off.
if (inInc >= 0) {
inc.splice(inInc, 1);
exc.push(tag);
} else if (inExc >= 0) {
exc.splice(inExc, 1);
} else {
inc.push(tag);
}
}
setNewsTags('include', inc);
setNewsTags('exclude', exc);
refreshNewsPanels();
});
// Reflect the saved value in the toggle on load.
var pill = document.getElementById('tone-toggle');
if (pill) pill.dataset.tone = currentTone();
});
window.cassandraSetTone = function (newTone) {
try { localStorage.setItem('cassandra.tone', newTone); } catch (e) {}
var pill = document.getElementById('tone-toggle');
if (pill) pill.dataset.tone = newTone;
// Trigger a re-fetch of every AI-driven HTMX target on the page.
// Easiest: dispatch a custom event that the relevant elements
// listen to. Simpler still: fire htmx.trigger on the well-known
// panels. We use the simple path.
['#dash-header-container', '#log-panel .panel-body',
'#indicators-body', '#log-content'].forEach(function (sel) {
var el = document.querySelector(sel);
if (el && window.htmx) window.htmx.trigger(el, 'tone-changed');
});
};
window.cassandraToggleTheme = function () {
var d = document.documentElement;
var t = d.dataset.theme === 'light' ? 'dark' : 'light';
d.dataset.theme = t;
try { localStorage.setItem('cassandra.theme', t); } catch (e) {}
};
window.cassandraSetLang = async function (newLang) {
var pill = document.getElementById('lang-toggle');
if (!pill) return;
var prev = pill.dataset.lang;
if (prev === newLang) return;
// Optimistic update — flip the pill immediately so the click feels
// responsive. Revert on PATCH failure.
pill.dataset.lang = newLang;
try {
var r = await fetch('/api/settings/language', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
credentials: 'same-origin',
body: JSON.stringify({lang: newLang}),
});
if (!r.ok) throw new Error('HTTP ' + r.status);
// Trigger HTMX-driven panels to re-fetch in the new language.
// Same shape as cassandraSetTone — every panel that listens to
// tone-changed also listens to lang-changed.
['#dash-header-container', '#log-panel .panel-body',
'#indicators-body', '#log-content'].forEach(function (sel) {
var el = document.querySelector(sel);
if (el && window.htmx) window.htmx.trigger(el, 'lang-changed');
});
} catch (e) {
pill.dataset.lang = prev;
console.warn('language switch failed:', e);
}
};
</script>
<script>
// Render any <time datetime="..."> in the browser's local timezone.
// Re-runs after every HTMX swap so freshly-loaded news rows pick up too.
function formatLocalTimes() {
document.querySelectorAll('time[datetime]:not([data-local])').forEach(function (t) {
try {
var d = new Date(t.getAttribute('datetime'));
if (isNaN(d.getTime())) return;
var date = d.toLocaleDateString(undefined, { day: '2-digit', month: 'short' });
var time = d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false });
t.textContent = date + ' ' + time;
t.title = d.toLocaleString();
t.setAttribute('data-local', '1');
} catch (e) {}
});
}
document.addEventListener('DOMContentLoaded', function () {
formatLocalTimes();
document.body.addEventListener('htmx:afterSwap', formatLocalTimes);
});
</script>
</head>
<body>
<div class="app">
<header class="app-header">
<a href="/" class="brand" aria-label="Dashboard">{{ BRAND_NAME }}</a>
{% if BETA_MODE %}<span class="beta-chip" title="Beta — feedback welcome at hello@read.markets">BETA</span>{% endif %}
{# Tone toggle is the one widget we keep visible in the mobile
header even when the drawer is closed — it directly affects the
readability of the content right next to it. #}
<div id="tone-toggle" class="tone-toggle tone-toggle--header" data-tone="INTERMEDIATE"
role="group" aria-label="Explanation level">
<button type="button" data-value="NOVICE"
onclick="cassandraSetTone('NOVICE')">Novice</button>
<button type="button" data-value="INTERMEDIATE"
onclick="cassandraSetTone('INTERMEDIATE')">Intermediate</button>
</div>
{# Mobile hamburger — shown only at ≤480px via CSS. #}
<button type="button" id="drawer-toggle" class="drawer-toggle"
aria-label="Open menu" aria-controls="mobile-drawer" aria-expanded="false">
<span class="drawer-toggle__bar"></span>
<span class="drawer-toggle__bar"></span>
<span class="drawer-toggle__bar"></span>
</button>
{# Wrapper: display:contents on desktop (zero layout effect), fixed
slide-out panel on mobile. Holds nav + header-right widgets. #}
<div id="mobile-drawer" class="mobile-drawer">
<nav>
<a href="/" class="{% if request.url.path == '/' %}active{% endif %}">Dashboard</a>
<a href="/news" class="{% if request.url.path == '/news' %}active{% endif %}">News</a>
<a href="/log" class="{% if request.url.path.startswith('/log') %}active{% endif %}">Log</a>
</nav>
<div class="header-right">
<button class="theme-toggle" type="button" aria-label="Toggle theme"
onclick="cassandraToggleTheme()">
<span class="theme-toggle__label"></span>
</button>
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %}
{% if cu and cu.user %}
<div id="lang-toggle" class="lang-toggle" data-lang="{{ cu.user.lang or 'en' }}"
role="group" aria-label="AI output language"
title="Language the AI uses for the log, digest and portfolio commentary">
<button type="button" data-value="en"
onclick="cassandraSetLang('en')">EN</button>
<button type="button" data-value="it"
onclick="cassandraSetLang('it')">IT</button>
</div>
{% endif %}
{% if cu and (cu.user or cu.is_admin) %}
<div class="user-menu">
<button type="button" id="user-menu-toggle" class="user-chip"
aria-haspopup="true" aria-expanded="false">
{% if cu.user %}{{ cu.user.email }}{% else %}admin{% endif %}
<span class="user-menu__caret"></span>
</button>
<div id="user-menu" class="user-menu__panel" role="menu" hidden>
{% if cu.user %}
<a href="/settings" role="menuitem" class="user-menu__item">Settings</a>
{% endif %}
<a href="/pricing" role="menuitem" class="user-menu__item">Pricing</a>
<a href="/terms" role="menuitem" class="user-menu__item">Terms</a>
<a href="/privacy" role="menuitem" class="user-menu__item">Privacy</a>
<a href="/disclaimer" role="menuitem" class="user-menu__item">Disclaimer</a>
<a href="/logout" role="menuitem" class="user-menu__item">Logout</a>
</div>
</div>
{% endif %}
<span class="meta">v0.1 · UTC</span>
</div>
</div>
</header>
{# Drawer backdrop. Hidden by default; CSS shows it when
body.drawer-open is set. Click closes the drawer. #}
<div id="drawer-backdrop" class="drawer-backdrop" hidden></div>
<script>
(function () {
var btn = document.getElementById('user-menu-toggle');
var menu = document.getElementById('user-menu');
if (!btn || !menu) return;
function close() { menu.hidden = true; btn.setAttribute('aria-expanded','false'); }
function open() { menu.hidden = false; btn.setAttribute('aria-expanded','true'); }
btn.addEventListener('click', function (e) {
e.stopPropagation();
if (menu.hidden) open(); else close();
});
document.addEventListener('click', function (e) {
if (!menu.hidden && !menu.contains(e.target) && e.target !== btn) close();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') close();
});
})();
</script>
<script>
// Mobile drawer (hamburger → right-side slide-out panel). The CSS
// gates visibility of #drawer-toggle to ≤480px, so on desktop this
// wiring is harmless — the click handler is attached but nobody
// can fire it.
(function () {
var btn = document.getElementById('drawer-toggle');
var drawer = document.getElementById('mobile-drawer');
var backdrop = document.getElementById('drawer-backdrop');
if (!btn || !drawer || !backdrop) return;
function open() {
document.body.classList.add('drawer-open');
backdrop.hidden = false;
btn.setAttribute('aria-expanded', 'true');
}
function close() {
document.body.classList.remove('drawer-open');
backdrop.hidden = true;
btn.setAttribute('aria-expanded', 'false');
}
btn.addEventListener('click', function () {
if (document.body.classList.contains('drawer-open')) close(); else open();
});
backdrop.addEventListener('click', close);
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && document.body.classList.contains('drawer-open')) close();
});
// Swipe-right inside the drawer closes — small native feel.
// Tracks pointer down then up; if the user moves >40px right
// and stays within 60° of horizontal, treat as a close gesture.
var startX = null, startY = null;
drawer.addEventListener('pointerdown', function (e) {
if (!document.body.classList.contains('drawer-open')) return;
startX = e.clientX; startY = e.clientY;
});
drawer.addEventListener('pointerup', function (e) {
if (startX === null) return;
var dx = e.clientX - startX;
var dy = Math.abs(e.clientY - startY);
startX = startY = null;
if (dx > 40 && dy < dx * 0.6) close();
});
// If a nav link inside the drawer is clicked, close after the
// navigation kicks off so the panel doesn't linger on the next page.
drawer.addEventListener('click', function (e) {
if (e.target.tagName === 'A') close();
});
})();
</script>
<main class="app-main">
{% block main %}{% endblock %}
</main>
{# Shared glossary tooltip (Novice mode). Single floating element
positioned by JS to escape sticky-bar stacking and viewport edges. #}
<div id="glossary-tooltip" role="tooltip" hidden></div>
<script>
(function () {
const tip = document.getElementById('glossary-tooltip');
let activeEl = null;
function position(el) {
// Measure after content is set so dimensions are accurate.
tip.style.left = '0px';
tip.style.top = '0px';
tip.hidden = false;
const rect = el.getBoundingClientRect();
const tipRect = tip.getBoundingClientRect();
const margin = 8;
// Decide above or below based on available space.
const spaceAbove = rect.top - margin;
const spaceBelow = window.innerHeight - rect.bottom - margin;
let top = (spaceAbove >= tipRect.height || spaceAbove >= spaceBelow)
? rect.top - tipRect.height - 6
: rect.bottom + 6;
// Clamp top into the viewport.
if (top < margin) top = margin;
if (top + tipRect.height > window.innerHeight - margin) {
top = window.innerHeight - tipRect.height - margin;
}
// Horizontal: anchor to term's left edge, clamp to viewport.
let left = rect.left;
if (left + tipRect.width > window.innerWidth - margin) {
left = window.innerWidth - tipRect.width - margin;
}
if (left < margin) left = margin;
tip.style.left = left + 'px';
tip.style.top = top + 'px';
tip.setAttribute('data-visible', '1');
}
function show(el) {
const def = el.getAttribute('data-def');
if (!def) return;
activeEl = el;
tip.textContent = def;
position(el);
}
function hide() {
activeEl = null;
tip.removeAttribute('data-visible');
tip.hidden = true;
}
// Event delegation with capture so we catch elements HTMX swaps in
// after page load.
document.addEventListener('mouseover', function (e) {
const el = e.target.closest && e.target.closest('.glossary');
if (el) show(el);
else if (activeEl && !e.target.closest('.glossary')) hide();
}, true);
document.addEventListener('focusin', function (e) {
if (e.target.classList && e.target.classList.contains('glossary')) {
show(e.target);
}
});
document.addEventListener('focusout', function (e) {
if (e.target.classList && e.target.classList.contains('glossary')) {
hide();
}
});
// Mobile / touch: tap to toggle, tap-elsewhere to dismiss.
document.addEventListener('click', function (e) {
const el = e.target.closest && e.target.closest('.glossary');
if (el) {
// Re-show (or toggle off if it's the currently-active one).
if (activeEl === el) hide();
else show(el);
e.preventDefault();
} else if (activeEl) {
hide();
}
}, true);
// Hide on scroll / resize so the tooltip doesn't drift away from
// its term.
window.addEventListener('scroll', hide, true);
window.addEventListener('resize', hide);
// Hide when HTMX swaps content (term may have been replaced).
document.body.addEventListener('htmx:beforeSwap', hide);
})();
</script>
<footer class="markets-bar"
hx-get="/api/markets-bar?as=html"
hx-trigger="load, every 60s"
hx-swap="innerHTML"
id="markets-bar">
<div class="markets-bar__inner">
<div class="markets-bar__list"><span class="empty">awaiting markets…</span></div>
</div>
</footer>
</div>
</body>
</html>