Cinder pass across /admin, the login gate, and the library's a11y floor

Uncommitted work from three design runs on this branch, against one design
system: docs/design-system.md is updated to match the CSS, not the reverse.

Library (Reader-facing):
- .chrome sticks at top: 0. Search and the tab row were unreachable three
  screens into a 300-item library, which is exactly where they earn their
  keep; everything above them still scrolls away on purpose.
- One :focus-visible ring (2px --paper) on the nine controls that defined
  none and fell back to the UA blue. .searchbar keeps its border recolour as
  a resting cue but no longer stands in for a ring.
- Mono labels lift 10px -> 11px everywhere. The brief names night reading and
  glare as the usage scene; 10px small-caps was where taste overrode it.
- A card in flight past 2s says "Saving..." and carries aria-busy. htmx sets
  neither, so the wait up to its 15s timeout was silent in both channels.
- Titles clamp at 3 lines; .is-new .title takes width: fit-content, or
  -webkit-box stretches the ember underline past the text it sizes to.

/admin:
- Overview routes into Lanes when a lane is unhealthy, prefixes each figure
  with its column word on the phone layout that drops the thead, labels state
  cells for a screen reader, and has an empty state where the sites table
  assumed rows.
- The admin shell picks up the library's chrome: htmx 15s timeout, the shared
  #notice slot, #sr-announce, filter.js. admin.css follows the same pass.
- admin_render_test.go and card_render_test.go render the templates directly,
  so markup regressions in either surface fail without a browser.

Login:
- DISCORD_GUILD_NAME (optional) names the community on the login screen and
  in the refusal message, so a stranger knows which Discord to ask for an
  invite. Unset degrades to a generic label; neither form names the guild id.

Handlers:
- maxChapterNum (9999) bounds both typed-chapter paths. uiChapter and
  adminSeriesCorrectLatest each parsed a float64 with no ceiling, so a
  hand-rolled POST stored 1e308 and every later reader of that row inherited
  it. Matches the max on the card's chapter input.

