feat(articles): reader UI polish — full-bleed + unified floating toolbar

Reader page is now a proper distraction-free reading surface instead
of a padded card inside the (app) layout.

Layout:
 - .detail-shell breaks out of the (app) layout's padded + max-width
   container via the 100vw + negative-margin-X trick, and additionally
   cancels the vertical padding (<main pt-2> + inner py-2) plus the
   bottom-chrome reservation. The reader theme therefore paints
   edge-to-edge including behind the PillNav. No more island-in-a-sea
   look.
 - Initial theme (light/sepia/dark) mirrors the global Mana theme at
   mount time by checking document.documentElement.classList.dark — so
   opening an article from a dark-mode app no longer flashes a white
   reader. User can still override per-article via the swatches.

Toolbar unification:
 - Old two-bar layout (top: back + typography, bottom: actions) fused
   into one floating pill-bar at the bottom. Three groups divided by
   vertical rules: nav | typography | actions. flex-wrap handles narrow
   screens gracefully.
 - position: fixed + bottom: calc(--bottom-chrome-height + 1rem) so the
   bar floats above Mana's PillNav without overlap. The CSS var comes
   from <main>'s style attribute and cascades even into fixed
   descendants.
 - backdrop-filter: blur(10px) + theme-specific semi-transparent
   background so the bar feels aerial, not docked.
 - Custom CSS tooltips on every button (data-tip attribute + ::after
   pseudo). Replaces the native `title` attribute which has a ~1s delay
   and inherits OS chrome. Tooltip bubble colors adapt to the active
   reader theme. aria-label stays for screen-readers.
 - Active-state swatches get an outline-ring instead of a background-
   swap so the chip color stays visible as a theme-preview.

Spacing:
 - meta-bar margin-top: 1.5rem → 4rem — clearer separation between the
   viewport edge and the article title.
 - ReaderView padding-bottom: 4rem → 14rem — last paragraph no longer
   visually attaches to the floating bar when scrolled to the end;
   there's a proper "you've reached the end" gap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-22 15:55:56 +02:00
parent 470f3b1b6c
commit 46c03e6a5b
2 changed files with 341 additions and 198 deletions

View file

