6af49e6790
Gives every bookmark a lifecycle bucket — `reading`, `archived`, or `finished` — so on-hold series leave the main list while still being polled for new chapters, completed series get a web-only bucket, and the userscript panel gains quick links to the web UI and both manga sites.
Design: `docs/superpowers/specs/2026-07-27-status-buckets-design.md`
## Data model
One additive column through the existing `addedColumns` migration list:
```sql
ALTER TABLE bookmarks ADD COLUMN status TEXT NOT NULL DEFAULT 'reading'
```
The `DEFAULT` backfills every pre-existing row as `reading`, so there is no separate migration step. `favorite` is unchanged and orthogonal — a series can be an archived favourite.
Rollback is safe: an old binary against the new database omits `status` from its INSERT (it gets the DEFAULT) and never mentions it in the conflict clause, so buckets survive.
## The write rule
`PUT /bookmarks/{key}` decodes a whole `Bookmark` and `Upsert` writes every column it knows about. `latest_checked_at` escaped this by staying out of `bookmarkColumns` entirely — `status` cannot, because the userscript must be able to archive and restore.
So an empty incoming status means **"no opinion"**, not a value, and resolves on the `VALUES` side of the upsert:
```sql
COALESCE(NULLIF(?, ''), (SELECT status FROM bookmarks WHERE key = ?), 'reading')
```
with `DO UPDATE SET status = excluded.status`.
It has to be this way round. `excluded.*` is the row *after* the `VALUES` expressions are evaluated, so applying the default there and then reading `excluded.status` in the conflict clause would see `'reading'` rather than the empty string — and would overwrite an archived row on every progress PUT from a client that knows nothing about the column. One expression, evaluated once, covers insert and update alike. The subquery runs inside the transaction, so it sees the row the statement is about to conflict with.
`TestUpsertEmptyStatusPreservesStored` is the guard on this.
`updated_at` behaviour is unchanged: it moves only when `last_chapter_num` changes, so archiving, finishing, restoring, and favouriting never reorder the list.
## Validation
`PUT /bookmarks/{key}` returns 400 for any status outside `{"", "reading", "archived", "finished"}`, and for `"finished"` specifically. Finishing a series is a web-UI decision, enforced server-side rather than by trusting every client to leave the value alone. The `/ui/*` endpoints have their own session-guarded route and are unaffected.
## Visibility
| Surface | All | Updated | Favourites | Archived | Finished |
|---|---|---|---|---|---|
| Web | reading | reading | reading | archived | finished |
| Userscript | reading | — | reading | archived | not shown |
Archived and finished appear in their own tab and nowhere else — including the web UI's "Continue reading" strip, which is now built from reading-only rows before tab filtering. An archived favourite shows up under Archived only: Favourites means "favourites I am currently reading".
## Backend
- **`store.go`** — `Bookmark.Status`, the column in `schema` / `addedColumns` / `bookmarkColumns` / `scanBookmark` / `Upsert`. `scanBookmark` normalises anything outside the three known buckets to `reading`, so no row can land in no list at all.
- **`store.go`** — `DueForLatestCheck` gains `AND status IS NOT 'finished'`. Archived series keep being polled; that is the whole point of archiving rather than deleting. Finished ones have nothing coming, so polling them only burns fetches and risks a spurious "new chapter" badge. `IS NOT` is null-safe, so a hand-edited NULL still qualifies.
- **`handlers.go`** — status validation on `PUT`, before any write.
- **`web.go`** — `buildListView` filters the new tabs and excludes both buckets from `all` / `new` / `fav` and the recent strip; new `POST /ui/bookmarks/{key}/status`, session-guarded like its siblings, read-modify-writing through `Store.Get` + `Store.Upsert` so the `updated_at` rule stays in one place.
- **`templates/`** — two more tabs; per-card controls (reading → Archive + Finish, archived → Restore + Finish, finished → Restore); empty-state copy for both new tabs.
- **`static/style.css`** — five tabs no longer divide a phone's width legibly, so the row scrolls sideways instead of squeezing.
## Userscript (1.3.0)
- Third tab **Archived** beside All and Favourites. A missing `status` reads as `reading`, so a list cached by the previous version still renders. `finished` matches no tab and is invisible everywhere.
- Per-item **Archive / Unarchive** button on the existing optimistic path: mutate local state and cache, `apiPut`, adopt the server's returned row.
- Header chip row linking the web UI and both manga sites, each `target="_blank" rel="noopener"`.
- `apiPut` now omits `status` unless the caller opts in — see below.
Still free of every `GM_*` API: plain `fetch`, page `localStorage`, on-page UI only.
## One bug worth calling out
The userscript's other mutations (`updateToCurrentChapter`, `setChapterManual`, `toggleFavorite`, `applyLatestChapterIfChanged`) build their payload with `Object.assign({}, existing, …)`, so they echoed the cached `status` back to the server. `GET /bookmarks` has no status filter — finished rows are in `state.list` and only hidden at render time — which made two failures reachable:
1. Reading a chapter of a series marked finished sent `"status":"finished"`, which the API rejects with 400. Progress never synced, behind a misleading "Offline — saved locally, will retry" toast, permanently.
2. Archiving on desktop and then reading on a phone whose cache predated the archive sent `"status":"reading"` and silently un-archived the series — contradicting the README's "reading an archived series leaves it archived".
Fixed at the single choke point: `apiPut(key, obj, { sendStatus = false })` strips `status` from a copy of the body unless the caller opts in, and only `toggleArchive` opts in. Only an explicit archive/restore has an opinion about the bucket; everything else omits the field so the server's keep-on-empty rule applies. Stripping merely the *invalid* values would not have been enough — a stale cached `"reading"` still clobbers a remote archive.
Also: the userscript's `backgroundRefreshLatest` now skips finished series, matching the server poller, instead of spending batch slots fetching pages for a series that has nothing coming.
## Known limitations, deliberate
Both are marked in-code with `ponytail:` comments naming the ceiling and the upgrade path:
- The poller's `Store.Get` + `Store.Upsert` is not wrapped in a transaction, so a client PUT that commits between the two is lost to the stale re-read. Already documented for read progress in `CLAUDE.md`; it now costs a status change too. Accepted for a single-user deployment.
- A card whose new status no longer matches the active tab stays on screen until the next list load. The alternative is an out-of-band swap or a full list refresh per toggle, and the card visibly showing its new state is enough feedback.
## Testing
`go test ./...` passes; `CGO_ENABLED=0 go build ./...` clean.
- **`store_test.go`** — a fresh row defaults to `reading`; a legacy database gains the column with every row `reading`; an `Upsert` carrying `""` preserves the stored bucket while a value replaces it; a status change does not move `updated_at`; `DueForLatestCheck` returns archived and skips finished; the poller's `Get` → `Upsert` round trip preserves `archived`.
- **`main_test.go`** — `PUT` with `finished` or garbage is 400, `""` / `reading` / `archived` round-trip; a PUT that omits the `status` key entirely (what a pre-1.3.0 userscript sends) preserves an archived bucket *and* applies the chapter progress in the same request.
- **`web_test.go`** — each tab returns only its bucket; the recent strip excludes archived and finished; the status endpoint requires a session, rejects unknown values, and does not move `updated_at`; the card renders the right controls per bucket.
Userscript has no automated harness, so it was checked against a live `https://asurascans.com` page: the chips resolve, Archive moves a series out of All and Favourites into Archived, the state survives a full reload (so it came from the server, not local optimism), Unarchive returns it, a series marked finished in the web UI appears in no tab, and — captured on the wire — the archive PUT carries `"status":"archived"` while a favourite toggle on that same archived series carries no `status` key at all.
Reviewed-on: #4
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
407 lines
14 KiB
CSS
407 lines
14 KiB
CSS
/* Mobile first. Dark by default because manga reading happens at night; the
|
|
light branch follows the system preference. */
|
|
:root {
|
|
color-scheme: dark light;
|
|
--bg: #14161a;
|
|
--surface: #1d2026;
|
|
--surface-2: #262a32;
|
|
--text: #e8eaed;
|
|
--muted: #9aa1ac;
|
|
--accent: #6aa9ff;
|
|
--accent-ink: #0b1220;
|
|
--danger: #ff6a6a;
|
|
--danger-ink: #2a0a0a;
|
|
--star: #ffc857;
|
|
/* A newly-published chapter is its own signal, distinct from "tap to act"
|
|
(accent) or "favourited" (star) — a third hue keeps those three meanings
|
|
from collapsing into one overloaded blue. */
|
|
--new: #34d8a6;
|
|
--new-ink: #0b1220;
|
|
/* Per-site tint so the two source sites this app exists to unify are
|
|
still tellable apart at a glance, not just by reading the label text. */
|
|
--site-asura-bg: #3a2a20;
|
|
--site-asura-text: #ffb37a;
|
|
--site-demonic-bg: #2e2440;
|
|
--site-demonic-text: #c9a8ff;
|
|
--radius: 12px;
|
|
}
|
|
|
|
/* Light theme darkens the four semantic hues (and flips their ink) rather
|
|
than reusing the dark-theme values verbatim: the originals were tuned to
|
|
sit on near-black surfaces and fall well under WCAG AA the moment the
|
|
surface goes to white — verified via computed contrast, not eyeballed. */
|
|
@media (prefers-color-scheme: light) {
|
|
:root {
|
|
--bg: #f4f5f7;
|
|
--surface: #ffffff;
|
|
--surface-2: #eceef2;
|
|
--text: #1a1d22;
|
|
--muted: #5d646e;
|
|
--accent: #1f5fd6;
|
|
--accent-ink: #ffffff;
|
|
--danger: #b42323;
|
|
--danger-ink: #ffffff;
|
|
--star: #7d5f00;
|
|
--new: #087a5c;
|
|
--new-ink: #ffffff;
|
|
--site-asura-bg: #fde8d8;
|
|
--site-asura-text: #8a4a1f;
|
|
--site-demonic-bg: #f0e6fa;
|
|
--site-demonic-text: #6b2fa8;
|
|
}
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
body {
|
|
margin: 0;
|
|
padding: 0 12px calc(24px + env(safe-area-inset-bottom));
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font: 16px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
}
|
|
|
|
/* Every interactive element clears the 44px touch-target floor. */
|
|
button, .primary, [role="tab"] {
|
|
min-height: 44px;
|
|
border-radius: var(--radius);
|
|
border: 0;
|
|
font: inherit;
|
|
cursor: pointer;
|
|
}
|
|
|
|
/* --- login --- */
|
|
|
|
.login-body {
|
|
display: grid;
|
|
place-items: center;
|
|
min-height: 100dvh;
|
|
}
|
|
|
|
.login-card {
|
|
width: min(380px, 100%);
|
|
padding: 24px;
|
|
background: var(--surface);
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
.login-card h1 { margin: 0 0 20px; font-size: 1.25rem; font-weight: 700; letter-spacing: -.01em; }
|
|
.login-card label { display: block; margin-bottom: 6px; color: var(--muted); font-size: .875rem; }
|
|
|
|
.login-card input {
|
|
width: 100%;
|
|
min-height: 44px;
|
|
padding: 0 12px;
|
|
margin-bottom: 12px;
|
|
background: var(--surface-2);
|
|
color: var(--text);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius);
|
|
font: inherit;
|
|
}
|
|
|
|
.login-card input:focus-visible { outline: 2px solid var(--accent); }
|
|
.login-card button { width: 100%; background: var(--accent); color: var(--accent-ink); font-weight: 600; }
|
|
.error { margin: 0 0 12px; color: var(--danger); font-size: .875rem; }
|
|
|
|
/* --- chrome --- */
|
|
|
|
.topbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 12px;
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.topbar h1 { margin: 0; font-size: 1.125rem; font-weight: 700; letter-spacing: -.01em; }
|
|
.ghost { padding: 0 12px; background: var(--surface-2); color: var(--muted); }
|
|
|
|
.search {
|
|
width: 100%;
|
|
min-height: 44px;
|
|
padding: 0 12px;
|
|
margin-bottom: 12px;
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius);
|
|
font: inherit;
|
|
}
|
|
|
|
.search:focus-visible { outline: 2px solid var(--accent); }
|
|
|
|
/* Five buttons no longer divide a phone's width legibly, so the row scrolls
|
|
sideways instead of squeezing. The scrollbar is hidden: on mobile there is
|
|
none anyway, and on desktop it would sit under the tab labels. */
|
|
.tabs {
|
|
display: flex; gap: 8px; margin-bottom: 16px;
|
|
overflow-x: auto; scrollbar-width: none; -webkit-overflow-scrolling: touch;
|
|
}
|
|
.tabs::-webkit-scrollbar { display: none; }
|
|
|
|
.tabs [role="tab"] {
|
|
flex: 1 0 auto;
|
|
padding-inline: 14px;
|
|
white-space: nowrap;
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--surface);
|
|
color: var(--muted);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.tabs [role="tab"].active { background: var(--accent); color: var(--accent-ink); font-weight: 600; }
|
|
|
|
/* --- continue reading --- */
|
|
|
|
.recent h2 { margin: 0 0 8px; font-size: .8125rem; font-weight: 600; text-transform: uppercase;
|
|
letter-spacing: .04em; color: var(--muted); }
|
|
|
|
.recent-strip {
|
|
display: flex;
|
|
gap: 10px;
|
|
overflow-x: auto;
|
|
padding-bottom: 8px;
|
|
margin-bottom: 16px;
|
|
scroll-snap-type: x mandatory;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.recent-card {
|
|
flex: 0 0 110px;
|
|
scroll-snap-align: start;
|
|
display: block;
|
|
padding: 8px;
|
|
background: var(--surface);
|
|
border-radius: var(--radius);
|
|
color: var(--text);
|
|
text-decoration: none;
|
|
}
|
|
|
|
/* A reserved slot even without cover art, so the ribbon always has a
|
|
dedicated area to sit in instead of overlapping the title text. */
|
|
.recent-cover {
|
|
position: relative;
|
|
display: block;
|
|
aspect-ratio: 3 / 4;
|
|
background: var(--surface-2);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.recent-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
.recent-title { display: block; margin-top: 6px; font-size: .8125rem; font-weight: 600; line-height: 1.35;
|
|
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
|
.recent-chapter { display: block; color: var(--muted); font-size: .75rem; font-variant-numeric: tabular-nums; }
|
|
|
|
/* --- list --- */
|
|
|
|
.list { display: grid; gap: 10px; }
|
|
|
|
.card {
|
|
display: grid;
|
|
grid-template-columns: 96px 1fr;
|
|
gap: 12px;
|
|
padding: 10px;
|
|
background: var(--surface);
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
.card .cover {
|
|
position: relative;
|
|
display: block;
|
|
width: 96px;
|
|
aspect-ratio: 3 / 4;
|
|
background: var(--surface-2);
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.card .cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
|
|
/* No og:image was ever scraped for this series. A bare --surface-2 rectangle
|
|
read as a cover still loading; the title's initial says the slot is filled
|
|
and nothing more is coming. Muted, not accent — a missing cover is an
|
|
absence, not a signal, and it must never outrank the New ribbon it sits
|
|
under. The two cover contexts share this rule and differ only in size. */
|
|
.cover-blank {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--muted);
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
line-height: 1;
|
|
letter-spacing: -.02em;
|
|
opacity: .65;
|
|
user-select: none;
|
|
}
|
|
|
|
.recent-cover .cover-blank { font-size: 1.625rem; }
|
|
.card .body { min-width: 0; }
|
|
/* 17px, not the 16px body floor: the title is the one thing per card the
|
|
squint test must catch first, so it needs a size step of its own, not
|
|
just the browser's default h3 bold riding on an unchanged size. */
|
|
.card .title { margin: 0 0 4px; font-size: 1.0625rem; font-weight: 700; line-height: 1.35; }
|
|
|
|
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0 0 10px;
|
|
font-size: .75rem; color: var(--muted); }
|
|
|
|
.site { padding: 2px 6px; background: var(--surface-2); border-radius: 6px; text-transform: uppercase;
|
|
letter-spacing: .03em; }
|
|
/* Per-site tint: the two source sites this app unifies are otherwise
|
|
identical chrome, so this is the one glanceable "which site" cue beyond
|
|
the label text itself. */
|
|
.site-asura { background: var(--site-asura-bg); color: var(--site-asura-text); }
|
|
.site-demonic { background: var(--site-demonic-bg); color: var(--site-demonic-text); }
|
|
/* Read progress is data, not metadata, so it takes the body text color
|
|
instead of the muted tone the label pills around it use. */
|
|
.chapter { color: var(--text); font-variant-numeric: tabular-nums; }
|
|
.new-chapter { color: var(--new); font-weight: 600; font-variant-numeric: tabular-nums; }
|
|
|
|
/* The signal the poller exists to surface: a corner ribbon on the cover
|
|
itself outranks any line of card metadata, so it reads on the squint
|
|
test even in a dense grid, not just on close inspection. Its own hue
|
|
(not accent) keeps "new chapter" distinct from "tap to act". */
|
|
.new-ribbon {
|
|
position: absolute;
|
|
top: 6px;
|
|
left: 6px;
|
|
padding: 2px 7px;
|
|
background: var(--new);
|
|
color: var(--new-ink);
|
|
font-size: .6875rem; /* 11px: the functional-text legibility floor */
|
|
font-weight: 700;
|
|
letter-spacing: .02em;
|
|
text-transform: uppercase;
|
|
border-radius: 6px;
|
|
box-shadow: 0 2px 6px rgba(0, 0, 0, .35);
|
|
}
|
|
|
|
.new-ribbon-sm { top: 4px; left: 4px; padding: 1px 5px; }
|
|
|
|
/* One row of equal-sized icon buttons: Continue no longer stretches to
|
|
fill leftover space (that was both the wrap bug at narrow widths and
|
|
what made it read as arbitrarily oversized). It carries the same 44px
|
|
footprint as the utility icons and is told apart by fill color alone.
|
|
Grid columns capped at 44px, not flex items fixed at 44px: on the
|
|
narrowest real Android widths four 44px targets plus gaps don't quite
|
|
fit beside a 96px cover, and minmax(0, 44px) lets the row compress a
|
|
couple of px instead of wrapping — imperceptible, and never wraps. */
|
|
.actions {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, minmax(0, 44px));
|
|
gap: 8px;
|
|
margin-top: 10px;
|
|
padding-top: 10px;
|
|
border-top: 1px solid var(--surface-2);
|
|
}
|
|
|
|
.primary {
|
|
display: grid;
|
|
place-items: center;
|
|
background: var(--accent);
|
|
color: var(--accent-ink);
|
|
font-size: 1.125rem;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.icon { display: grid; place-items: center; padding: 0;
|
|
background: var(--surface-2); color: var(--text); font-size: 1.125rem; }
|
|
.icon.on { color: var(--star); }
|
|
.icon.danger { color: var(--danger); }
|
|
|
|
/* Hand-drawn inline SVG rather than the OS emoji font: ☆ ✎ 🗑 ▶ each render in
|
|
a different face at a different optical weight and colour (some fonts force
|
|
their own), which is the one place the card ignored its own type and colour
|
|
system. currentColor keeps them on .icon / .icon.on / .icon.danger /
|
|
.primary without a second palette. 20px, not the 1.125rem font-size the
|
|
glyphs used: a glyph inks about two thirds of its em box, so matching the
|
|
old optical size means an SVG a step larger than the font-size it replaces. */
|
|
.icon svg, .primary svg { width: 20px; height: 20px; display: block; }
|
|
|
|
/* Wraps, like .actions and .confirm-row: the hint claims its own full-width
|
|
row (flex-basis 100%) so input + Save keep the whole card width below it.
|
|
Without the wrap the three children fought for one unwrapped row and pushed
|
|
Save off-screen — ~97px of page overflow at 390px, 127px at 360px. */
|
|
.chapter-form { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
|
|
|
|
.chapter-form input {
|
|
/* flex-basis 0 + min-width 0, not flex: 1: a number input's default
|
|
intrinsic width is ~20 characters, so on `flex: 1` (basis auto) it refused
|
|
to shrink and pushed Save onto a third row. A chapter number needs five
|
|
characters, and the field can have whatever is left over. */
|
|
flex: 1 1 0;
|
|
min-width: 0;
|
|
min-height: 44px;
|
|
padding: 0 12px;
|
|
background: var(--surface-2);
|
|
color: var(--text);
|
|
border: 1px solid transparent;
|
|
border-radius: var(--radius);
|
|
font: inherit;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.chapter-form button { padding: 0 14px; background: var(--accent); color: var(--accent-ink); font-weight: 600; }
|
|
/* flex: 1 0 100% — own row above the field. The margin-bottom is 0 because the
|
|
parent's 8px gap already spaces it; keeping both double-spaced the hint. */
|
|
.hint { flex: 1 0 100%; margin: 0; font-size: .75rem; color: var(--muted); font-variant-numeric: tabular-nums; }
|
|
|
|
/* Delete confirm, themed in place of a native confirm() dialog: that OS
|
|
chrome renders outside the app's own dark surface, breaking the
|
|
night-reading brand constraint at the one moment attention matters most. */
|
|
.confirm-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
|
|
/* Own full-width row, same as .chapter-form's hint. Sharing one row with both
|
|
buttons broke the prompt into "Remove / this?" while its own answer sat
|
|
whole beside it; nowrap alone only converted that into 26px of page
|
|
overflow, because a nowrap flex item will not shrink past its text. */
|
|
.confirm-row span { flex: 1 0 100%; font-size: .8125rem; white-space: nowrap; }
|
|
.confirm-row .danger-solid { padding: 0 14px; background: var(--danger); color: var(--danger-ink); font-weight: 600; }
|
|
.confirm-row button[type="button"] { padding: 0 14px; background: var(--surface-2); color: var(--text); }
|
|
|
|
.empty { padding: 32px 12px; text-align: center; color: var(--muted); }
|
|
|
|
/* --- mutation feedback --- */
|
|
|
|
/* htmx adds .htmx-request to the element named by hx-indicator while a
|
|
request is in flight; dimming the whole card is the visible "it's working"
|
|
signal the templates otherwise never give. */
|
|
.card.htmx-request { opacity: .6; }
|
|
|
|
.error-inline {
|
|
margin: 8px 0 0;
|
|
padding: 6px 10px;
|
|
background: var(--surface-2);
|
|
color: var(--danger);
|
|
border-radius: 8px;
|
|
font-size: .8125rem;
|
|
}
|
|
|
|
.error-inline[hidden] { display: none; }
|
|
|
|
/* Cards hidden by the search filter. The .chapter-form { display: flex }
|
|
rule above has equal specificity to the UA [hidden] rule, so without this
|
|
override the form stays visible even when the hidden attribute is set. */
|
|
.card[hidden] { display: none; }
|
|
.chapter-form[hidden] { display: none; }
|
|
.confirm-row[hidden] { display: none; }
|
|
|
|
/* --- wide screens --- */
|
|
|
|
@media (min-width: 900px) {
|
|
body { max-width: 1100px; margin: 0 auto; padding-inline: 24px; }
|
|
.list { grid-template-columns: repeat(2, 1fr); }
|
|
.recent-card { flex-basis: 140px; }
|
|
}
|
|
|
|
@media (min-width: 1300px) {
|
|
.list { grid-template-columns: repeat(3, 1fr); }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
* { animation: none !important; transition: none !important; }
|
|
}
|