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