@ -96,7 +96,11 @@
<style>
.reader {
overflow-y: auto;
padding: 1.5rem clamp(1rem, 5vw, 3rem) 4rem;
/* Generous bottom padding — clears the floating toolbar (~4rem of */
/* height + chrome) AND leaves a comfortable "you've reached the */
/* end" gap so the last paragraph isn't visually attached to the */
/* bar when the user hits the bottom of the scroll. */
padding: 1.5rem clamp(1rem, 5vw, 3rem) 14rem;
font-size: var(--reader-font-size);
line-height: 1.65;
max-width: 700px;

View file

@ -10,6 +10,7 @@
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { TagField } from '@mana/shared-ui';
import { useArticle, useArticleTagIds } from '../queries';
import { articlesStore } from '../stores/articles.svelte';
@ -38,9 +39,20 @@
// Typography state — per-session only for now. Persisting into userSettings
// comes later; M2 just gets the UX loop working.
let fontSize = $state(1);
// Default reader theme follows the global app theme so opening an
// article from a dark-mode Mana doesn't flash a white reader. The
// swatch buttons still let the user override per-article (e.g. sepia
// for late-evening reading regardless of the app's theme).
let theme = $state<'light' | 'dark' | 'sepia'>('light');
let fontFamily = $state<'serif' | 'sans'>('serif');
onMount(() => {
if (typeof document === 'undefined') return;
if (document.documentElement.classList.contains('dark')) {
theme = 'dark';
}
});
// Refs handed off to HighlightLayer: `shell` is the positioning anchor
// for the floating menu, `readerScroller` is where text lives + where
// selection events fire.
@ -94,79 +106,6 @@
</svelte:head>
<div class="detail-shell detail-{theme}" bind:this={shell}>
<header class="topbar">
<button type="button" class="topbtn" onclick={() => goto('/articles')} aria-label="Zurück">
← Zurück
</button>
{#if article}
<div class="type-controls">
<button
type="button"
class="topbtn"
onclick={() => (fontSize = Math.max(0.85, fontSize - 0.075))}
title="Kleiner"
aria-label="Schrift kleiner"
>
A
</button>
<button
type="button"
class="topbtn"
onclick={() => (fontSize = Math.min(1.35, fontSize + 0.075))}
title="Größer"
aria-label="Schrift größer"
>
A+
</button>
<span class="divider"></span>
<button
type="button"
class="topbtn"
class:active={fontFamily === 'serif'}
onclick={() => (fontFamily = 'serif')}
title="Serif"
>
Serif
</button>
<button
type="button"
class="topbtn"
class:active={fontFamily === 'sans'}
onclick={() => (fontFamily = 'sans')}
title="Sans"
>
Sans
</button>
<span class="divider"></span>
<button
type="button"
class="topbtn swatch swatch-light"
class:active={theme === 'light'}
onclick={() => (theme = 'light')}
aria-label="Heller Modus"
title="Hell"
></button>
<button
type="button"
class="topbtn swatch swatch-sepia"
class:active={theme === 'sepia'}
onclick={() => (theme = 'sepia')}
aria-label="Sepia-Modus"
title="Sepia"
></button>
<button
type="button"
class="topbtn swatch swatch-dark"
class:active={theme === 'dark'}
onclick={() => (theme = 'dark')}
aria-label="Dunkler Modus"
title="Dunkel"
></button>
</div>
{/if}
</header>
{#if article$.loading}
<p class="placeholder">Lädt…</p>
{:else if !article}
@ -214,36 +153,160 @@
htmlVersion={article.htmlContent}
/>
<footer class="actionbar">
<footer class="floating-bar" aria-label="Lese-Werkzeuge">
<div class="bar-group nav-group">
<button
type="button"
class="actionbtn"
class="bar-btn"
onclick={() => goto('/articles')}
aria-label="Zurück zur Liste"
data-tip="Zurück zur Leseliste"
>
</button>
</div>
<span class="bar-divider" aria-hidden="true"></span>
<div class="bar-group type-group">
<button
type="button"
class="bar-btn"
onclick={() => (fontSize = Math.max(0.85, fontSize - 0.075))}
aria-label="Schrift kleiner"
data-tip="Schrift kleiner"
>
A
</button>
<button
type="button"
class="bar-btn"
onclick={() => (fontSize = Math.min(1.35, fontSize + 0.075))}
aria-label="Schrift größer"
data-tip="Schrift größer"
>
A+
</button>
<button
type="button"
class="bar-btn"
class:active={fontFamily === 'serif'}
onclick={() => (fontFamily = 'serif')}
data-tip="Serif-Schrift"
>
Serif
</button>
<button
type="button"
class="bar-btn"
class:active={fontFamily === 'sans'}
onclick={() => (fontFamily = 'sans')}
data-tip="Sans-Serif-Schrift"
>
Sans
</button>
<button
type="button"
class="bar-btn swatch swatch-light"
class:active={theme === 'light'}
onclick={() => (theme = 'light')}
aria-label="Heller Modus"
data-tip="Heller Modus"
></button>
<button
type="button"
class="bar-btn swatch swatch-sepia"
class:active={theme === 'sepia'}
onclick={() => (theme = 'sepia')}
aria-label="Sepia-Modus"
data-tip="Sepia-Modus"
></button>
<button
type="button"
class="bar-btn swatch swatch-dark"
class:active={theme === 'dark'}
onclick={() => (theme = 'dark')}
aria-label="Dunkler Modus"
data-tip="Dunkler Modus"
></button>
</div>
<span class="bar-divider" aria-hidden="true"></span>
<div class="bar-group action-group">
<button
type="button"
class="bar-btn"
class:active={article.status === 'finished'}
onclick={toggleRead}
aria-label={article.status === 'finished'
? 'Als ungelesen markieren'
: 'Als gelesen markieren'}
data-tip={article.status === 'finished'
? 'Als ungelesen markieren'
: 'Als gelesen markieren'}
>
{article.status === 'finished' ? '✓ Gelesen' : 'Als gelesen markieren'}
{article.status === 'finished' ? '✓' : '○'}
</button>
<button
type="button"
class="actionbtn"
class="bar-btn"
class:active={article.isFavorite}
onclick={toggleFavorite}
aria-label="Favorit umschalten"
aria-label={article.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
data-tip={article.isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren'}
>
{article.isFavorite ? '★ Favorit' : '☆ Favorit'}
{article.isFavorite ? '★' : '☆'}
</button>
<button type="button" class="actionbtn" onclick={archive}>Archivieren</button>
<a class="actionbtn" href={article.originalUrl} target="_blank" rel="noopener noreferrer">
Original ↗
<button
type="button"
class="bar-btn"
onclick={archive}
aria-label="Artikel archivieren"
data-tip="Artikel archivieren"
>
</button>
<a
class="bar-btn"
href={article.originalUrl}
target="_blank"
rel="noopener noreferrer"
aria-label="Original-Seite öffnen"
data-tip="Original-Seite öffnen"
>
</a>
<span class="spacer"></span>
<button type="button" class="actionbtn danger" onclick={deleteArticle}>Löschen</button>
<button
type="button"
class="bar-btn danger"
onclick={deleteArticle}
aria-label="Artikel löschen"
data-tip="Artikel löschen"
>
🗑
</button>
</div>
</footer>
{/if}
</div>
<style>
.detail-shell {
/* Break out of the (app) layout's padded container so the reader */
/* fills the whole viewport edge-to-edge. The horizontal escape is */
/* the `100vw` + negative-margin-X trick that cancels the centered */
/* `max-w-7xl mx-auto px-3 sm:px-6 lg:px-8` wrapper. The vertical */
/* escape uses equally-negative margins to consume <main>'s pt-2 */
/* AND its dynamic padding-bottom (which was reserving space for the */
/* bottom chrome). The reader theme then paints behind the floating */
/* PillNav too — far better than a theme-background island floating */
/* in a page-background sea. */
width: 100vw;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
margin-top: calc(-1 * (0.5rem + 0.5rem)); /* <main pt-2> + inner py-2 */
margin-bottom: calc(-1 * (var(--bottom-chrome-height, 0px) + 8px + 0.5rem));
min-height: 100dvh;
display: flex;
flex-direction: column;
@ -263,68 +326,9 @@
background: #0f172a;
color: #e2e8f0;
}
.topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: inherit;
}
.type-controls {
display: flex;
gap: 0.3rem;
margin-left: auto;
flex-wrap: wrap;
}
.divider {
width: 1px;
height: 1.2em;
background: color-mix(in srgb, currentColor 25%, transparent);
align-self: center;
margin: 0 0.2rem;
}
.topbtn {
font: inherit;
font-size: 0.82rem;
padding: 0.3rem 0.65rem;
background: transparent;
color: inherit;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.45rem;
cursor: pointer;
text-decoration: none;
line-height: 1.1;
}
.topbtn:hover {
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.topbtn.active {
background: color-mix(in srgb, currentColor 10%, transparent);
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.swatch {
width: 1.7rem;
height: 1.7rem;
padding: 0;
border-radius: 50%;
overflow: hidden;
}
.swatch-light {
background: #ffffff;
}
.swatch-sepia {
background: #f4ecd8;
}
.swatch-dark {
background: #0f172a;
}
.meta-bar {
max-width: 700px;
margin: 1.2rem auto 0;
margin: 4rem auto 0;
padding: 0 clamp(1rem, 5vw, 3rem);
width: 100%;
}
@ -343,49 +347,184 @@
.tags-row {
margin-top: 0.75rem;
}
.actionbar {
position: sticky;
bottom: 0;
display: flex;
gap: 0.4rem;
padding: 0.55rem 0.9rem;
border-top: 1px solid color-mix(in srgb, currentColor 12%, transparent);
background: inherit;
flex-wrap: wrap;
align-items: center;
}
.actionbtn {
font: inherit;
font-size: 0.85rem;
padding: 0.4rem 0.75rem;
background: transparent;
color: inherit;
border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
border-radius: 0.45rem;
cursor: pointer;
text-decoration: none;
}
.actionbtn:hover {
border-color: color-mix(in srgb, currentColor 35%, transparent);
}
.actionbtn.active {
background: color-mix(in srgb, #f97316 85%, transparent);
color: white;
border-color: #f97316;
}
.actionbtn.danger {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 30%, transparent);
}
.actionbtn.danger:hover {
background: color-mix(in srgb, #ef4444 10%, transparent);
}
.spacer {
flex: 1;
}
.placeholder {
text-align: center;
margin: 3rem auto;
opacity: 0.7;
}
/* ─── Floating unified toolbar ────────────────────────────
*
* One bar at the bottom replaces what used to be a top bar (back +
* typography) and a bottom bar (article actions). Three groups
* divided by vertical rules: nav | typography | actions.
*
* `position: fixed` + center-X transform produces the floating-pill
* look; it stays put while the reader scrolls. `bottom: 1rem` leaves
* enough gap from the viewport edge to feel like a pill, not a docked
* toolbar. On narrow screens the groups wrap onto multiple rows via
* flex-wrap — still readable, just taller.
*/
.floating-bar {
position: fixed;
/* Clear Mana's own bottom-stack (PillNavigation + QuickInputBar + */
/* TagStrip). The layout publishes its total height as */
/* `--bottom-chrome-height` on <main>, which cascades down into our */
/* detail-shell even though we're position: fixed (inheritance is */
/* DOM-based, not layout-based). Fallback 0 keeps the bar sensible */
/* if this page ever renders outside the app shell (e.g. a test). */
bottom: calc(var(--bottom-chrome-height, 0px) + 1rem);
left: 50%;
transform: translateX(-50%);
z-index: 20;
display: flex;
align-items: center;
gap: 0.35rem;
padding: 0.45rem 0.65rem;
border-radius: 999px;
background: color-mix(in srgb, currentColor 3%, Canvas);
border: 1px solid color-mix(in srgb, currentColor 15%, transparent);
box-shadow:
0 8px 24px -8px color-mix(in srgb, currentColor 35%, transparent),
0 2px 6px -2px color-mix(in srgb, currentColor 20%, transparent);
backdrop-filter: blur(10px);
max-width: calc(100vw - 2rem);
flex-wrap: wrap;
justify-content: center;
}
/* Reader-theme surface overrides — Canvas above is the browser-neutral
* default; each theme pins a proper opaque backdrop so text on/around
* the bar stays legible. */
.detail-light .floating-bar {
background: color-mix(in srgb, #ffffff 92%, transparent);
}
.detail-sepia .floating-bar {
background: color-mix(in srgb, #f4ecd8 92%, transparent);
}
.detail-dark .floating-bar {
background: color-mix(in srgb, #0f172a 88%, transparent);
}
.bar-group {
display: flex;
gap: 0.25rem;
align-items: center;
}
.bar-divider {
width: 1px;
height: 1.3rem;
background: color-mix(in srgb, currentColor 20%, transparent);
margin: 0 0.15rem;
}
.bar-btn {
font: inherit;
font-size: 0.82rem;
min-width: 2rem;
height: 2rem;
padding: 0 0.55rem;
background: transparent;
color: inherit;
border: 1px solid transparent;
border-radius: 999px;
cursor: pointer;
text-decoration: none;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
.bar-btn:hover {
background: color-mix(in srgb, currentColor 8%, transparent);
}
/* Custom tooltip: small label bubble above the button on hover. The
* native `title` tooltip has a ~1s delay and inherits the OS style,
* which feels sluggish for a reader-toolbar where the user is
* scanning icons. `data-tip` is set declaratively on each button so
* swapping copy per state (read / unread, favorite / unmark) stays
* a Svelte attribute reactivity concern, not a CSS one. */
.bar-btn[data-tip]::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 0.4rem);
left: 50%;
transform: translateX(-50%);
padding: 0.3rem 0.55rem;
border-radius: 0.4rem;
font-size: 0.72rem;
font-weight: 500;
white-space: nowrap;
color: #f1f5f9;
background: #0f172a;
box-shadow: 0 4px 10px -2px rgba(0, 0, 0, 0.25);
opacity: 0;
pointer-events: none;
transition: opacity 120ms ease;
/* Keep the tooltip above the bar's own backdrop. */
z-index: 1;
}
.bar-btn[data-tip]:hover::after,
.bar-btn[data-tip]:focus-visible::after {
opacity: 1;
transition-delay: 120ms;
}
/* Light-mode readers get an inverted bubble so the tooltip doesn't
* look like just a darker blob — pops off the light page. */
.detail-light .bar-btn[data-tip]::after {
color: #f1f5f9;
background: #1e293b;
}
.detail-sepia .bar-btn[data-tip]::after {
color: #f4ecd8;
background: #433422;
}
.detail-dark .bar-btn[data-tip]::after {
color: #0f172a;
background: #e2e8f0;
}
.bar-btn.active {
background: color-mix(in srgb, #f97316 18%, transparent);
color: #ea580c;
}
.detail-dark .bar-btn.active {
color: #fdba74;
}
.bar-btn.danger:hover {
background: color-mix(in srgb, #ef4444 15%, transparent);
color: #ef4444;
}
.swatch {
width: 1.5rem;
height: 1.5rem;
min-width: 1.5rem;
padding: 0;
border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
}
.swatch:hover {
border-color: color-mix(in srgb, currentColor 55%, transparent);
}
.swatch.active {
background: currentColor;
outline: 2px solid color-mix(in srgb, #f97316 80%, transparent);
outline-offset: 1px;
}
.swatch-light {
background: #ffffff;
}
.swatch-sepia {
background: #f4ecd8;
}
.swatch-dark {
background: #0f172a;
}
/* Override `.swatch.active { background: currentColor }` so the color
* chip stays the theme-preview color even when selected. */
.swatch-light.active {
background: #ffffff;
}
.swatch-sepia.active {
background: #f4ecd8;
}
.swatch-dark.active {
background: #0f172a;
}
</style>