3c935ba7c3
The card action strip is icon-only, so nothing on screen said what the six
glyphs do. This adds a permanent key line under the tabs naming each one.
The key is tab-shaped, not row-shaped: archived and finished swap Archive
for Restore, and finished drops Done — the same conditions card.html already
uses for the buttons. It rides along in writeChromeOOB, otherwise an htmx
tab switch would leave the previous bucket's key behind.
Also from the Claude Design pass:
• per-action accent on hover/press — slate archive, moss finished, clay
chapter — so a press says which lane it belongs to; ember stays reserved for
new-chapter heat
• cover 74→80px desktop / 86→93px phone, title 19→21px, meta 10→11px
• favourite mark pinned to the right edge of the measure instead of trailing
the title, which drifted whenever a new-chapter title shrink-wrapped
• recent strip sizes now derive from --cover-w instead of repeating magic
numbers
### Brand mark
The design project grew a logo, so it lands in three places with three
different colour sources:
• `backend/static/logo.svg` — fixed palette, because a favicon has no page to
inherit from. Linked as `rel="icon"` from both the app and the login page.
• `{{define "mark"}}` in chrome.html — takes `--ink`/`--ember`/currentColor, so
the header mark flips with the light/dark theme. Used by app.html and
login.html.
• the userscript panel header — same drawing inside the shadow root, next to
the tokens it uses.
`.brand` becomes a flex row in both stylesheets and the topbar aligns centre
rather than baseline. The 5px stroke on the 200-unit grid thins out at brand
size, so it is nudged to 6.5 instead of scaled blindly. No mark on the edge
tab: a 7px sliver has nowhere to put one.
go test ./... passes (static-asset test now covers logo.svg), node --test on
the userscript logic suite passes 14/14. Verified live at 412px and desktop:
key renders, per-tab variant correct on /?tab=finished (play/star/pencil/undo/
trash, no Done), and the OOB swap fires on /ui/list.
Reviewed-on: #12
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
818 lines
24 KiB
CSS
818 lines
24 KiB
CSS
/* Fonts are served from this binary, not from Google: this UI is read in
|
||
Bromite, where fonts.googleapis.com is routinely blocked, and over a LAN
|
||
with no internet route. Latin subsets only, woff2 only — every browser that
|
||
runs the app supports it, so there is no second format to ship. Roughly
|
||
120 KB for all five faces, embedded via //go:embed static. */
|
||
@font-face {
|
||
font-family: "Instrument Serif";
|
||
font-style: normal;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url(/static/fonts/instrument-serif-400-latin.woff2) format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "Instrument Serif";
|
||
font-style: italic;
|
||
font-weight: 400;
|
||
font-display: swap;
|
||
src: url(/static/fonts/instrument-serif-400-italic-latin.woff2) format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "IBM Plex Mono";
|
||
font-style: normal;
|
||
font-weight: 500;
|
||
font-display: swap;
|
||
src: url(/static/fonts/ibm-plex-mono-500-latin.woff2) format("woff2");
|
||
}
|
||
@font-face {
|
||
font-family: "IBM Plex Mono";
|
||
font-style: normal;
|
||
font-weight: 600;
|
||
font-display: swap;
|
||
src: url(/static/fonts/ibm-plex-mono-600-latin.woff2) format("woff2");
|
||
}
|
||
/* One variable file covers the 400–700 range the design uses. */
|
||
@font-face {
|
||
font-family: "DM Sans";
|
||
font-style: normal;
|
||
font-weight: 400 700;
|
||
font-display: swap;
|
||
src: url(/static/fonts/dm-sans-var-latin.woff2) format("woff2");
|
||
}
|
||
|
||
/* Cinder — editorial serif, containerless sheets, ash hairlines.
|
||
Heat is purely typographic: a series with an unread chapter turns its title
|
||
crimson and gains an ember underline. Everything else stays cool. Dark is
|
||
the default because this is read at night on a phone; the light branch
|
||
re-tunes the same tokens onto paper. */
|
||
:root {
|
||
color-scheme: dark light;
|
||
|
||
--font-display: "Instrument Serif", Georgia, "Times New Roman", serif;
|
||
--font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||
--font-body: "DM Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
|
||
|
||
--ink: #100f0e; /* page */
|
||
--ash: #161413; /* recessed panel (chapter form) */
|
||
--dim: #0d0c0b; /* archived / finished rows sink */
|
||
--rule: #221f1d; /* hairline between sheets */
|
||
--rule-soft: #1a1817; /* measure edges */
|
||
--field-line: #2c2926;
|
||
--hover: #1a1816;
|
||
|
||
--paper: #f2ece5; /* highest-contrast text, primary button */
|
||
--paper-hot: #f0d3cb; /* title of a series with a new chapter */
|
||
--paper-dim: #ddd5cb; /* resting title */
|
||
--mute: #8d857c; /* secondary text, idle icons */
|
||
/* Every mute-2 use is 10px mono, so it has to clear 4.5:1 on --ink even
|
||
though it reads as the quietest step. */
|
||
--mute-2: #877f76; /* labels, hints */
|
||
--faint: #3a3733; /* meta separators */
|
||
--faint-2: #57504b; /* cover monogram */
|
||
|
||
--ember: #e0452c; /* the only heat */
|
||
--ember-wash: #1a1211; /* ember-tinted surface */
|
||
--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
|
||
never mistaken across the room for an unread chapter. */
|
||
--danger: #cf5c4d;
|
||
--danger-wash: #211311;
|
||
--danger-ink: #150808;
|
||
--danger-soft: #e2aaa1;
|
||
--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 */
|
||
|
||
/* Desktop cell borders for the two coloured action states. */
|
||
--play-hot-line: #3a1d18;
|
||
--fav-line: #332b14;
|
||
|
||
--asura: #7d93a5;
|
||
--demonic: #a98a78;
|
||
|
||
/* Covers are often missing; the hatch keeps the slot honest instead of
|
||
faking artwork. */
|
||
--hatch: repeating-linear-gradient(135deg, #211d1b 0 5px, #191614 5px 10px);
|
||
--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: 93px;
|
||
--row-gap: 14px;
|
||
}
|
||
|
||
/* Light mode: same rules, cooler paper. Hues are re-tuned, not reused — the
|
||
dark ember is too bright to read as text on off-white. */
|
||
@media (prefers-color-scheme: light) {
|
||
:root {
|
||
--ink: #f7f4ef;
|
||
--ash: #efeae3;
|
||
--dim: #f1ede7;
|
||
--rule: #e0dad2;
|
||
--rule-soft: #e8e3dc;
|
||
--field-line: #d4cdc4;
|
||
--hover: #efeae3;
|
||
|
||
--paper: #191715;
|
||
--paper-hot: #a33018;
|
||
--paper-dim: #191715;
|
||
--mute: #6b645d;
|
||
--mute-2: #6c655e; /* clears 4.5:1 on --ash too, not just --ink */
|
||
--faint: #c9c2ba;
|
||
--faint-2: #a8a098;
|
||
|
||
--ember: #c23a22;
|
||
--ember-wash: #fbeee9;
|
||
--ember-ink: #fff;
|
||
--ember-soft: #8d2c17;
|
||
--danger: #97362a;
|
||
--danger-wash: #fbe9e5;
|
||
--danger-ink: #fff;
|
||
--danger-soft: #7c2c22;
|
||
--brass: #8a681c;
|
||
--slate: #3f6689;
|
||
--moss: #3d6c46;
|
||
--clay: #7c5533;
|
||
--trash: #8c6558;
|
||
|
||
--play-hot-line: #f0cfc6;
|
||
--fav-line: #e3d3a4;
|
||
|
||
--asura: #4f6b80;
|
||
--demonic: #8a6a55;
|
||
|
||
--hatch: repeating-linear-gradient(135deg, #e6e0d8 0 5px, #efeae3 5px 10px);
|
||
--hatch-dim: repeating-linear-gradient(135deg, #ebe6de 0 5px, #f2eee8 5px 10px);
|
||
}
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
|
||
body {
|
||
margin: 0;
|
||
background: var(--ink);
|
||
color: var(--paper-dim);
|
||
font: 400 15px/1.5 var(--font-body);
|
||
display: flex;
|
||
justify-content: center;
|
||
-webkit-text-size-adjust: 100%;
|
||
}
|
||
|
||
a { color: inherit; text-decoration: none; }
|
||
button, input { font: inherit; }
|
||
button { cursor: pointer; }
|
||
|
||
/* Every hideable thing here is a flex container, and display beats hidden. */
|
||
[hidden] { display: none !important; }
|
||
|
||
/* Ends flush with the right edge of the card (30% wide × 233% travel), so the
|
||
card itself never needs overflow: hidden to contain it. */
|
||
@keyframes barSlide { from { transform: translateX(-100%) } to { transform: translateX(233%) } }
|
||
@keyframes sheetIn { from { opacity: 0; transform: translateY(-4px) } to { opacity: 1; transform: none } }
|
||
/* The law: ember means "new chapter", and nothing else on a list screen —
|
||
busy, error and destruction all stay off it, so a tired glance never
|
||
misreads a system state as unread heat. The one exception is the brand
|
||
itself (the wordmark, and the login screen, which shows no series at all).
|
||
Destruction has its own token, --danger. */
|
||
@keyframes mutePulse { 0%, 100% { opacity: .35 } 50% { opacity: 1 } }
|
||
|
||
/* One measured column, edges drawn rather than boxed. */
|
||
.sheet {
|
||
width: 100%;
|
||
max-width: var(--measure);
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-left: 1px solid var(--rule-soft);
|
||
border-right: 1px solid var(--rule-soft);
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
|
||
/* ---- brand + chrome ---- */
|
||
.brand {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font: 400 26px/1 var(--font-display);
|
||
color: var(--paper);
|
||
}
|
||
.brand em { color: var(--ember); font-style: italic; }
|
||
.brand .mark { width: 30px; height: 26px; flex: none; overflow: visible; }
|
||
/* The line art is drawn at a 5px stroke on a 200-unit grid; at brand size that
|
||
thins out, so it is nudged up rather than scaled down blindly. */
|
||
.brand .mark g { stroke-width: 6.5; }
|
||
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
padding: 22px 20px 14px;
|
||
}
|
||
.topbar form { margin: 0; }
|
||
|
||
.ghost {
|
||
position: relative;
|
||
padding: 0;
|
||
border: none;
|
||
border-bottom: 1px solid var(--field-line);
|
||
background: transparent;
|
||
color: var(--mute);
|
||
font: 500 11px var(--font-mono);
|
||
letter-spacing: .1em;
|
||
text-transform: uppercase;
|
||
}
|
||
.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; }
|
||
|
||
.chrome { display: flex; flex-direction: column; }
|
||
|
||
.searchbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
height: 44px;
|
||
margin: 0 20px 14px;
|
||
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. */
|
||
.searchbar:focus-within { border-bottom-color: var(--paper); color: var(--paper); }
|
||
.searchbar svg { width: 15px; height: 15px; flex: none; }
|
||
.search {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 44px;
|
||
border: none;
|
||
background: transparent;
|
||
color: var(--paper);
|
||
font: 400 15px var(--font-body);
|
||
outline: none;
|
||
}
|
||
.search::placeholder { color: var(--mute-2); }
|
||
|
||
/* Tabs are set in the display serif and underlined, not chipped. */
|
||
.tabs {
|
||
display: flex;
|
||
gap: 16px;
|
||
padding: 2px 20px 0;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scrollbar-width: none;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
.tabs::-webkit-scrollbar { display: none; }
|
||
.tabs a {
|
||
flex: none;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 7px;
|
||
/* "All" is two characters in a 17px serif — 15px of target without this. */
|
||
min-width: 44px;
|
||
padding: 8px 0 12px;
|
||
color: var(--mute);
|
||
font: 400 17px var(--font-display);
|
||
white-space: nowrap;
|
||
}
|
||
.tabs a:hover { color: var(--paper-dim); }
|
||
.tabs a.active {
|
||
color: var(--paper);
|
||
border-bottom: 2px solid var(--paper);
|
||
margin-bottom: -1px;
|
||
}
|
||
/* Updated is the one tab that carries heat. */
|
||
.tabs .tab-new { color: var(--ember); }
|
||
.tabs .tab-new.active { border-bottom-color: var(--ember); }
|
||
.count {
|
||
font: 600 10px var(--font-mono);
|
||
letter-spacing: 0;
|
||
padding: 2px 5px;
|
||
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 ---- */
|
||
.recent {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
padding: 16px 0 18px;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
.recent h2 {
|
||
margin: 0;
|
||
padding: 0 20px;
|
||
font: 500 10px/1 var(--font-mono);
|
||
letter-spacing: .2em;
|
||
text-transform: uppercase;
|
||
color: var(--mute-2);
|
||
}
|
||
.recent-strip {
|
||
display: flex;
|
||
gap: 14px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scrollbar-width: none;
|
||
padding: 0 20px 4px;
|
||
}
|
||
.recent-strip::-webkit-scrollbar { display: none; }
|
||
.recent-card {
|
||
width: var(--cover-w);
|
||
flex: none;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
}
|
||
.recent-cover {
|
||
position: relative;
|
||
width: var(--cover-w);
|
||
height: calc(var(--cover-w) * 4 / 3);
|
||
background: var(--hatch);
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
}
|
||
.recent-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||
.recent-title {
|
||
font: 400 16px/1.25 var(--font-display);
|
||
color: var(--paper-dim);
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
.recent-chapter {
|
||
font: 500 11px/1 var(--font-mono);
|
||
letter-spacing: .1em;
|
||
color: var(--mute-2);
|
||
}
|
||
.recent-card.is-new .recent-title { color: var(--paper-hot); }
|
||
.recent-card.is-new .recent-chapter { color: var(--ember); }
|
||
|
||
/* The rule at the cover foot is the only cover ornament: ember for a new
|
||
chapter, brass for a favourite. */
|
||
.foot-rule {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
height: 3px;
|
||
background: var(--ember);
|
||
}
|
||
.foot-rule.brass { background: var(--brass); }
|
||
|
||
.monogram {
|
||
font: 400 32px var(--font-display);
|
||
color: var(--faint-2);
|
||
}
|
||
|
||
/* ---- rows ---- */
|
||
.list { display: flex; flex-direction: column; }
|
||
|
||
.card {
|
||
position: relative;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding: 18px 20px;
|
||
border-bottom: 1px solid var(--rule);
|
||
animation: sheetIn .18s ease-out;
|
||
}
|
||
.card.is-dim { background: var(--dim); }
|
||
|
||
.row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--row-gap); }
|
||
|
||
.cover {
|
||
position: relative;
|
||
width: var(--cover-w);
|
||
height: calc(var(--cover-w) * 4 / 3);
|
||
flex: none;
|
||
background: var(--hatch);
|
||
display: grid;
|
||
place-items: center;
|
||
overflow: hidden;
|
||
}
|
||
.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; }
|
||
|
||
.body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.title-line { display: flex; gap: 8px; align-items: flex-start; }
|
||
.title {
|
||
margin: 0;
|
||
flex: 1;
|
||
min-width: 0;
|
||
font: 400 21px/1.2 var(--font-display);
|
||
color: var(--paper-dim);
|
||
}
|
||
/* Heat: crimson title over an ember hairline sized to the text, not the row. */
|
||
.is-new .title {
|
||
flex: 0 1 auto;
|
||
align-self: flex-start;
|
||
color: var(--paper-hot);
|
||
border-bottom: 1px solid var(--ember);
|
||
padding-bottom: 3px;
|
||
}
|
||
.is-dim .title { font-style: italic; color: var(--mute); }
|
||
.is-dim .fav-mark { color: var(--mute-2); }
|
||
/* 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 {
|
||
margin: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
flex-wrap: wrap;
|
||
font: 500 11px/1 var(--font-mono);
|
||
letter-spacing: .12em;
|
||
text-transform: uppercase;
|
||
color: var(--mute);
|
||
}
|
||
.meta .sep { color: var(--faint); }
|
||
.site-asura { color: var(--asura); }
|
||
.site-demonic { color: var(--demonic); }
|
||
.new-chapter { color: var(--ember); }
|
||
.state { display: flex; align-items: center; gap: 4px; color: var(--mute); }
|
||
.state svg { width: 10px; height: 10px; }
|
||
.is-dim .meta { color: var(--mute-2); }
|
||
.is-dim .site-asura, .is-dim .site-demonic { color: var(--mute); filter: grayscale(.6); }
|
||
|
||
/* ---- action strip: full-width on a phone, hairline-divided cells ---- */
|
||
.actions {
|
||
flex: 1 0 100%;
|
||
display: flex;
|
||
border-top: 1px solid var(--rule);
|
||
}
|
||
.actions > * {
|
||
flex: 1 1 0;
|
||
min-width: 0;
|
||
height: 46px;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 0;
|
||
border: none;
|
||
border-right: 1px solid var(--rule);
|
||
background: transparent;
|
||
color: var(--mute);
|
||
}
|
||
.actions > *:last-child { border-right: none; }
|
||
.actions svg { width: 17px; height: 17px; }
|
||
.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); }
|
||
.is-new .actions .play { color: var(--ember); }
|
||
.actions .play:hover { background: var(--hover); }
|
||
.actions .on { color: var(--brass); }
|
||
.actions .restore { color: var(--paper); }
|
||
.actions .remove { color: var(--trash); }
|
||
.actions .remove:hover { color: var(--danger); }
|
||
.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(--danger-wash); color: var(--danger); }
|
||
.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; }
|
||
|
||
.chapter-form {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin: 0;
|
||
padding: 14px;
|
||
background: var(--ash);
|
||
}
|
||
.chapter-form .hint {
|
||
margin: 0;
|
||
font: 500 10px/1 var(--font-mono);
|
||
letter-spacing: .14em;
|
||
text-transform: uppercase;
|
||
color: var(--mute-2);
|
||
}
|
||
.chapter-form .field { display: flex; gap: 10px; }
|
||
.chapter-form input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
height: 46px;
|
||
padding: 0 12px;
|
||
border: 1px solid var(--field-line);
|
||
background: var(--ink);
|
||
color: var(--paper);
|
||
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); }
|
||
.chapter-form input::-webkit-outer-spin-button,
|
||
.chapter-form input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||
.chapter-form button {
|
||
height: 46px;
|
||
padding: 0 20px;
|
||
border: none;
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
font: 400 17px var(--font-display);
|
||
}
|
||
|
||
.confirm-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
padding: 14px;
|
||
background: var(--danger-wash);
|
||
}
|
||
/* 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 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);
|
||
font: 500 12px var(--font-body);
|
||
}
|
||
.confirm-row button:hover { color: var(--paper); }
|
||
.confirm-row .danger-solid {
|
||
border: none;
|
||
background: var(--danger);
|
||
color: var(--danger-ink);
|
||
font-weight: 600;
|
||
}
|
||
/* Archive and finish are reversible, so their confirm asks in grey — only the
|
||
irreversible remove gets the danger 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;
|
||
align-items: center;
|
||
gap: 9px;
|
||
margin: 0;
|
||
padding: 11px 13px;
|
||
background: var(--ash);
|
||
border-left: 2px solid var(--mute);
|
||
font: 400 16px var(--font-display);
|
||
color: var(--paper-dim);
|
||
}
|
||
.error-inline::before {
|
||
content: "";
|
||
flex: none;
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
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: a grey hairline slides across the top of the sheet — deliberately not
|
||
ember, which on a list screen only ever means "new chapter". */
|
||
.card.htmx-request .actions { pointer-events: none; opacity: .5; }
|
||
.card.htmx-request::before {
|
||
content: "";
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
width: 30%;
|
||
height: 1px;
|
||
background: var(--mute);
|
||
animation: barSlide 1.15s linear infinite;
|
||
}
|
||
|
||
/* ---- empty ---- */
|
||
.empty {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
padding: 40px 20px 48px;
|
||
}
|
||
.empty strong { font: 400 20px var(--font-display); color: var(--paper); }
|
||
.empty .clear-search {
|
||
align-self: flex-start;
|
||
margin-top: 4px;
|
||
height: 44px;
|
||
padding: 0 16px;
|
||
border: 1px solid var(--field-line);
|
||
background: transparent;
|
||
color: var(--paper-dim);
|
||
font: 400 16px var(--font-display);
|
||
}
|
||
.empty .clear-search:hover { color: var(--paper); border-color: var(--paper); }
|
||
.empty p {
|
||
margin: 0;
|
||
max-width: 44ch;
|
||
font: 400 14px/1.6 var(--font-body);
|
||
color: var(--mute);
|
||
text-wrap: pretty;
|
||
}
|
||
|
||
/* ---- login ---- */
|
||
.login-card {
|
||
width: 100%;
|
||
max-width: 420px;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: space-between;
|
||
padding: 52px 28px 40px;
|
||
}
|
||
.login-card .eyebrow {
|
||
font: 500 10px/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);
|
||
letter-spacing: -.01em;
|
||
color: var(--paper);
|
||
}
|
||
.login-card h1 em { color: var(--ember); font-style: italic; }
|
||
.login-card form { display: flex; flex-direction: column; gap: 18px; }
|
||
.login-card label {
|
||
font: 500 10px/1 var(--font-mono);
|
||
letter-spacing: .16em;
|
||
text-transform: uppercase;
|
||
color: var(--mute);
|
||
}
|
||
.login-card input {
|
||
width: 100%;
|
||
height: 54px;
|
||
margin-top: 9px;
|
||
padding: 0 2px;
|
||
border: none;
|
||
border-bottom: 1px solid var(--field-line);
|
||
background: transparent;
|
||
color: var(--paper);
|
||
font: 500 20px var(--font-mono);
|
||
letter-spacing: .16em;
|
||
outline: none;
|
||
}
|
||
.login-card input:focus { border-bottom-color: var(--paper); }
|
||
.login-card .error {
|
||
margin: 0;
|
||
min-height: 20px;
|
||
font: 400 13px/1.4 var(--font-body);
|
||
color: var(--danger);
|
||
}
|
||
.login-card button {
|
||
height: 54px;
|
||
border: 1px solid var(--paper);
|
||
background: var(--paper);
|
||
color: var(--ink);
|
||
font: 400 19px var(--font-display);
|
||
}
|
||
.login-card button:hover {
|
||
background: var(--ember);
|
||
border-color: var(--ember);
|
||
color: #fff;
|
||
}
|
||
|
||
/* ---- desktop: same measure, actions fold up beside the row ---- */
|
||
@media (min-width: 720px) {
|
||
:root { --cover-w: 80px; --row-gap: 20px; }
|
||
.topbar { padding: 26px 32px 18px; }
|
||
.brand { font-size: 30px; }
|
||
.brand .mark { width: 35px; height: 30px; }
|
||
|
||
.chrome {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 26px;
|
||
padding: 0 32px;
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
.tabs { order: 1; flex: none; gap: 20px; padding: 0; border-bottom: none; }
|
||
.tabs a { padding: 10px 0 14px; font-size: 18px; }
|
||
.searchbar { order: 2; flex: 1; margin: 0; border-bottom: none; }
|
||
.recent h2, .recent-strip { padding-left: 32px; padding-right: 32px; }
|
||
.keyrow {
|
||
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; }
|
||
.row { flex-wrap: nowrap; align-items: center; }
|
||
.cover .monogram { font-size: 28px; }
|
||
.title { font-size: 22px; }
|
||
|
||
.actions { flex: none; gap: 4px; border-top: none; }
|
||
.actions > * {
|
||
flex: none;
|
||
width: 44px;
|
||
height: 44px;
|
||
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: 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
|
||
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. */
|
||
.chapter-form, .confirm-row, .error-inline {
|
||
margin-left: calc(var(--cover-w) + var(--row-gap));
|
||
}
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
/* Pseudo-elements need naming explicitly — `*` does not match them, and the
|
||
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; }
|
||
}
|