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
+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; }
}