Confirm-gate lifecycle actions, cluster the action strip

Fixes the 2026-07-30 impeccable critique of the web UI.

Archive and finish now open their own .confirm-row disclosure, the
pattern delete already used, so no single tap moves a series out of the
list. Restore stays instant — it is the reversal. The two reversible
confirms wear grey; remove keeps the ember wash.

The six action cells cluster by consequence: navigate (play) | organize
(favourite, chapter) | lifecycle (archive/restore, finish, remove). The
lifecycle cells sit on a recessed ground, separated by a hairline on a
phone and by a gap on desktop, so the 4th-slot icon swapping between
.box and .restore no longer breaks position memory.

htmx:responseError branches on status: 401 offers a login link, 400
surfaces the server's own reason, everything else keeps the retry copy.

Busy bar and inline error move to --mute, leaving --ember exclusive to
the new-chapter signal.

Also: aria-live on the confirm rows, max on the chapter input, cover
width and row gap as tokens instead of a hardcoded 94px indent, and
mobile covers 30% larger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:28:31 +07:00
parent ac3ee9b298
commit 8876e5e364
4 changed files with 172 additions and 64 deletions
+55 -15
View File
@@ -84,6 +84,10 @@
--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
@@ -142,7 +146,9 @@ button { cursor: pointer; }
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(320%) } }
@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } }
@keyframes emberPulse { 0%, 100% { opacity: .5 } 50% { opacity: 1 } }
/* Transient system states pulse in grey: ember stays reserved for "new
chapter" so a tired glance never misreads a busy bar as unread heat. */
@keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
/* One measured column, edges drawn rather than boxed. */
.sheet {
@@ -335,12 +341,12 @@ button { cursor: pointer; }
}
.card.is-dim { background: var(--dim); }
.row { display: flex; flex-wrap: wrap; gap: 14px; }
.row { display: flex; flex-wrap: wrap; gap: var(--row-gap); }
.cover {
position: relative;
width: 66px;
height: 88px;
width: var(--cover-w);
height: calc(var(--cover-w) * 4 / 3);
flex: none;
background: var(--hatch);
display: grid;
@@ -348,7 +354,7 @@ button { cursor: pointer; }
overflow: hidden;
}
.cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 24px; }
.cover .monogram { font-size: 30px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.body {
@@ -428,9 +434,20 @@ button { cursor: pointer; }
.actions .remove { color: var(--trash); }
.actions .remove:hover { color: var(--ember); }
.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(--ember-wash); color: var(--ember); }
.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; }
@@ -498,6 +515,17 @@ button { cursor: pointer; }
color: var(--ember-ink);
font-weight: 600;
}
/* Archive and finish are reversible, so their confirm asks in grey — only the
irreversible remove gets the ember 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;
@@ -505,10 +533,10 @@ button { cursor: pointer; }
gap: 9px;
margin: 0;
padding: 11px 13px;
background: var(--ember-wash);
border-left: 2px solid var(--ember);
background: var(--ash);
border-left: 2px solid var(--mute);
font: 400 16px var(--font-display);
color: var(--ember-soft);
color: var(--paper-dim);
}
.error-inline::before {
content: "";
@@ -516,11 +544,16 @@ button { cursor: pointer; }
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--ember);
animation: emberPulse 1.4s ease-in-out infinite;
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: the hairline at the top of the sheet burns across it. */
/* Busy: a grey hairline slides across the top of the sheet — deliberately not
ember, which only ever means "new chapter". */
.card.htmx-request { overflow: hidden; }
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
.card.htmx-request::before {
@@ -530,7 +563,7 @@ button { cursor: pointer; }
left: 0;
width: 30%;
height: 1px;
background: var(--ember);
background: var(--mute);
animation: barSlide 1.15s linear infinite;
}
@@ -616,6 +649,7 @@ button { cursor: pointer; }
/* ---- 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; }
@@ -634,8 +668,7 @@ button { cursor: pointer; }
.recent-cover { height: 133px; }
.card { padding: 18px 32px; }
.row { flex-wrap: nowrap; align-items: center; gap: 20px; }
.cover { width: 74px; height: 100px; }
.row { flex-wrap: nowrap; align-items: center; }
.cover .monogram { font-size: 26px; }
.title { font-size: 21px; }
@@ -646,10 +679,17 @@ button { cursor: pointer; }
height: 40px;
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: #3a1d18; }
.actions .on { border-color: #332b14; }
.chapter-form, .confirm-row, .error-inline { margin-left: 94px; }
/* 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) {