go test ./... green.
This commit is contained in:
2026-08-27 23:05:40 +07:00
parent cddd16bcdc
commit af07314bb6
32 changed files with 1567 additions and 237 deletions
+222 -52
View File
@@ -2,12 +2,6 @@
--measure-wide: 1080px;
}
@media (prefers-color-scheme: light) {
:root {
--measure-wide: 1080px;
}
}
.admin-sheet {
max-width: var(--measure-wide);
}
@@ -54,12 +48,18 @@
.navrow a:hover {
color: var(--paper-dim);
background: var(--hover);
}
.navrow a.active {
color: var(--paper);
border-bottom: 2px solid var(--paper);
color: var(--patina);
border-bottom: 2px solid var(--patina);
margin-bottom: -1px;
background: var(--patina-wash);
padding-left: 6px;
padding-right: 6px;
margin-left: -6px;
margin-right: -6px;
}
.admin-page {
@@ -97,15 +97,13 @@
border-bottom: none;
}
.admin-sheet .readerlist,
.admin-sheet .lanelist {
.admin-sheet .readerlist {
margin: 0;
padding: 0;
list-style: none;
}
.admin-sheet .readerlist li,
.admin-sheet .lanelist li {
.admin-sheet .readerlist li {
display: flex;
align-items: center;
flex-wrap: wrap;
@@ -115,12 +113,12 @@
border-top: 1px solid var(--rule);
}
.admin-sheet .reader-actions {
display: flex;
flex: 0 0 auto;
gap: 18px;
margin-left: auto;
white-space: nowrap;
gap: 28px;
align-items: center;
}
.admin-sheet .reader-actions .ghost,
@@ -134,13 +132,11 @@
color: var(--danger);
}
.admin-sheet .readerlist form {
margin: 0;
}
.admin-sheet .lane-browser {
padding: 12px 0 0;
}
.admin-sheet .ghost.danger {
color: var(--danger);
@@ -159,9 +155,7 @@
.admin-sheet .reader-sessions,
.admin-sheet .reader-sightings,
.admin-sheet .reader-blocked,
.admin-sheet .lane-fact,
.admin-sheet .lane-mark {
.admin-sheet .reader-blocked {
font: 500 13px/1.4 var(--font-mono);
letter-spacing: .06em;
text-transform: uppercase;
@@ -171,25 +165,14 @@
color: var(--paper-dim);
}
.admin-sheet .reader-sightings,
.admin-sheet .lane-fact {
.admin-sheet .reader-sightings {
color: var(--mute);
}
.admin-sheet .reader-blocked,
.admin-sheet .lane-mark {
.admin-sheet .reader-blocked {
color: var(--patina);
}
.admin-sheet .lane-site {
font: 400 19px/1.2 var(--font-display);
color: var(--paper-dim);
}
.admin-sheet .lanelist li.attention .lane-site {
color: var(--danger);
}
/* A single grid keeps row rules continuous; cell padding supplies gutters. */
.admin-sheet .sechead {
display: flex;
@@ -301,9 +284,10 @@
}
.admin-sheet .tbl .c-act .ghost + .ghost {
margin-left: 12px;
margin-left: 28px;
}
.admin-sheet .tbl .trow > .confirm-row {
grid-column: 1 / -1;
padding: 10px 12px;
@@ -324,6 +308,10 @@
gap: 16px;
padding: 11px 24px 11px 0;
}
.admin-sheet .stat + .stat {
border-left: 1px solid var(--rule);
padding-left: 24px;
}
.admin-sheet .stat .lbl {
font: 500 13px/1.3 var(--font-mono);
@@ -345,34 +333,53 @@
.admin-sheet .stat a.fig {
/* The hover rule below sets the border on :hover only, which shifts the
row a pixel on hover; the transparent baseline holds the layout. */
border-bottom: 1px solid transparent;
border-bottom: 1px solid color-mix(in oklch, var(--patina) 22%, transparent);
}
.admin-sheet .stat a.fig:hover {
color: var(--patina);
border-bottom: 1px solid var(--patina);
background: var(--patina-wash);
}
@media (max-width: 600px) {
.admin-sheet .stat + .stat {
border-left: none;
border-top: 1px solid var(--rule);
padding-left: 0;
}
}
.admin-sheet .mark,
.admin-sheet .mark-faint {
font: 500 13px/1 var(--font-mono);
font: 500 12px/1.2 var(--font-mono);
letter-spacing: .06em;
text-transform: uppercase;
white-space: nowrap;
padding: 2px 6px;
border-radius: 2px;
border: 1px solid transparent;
}
.admin-sheet .mark {
color: var(--patina);
background: var(--patina-wash);
border-color: var(--patina-line);
}
.admin-sheet .mark-faint {
color: var(--mute-2);
background: transparent;
border-color: transparent;
padding-left: 0;
padding-right: 0;
}
.admin-sheet .mark.mark-strong {
font-size: 13px;
letter-spacing: .14em;
font-size: 12px;
letter-spacing: .12em;
color: var(--patina);
background: var(--patina-wash);
border-color: var(--patina-line);
padding: 3px 7px;
}
.admin-sheet .mark.mark-strong::before {
@@ -387,9 +394,21 @@
}
.admin-sheet .mark.bad {
color: var(--danger);
color: var(--slate);
background: var(--slate-wash);
border-color: var(--slate);
font-weight: 600;
}
.admin-sheet .mark.bad::before {
content: "";
display: inline-block;
width: 7px;
height: 7px;
background: var(--slate);
margin-right: 8px;
vertical-align: .08em;
border-radius: 1px; /* square, not circle — distinguishes from .mark-strong patina dot */
}
.admin-sheet .tbl.sites {
grid-template-columns: 170px repeat(4, 106px) minmax(180px, 1fr);
}
@@ -402,18 +421,56 @@
}
.admin-sheet .tbl.sites .c-state {
color: var(--patina);
color: var(--mute-2);
white-space: nowrap;
padding: 2px 7px;
border-radius: 2px;
border: 1px solid transparent;
background: transparent;
justify-self: start;
align-self: center;
font: 500 11px/1 var(--font-mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.admin-sheet .tbl.sites .c-state.bad,
.admin-sheet .tbl.lanes .c-skip .bad,
.admin-sheet .tbl.lanes .trow.attention .c-site {
color: var(--danger);
.admin-sheet .tbl.sites .c-state.ok {
color: var(--patina);
background: var(--patina-wash);
border-color: var(--patina-line);
}
.admin-sheet .tbl.sites .c-state.bad {
color: var(--slate);
background: var(--slate-wash);
border-color: var(--slate);
font-weight: 600;
}
.admin-sheet .tbl.lanes .c-skip .ok {
color: var(--patina);
background: var(--patina-wash);
border: 1px solid var(--patina-line);
padding: 1px 5px;
border-radius: 2px;
}
.admin-sheet .tbl.lanes .trow.attention .c-site {
color: var(--patina);
}
.admin-sheet .tbl.lanes .c-skip .bad {
color: var(--slate);
background: var(--slate-wash);
border: 1px solid var(--slate);
padding: 1px 5px;
border-radius: 2px;
font-weight: 600;
}
.admin-sheet .tbl.lanes .trow.attention {
background: var(--patina-wash);
box-shadow: inset 1px 0 0 var(--patina);
}
.admin-sheet .tbl.series .trow.attention {
background: var(--patina-wash);
box-shadow: inset 1px 0 0 var(--patina);
}
.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5),
@@ -572,8 +629,13 @@
.admin-sheet .segrow a.active {
border-bottom-color: var(--patina);
color: var(--patina);
background: var(--patina-wash);
}
.admin-sheet .segrow a:hover {
color: var(--paper-dim);
background: var(--hover);
}
.admin-sheet .listhead {
display: flex;
align-items: baseline;
@@ -612,10 +674,11 @@
}
.admin-sheet .pager .pg.disabled {
color: var(--faint);
color: var(--mute);
pointer-events: none;
}
.admin-sheet .empty {
padding: 28px 0;
text-align: center;
@@ -669,6 +732,27 @@
color: var(--ink);
}
.admin-sheet .readerlist li .confirm-row {
width: 100%;
margin-top: 8px;
}
/* Busy: same muted hairline the reader library uses, never ember. */
.admin-sheet .trow.htmx-request,
.admin-sheet #readers.htmx-request,
.admin-sheet #lanes.htmx-request,
.admin-sheet #detail-meta.htmx-request,
.admin-sheet .dform.htmx-request {
opacity: .6;
pointer-events: none;
}
.admin-sheet .ghost:focus-visible {
outline: 2px solid var(--patina);
outline-offset: 2px;
color: var(--paper);
}
.admin-sheet .tbl.series .row-msg {
grid-column: 1 / -1;
margin-top: 6px;
@@ -677,7 +761,6 @@
}
.admin-sheet .detail-back {
display: inline-block;
margin: 18px 0 0;
}
@@ -819,6 +902,23 @@
margin-left: auto;
text-align: right;
}
/* The thead is gone, so every cell that was only legible under a column
heading prints its own. The label is data, not decoration: without it
"12 40 6h" is three numbers in the same face with no way to tell which
is which. Set in the mono face at label size so the figure still leads
visually. */
.admin-sheet .tbl.lanes [data-label] {
white-space: nowrap;
}
.admin-sheet .tbl.lanes [data-label]::before {
content: attr(data-label) " ";
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mute-2);
}
}
@media (max-width: 899px) {
@@ -834,7 +934,12 @@
.admin-sheet .tbl .trow {
display: flex;
flex-wrap: wrap;
gap: 2px 10px;
/* Cells no longer share a font size — a label-value pair is 11px over
15px — so they hang off the baseline, not off the box top. */
align-items: baseline;
/* Wider than the mouse layout's column gutter: each cell now carries its
own label, so the eye needs the pair to read as one unit. */
gap: 2px 18px;
padding: 11px 0;
border-bottom: 1px solid var(--rule);
}
@@ -871,4 +976,69 @@
.admin-sheet .tbl .c-act {
margin-left: auto;
}
/* A cell is one label-value pair and must not break between them. */
.admin-sheet .tbl [data-label] {
white-space: nowrap;
}
.admin-sheet .tbl [data-label]::before {
content: attr(data-label) " ";
font-size: 11px;
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mute-2);
}
}
/* ---- touch: the reader's library is sized for a thumb throughout, this
sheet was not — it is a data surface that grew on a mouse. Gated on the
input method rather than the width, because the phone is not the only
coarse pointer and a 1400px touch laptop has the same problem. Vertical
only: horizontal space is what this surface is short of. ---- */
@media (pointer: coarse) {
/* The figure is the door — the label beside it is not a link — so the row
gives up its own padding to the cell that has to be hit. */
.admin-sheet .stat {
padding: 0 24px 0 0;
}
/* A one-digit count is 11px wide: the minimum is on the box, not the
glyph, or the door is a target the thumb has to aim at. */
.admin-sheet .stat .fig {
min-width: 44px;
padding: 14px 0 14px 20px;
text-align: right;
}
.admin-sheet .tbl .c-title a,
.admin-sheet .tbl a.fig,
.admin-sheet .tbl a.c-site,
/* The collapsed lanes row sets padding on every direct cell, which is a
more specific selector than the line above — its own Site link has to be
named here or it keeps the mouse metric. */
.admin-sheet .tbl.lanes .trow > a.c-site {
display: inline-block;
min-width: 44px;
padding: 10px 0;
}
.admin-sheet .pager {
gap: 20px;
}
.admin-sheet .pager .pg {
padding: 16px 0;
}
.admin-sheet .segrow a {
padding: 15px 11px 16px;
}
.admin-sheet .fsel select,
.admin-sheet .pausebar select,
.admin-sheet .dform input {
padding-top: 11px;
padding-bottom: 12px;
}
}
+142 -13
View File
@@ -1,7 +1,7 @@
// Title search runs entirely in the browser: the full list is already in the
// DOM, so filtering it needs no request.
(function () {
function applyFilter() {
function applyFilter(e) {
var box = document.getElementById("search");
if (!box) return;
var query = box.value.trim();
@@ -30,6 +30,21 @@
none.hidden = !(needle !== "" && cards.length > 0 && visible === 0);
if (!none.hidden) none.querySelector(".no-match-q").textContent = query;
}
var isCardSwap = e && e.detail && e.detail.target && e.detail.target.classList && e.detail.target.classList.contains("card");
if (isCardSwap) return;
var announce = document.getElementById("sr-announce");
if (announce) {
if (needle === "") {
announce.textContent = "";
} else if (cards.length === 0) {
announce.textContent = "";
} else if (visible === 0) {
announce.textContent = 'No titles match "' + query + '"';
} else {
announce.textContent = visible + ' ' + (visible === 1 ? 'title matches' : 'titles match') + ' "' + query + '"';
}
}
}
document.addEventListener("input", function (e) {
@@ -47,6 +62,54 @@
// htmx replaces the list on a tab switch, so re-apply to the new cards.
document.body.addEventListener("htmx:afterSwap", applyFilter);
document.addEventListener("bmgr:refilter", applyFilter);
// Focus handoff on remove: the card and its focused button vanish with the
// swap, so focus would fall to <body> in silence. Remember the next card's
// play link before the DELETE and hand focus to it after, or to the list
// when it was the last row.
var pendingRemove = null;
document.body.addEventListener("htmx:beforeRequest", function (e) {
var elt = e.detail.elt;
if (!elt || !elt.getAttribute("hx-delete")) return;
var card = elt.closest(".card");
if (!card) return;
var nxt = card.nextElementSibling;
while (nxt && (!nxt.classList.contains("card") || nxt.hidden)) nxt = nxt.nextElementSibling;
var play = nxt && nxt.querySelector(".play");
pendingRemove = { card: card, play: play };
});
document.body.addEventListener("htmx:afterSwap", function (e) {
if (!pendingRemove) return;
if (e.detail.target !== pendingRemove.card) return;
if (pendingRemove.play && document.body.contains(pendingRemove.play)) {
pendingRemove.play.focus();
} else {
var list = document.getElementById("list");
if (list) list.focus();
}
pendingRemove = null;
var notice = document.getElementById("notice");
if (notice && !notice.hidden) {
notice.scrollIntoView({
block: "nearest",
behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
});
}
});
document.body.addEventListener("htmx:responseError", function () { pendingRemove = null; });
document.body.addEventListener("htmx:sendError", function () { pendingRemove = null; });
// htmx sets no aria-busy, so the in-flight window — up to the 15s timeout on
// a phone in a dead zone — is silent to a screen reader; the busy bar and the
// "Saving…" word are the visual half of the same signal. A successful swap
// replaces the card and takes the attribute with it; afterRequest is for the
// failures, where the card stays.
document.body.addEventListener("htmx:beforeRequest", function (e) {
var card = e.detail.elt && e.detail.elt.closest(".card");
if (card) card.setAttribute("aria-busy", "true");
});
document.body.addEventListener("htmx:afterRequest", function (e) {
var card = e.detail.elt && e.detail.elt.closest(".card");
if (card) card.removeAttribute("aria-busy");
});
})();
function setActiveTab(el) {
@@ -121,19 +184,72 @@ document.addEventListener("keydown", function (e) {
if (e.key !== "Escape" || !e.target.closest) return;
var card = e.target.closest(".card");
var owner = card && card.querySelector(".actions .open");
if (!owner) return;
closeCardPanels(card.id.replace(/^card-/, ""));
owner.focus();
if (owner) {
closeCardPanels(card.id.replace(/^card-/, ""));
owner.focus();
return;
}
// Admin confirm rows: any open .confirm-row that contains the focused element
var row = e.target.closest(".confirm-row");
if (row && !row.hidden) {
row.hidden = true;
var opener = row.id && document.querySelector('[aria-controls="' + row.id + '"]');
if (opener) {
opener.setAttribute("aria-expanded", "false");
opener.focus();
}
return;
}
// Also handle when focus is outside row but row is open on page (e.g., opener focused)
var openRow = document.querySelector(".admin-sheet .confirm-row:not([hidden])");
if (openRow) {
openRow.hidden = true;
var op = openRow.id && document.querySelector('[aria-controls="' + openRow.id + '"]');
if (op) {
op.setAttribute("aria-expanded", "false");
op.focus();
}
}
});
// A failed favourite/chapter/delete request leaves the card in place (htmx
// does not swap on a non-2xx response) but otherwise gives no sign anything
// went wrong. Surface it inline instead of leaving the tap looking ignored.
(function () {
function showError(elt, message, linkHref, linkText) {
function slotFor(elt) {
var card = elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
if (!slot) return;
if (card) {
var s = card.querySelector(".error-inline");
if (s) return s;
}
// Admin: per-form inline slot
var dform = elt.closest(".dform");
if (dform) {
var ds = dform.querySelector(".error-inline");
if (ds) return ds;
}
// Admin: per-reader roster
var readers = elt.closest("#readers");
if (readers) {
var rs = readers.querySelector(".error-inline");
if (rs) return rs;
}
// Admin: series row — reuse row-msg if present, otherwise fall back
var trow = elt.closest(".trow");
if (trow) {
var ts = trow.querySelector(".error-inline") || trow.querySelector(".row-msg");
if (ts) return ts;
}
return document.getElementById("notice");
}
function inCard(slot) {
return slot.classList.contains("error-inline");
}
function show(slot, message, linkHref, linkText) {
if (slot.id === "notice") slot.setAttribute("role", "status");
slot.textContent = message;
if (linkHref) {
var a = document.createElement("a");
@@ -155,9 +271,10 @@ document.addEventListener("keydown", function (e) {
}
document.body.addEventListener("htmx:beforeRequest", function (e) {
var card = e.detail.elt.closest(".card");
var slot = card && card.querySelector(".error-inline");
var slot = slotFor(e.detail.elt);
if (slot) slot.hidden = true;
var n = document.getElementById("notice");
if (n && !n.hidden && n.textContent.indexOf("Removed ") === 0) n.hidden = true;
});
// The handlers answer a bad value with http.Error, i.e. a short plain-text
@@ -170,19 +287,31 @@ document.addEventListener("keydown", function (e) {
}
document.body.addEventListener("htmx:responseError", function (e) {
var slot = slotFor(e.detail.elt);
if (!slot) return;
var xhr = e.detail.xhr;
// "Saved" is only true of a card's own write; a failed tab switch saved
// nothing because it was never a write.
if (xhr.status === 401) {
showError(e.detail.elt, "Session expired — nothing was saved.", "/login", "Log in again");
show(slot, inCard(slot) ? "Session expired — nothing was saved." : "Session expired.",
"/login", "Log in again");
return;
}
if (xhr.status === 400) {
var reason = reasonFrom(xhr);
showError(e.detail.elt, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
show(slot, reason ? reason + "." : "That value wasn't accepted — check it and try again.");
return;
}
showError(e.detail.elt, "Couldn't save — try again.");
show(slot, inCard(slot) ? "Couldn't save — try again." : "That didn't go through — try again.");
});
document.body.addEventListener("htmx:sendError", function (e) {
showError(e.detail.elt, "No connection — try again.");
var slot = slotFor(e.detail.elt);
if (slot) show(slot, "No connection — try again.");
});
// htmx-config caps every request at 15s; without this the give-up is as
// silent as the hang it replaced.
document.body.addEventListener("htmx:timeout", function (e) {
var slot = slotFor(e.detail.elt);
if (slot) show(slot, "Timed out — try again.");
});
})();
+246 -51
View File
@@ -55,9 +55,9 @@
--ink: #100f0e; /* page */
--ash: #161413; /* recessed panel (chapter form) */
--dim: #0d0c0b; /* archived rows sink */
--rule: #221f1d; /* hairline between sheets */
--rule-soft: #1a1817; /* measure edges */
--field-line: #2c2926;
--rule: #221f1d; /* decorative hairlines — not interactive — hairline between sheets */
--rule-soft: #1a1817; /* decorative hairlines — not interactive — measure edges */
--field-line: #4a4540; /* ≈■ #4a4540 — interactive border, distinct from decorative --rule/--rule-soft */
--hover: #1a1816;
--paper: #f2ece5; /* highest-contrast text, primary button */
@@ -70,8 +70,8 @@
--faint: #3a3733; /* meta separators */
--faint-2: #57504b; /* cover monogram */
--ember: #e0452c; /* the only heat */
--ember-wash: #1a1211; /* ember-tinted surface */
--ember: #e85a41; /* the only heat — verified against --ember-wash, not --ink — ≈■ #e85a41 — verified 5.31:1 on --ember-wash #1c0f0d, not --ink */
--ember-wash: #1c0f0d; /* ■ #1c0f0d — verified against this wash for is-new card/Recent/Updated/libswitch */
--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
@@ -80,6 +80,7 @@
--danger-wash: #211311;
--danger-ink: #150808;
--danger-soft: #e2aaa1;
--danger-line: #4a2e2b; /* resting hairline for Remove — muted danger, not alarm */
--brass: #b8912f; /* favourite — a second, cooler metal */
/* One accent per action, so a press says which lane it belongs to. All three
are held at the same weight as --brass: muted, no ember competition. */
@@ -92,7 +93,17 @@
blue. Neither ember (new chapter) nor danger (destruction) may say
"system unhealthy". */
--patina: #5fb3a6;
--patina-wash: #12201e;
--patina-soft: #9ad6cd;
--patina-line: #1e3a37;
--brass-wash: #231e0f;
--brass-soft: #e0c495;
--slate-wash: #14202a;
--slate-soft: #a8bdd2;
--clay-wash: #241a13;
--clay-soft: #d9b79d;
--moss-wash: #13211a;
--moss-soft: #a9ceb1;
/* Desktop cell borders for the two coloured action states. */
--play-hot-line: #3a1d18;
--fav-line: #332b14;
@@ -116,6 +127,9 @@
desktop, so both live here rather than as magic numbers. */
--cover-w: 93px;
--row-gap: 14px;
/* The desktop scale-up lives in a token because `zoom` multiplies viewport
units too: anything sized in vh has to divide it back out. */
--zoom: 1;
}
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
@@ -125,8 +139,8 @@
--ink: #f7f4ef;
--ash: #efeae3;
--dim: #f1ede7;
--rule: #e0dad2;
--rule-soft: #e8e3dc;
--rule: #e0dad2; /* decorative hairlines — not interactive */
--rule-soft: #e8e3dc; /* decorative hairlines — not interactive */
--field-line: #d4cdc4;
--hover: #efeae3;
@@ -138,30 +152,42 @@
--faint: #c9c2ba;
--faint-2: #a8a098;
--ember: #c23a22;
--ember-wash: #fbeee9;
--ember: #c23a22; /* verified against --ember-wash, not --ink */
--ember-wash: #fbeee9; /* verified against --ember-wash, not --ink — wash ground */
--ember-ink: #fff;
--ember-soft: #8d2c17;
--ember-soft: #7c2314; /* from #8d2c17 — crimson not brown, 4.9:1 on #fbeee9 */
--danger: #97362a;
--danger-wash: #fbe9e5;
--danger-ink: #fff;
--danger-soft: #7c2c22;
--danger-line: #d8b8b0; /* resting hairline for Remove — light counterpart to #4a2e2b */
--brass: #8a681c;
--slate: #3f6689;
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558;
--patina: #1f6f66;
--patina-wash: #def3f0;
--patina-soft: #144a43;
--patina-line: #b8ddd8;
--brass-wash: #fdf6e3;
--brass-soft: #5a4812;
--slate-wash: #e8eef5;
--slate-soft: #24445f;
--clay-wash: #fdf0e6;
--clay-soft: #5a3520;
--moss-wash: #e7f3e8;
--moss-soft: #23482a;
--play-hot-line: #f0cfc6;
--fav-line: #e3d3a4;
--asura: #4f6b80;
--demonic: #8a6a55;
--comix: #5f7250;
--demonic: #7a5c4a; /* from #8a6a55 — 5.1:1 on #f7f4ef, ~4.7:1 on #efeae3 */
--comix: #5a6c4b; /* from #5f7250 — 5.2:1 on #f7f4ef, 4.77:1 on #efeae3 — was 4.38:1 on ash, fails AA */
--kagane: #6f5f7d;
--novelfull: #7d6f4f;
--lightnovelworld: #4f7d70;
--novelfull: #6f5f3f; /* from #7d6f4f — same bar */
--lightnovelworld: #3d6b5e; /* from #4f7d70 — lifts 3.90→4.8:1 on ash */
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
@@ -178,6 +204,11 @@ body {
display: flex;
justify-content: center;
-webkit-text-size-adjust: 100%;
/* Every shell sets viewport-fit=cover, which puts the sheet's own hairline
borders and its 20px gutter under a landscape cutout on a notched phone.
Zero on every other device, so it costs nothing to honour here. */
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
a { color: inherit; text-decoration: none; }
@@ -186,6 +217,19 @@ button { cursor: pointer; }
/* Every hideable thing here is a flex container, and display beats hidden. */
[hidden] { display: none !important; }
/* Visually hidden but still announced — the live region for mutation and
filter results. Display:none would silence it. */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
card itself never needs overflow: hidden to contain it. */
@@ -203,6 +247,9 @@ button { cursor: pointer; }
width: 100%;
max-width: var(--measure);
min-height: 100vh;
/* The URL bar is inside 100vh on mobile Chromium, so the flush bottom
border sits under it. dvh is the visible box; vh above is the fallback. */
min-height: 100dvh;
display: flex;
flex-direction: column;
border-left: 1px solid var(--rule-soft);
@@ -248,6 +295,15 @@ button { cursor: pointer; }
.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; }
/* Nav and standalone controls share one paper ring. The per-action cells in
the card strip carry their own accent rings instead; everything here would
otherwise fall back to the UA default, which is a bright blue on a palette
tuned for a dark room. */
.ghost:focus-visible,
.tabs a:focus-visible,
.libswitch a:focus-visible,
.empty .clear-search:focus-visible,
.login-card button:focus-visible { outline: 2px solid var(--paper); outline-offset: 2px; }
/* ---- userscript setup: collapsed by default, one hairline, no card ---- */
.setup {
@@ -261,7 +317,7 @@ button { cursor: pointer; }
align-items: center;
min-height: 44px;
padding: 0;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
@@ -297,7 +353,21 @@ button { cursor: pointer; }
}
.chrome { display: flex; flex-direction: column; }
/* Sticky: the buckets and the search box are how a large library is triaged,
and a phone three screens in otherwise has to scroll back to the top to
reach either. The keyrow below is deliberately left to scroll away — it
teaches the icon strip once, and 150px of permanent chrome on an 844px
phone costs more than re-scrolling for a reminder does. */
.chrome {
display: flex;
flex-direction: column;
position: sticky;
top: 0;
/* Cards are position: relative, so without this they paint over it. */
z-index: 2;
background: var(--ink);
padding-top: env(safe-area-inset-top);
}
.searchbar {
display: flex;
@@ -308,8 +378,9 @@ button { cursor: pointer; }
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. */
/* The bar carries a resting recolour on focus-within, and the input carries
its own ring: the 1px border change alone is a weaker indicator than every
other control on the sheet gets, on the control most worth finding. */
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
.searchbar svg { width: 15px; height: 15px; flex: none; }
.search {
@@ -320,8 +391,8 @@ button { cursor: pointer; }
background: transparent;
color: var(--paper);
font: 400 15px var(--font-body);
outline: none;
}
.search:focus-visible { outline: 2px solid var(--paper); outline-offset: 3px; }
.search::placeholder { color: var(--mute-2); }
/* Tabs are set in the display serif and underlined, not chipped. */
@@ -333,6 +404,9 @@ button { cursor: pointer; }
overflow-y: hidden;
scrollbar-width: none;
border-bottom: 1px solid var(--rule);
/* Right-edge fade — the pills and 4th tab crop invisibly without it. */
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}
.tabs::-webkit-scrollbar { display: none; }
.tabs a {
@@ -348,21 +422,25 @@ button { cursor: pointer; }
font: 400 17px var(--font-display);
white-space: nowrap;
}
.tabs a:hover { color: var(--paper-dim); }
.tabs a:hover { color: var(--paper-dim); background: var(--hover); border-radius: 2px 2px 0 0; }
.tabs a.active {
color: var(--paper);
border-bottom: 2px solid var(--paper);
margin-bottom: -1px;
background: var(--hover);
}
/* Updated is the one tab that carries heat. */
.tabs .tab-new { color: var(--ember); }
.tabs .tab-new.active { border-bottom-color: var(--ember); }
.tabs .tab-new.active { border-bottom-color: var(--ember); background: var(--ember-wash); color: var(--ember); }
.tabs .tab-new:hover { background: var(--ember-wash); color: var(--ember); }
.count {
font: 600 10px var(--font-mono);
letter-spacing: 0;
padding: 2px 5px;
border: 1px solid currentColor;
background: var(--ink);
}
.tab-new .count { background: var(--ember-wash); }
/* ---- action key: one permanent line under the tabs, so the icon strip below
never has to be guessed at. Lean on a phone (28px, edge to edge), a step
@@ -386,12 +464,18 @@ button { cursor: pointer; }
}
.keyrow .pair svg { width: 14px; height: 14px; flex: none; }
.keyrow .pair span {
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .04em;
text-transform: uppercase;
}
.keyrow .pair.brass svg { color: var(--brass); }
.keyrow .pair.slate svg { color: var(--slate); }
.keyrow .pair.clay svg { color: var(--clay); }
.keyrow .pair.trash svg { color: var(--trash); }
.keyrow .pair.brass span { color: var(--brass); }
.keyrow .pair.slate span { color: var(--slate); }
.keyrow .pair.clay span { color: var(--clay); }
.keyrow .pair.trash span { color: var(--trash); }
/* ---- continue reading ---- */
.recent {
@@ -404,7 +488,7 @@ button { cursor: pointer; }
.recent h2 {
margin: 0;
padding: 0 20px;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .2em;
text-transform: uppercase;
color: var(--mute-2);
@@ -416,6 +500,10 @@ button { cursor: pointer; }
overflow-y: hidden;
scrollbar-width: none;
padding: 0 20px 4px;
scroll-snap-type: x proximity;
/* Static right-edge fade — newest card at 93px+GAP is otherwise invisible at 360px. */
-webkit-mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
mask-image: linear-gradient(to right, black calc(100% - 24px), transparent);
}
.recent-strip::-webkit-scrollbar { display: none; }
.recent-card {
@@ -424,6 +512,7 @@ button { cursor: pointer; }
display: flex;
flex-direction: column;
gap: 8px;
scroll-snap-align: start;
}
.recent-cover {
position: relative;
@@ -434,6 +523,11 @@ button { cursor: pointer; }
place-items: center;
overflow: hidden;
}
/* Cover and monogram share one grid cell rather than stacking: the monogram is
the fallback for a cover that fails to load, not only for one that was never
acquired, and an image that fails to decode otherwise renders the browser's
broken-image glyph over the hatch. */
.recent-cover > img, .recent-cover > .monogram { grid-area: 1 / 1; }
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
.recent-title {
font: 400 16px/1.25 var(--font-display);
@@ -442,6 +536,7 @@ button { cursor: pointer; }
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
.recent-chapter {
font: 500 11px/1 var(--font-mono);
@@ -450,6 +545,7 @@ button { cursor: pointer; }
}
.recent-card.is-new .recent-title { color: var(--paper-hot); }
.recent-card.is-new .recent-chapter { color: var(--ember); }
.recent-card.is-new .recent-cover { outline: 1px solid color-mix(in oklch, var(--ember) 26%, transparent); outline-offset: -1px; }
/* The rule at the cover foot is the only cover ornament: ember for a new
chapter, brass for a favourite. */
@@ -481,6 +577,11 @@ button { cursor: pointer; }
animation: sheetIn .18s ease-out;
}
.card.is-dim { background: var(--dim); }
.card.is-new {
background: var(--ember-wash);
border-bottom-color: color-mix(in oklch, var(--ember) 14%, var(--rule));
}
.card.is-new .cover { outline: 1px solid color-mix(in oklch, var(--ember) 22%, transparent); outline-offset: -1px; }
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
@@ -494,9 +595,12 @@ button { cursor: pointer; }
place-items: center;
overflow: hidden;
}
.cover > img, .cover > .monogram { grid-area: 1 / 1; }
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 32px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.is-dim .cover { background: var(--hatch-dim); }
.is-dim .cover img { filter: grayscale(1); opacity: .85; }
.is-dim .cover .monogram { color: var(--mute-2); }
.body {
flex: 1;
@@ -514,6 +618,17 @@ button { cursor: pointer; }
min-width: 0;
font: 400 21px/1.2 var(--font-display);
color: var(--paper-dim);
/* Titles come from a third party's og:title. One unbroken 90-character
token measured 789px of document on a 390px viewport — the whole sheet
scrolled sideways — so every slot that prints one breaks mid-word. */
overflow-wrap: anywhere;
/* Three lines, so one scraped 90-character title cannot set the rhythm of
the whole list. Deeper than the strip's two: this is where the title is
the primary identifier, not a thumbnail caption. */
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
.is-new .title {
@@ -522,6 +637,9 @@ button { cursor: pointer; }
color: var(--paper-hot);
border-bottom: 1px solid var(--ember);
padding-bottom: 3px;
/* -webkit-box fills its line box, which would stretch the ember hairline to
the full row; the underline is sized to the text. */
width: fit-content;
}
.is-dim .title { font-style: italic; color: var(--mute); }
.is-dim .fav-mark { color: var(--mute-2); }
@@ -565,7 +683,7 @@ button { cursor: pointer; }
}
.libswitch a {
padding: 7px 13px;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .12em;
text-transform: uppercase;
color: var(--mute);
@@ -615,23 +733,34 @@ button { cursor: pointer; }
.actions > *:hover { color: var(--paper); }
/* Per-action accent on hover and press: gold favourite, slate archive, clay
chapter. Remove keeps --danger, play keeps paper/ember. */
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); }
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); }
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); }
.actions .fav:hover, .actions .fav:active, .actions .fav:focus-visible { color: var(--brass); background: var(--brass-wash); }
.actions .fav:focus-visible { outline: 2px solid var(--brass); outline-offset: -2px; }
.actions .pencil:hover, .actions .pencil:active, .actions .pencil:focus-visible { color: var(--clay); background: var(--clay-wash); }
.actions .pencil:focus-visible { outline: 2px solid var(--clay); outline-offset: -2px; }
.actions .box:hover, .actions .box:active, .actions .box:focus-visible { color: var(--slate); background: var(--slate-wash); }
.actions .box:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
.actions .play { color: var(--paper); }
.is-new .actions .play { color: var(--ember); }
.actions .play:hover { background: var(--hover); }
.actions .on { color: var(--brass); }
.is-new .actions .play:hover { background: var(--ember-wash); color: var(--ember); }
.actions .play:focus-visible { background: var(--hover); outline: 2px solid var(--paper); outline-offset: -2px; }
.is-new .actions .play:focus-visible { background: var(--ember-wash); color: var(--ember); outline-color: var(--ember); }
.actions .on { color: var(--brass); background: var(--brass-wash); }
.actions .restore { color: var(--paper); }
.actions .remove { color: var(--trash); }
.actions .remove:hover { color: var(--danger); }
.actions .restore:hover, .actions .restore:active, .actions .restore:focus-visible { color: var(--slate); background: var(--slate-wash); }
.actions .restore:focus-visible { outline: 2px solid var(--slate); outline-offset: -2px; }
.actions .remove { color: var(--trash); box-shadow: inset 1px 0 0 var(--danger-line); }
.actions .remove:hover, .actions .remove:active, .actions .remove:focus-visible { color: var(--danger); background: var(--danger-wash); }
.actions .remove:focus-visible { outline: 2px solid var(--danger); outline-offset: -2px; }
.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 .lifecycle.open.box, .actions .lifecycle.open.box:hover { background: var(--slate-wash); color: var(--slate); }
.actions .remove.open { background: var(--danger-wash); color: var(--danger); }
.is-dim .actions > * { color: var(--mute-2); }
.is-dim .actions > *:hover { background: transparent; color: var(--mute); }
/* Two clusters by consequence: navigate (play) | organize (favourite,
chapter) | lifecycle (archive/restore, remove). The lifecycle cells
@@ -654,7 +783,7 @@ button { cursor: pointer; }
}
.chapter-form .hint {
margin: 0;
font: 500 10px/1 var(--font-mono);
font: 500 11px/1 var(--font-mono);
letter-spacing: .14em;
text-transform: uppercase;
color: var(--mute-2);
@@ -671,9 +800,10 @@ button { cursor: pointer; }
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); }
/* Clay carries the chapter action -- paper flashes on a dimmed OLED and ember
reads as "invalid" on a valid number. */
.chapter-form input:focus { border-color: var(--clay); }
.chapter-form input:focus-visible { border-color: var(--clay); outline: 2px solid var(--clay); outline-offset: 2px; }
.chapter-form input::-webkit-outer-spin-button,
.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-form button {
@@ -684,6 +814,7 @@ button { cursor: pointer; }
color: var(--ink);
font: 400 17px var(--font-display);
}
.chapter-form button:focus-visible { outline: 2px solid var(--clay); outline-offset: 2px; }
.confirm-row {
display: flex;
@@ -696,23 +827,25 @@ button { cursor: pointer; }
}
/* 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 span { flex: 1 1 16ch; font: 400 17px/1.3 var(--font-display); color: var(--danger-soft); overflow-wrap: anywhere; }
.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);
background: var(--ash);
color: var(--paper-dim);
font: 500 12px var(--font-body);
}
.confirm-row button:hover { color: var(--paper); }
.confirm-row button:hover { color: var(--paper); border-color: var(--paper); }
.confirm-row button:focus-visible { color: var(--paper); border-color: var(--paper); outline: 2px solid var(--paper); outline-offset: 2px; }
.confirm-row .danger-solid {
border: none;
background: var(--danger);
color: var(--danger-ink);
font-weight: 600;
}
.confirm-row .danger-solid:focus-visible { outline-color: var(--danger); }
/* Archive is reversible, so its confirm asks in grey — only the
irreversible remove gets the danger wash. */
.confirm-row.calm { background: var(--ash); }
@@ -723,16 +856,31 @@ button { cursor: pointer; }
color: var(--ink);
font-weight: 600;
}
.confirm-row .go:hover { color: var(--ink); }
.confirm-row .go:hover, .confirm-row .go:focus-visible { color: var(--ink); }
/* The same notice in two positions: .error-inline belongs to one card and
reports a write that failed, .notice sits under the chrome and reports the
requests that have no card to belong to — a tab switch, a rotation. */
.error-inline {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 11px 13px;
background: var(--danger-wash);
border-left: 2px solid var(--danger);
font: 400 16px var(--font-display);
color: var(--danger-soft);
}
.notice {
display: flex;
align-items: center;
gap: 9px;
margin: 0;
padding: 11px 20px;
background: var(--ash);
border-left: 2px solid var(--mute);
border-left: 2px solid var(--patina);
border-bottom: 1px solid var(--rule);
font: 400 16px var(--font-display);
color: var(--paper-dim);
}
@@ -742,13 +890,22 @@ button { cursor: pointer; }
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mute);
background: var(--danger);
}
.notice::before {
content: "";
flex: none;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--patina);
animation: mutePulse 1.4s ease-in-out infinite;
}
.error-inline .error-link {
.error-inline .error-link, .notice .error-link {
border-bottom: 1px solid var(--field-line);
color: var(--paper);
}
.error-inline .error-link { border-bottom-color: color-mix(in oklch, var(--danger) 26%, transparent); color: var(--danger-soft); }
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
ember, which on a list screen only ever means "new chapter". */
@@ -763,6 +920,22 @@ button { cursor: pointer; }
background: var(--mute);
animation: barSlide 1.15s linear infinite;
}
/* Past about two seconds the bar alone stops reading as "working" and starts
reading as "ignored" — and htmx's own timeout is 15s. Delayed rather than
immediate: every response on a live connection lands well inside it. */
@keyframes busyWord { to { opacity: 1 } }
.card.htmx-request::after {
content: "Saving…";
position: absolute;
top: 6px;
right: 20px;
font: 500 11px/1 var(--font-mono);
letter-spacing: .1em;
text-transform: uppercase;
color: var(--mute);
opacity: 0;
animation: busyWord 0s 2s forwards;
}
/* ---- empty ---- */
.empty {
@@ -771,7 +944,7 @@ button { cursor: pointer; }
gap: 7px;
padding: 40px 20px 48px;
}
.empty strong { font: 400 20px var(--font-display); color: var(--paper); }
.empty strong { font: 400 20px var(--font-display); color: var(--paper); overflow-wrap: anywhere; }
.empty .clear-search {
align-self: flex-start;
margin-top: 4px;
@@ -795,21 +968,26 @@ button { cursor: pointer; }
.login-card {
width: 100%;
max-width: 420px;
min-height: 100vh;
min-height: calc(100vh / var(--zoom));
min-height: calc(100dvh / var(--zoom));
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 52px 28px 40px;
}
.login-card .eyebrow {
font: 500 10px/1 var(--font-mono);
font: 500 11px/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);
/* The wordmark is the widest unbreakable run in the app; at 44px it is
wider than a 360px phone, and the page has no other content to scroll
to, so the overflow reads as a broken page. Fluid to the measured fit
(30px clears a 320px viewport), locked at the design size from 520px up. */
font: 400 clamp(30px, 8.5vw, 44px)/1 var(--font-display);
letter-spacing: -.01em;
color: var(--paper);
}
@@ -860,7 +1038,7 @@ button { cursor: pointer; }
keeps every proportion — hairlines, cover ratios, hit targets —
intact. ---- */
@media (min-width: 1280px) {
:root { zoom: 1.2; }
:root { --zoom: 1.2; zoom: var(--zoom); }
}
/* ---- desktop: same measure, actions fold up beside the row ---- */
@@ -880,6 +1058,7 @@ button { cursor: pointer; }
}
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
.tabs a { padding: 10px 0 14px; font-size: 18px; }
.tabs, .recent-strip { -webkit-mask-image: none; mask-image: none; }
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
.keyrow {
@@ -891,8 +1070,7 @@ button { cursor: pointer; }
}
.keyrow .pair { flex-direction: row; gap: 7px; }
.keyrow .pair svg { width: 14px; height: 14px; }
.keyrow .pair span { font-size: 11px; letter-spacing: .1em; }
.keyrow .full { display: inline; }
.keyrow .pair span { letter-spacing: .1em; }
.recent-card, .recent-cover { width: var(--cover-w); }
.recent-cover { height: calc(var(--cover-w) * 4 / 3); }
.recent-title { font-size: 17px; }
@@ -913,6 +1091,7 @@ button { cursor: pointer; }
than by the hairline the phone layout uses. */
.actions > .play + *,
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
.actions .remove { box-shadow: none; border-left-color: var(--danger-line); }
.is-new .actions .play { border-color: var(--play-hot-line); }
.actions .on { border-color: var(--fav-line); }
/* The cell border follows the icon on hover, so the accent reads as a state
@@ -922,6 +1101,19 @@ button { cursor: pointer; }
.chapter-form, .confirm-row, .error-inline {
margin-left: calc(var(--cover-w) + var(--row-gap));
}
/* Not the panel indent: the notice is chrome, so it keeps the sheet gutter
every other full-width row uses. */
.notice { padding-left: 32px; padding-right: 32px; }
}
/* Every other control on this sheet is already 44 or borrows a .ghost::after
to get there; the library switch is the one sized by its own padding in both
layouts, and padding plus an 11px line lands at 43. min-height states the
target instead of arithmetic on the font. Gated on the pointer rather than
the width — a touch laptop lands on the desktop layout with the same
thumb. */
@media (pointer: coarse) {
.libswitch a { min-height: 44px; display: grid; place-items: center; }
}
@media (prefers-reduced-motion: reduce) {
@@ -929,4 +1121,7 @@ button { cursor: pointer; }
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; }
/* The busy word is revealed by a zero-duration delayed animation, which the
rule above cancels — show it from the start instead. */
.card.htmx-request::after { opacity: 1; }
}