read.markets/app/templates/base.html
Giorgio Gilestro 3e1a14f334 ui: flip tone relabel — "Pro" now maps to INTERMEDIATE, not NOVICE
Reverses the polarity of 71155a6 to match the actual semantics:

- "Novice" stays labelled "Novice" → glossary tooltips, plainer prose.
- "Intermediate" is relabelled "Pro" → terse, assumes fluency, no
  hand-holding. This is the mode an expert reader wants, so the "Pro"
  badge actually fits.

Backend tone values (NOVICE, INTERMEDIATE) are unchanged — no API,
prompt, or stored-preference impact. Only the display strings flip.

Also drops the .tone-toggle button min-width: 10em override added in
71155a6. With "Intermediate" gone from the visible label, the longest
remaining label is "Novice" (6 chars), which fits the shared 5.5em
just like the theme and language toggles.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-29 11:23:52 +02:00

476 lines
20 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') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/layout.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/panels.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/dashboard.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/portfolio.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/log-chat.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/auth.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/settings.css') }}?v={{ ASSET_VERSION }}" />
<link rel="stylesheet" href="{{ url_for('static', path='/css/news.css') }}?v={{ ASSET_VERSION }}" />
<script src="{{ url_for('static', path='/js/htmx.min.js') }}?v={{ ASSET_VERSION }}" 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();
// Same for the theme toggle — pull the current theme that the
// top-of-page inline script already wrote to <html data-theme>.
var themePill = document.getElementById('theme-toggle');
if (themePill) themePill.dataset.theme = document.documentElement.dataset.theme || 'light';
});
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.cassandraSetTheme = function (newTheme) {
document.documentElement.dataset.theme = newTheme;
var pill = document.getElementById('theme-toggle');
if (pill) pill.dataset.theme = newTheme;
try { localStorage.setItem('cassandra.theme', newTheme); } 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">
{# Left group keeps brand + BETA chip pinned together as a single
layout cell so the chip can't drift away from the wordmark when
the header grows or shrinks. #}
<div class="header-left">
<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 %}
</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">
{# The "Pro" label maps to the INTERMEDIATE tone server-side —
kept that way to avoid touching every stored user preference
and API contract. The mode itself (terse, no glossary
tooltips, assumes fluency) is unchanged; only the display
label changes. #}
<div id="tone-toggle" class="tone-toggle" 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')">Pro</button>
</div>
<div id="theme-toggle" class="theme-toggle" data-theme="light"
role="group" aria-label="Theme">
<button type="button" data-value="light"
onclick="cassandraSetTheme('light')">Light</button>
<button type="button" data-value="dark"
onclick="cassandraSetTheme('dark')">Dark</button>
</div>
{% 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>