From 31a8efc27d2b70fe7ecdee188a1b641cf1d40705 Mon Sep 17 00:00:00 2001 From: Giorgio Gilestro Date: Fri, 29 May 2026 11:00:11 +0200 Subject: [PATCH] ui: regroup topbar + unify the three header toggles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Header layout was visibly broken on desktop after the mobile-drawer change: flex space-between distributed brand, BETA, tone-toggle, nav and header-right across the bar, so BETA drifted away from the brand wordmark and the tone-toggle landed in the middle of the row. Markup: brand + BETA are now wrapped in .header-left so they ride together. The tone-toggle moves back inside .header-right next to theme + lang where it logically belongs. CSS: the header switches to grid (1fr auto 1fr) on desktop, which truly centres the nav regardless of side-group widths. The mobile @media block reverts to flex so the hamburger + slide-out drawer still work. Toggle redesign (tone, theme, language): - The single-button theme widget becomes a Light | Dark segmented control matching the other two so all three read as one cluster. cassandraToggleTheme is replaced by cassandraSetTheme(theme), the toggle's data-theme attribute is synced on page load. - All three share one CSS rule set: same padding, font, border, and a min-width so the active-only width matches the expanded width (no layout jump on hover). - On hover-capable devices each toggle collapses to just the active option; hovering (or keyboard focus-within) reveals both. Touch devices keep both visible — the @media (hover: hover) gate handles that and the mobile-drawer block overrides it explicitly so the drawer-stacked controls remain full-width with both options shown. Co-Authored-By: Claude Opus 4.7 --- app/static/css/layout.css | 154 ++++++++++++++++++++++---------------- app/templates/base.html | 50 +++++++------ 2 files changed, 119 insertions(+), 85 deletions(-) diff --git a/app/static/css/layout.css b/app/static/css/layout.css index 748b8db..5fae678 100644 --- a/app/static/css/layout.css +++ b/app/static/css/layout.css @@ -36,9 +36,15 @@ a:hover { text-decoration: underline; } } .app-header { - display: flex; + /* Three-column grid: brand+BETA pinned left, nav truly centered in + the middle column regardless of side widths, header-right pinned + right. The mobile-drawer wrapper is display:contents on desktop so + its children (nav, .header-right) become direct grid items and + land in columns 2 and 3 by source order. */ + display: grid; + grid-template-columns: 1fr auto 1fr; align-items: center; - justify-content: space-between; + gap: 14px; border-bottom: 1px solid var(--border); padding: 10px 18px; background: var(--surface); @@ -48,6 +54,13 @@ a:hover { text-decoration: underline; } top: 0; z-index: 50; } +.app-header .header-left { + display: inline-flex; + align-items: center; + gap: 10px; + justify-self: start; +} +.app-header nav { justify-self: center; } .app-header .brand { color: var(--accent); font-weight: 700; @@ -59,6 +72,7 @@ a:hover { text-decoration: underline; } margin-left: 18px; color: var(--muted); } +.app-header nav a:first-child { margin-left: 0; } .app-header nav a.active { color: var(--text); } .app-header .meta { color: var(--muted); font-size: 11px; } @@ -67,7 +81,12 @@ a:hover { text-decoration: underline; } * the @media block at the bottom converts it to a fixed slide-out. */ .mobile-drawer { display: contents; } -.app-header .header-right { display: flex; align-items: center; gap: 14px; } +.app-header .header-right { + display: flex; + align-items: center; + gap: 14px; + justify-self: end; +} /* Hamburger button — only visible at ≤480px (rule in the mobile block). * Three thin bars; uses the same border/muted treatment as the other @@ -102,51 +121,14 @@ a:hover { text-decoration: underline; } transition: opacity 120ms ease-out; } body.drawer-open .drawer-backdrop { opacity: 1; } -.theme-toggle { - background: transparent; - border: 1px solid var(--border); - color: var(--muted); - padding: 3px 8px; - font-family: var(--font-mono); - font-size: 10px; - letter-spacing: 0.08em; - cursor: pointer; - text-transform: lowercase; -} -.theme-toggle:hover { color: var(--accent); border-color: var(--accent); } -.theme-toggle__label::before { content: "◐ light"; } -[data-theme="dark"] .theme-toggle__label::before { content: "◐ dark"; } - -/* Tone toggle (segmented control: Novice | Intermediate) */ -.tone-toggle { - display: inline-flex; - border: 1px solid var(--border); - font-family: var(--font-mono); - font-size: 10.5px; - letter-spacing: 0.06em; - text-transform: uppercase; -} -.tone-toggle button { - background: transparent; - color: var(--muted); - border: 0; - padding: 4px 10px; - cursor: pointer; - font: inherit; - letter-spacing: inherit; - text-transform: inherit; -} -.tone-toggle button + button { border-left: 1px solid var(--border); } -.tone-toggle button:hover { color: var(--accent); } -.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"], -.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"] { - background: var(--accent); - color: var(--bg); -} - -/* Language toggle in the topbar — same visual rhythm as the tone - * toggle so the two controls read as a pair. Only EN and IT are - * visible here; the WIP languages (ES/FR/DE) live in /settings. */ +/* Segmented toggles — tone (Novice | Intermediate), theme (Light | Dark) + * and language (EN | IT) share one visual rhythm so the three controls + * read as a single cluster in the header. By default only the currently + * active option is rendered; hover or keyboard focus reveals both so the + * user can pick the other. Touch devices (which can't hover) show both + * options at all times; the @media (hover: hover) gate handles that. */ +.tone-toggle, +.theme-toggle, .lang-toggle { display: inline-flex; border: 1px solid var(--border); @@ -155,24 +137,65 @@ body.drawer-open .drawer-backdrop { opacity: 1; } letter-spacing: 0.06em; text-transform: uppercase; } +.tone-toggle button, +.theme-toggle button, .lang-toggle button { background: transparent; color: var(--muted); border: 0; - padding: 4px 8px; + padding: 4px 10px; cursor: pointer; font: inherit; letter-spacing: inherit; text-transform: inherit; + /* Fixed min-width so the active-only width matches the expanded width + of a single button — prevents the layout jumping as the user + mouses over and the second option appears. */ + min-width: 5.5em; + text-align: center; } +.tone-toggle button + button, +.theme-toggle button + button, .lang-toggle button + button { border-left: 1px solid var(--border); } +.tone-toggle button:hover, +.theme-toggle button:hover, .lang-toggle button:hover { color: var(--accent); } + +/* Active-option highlighting (data-* attribute on the container is + * authored by JS on load and on every change). */ +.tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"], +.tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"], +.theme-toggle[data-theme="light"] button[data-value="light"], +.theme-toggle[data-theme="dark"] button[data-value="dark"], .lang-toggle[data-lang="en"] button[data-value="en"], .lang-toggle[data-lang="it"] button[data-value="it"] { background: var(--accent); color: var(--bg); } +/* Collapse-when-idle behaviour: on hover-capable devices, hide the + * non-active option until the user hovers (or keyboard-focuses) the + * toggle. The mobile drawer overrides this further down. */ +@media (hover: hover) { + .tone-toggle button, + .theme-toggle button, + .lang-toggle button { display: none; } + .tone-toggle[data-tone="NOVICE"] button[data-value="NOVICE"], + .tone-toggle[data-tone="INTERMEDIATE"] button[data-value="INTERMEDIATE"], + .theme-toggle[data-theme="light"] button[data-value="light"], + .theme-toggle[data-theme="dark"] button[data-value="dark"], + .lang-toggle[data-lang="en"] button[data-value="en"], + .lang-toggle[data-lang="it"] button[data-value="it"] { + display: inline-block; + } + .tone-toggle:hover button, + .tone-toggle:focus-within button, + .theme-toggle:hover button, + .theme-toggle:focus-within button, + .lang-toggle:hover button, + .lang-toggle:focus-within button { display: inline-block; } +} + .app-main { padding: 14px; display: grid; @@ -238,10 +261,11 @@ body.drawer-open .drawer-backdrop { opacity: 1; } /* --- Mobile (≤480px) -------------------------------------------------- */ @media (max-width: 480px) { - /* Tighten the topbar so the brand, tone toggle and hamburger all fit - on a 360px phone. Drop the letter-spacing because at 11px tracking - eats horizontal space the brand cannot spare. */ + /* Revert to flex on mobile so the drawer-toggle can pin to the right + via margin-left:auto and the off-screen drawer doesn't try to claim + a grid column. */ .app-header { + display: flex; padding: 8px 12px; gap: 8px; letter-spacing: 0.04em; @@ -263,15 +287,6 @@ body.drawer-open .drawer-backdrop { opacity: 1; } the drawer (the .mobile-drawer block below). */ .drawer-toggle { display: flex; margin-left: auto; } - /* Keep the tone toggle visible but trim it: just N / I letters so it - fits next to brand + hamburger. */ - .tone-toggle--header { - font-size: 9.5px; - } - .tone-toggle--header button { - padding: 4px 7px; - } - /* The drawer wrapper: full-height slide-out from the right. The content inside (nav + header-right) becomes a vertical stack with comfortable touch targets. */ @@ -321,14 +336,25 @@ body.drawer-open .drawer-backdrop { opacity: 1; } gap: 14px; margin-top: 20px; } + .mobile-drawer .tone-toggle, .mobile-drawer .theme-toggle, .mobile-drawer .lang-toggle { + display: inline-flex; width: 100%; justify-content: center; } - .mobile-drawer .lang-toggle { display: inline-flex; } - .mobile-drawer .lang-toggle button, - .mobile-drawer .theme-toggle { padding: 10px; font-size: 11.5px; } + /* Inside the drawer all options stay visible — undoes the + hover-collapse from the @media (hover: hover) block above. Also + splits the row evenly and bumps the button padding for thumb taps. */ + .mobile-drawer .tone-toggle button, + .mobile-drawer .theme-toggle button, + .mobile-drawer .lang-toggle button { + display: inline-block; + flex: 1; + padding: 10px; + font-size: 11.5px; + min-width: 0; + } /* The user-menu's dropdown becomes redundant inside the drawer — surface its links flat as a list, and hide the chip button. */ diff --git a/app/templates/base.html b/app/templates/base.html index 2368970..f4218a5 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -126,6 +126,10 @@ // 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 . + var themePill = document.getElementById('theme-toggle'); + if (themePill) themePill.dataset.theme = document.documentElement.dataset.theme || 'light'; }); window.cassandraSetTone = function (newTone) { @@ -143,11 +147,11 @@ }); }; - 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.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) { @@ -205,18 +209,12 @@
- {{ BRAND_NAME }} - {% if BETA_MODE %}BETA{% 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. #} -
- - + {# 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. #} +
+ {{ BRAND_NAME }} + {% if BETA_MODE %}BETA{% endif %}
{# Mobile hamburger — shown only at ≤480px via CSS. #} @@ -236,10 +234,20 @@ Log
- +
+ + +
+
+ + +
{% set cu = request.state.current_user if request.state and request.state.current_user is defined else None %} {% if cu and cu.user %}