Files
mangaBookmark/backend/static/style.css
T
sulthan f3b55fd883 Work the design critique down: chapter format, colour law, search, strip, a11y (#11)
Two rounds of design-critique fixes on the web UI. Every visual change was verified at 390x844 and 1280x900 in both dark and light with screenshots; `go test ./...` is green throughout; no new dependencies.

## Earlier commits on this branch

The two oldest commits predate this session and were never opened as their own PR, so they are under review here too:

- Confirm-gate the lifecycle actions, cluster the action strip by consequence.
- Fix the accessibility findings from the audit: contrast, focus, reduced motion.

## The rest

**Chapter format.** The userscript and the poller both write `"Chapter N"`, and the templates prefixed `Ch ` again, so every real Asura row read `Ch Chapter 250` — while a manual edit stored a bare `250`, leaving two formats in one list. `DisplayChapter`/`DisplayLatest` on `Bookmark` now strip the lead-in and re-add exactly one `Ch `.

**Zero-result search.** The client filter only toggled `card.hidden`, so a query matching nothing left a blank list under a fully populated, unfiltered "Continue reading" strip. There is now a no-match state with a Clear-search button, and the strip goes down while a filter is active.

**The colour law.** `--ember` is documented as meaning "new chapter" and was spent on eight things, including setting "Nothing new." in the colour reserved for new chapters. Destruction moves to a new `--danger` token; text-input focus follows the searchbar idiom and turns `--paper`. Contrast, both themes: `--danger` on the page 4.82 / 6.65, the solid Remove button 4.94 / 7.30, the confirm question 9.00 / 7.98.

**The remove confirm.** Buttons 40px 8px apart became 46px 12px apart, and the question names the series and the loss instead of asking "Remove this?". It opens with **Cancel** focused, not Remove — the two reversible rows still open on their affirmative.

**The recent strip.** It was the head of the same `updated_at DESC` list rendered directly below it, on every tab, costing ~240px of the first phone screen. It is now scoped to series with a chapter waiting, and only on All. With nothing new anywhere it does not render — deliberate.

**Stale chrome.** The strip and the Updated badge describe the whole library but live outside the swapped `#list`, so archiving a series left it under "Continue reading" with the badge still counting it, and `/?tab=all` reached by htmx differed from the same URL reloaded. Both regions move into `chrome.html` and refresh out of band on every mutation and every tab switch.

**Accessibility and touch.** Esc closes any open panel and returns focus to the cell that owns it; opening a confirm moves focus into it; the inline error scrolls into view and no longer self-destructs after 5s; every tab and desktop action cell clears 44px; `role="alert"` on the login error; the card monogram is no longer announced; the busy bar is clipped by its own travel rather than by `overflow: hidden` on the card.

**Chapter form label.** The panel's only visible text named the published chapter while the field held your progress. The field gets a real label; "Latest known" moves below it.

**gzip.** Nothing was compressed. A stdlib middleware handles the four text types and leaves woff2 alone: style.css 21.8 -> 6.5 KB, htmx 50.9 -> 16.4, filter.js 7.6 -> 2.9.

## Not addressed

The `role="status"` error slot is still mutated while hidden and then revealed, which is the non-announcing pattern the confirm rows were fixed for. Delete is still a silent vanish. Both are flagged in the critique snapshot under `.impeccable/critique/`.

Design health went 24/36 (66.7%) to 29/40 (72.5%) between snapshots; the two P1s that survived were found and fixed after that run.

Reviewed-on: #11
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
2026-07-30 22:34:23 +07:00

748 lines
21 KiB
CSS
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* Fonts are served from this binary, not from Google: this UI is read in
Bromite, where fonts.googleapis.com is routinely blocked, and over a LAN
with no internet route. Latin subsets only, woff2 only — every browser that
runs the app supports it, so there is no second format to ship. Roughly
120 KB for all five faces, embedded via //go:embed static. */
@font-face {
font-family: "Instrument Serif";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/static/fonts/instrument-serif-400-latin.woff2) format("woff2");
}
@font-face {
font-family: "Instrument Serif";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url(/static/fonts/instrument-serif-400-italic-latin.woff2) format("woff2");
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/static/fonts/ibm-plex-mono-500-latin.woff2) format("woff2");
}
@font-face {
font-family: "IBM Plex Mono";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/static/fonts/ibm-plex-mono-600-latin.woff2) format("woff2");
}
/* One variable file covers the 400–700 range the design uses. */
@font-face {
font-family: "DM Sans";
font-style: normal;
font-weight: 400 700;
font-display: swap;
src: url(/static/fonts/dm-sans-var-latin.woff2) format("woff2");
}
/* Cinder — editorial serif, containerless sheets, ash hairlines.
Heat is purely typographic: a series with an unread chapter turns its title
crimson and gains an ember underline. Everything else stays cool. Dark is
the default because this is read at night on a phone; the light branch
re-tunes the same tokens onto paper. */
:root {
color-scheme: dark light;
--font-display: "Instrument Serif", Georgia, "Times New Roman", serif;
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
--font-body: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--ink: #100f0e; /* page */
--ash: #161413; /* recessed panel (chapter form) */
--dim: #0d0c0b; /* archived / finished rows sink */
--rule: #221f1d; /* hairline between sheets */
--rule-soft: #1a1817; /* measure edges */
--field-line: #2c2926;
--hover: #1a1816;
--paper: #f2ece5; /* highest-contrast text, primary button */
--paper-hot: #f0d3cb; /* title of a series with a new chapter */
--paper-dim: #ddd5cb; /* resting title */
--mute: #8d857c; /* secondary text, idle icons */
/* Every mute-2 use is 10px mono, so it has to clear 4.5:1 on --ink even
though it reads as the quietest step. */
--mute-2: #877f76; /* labels, hints */
--faint: #3a3733; /* meta separators */
--faint-2: #57504b; /* cover monogram */
--ember: #e0452c; /* the only heat */
--ember-wash: #1a1211; /* ember-tinted surface */
--ember-ink: #150907; /* text on solid ember */
--ember-soft: #eda798; /* text on ember wash */
/* Destruction is hot but not ember: a duller oxblood, so a remove confirm is
never mistaken across the room for an unread chapter. */
--danger: #cf5c4d;
--danger-wash: #211311;
--danger-ink: #150808;
--danger-soft: #e2aaa1;
--brass: #b8912f; /* favourite — a second, cooler metal */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
--fav-line: #332b14;
--asura: #7d93a5;
--demonic: #a98a78;
/* Covers are often missing; the hatch keeps the slot honest instead of
faking artwork. */
--hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
--hatch-dim: repeating-linear-gradient(135deg, #1b1918 0 5px, #151313 5px 10px);
--measure: 760px;
/* Cover width + row gap: the disclosure panels indent past the cover on
desktop, so both live here rather than as magic numbers. */
--cover-w: 86px;
--row-gap: 14px;
}
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
dark ember is too bright to read as text on off-white. */
@media (prefers-color-scheme: light) {
:root {
--ink: #f7f4ef;
--ash: #efeae3;
--dim: #f1ede7;
--rule: #e0dad2;
--rule-soft: #e8e3dc;
--field-line: #d4cdc4;
--hover: #efeae3;
--paper: #191715;
--paper-hot: #a33018;
--paper-dim: #191715;
--mute: #6b645d;
--mute-2: #6c655e; /* clears 4.5:1 on --ash too, not just --ink */
--faint: #c9c2ba;
--faint-2: #a8a098;
--ember: #c23a22;
--ember-wash: #fbeee9;
--ember-ink: #fff;
--ember-soft: #8d2c17;
--danger: #97362a;
--danger-wash: #fbe9e5;
--danger-ink: #fff;
--danger-soft: #7c2c22;
--brass: #8a681c;
--trash: #8c6558;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
--asura: #4f6b80;
--demonic: #8a6a55;
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--ink);
color: var(--paper-dim);
font: 400 15px/1.5 var(--font-body);
display: flex;
justify-content: center;
-webkit-text-size-adjust: 100%;
}
a { color: inherit; text-decoration: none; }
button, input { font: inherit; }
button { cursor: pointer; }
/* Every hideable thing here is a flex container, and display beats hidden. */
[hidden] { display: none !important; }
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
card itself never needs overflow: hidden to contain it. */
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(233%) } }
@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } }
/* The law: ember means "new chapter", and nothing else on a list screen —
busy, error and destruction all stay off it, so a tired glance never
misreads a system state as unread heat. The one exception is the brand
itself (the wordmark, and the login screen, which shows no series at all).
Destruction has its own token, --danger. */
@keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
/* One measured column, edges drawn rather than boxed. */
.sheet {
width: 100%;
max-width: var(--measure);
min-height: 100vh;
display: flex;
flex-direction: column;
border-left: 1px solid var(--rule-soft);
border-right: 1px solid var(--rule-soft);
padding-bottom: env(safe-area-inset-bottom);
}
/* ---- brand + chrome ---- */
.brand {
margin: 0;
font: 400 26px/1 var(--font-display);
color: var(--paper);
}
.brand em { color: var(--ember); font-style: italic; }
.topbar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 16px;
padding: 22px 20px 14px;
}
.topbar form { margin: 0; }
.ghost {
position: relative;
padding: 0;
border: none;
border-bottom: 1px solid var(--field-line);
background: transparent;
color: var(--mute);
font: 500 11px var(--font-mono);
letter-spacing: .1em;
text-transform: uppercase;
}
.ghost:hover { color: var(--paper); border-bottom-color: var(--paper); }
/* The label is 15px tall by design; the thumb gets 44 without moving it. */
.ghost::after { content: ""; position: absolute; inset: -15px -12px; }
.chrome { display: flex; flex-direction: column; }
.searchbar {
display: flex;
align-items: center;
gap: 10px;
height: 44px;
margin: 0 20px 14px;
border-bottom: 1px solid var(--rule);
color: var(--mute-2);
}
/* The input drops its own outline, so the bar it sits in carries the focus
ring — same move the two other inputs make with their border. */
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
.searchbar svg { width: 15px; height: 15px; flex: none; }
.search {
flex: 1;
min-width: 0;
height: 44px;
border: none;
background: transparent;
color: var(--paper);
font: 400 15px var(--font-body);
outline: none;
}
.search::placeholder { color: var(--mute-2); }
/* Tabs are set in the display serif and underlined, not chipped. */
.tabs {
display: flex;
gap: 16px;
padding: 2px 20px 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
border-bottom: 1px solid var(--rule);
}
.tabs::-webkit-scrollbar { display: none; }
.tabs a {
flex: none;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
/* "All" is two characters in a 17px serif — 15px of target without this. */
min-width: 44px;
padding: 8px 0 12px;
color: var(--mute);
font: 400 17px var(--font-display);
white-space: nowrap;
}
.tabs a:hover { color: var(--paper-dim); }
.tabs a.active {
color: var(--paper);
border-bottom: 2px solid var(--paper);
margin-bottom: -1px;
}
/* Updated is the one tab that carries heat. */
.tabs .tab-new { color: var(--ember); }
.tabs .tab-new.active { border-bottom-color: var(--ember); }
.count {
font: 600 10px var(--font-mono);
letter-spacing: 0;
padding: 2px 5px;
border: 1px solid currentColor;
}
/* ---- continue reading ---- */
.recent {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 0 18px;
border-bottom: 1px solid var(--rule);
}
.recent h2 {
margin: 0;
padding: 0 20px;
font: 500 10px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
}
.recent-strip {
display: flex;
gap: 14px;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
padding: 0 20px 4px;
}
.recent-strip::-webkit-scrollbar { display: none; }
.recent-card {
width: 92px;
flex: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.recent-cover {
position: relative;
width: 92px;
height: 123px;
background: var(--hatch);
display: grid;
place-items: center;
overflow: hidden;
}
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
.recent-title {
font: 400 15px/1.2 var(--font-display);
color: var(--paper-dim);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.recent-chapter {
font: 500 10px/1 var(--font-mono);
letter-spacing: .1em;
color: var(--mute-2);
}
.recent-card.is-new .recent-title { color: var(--paper-hot); }
.recent-card.is-new .recent-chapter { color: var(--ember); }
/* The rule at the cover foot is the only cover ornament: ember for a new
chapter, brass for a favourite. */
.foot-rule {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 3px;
background: var(--ember);
}
.foot-rule.brass { background: var(--brass); }
.monogram {
font: 400 32px var(--font-display);
color: var(--faint-2);
}
/* ---- rows ---- */
.list { display: flex; flex-direction: column; }
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
padding: 18px 20px;
border-bottom: 1px solid var(--rule);
animation: sheetIn .18s ease-out;
}
.card.is-dim { background: var(--dim); }
.row { display: flex; flex-wrap: wrap; gap: var(--row-gap); }
.cover {
position: relative;
width: var(--cover-w);
height: calc(var(--cover-w) * 4 / 3);
flex: none;
background: var(--hatch);
display: grid;
place-items: center;
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 30px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.title-line { display: flex; gap: 8px; align-items: flex-start; }
.title {
margin: 0;
flex: 1;
min-width: 0;
font: 400 19px/1.2 var(--font-display);
color: var(--paper-dim);
}
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
.is-new .title {
flex: 0 1 auto;
align-self: flex-start;
color: var(--paper-hot);
border-bottom: 1px solid var(--ember);
padding-bottom: 3px;
}
.is-dim .title { font-style: italic; color: var(--mute); }
.is-dim .fav-mark { color: var(--mute-2); }
.fav-mark { flex: none; width: 13px; height: 13px; margin-top: 5px; color: var(--brass); }
.meta {
margin: 0;
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
font: 500 10px/1 var(--font-mono);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--mute);
}
.meta .sep { color: var(--faint); }
.site-asura { color: var(--asura); }
.site-demonic { color: var(--demonic); }
.new-chapter { color: var(--ember); }
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
.state svg { width: 9px; height: 9px; }
.is-dim .meta { color: var(--mute-2); }
.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); }
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
.actions {
flex: 1 0 100%;
display: flex;
border-top: 1px solid var(--rule);
}
.actions > * {
flex: 1 1 0;
min-width: 0;
height: 46px;
display: grid;
place-items: center;
padding: 0;
border: none;
border-right: 1px solid var(--rule);
background: transparent;
color: var(--mute);
}
.actions > *:last-child { border-right: none; }
.actions svg { width: 17px; height: 17px; }
.actions > *:hover { color: var(--paper); }
.actions .play { color: var(--paper); }
.is-new .actions .play { color: var(--ember); }
.actions .play:hover { background: var(--hover); }
.actions .on { color: var(--brass); }
.actions .restore { color: var(--paper); }
.actions .remove { color: var(--trash); }
.actions .remove:hover { color: var(--danger); }
.actions .open { background: var(--hover); color: var(--paper); }
/* Lifecycle cells already sit on --ash, which is what --hover resolves to, so
an open one needs the next step up to stay legible as the panel's owner. */
.actions .lifecycle.open { background: var(--rule); }
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
.is-dim .actions > * { color: var(--mute-2); }
/* Three clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, finish, remove). The lifecycle cells
sit on a recessed ground so the thumb reads "this one moves the series"
before it reads which icon it landed on. */
.actions > .lifecycle { background: var(--ash); }
.actions > .play + *,
.actions > *:not(.lifecycle) + .lifecycle { box-shadow: inset 1px 0 0 var(--field-line); }
/* ---- disclosure panels ---- */
.chapter-form, .confirm-row, .error-inline { animation: sheetIn .18s ease-out; }
.chapter-form {
display: flex;
flex-direction: column;
gap: 10px;
margin: 0;
padding: 14px;
background: var(--ash);
}
.chapter-form .hint {
margin: 0;
font: 500 10px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--mute-2);
}
.chapter-form .field { display: flex; gap: 10px; }
.chapter-form input {
flex: 1;
min-width: 0;
height: 46px;
padding: 0 12px;
border: 1px solid var(--field-line);
background: var(--ink);
color: var(--paper);
font: 500 17px var(--font-mono);
outline: none;
}
/* Focus follows the .searchbar idiom — paper, not heat: a red border on a
valid number field reads as "invalid". */
.chapter-form input:focus { border-color: var(--paper); }
.chapter-form input::-webkit-outer-spin-button,
.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-form button {
height: 46px;
padding: 0 20px;
border: none;
background: var(--paper);
color: var(--ink);
font: 400 17px var(--font-display);
}
.confirm-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
padding: 14px;
background: var(--danger-wash);
}
/* The remove question names the series, so it has to be able to take the row
to itself and push the buttons onto their own line. */
.confirm-row span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); }
.confirm-row div { display: flex; flex: none; gap: 12px; margin-left: auto; }
.confirm-row button {
height: 46px;
padding: 0 14px;
border: 1px solid var(--field-line);
background: transparent;
color: var(--mute);
font: 500 12px var(--font-body);
}
.confirm-row button:hover { color: var(--paper); }
.confirm-row .danger-solid {
border: none;
background: var(--danger);
color: var(--danger-ink);
font-weight: 600;
}
/* Archive and finish are reversible, so their confirm asks in grey — only the
irreversible remove gets the danger wash. */
.confirm-row.calm { background: var(--ash); }
.confirm-row.calm span { color: var(--paper-dim); }
.confirm-row .go {
border: none;
background: var(--paper);
color: var(--ink);
font-weight: 600;
}
.confirm-row .go:hover { color: var(--ink); }
.error-inline {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 11px 13px;
background: var(--ash);
border-left: 2px solid var(--mute);
font: 400 16px var(--font-display);
color: var(--paper-dim);
}
.error-inline::before {
content: "";
flex: none;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mute);
animation: mutePulse 1.4s ease-in-out infinite;
}
.error-inline .error-link {
border-bottom: 1px solid var(--field-line);
color: var(--paper);
}
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
ember, which on a list screen only ever means "new chapter". */
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
.card.htmx-request::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 30%;
height: 1px;
background: var(--mute);
animation: barSlide 1.15s linear infinite;
}
/* ---- empty ---- */
.empty {
display: flex;
flex-direction: column;
gap: 7px;
padding: 40px 20px 48px;
}
.empty strong { font: 400 20px var(--font-display); color: var(--paper); }
.empty .clear-search {
align-self: flex-start;
margin-top: 4px;
height: 44px;
padding: 0 16px;
border: 1px solid var(--field-line);
background: transparent;
color: var(--paper-dim);
font: 400 16px var(--font-display);
}
.empty .clear-search:hover { color: var(--paper); border-color: var(--paper); }
.empty p {
margin: 0;
max-width: 44ch;
font: 400 14px/1.6 var(--font-body);
color: var(--mute);
text-wrap: pretty;
}
/* ---- login ---- */
.login-card {
width: 100%;
max-width: 420px;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 52px 28px 40px;
}
.login-card .eyebrow {
font: 500 10px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
}
.login-card h1 {
margin: 14px 0 0;
font: 400 44px/1 var(--font-display);
letter-spacing: -.01em;
color: var(--paper);
}
.login-card h1 em { color: var(--ember); font-style: italic; }
.login-card form { display: flex; flex-direction: column; gap: 18px; }
.login-card label {
font: 500 10px/1 var(--font-mono);
letter-spacing: .16em;
text-transform: uppercase;
color: var(--mute);
}
.login-card input {
width: 100%;
height: 54px;
margin-top: 9px;
padding: 0 2px;
border: none;
border-bottom: 1px solid var(--field-line);
background: transparent;
color: var(--paper);
font: 500 20px var(--font-mono);
letter-spacing: .16em;
outline: none;
}
.login-card input:focus { border-bottom-color: var(--paper); }
.login-card .error {
margin: 0;
min-height: 20px;
font: 400 13px/1.4 var(--font-body);
color: var(--danger);
}
.login-card button {
height: 54px;
border: 1px solid var(--paper);
background: var(--paper);
color: var(--ink);
font: 400 19px var(--font-display);
}
.login-card button:hover {
background: var(--ember);
border-color: var(--ember);
color: #fff;
}
/* ---- desktop: same measure, actions fold up beside the row ---- */
@media (min-width: 720px) {
:root { --cover-w: 74px; --row-gap: 20px; }
.topbar { padding: 26px 32px 18px; }
.brand { font-size: 30px; }
.chrome {
flex-direction: row;
align-items: center;
gap: 26px;
padding: 0 32px;
border-bottom: 1px solid var(--rule);
}
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
.tabs a { padding: 10px 0 14px; font-size: 18px; }
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
.recent-card, .recent-cover { width: 100px; }
.recent-cover { height: 133px; }
.card { padding: 18px 32px; }
.row { flex-wrap: nowrap; align-items: center; }
.cover .monogram { font-size: 26px; }
.title { font-size: 21px; }
.actions { flex: none; gap: 4px; border-top: none; }
.actions > * {
flex: none;
width: 44px;
height: 44px;
border: 1px solid var(--rule);
}
/* Cells are already gapped here, so the clusters separate by space rather
than by the hairline the phone layout uses. */
.actions > .play + *,
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
.is-new .actions .play { border-color: var(--play-hot-line); }
.actions .on { border-color: var(--fav-line); }
/* Panels line up with the body text, i.e. past the cover and its gap. */
.chapter-form, .confirm-row, .error-inline {
margin-left: calc(var(--cover-w) + var(--row-gap));
}
}
@media (prefers-reduced-motion: reduce) {
/* Pseudo-elements need naming explicitly — `*` does not match them, and the
busy bar and error dot are both ::before. Their static form still reads:
the bar stays drawn and .actions stays dimmed. */
*, *::before, *::after { animation: none !important; transition: none !important; }
}