feat(web): permanent action key under the tabs

The card action strip is icon-only, so nothing on screen said what the
six glyphs do. Add a keyrow line under the tabs that names each one.

The key follows the tab, not the row: archived and finished swap Archive
for Restore, and finished drops Done — same conditions card.html already
uses for the buttons themselves. It therefore rides along in
writeChromeOOB, or a tab switch would leave the previous bucket's key.

Also from the Claude Design pass: per-action accent colours on hover and
press (slate archive, moss finished, clay chapter) so a press says which
lane it belongs to, a slightly larger cover and title, and the favourite
mark pinned to the right edge of the measure instead of trailing the
title, which drifted whenever a new-chapter title shrink-wrapped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 03:18:54 +07:00
parent f3b55fd883
commit 42b93dc155
4 changed files with 113 additions and 26 deletions
+79 -17
View File
@@ -81,6 +81,11 @@
--danger-ink: #150808; --danger-ink: #150808;
--danger-soft: #e2aaa1; --danger-soft: #e2aaa1;
--brass: #b8912f; /* favourite — a second, cooler metal */ --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. */
--slate: #7fa0c0; /* archive */
--moss: #7fae86; /* finished */
--clay: #b5906f; /* set chapter */
--trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */ --trash: #977671; /* remove, resting — icons need 3:1, not 4.5:1 */
/* Desktop cell borders for the two coloured action states. */ /* Desktop cell borders for the two coloured action states. */
@@ -98,7 +103,7 @@
--measure: 760px; --measure: 760px;
/* Cover width + row gap: the disclosure panels indent past the cover on /* Cover width + row gap: the disclosure panels indent past the cover on
desktop, so both live here rather than as magic numbers. */ desktop, so both live here rather than as magic numbers. */
--cover-w: 86px; --cover-w: 93px;
--row-gap: 14px; --row-gap: 14px;
} }
@@ -131,6 +136,9 @@
--danger-ink: #fff; --danger-ink: #fff;
--danger-soft: #7c2c22; --danger-soft: #7c2c22;
--brass: #8a681c; --brass: #8a681c;
--slate: #3f6689;
--moss: #3d6c46;
--clay: #7c5533;
--trash: #8c6558; --trash: #8c6558;
--play-hot-line: #f0cfc6; --play-hot-line: #f0cfc6;
@@ -285,6 +293,35 @@ button { cursor: pointer; }
border: 1px solid currentColor; border: 1px solid currentColor;
} }
/* ---- 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
bigger on desktop where there is room to read it. ---- */
.keyrow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
padding: 9px 18px 10px;
border-bottom: 1px solid var(--rule);
}
/* On a phone each key stacks: icon over word, so the word gets the full cell
width and can stay the long form. */
.keyrow .pair {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--mute);
}
.keyrow .pair svg { width: 14px; height: 14px; flex: none; }
.keyrow .pair span {
font: 500 10px/1 var(--font-mono);
letter-spacing: .04em;
text-transform: uppercase;
}
.keyrow .pair.brass svg { color: var(--brass); }
.keyrow .pair.trash svg { color: var(--trash); }
/* ---- continue reading ---- */ /* ---- continue reading ---- */
.recent { .recent {
display: flex; display: flex;
@@ -311,7 +348,7 @@ button { cursor: pointer; }
} }
.recent-strip::-webkit-scrollbar { display: none; } .recent-strip::-webkit-scrollbar { display: none; }
.recent-card { .recent-card {
width: 92px; width: var(--cover-w);
flex: none; flex: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -319,8 +356,8 @@ button { cursor: pointer; }
} }
.recent-cover { .recent-cover {
position: relative; position: relative;
width: 92px; width: var(--cover-w);
height: 123px; height: calc(var(--cover-w) * 4 / 3);
background: var(--hatch); background: var(--hatch);
display: grid; display: grid;
place-items: center; place-items: center;
@@ -328,7 +365,7 @@ button { cursor: pointer; }
} }
.recent-cover img { width: 100%; height: 100%; object-fit: cover; } .recent-cover img { width: 100%; height: 100%; object-fit: cover; }
.recent-title { .recent-title {
font: 400 15px/1.2 var(--font-display); font: 400 16px/1.25 var(--font-display);
color: var(--paper-dim); color: var(--paper-dim);
display: -webkit-box; display: -webkit-box;
-webkit-line-clamp: 2; -webkit-line-clamp: 2;
@@ -336,7 +373,7 @@ button { cursor: pointer; }
overflow: hidden; overflow: hidden;
} }
.recent-chapter { .recent-chapter {
font: 500 10px/1 var(--font-mono); font: 500 11px/1 var(--font-mono);
letter-spacing: .1em; letter-spacing: .1em;
color: var(--mute-2); color: var(--mute-2);
} }
@@ -374,7 +411,7 @@ button { cursor: pointer; }
} }
.card.is-dim { background: var(--dim); } .card.is-dim { background: var(--dim); }
.row { display: flex; flex-wrap: wrap; gap: var(--row-gap); } .row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
.cover { .cover {
position: relative; position: relative;
@@ -387,7 +424,7 @@ button { cursor: pointer; }
overflow: hidden; overflow: hidden;
} }
.cover img { width: 100%; height: 100%; object-fit: cover; } .cover img { width: 100%; height: 100%; object-fit: cover; }
.cover .monogram { font-size: 30px; } .cover .monogram { font-size: 32px; }
.is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; } .is-dim .cover { background: var(--hatch-dim); filter: grayscale(1); opacity: .85; }
.body { .body {
@@ -395,6 +432,7 @@ button { cursor: pointer; }
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center;
gap: 8px; gap: 8px;
} }
@@ -403,7 +441,7 @@ button { cursor: pointer; }
margin: 0; margin: 0;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font: 400 19px/1.2 var(--font-display); font: 400 21px/1.2 var(--font-display);
color: var(--paper-dim); color: var(--paper-dim);
} }
/* Heat: crimson title over an ember hairline sized to the text, not the row. */ /* Heat: crimson title over an ember hairline sized to the text, not the row. */
@@ -416,7 +454,9 @@ button { cursor: pointer; }
} }
.is-dim .title { font-style: italic; color: var(--mute); } .is-dim .title { font-style: italic; color: var(--mute); }
.is-dim .fav-mark { color: var(--mute-2); } .is-dim .fav-mark { color: var(--mute-2); }
.fav-mark { flex: none; width: 13px; height: 13px; margin-top: 5px; color: var(--brass); } /* The mark always sits at the right edge of the measure, not after the last
word — a new-chapter title shrink-wraps, so without this it drifts. */
.fav-mark { flex: none; margin-left: auto; width: 14px; height: 14px; margin-top: 5px; color: var(--brass); }
.meta { .meta {
margin: 0; margin: 0;
@@ -424,7 +464,7 @@ button { cursor: pointer; }
align-items: center; align-items: center;
gap: 9px; gap: 9px;
flex-wrap: wrap; flex-wrap: wrap;
font: 500 10px/1 var(--font-mono); font: 500 11px/1 var(--font-mono);
letter-spacing: .12em; letter-spacing: .12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--mute); color: var(--mute);
@@ -434,7 +474,7 @@ button { cursor: pointer; }
.site-demonic { color: var(--demonic); } .site-demonic { color: var(--demonic); }
.new-chapter { color: var(--ember); } .new-chapter { color: var(--ember); }
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); } .state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
.state svg { width: 9px; height: 9px; } .state svg { width: 10px; height: 10px; }
.is-dim .meta { color: var(--mute-2); } .is-dim .meta { color: var(--mute-2); }
.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); } .is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); }
@@ -459,6 +499,12 @@ button { cursor: pointer; }
.actions > *:last-child { border-right: none; } .actions > *:last-child { border-right: none; }
.actions svg { width: 17px; height: 17px; } .actions svg { width: 17px; height: 17px; }
.actions > *:hover { color: var(--paper); } .actions > *:hover { color: var(--paper); }
/* Per-action accent on hover and press: gold favourite, slate archive, moss
finished, 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 .finish:hover, .actions .finish:active, .actions .finish:focus-visible { color: var(--moss); }
.actions .play { color: var(--paper); } .actions .play { color: var(--paper); }
.is-new .actions .play { color: var(--ember); } .is-new .actions .play { color: var(--ember); }
.actions .play:hover { background: var(--hover); } .actions .play:hover { background: var(--hover); }
@@ -696,7 +742,7 @@ button { cursor: pointer; }
/* ---- desktop: same measure, actions fold up beside the row ---- */ /* ---- desktop: same measure, actions fold up beside the row ---- */
@media (min-width: 720px) { @media (min-width: 720px) {
:root { --cover-w: 74px; --row-gap: 20px; } :root { --cover-w: 80px; --row-gap: 20px; }
.topbar { padding: 26px 32px 18px; } .topbar { padding: 26px 32px 18px; }
.brand { font-size: 30px; } .brand { font-size: 30px; }
@@ -711,13 +757,25 @@ button { cursor: pointer; }
.tabs a { padding: 10px 0 14px; font-size: 18px; } .tabs a { padding: 10px 0 14px; font-size: 18px; }
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; } .searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; } .recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
.recent-card, .recent-cover { width: 100px; } .keyrow {
.recent-cover { height: 133px; } align-items: center;
justify-content: flex-start;
gap: 26px;
height: 36px;
padding: 0 32px;
}
.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; }
.recent-card, .recent-cover { width: var(--cover-w); }
.recent-cover { height: calc(var(--cover-w) * 4 / 3); }
.recent-title { font-size: 17px; }
.card { padding: 18px 32px; } .card { padding: 18px 32px; }
.row { flex-wrap: nowrap; align-items: center; } .row { flex-wrap: nowrap; align-items: center; }
.cover .monogram { font-size: 26px; } .cover .monogram { font-size: 28px; }
.title { font-size: 21px; } .title { font-size: 22px; }
.actions { flex: none; gap: 4px; border-top: none; } .actions { flex: none; gap: 4px; border-top: none; }
.actions > * { .actions > * {
@@ -732,6 +790,10 @@ button { cursor: pointer; }
.actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; } .actions > *:not(.lifecycle) + .lifecycle { margin-left: 10px; box-shadow: none; }
.is-new .actions .play { border-color: var(--play-hot-line); } .is-new .actions .play { border-color: var(--play-hot-line); }
.actions .on { border-color: var(--fav-line); } .actions .on { border-color: var(--fav-line); }
/* The cell border follows the icon on hover, so the accent reads as a state
rather than a stray colour. */
.actions .fav:hover, .actions .pencil:hover,
.actions .box:hover, .actions .finish:hover { border-color: currentColor; }
/* Panels line up with the body text, i.e. past the cover and its gap. */ /* Panels line up with the body text, i.e. past the cover and its gap. */
.chapter-form, .confirm-row, .error-inline { .chapter-form, .confirm-row, .error-inline {
+2
View File
@@ -61,6 +61,8 @@
</nav> </nav>
</div> </div>
{{template "keyrow" .}}
{{template "recent" .}} {{template "recent" .}}
<main id="list" class="list"> <main id="list" class="list">
+26 -5
View File
@@ -1,9 +1,9 @@
{{/* The two regions that live outside the swapped #list: the "Continue {{/* The regions that live outside the swapped #list: the "Continue reading"
reading" strip and the Updated badge. Both are rendered inline by app.html strip, the Updated badge and the action key. All are rendered inline by
and again, out of band, on every /ui/ response — a mutation must not leave app.html and again, out of band, on every /ui/ response — a mutation must
them describing the library as it was before the tap. not leave them describing the library as it was before the tap.
Both always render, hidden when they have nothing to say, so an out-of-band All always render, hidden when they have nothing to say, so an out-of-band
swap always has an element with the right id to replace. */}} swap always has an element with the right id to replace. */}}
{{define "recent"}} {{define "recent"}}
@@ -27,4 +27,25 @@
</section> </section>
{{end}} {{end}}
{{/* The action key. The icon strip on a card is unlabelled, so one permanent
line under the tabs names every glyph. It follows the tab rather than the
row: the archived and finished buckets swap Archive for Restore, and a
finished series has no Done to offer. */}}
{{define "keyrow"}}
<div class="keyrow" id="keyrow" aria-label="Action key"{{if .OOB}} hx-swap-oob="true"{{end}}>
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-play"/></svg><span>Read</span></span>
<span class="pair brass"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-star"/></svg><span>Fav</span></span>
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-pencil"/></svg><span>Chapter</span></span>
{{if or (eq .Tab "archived") (eq .Tab "finished")}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-undo"/></svg><span>Restore</span></span>
{{else}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-box"/></svg><span>Archive</span></span>
{{end}}
{{if ne .Tab "finished"}}
<span class="pair"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-check"/></svg><span>Done</span></span>
{{end}}
<span class="pair trash"><svg viewBox="0 0 24 24" aria-hidden="true"><use href="#i-trash"/></svg><span>Delete</span></span>
</div>
{{end}}
{{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}} {{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}}
+6 -4
View File
@@ -248,12 +248,14 @@ func currentTab(r *http.Request) string {
return u.Query().Get("tab") return u.Query().Get("tab")
} }
// writeChromeOOB appends the two regions that live outside #list — the recent // writeChromeOOB appends the regions that live outside #list — the recent
// strip and the Updated badge — as out-of-band swaps, so a mutation cannot // strip, the Updated badge and the action key — as out-of-band swaps, so a
// leave them describing the library as it was before the tap. // mutation cannot leave them describing the library as it was before the tap.
// The key is in here because it is tab-shaped too: archived and finished swap
// Archive for Restore.
func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) { func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
view.OOB = true view.OOB = true
for _, name := range []string{"recent", "newcount"} { for _, name := range []string{"recent", "newcount", "keyrow"} {
if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil { if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
// The card is already written; stale chrome beats a torn response. // The card is already written; stale chrome beats a torn response.
log.Printf("render %s oob: %v", name, err) log.Printf("render %s oob: %v", name, err)