Two follow-ups to 0.87.2 + 0.87.3 from the adversarial review:
1. **CSRF cookie-ride.** Bearer-bypass branch checked Authorization
header presence only — an attacker page could mint `Authorization:
Bearer junk` and ride the still-attached session cookie. Bypass now
requires bearer AND no cookie. Drive the cookie-name comparison off
`SESSION_COOKIE_NAME` and split on `=` (not prefix).
2. **`require_can_edit` admin path open to bearer.** Now reads admin
authority off an `Option<CurrentSessionUser>` extractor — None on
bearer-only requests. Owner-match path still accepts bearer; admin
path is cookie-gated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three frontend findings (medium):
1. **`hooks.server.ts` didn't chain client-disconnect into the upstream
fetch.** Closing an admin tab left the backend's `broadcast::Receiver`
+ spawned tokio task running until next backend restart. Listen for
`event.request.signal.abort` and forward to the timeout controller.
2. **`admin/analysis/+page.svelte` missing visibility/pagehide/pageshow
handlers.** Mobile Safari throttles SSE in background tabs; bfcache
leaves a dead connection on back-nav. Apply the close/open-stream
pattern from `admin/crawler/+page.svelte`.
3. **`loadCoverage` had no AbortController.** Debounced search
keystrokes raced. Per-loader controller plumbed through
`getAnalysisMangaCoverage`.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three medium vision-manager findings:
1. **`vision_running()` conflated "container absent" with "inspect
failed".** A transient docker-socket-proxy hiccup made the manager
think vision had stopped, which reset `up_for`/`idle_for` and
defeated MAX_UPTIME. Distinguishes: exit 0 → echo true/false;
"No such container" → false; else → ERR (loop skips tick).
2. **`crawl_running()` fail-closed where `analysis_enabled()` fail-opens.**
A psql `ERR` logged a misleading "RAM mutex" line every tick.
Numeric-guard at the call site with a distinct log.
3. **`start_vision` left wedged containers running** on
`START_HEALTH_TIMEOUT` expiry — pinned forever in not-ready state.
Now `stop_vision` so next tick retries from a clean state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`reclaim_orphaned` previously ran only inside `spawn_crawler_daemon` at
startup. A deploy with the crawler disabled and analysis enabled
(analysis-only mode) never refunded orphaned `analyze_page` leases —
recovery deferred to the lazy lease-expiry path. Wire it into
`spawn_analysis_daemon` too. Safe under multi-replica + idempotent
against the crawler-side call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three medium correctness fixes:
1. **Pagination ORDER BY missing tiebreakers** — four admin list queries
sorted by a timestamp or `chapters.number` with no stable secondary
sort. Add `id` tiebreakers across `repo::admin_view`,
`repo::admin_audit`, `repo::bookmark`.
2. **`enqueue_pages` NOT EXISTS read-then-insert race** — concurrent
admin clicks could land duplicate analyze_page jobs. Migration 0031
adds a partial unique index mirroring 0014; query relies on
`ON CONFLICT DO NOTHING`.
3. **`record_duration` overwrote a prior done row's duration on a
non-terminal failed retry of a force-re-analyze.** Gate the call
on "did this attempt actually write a row?".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`VisionClient::analyze` ran `image::load_from_memory`, `DynamicImage::resize_exact(Triangle)`,
and per-band JPEG encodes directly on the tokio task. Each call is
hundreds of ms of pure CPU per page. With multiple workers, the runtime
threads got starved — axum handlers, SSE streams, the crawler daemon's
async timers, and other tasks sharing the runtime all stalled while
analysis was active.
Extract a `prepare_analysis` helper that does ALL the CPU work
(decode + plan + width-reduce + slice + JPEG encode) and returns a
`PreparedAnalysis { Single | Sliced | Undecodable }` of already-encoded
byte payloads. `analyze` calls it inside `tokio::task::spawn_blocking`,
then the async HTTP loop only iterates over the prepared bytes — no
image-crate operations happen on the runtime any more.
Single page → one spawn_blocking → one HTTP call.
Tall page → one spawn_blocking → N OCR calls + 1 grounding call.
Memory peak rises slightly for the Sliced path (all bands held
encoded at the same time instead of one-at-a-time) — for a typical
manga page split into 4 slices at ~200 KB each, that's ~600 KB of
extra peak. Negligible vs. the runtime-starvation cost.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two high findings sharing one root cause: long-running daemon work that
ignored the shutdown signal until it finished naturally.
**Cron tick (crawler).** `CronContext::run_tick` ran the metadata-pass
body inside `AssertUnwindSafe(...).catch_unwind().await` with no
cancellation. A fresh-catalog walk (many minutes) wedged
`DaemonHandle::shutdown`, `Supervisors::reload_crawler` (under the
supervisor lock), and SIGTERM responsiveness for the whole pass.
Wrap the body in `tokio::select! { biased; _ = self.cancel.cancelled() => ...; r = body => ... }`.
On cancel the body future drops, which cascades to dropping
`metadata.run()` — exactly what gives Chromium/lease teardown a chance
via the BrowserManager idle reaper. The advisory unlock + conn drop
still run unconditionally.
**Analysis dispatch.** `process_lease` wrapped the vision call only in
`tokio::time::timeout(self.job_timeout, …)` — default 600s. Toggling
analysis off in the dashboard (under the supervisor lock) or stopping
the container blocked on whichever call was in flight. Same fix:
`tokio::select!` against `self.cancel.cancelled()`. On cancel,
`jobs::release` returns the lease to the queue without burning an
attempt — next tick picks it up.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`require_can_edit` matched `Some(owner) != caller → Forbidden` but let
`None` through. Every crawler row (`repo::crawler::upsert_manga`) has
NULL `uploaded_by`, so any signed-in user could PATCH /api/v1/mangas/<id>
and rewrite the catalog — `update`, `put_cover`, and `delete_cover`
were all in scope. With self-registration on by default, the path was:
register → mass-edit catalog + delete cover blobs from storage.
The original carve-out at the comment said "Once an admin role lands the
NULL case can flip to admin-only." The admin role landed in migration
0018; flipping it now. New rule:
* `Some(owner)` and owner == caller → ok (unchanged)
* `Some(_)` and caller is admin → ok (admin moderation)
* `Some(_)` otherwise → 403 (unchanged)
* `None` and caller is admin → ok (NEW — operator can curate)
* `None` otherwise → 403 (was: ok — the bug)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four high/medium findings from the audit, plus their tests:
1. **SSRF on admin-editable URLs.** `CrawlerSettings::start_url` and
`AnalysisSettings::endpoint` validated only with `Url::parse`. A
hostile or CSRF-able admin could repoint at `169.254.169.254`
(cloud metadata), `127.0.0.1:5432` (postgres), or any RFC1918 host
— and the vision worker bearer-attaches an env-managed API key to
every call. Extract `ensure_public_target` from `safety::is_safe_url`
(allowlist-free public-host check: scheme + private-IP literal +
localhost) and wire it into both fields. Docker DNS names
(`mangalord-vision`) keep passing because they're hostnames, not
IP literals. The analysis check is gated on `enabled=true` so the
dev-default `localhost:8000` stays usable until the worker is
actually turned on (toggling enabled=true later re-runs the gate).
2. **Admin CSRF fail-open default.** `ADMIN_ALLOWED_ORIGINS=` empty
silently skipped the entire CSRF check, so an operator forgetting
the env var shipped an unguarded admin surface. Cookie-auth POSTs
now fail-closed when the allowlist is empty AND when neither
`Origin` nor `Referer` accompanies the request. Two carve-outs:
`Authorization: Bearer …` callers bypass (bots can't be CSRF'd),
and requests with NO session cookie at all bypass to let the auth
extractor return a clean 401 instead of a confusing 403.
3. **Analysis HTTP clients didn't `.no_proxy()`.** The crawler client
already did. Ambient `HTTP_PROXY`/`HTTPS_PROXY` in container env
would exfiltrate page-image bytes + the bearer key through an
upstream proxy. Same for the readiness probe.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three operational gaps tripped a recent review:
1. **Postgres had no `restart:` policy.** tor / docker-socket-proxy /
vision-manager all set `unless-stopped`; pg was the lone exception.
A transient OOM or panic left backend's `depends_on: service_healthy`
blocking every future restart and took the stack offline. Add
`unless-stopped` on postgres and on backend/frontend in the base
compose so the prod overlay is the same shape.
2. **`BACKEND_PROXY_TIMEOUT_MS` was documented but silently a no-op.**
`.env.example` carried it but `docker-compose.yml` never forwarded it
into the frontend service env, so the value never reached
`hooks.server.ts`. Wire it through.
3. **`.env.example` omitted required env vars.** Operators copying the
template got no admin bootstrap, no boot-seed crawler URL, no
analysis configuration. Add: `ADMIN_USERNAME`, `ADMIN_PASSWORD`,
`PRIVATE_MODE`, `ALLOW_SELF_REGISTER`, `CRAWLER_START_URL`,
`CRAWLER_CDN_HOST`, `ANALYSIS_ENABLED`, `ANALYSIS_VISION_URL`,
`ANALYSIS_VISION_MODEL`, `ANALYSIS_WORKERS`,
`ANALYSIS_JOB_TIMEOUT_SECS`, `ANALYSIS_API_KEY` — each with the
comment block that explains seed-vs-runtime semantics.
Also adds `frontend/vite.config.ts.timestamp-*.mjs` to `.gitignore` — a
crashed dev server leaves these transient files behind otherwise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review follow-ups: the audit row's expand was mouse-only (onclick on a bare
<tr>). Move the toggle to a real <button> in the actions cell with
aria-expanded + aria-label so keyboard/AT users can open a row's payload; the
row onclick stays as a mouse convenience (stopPropagation avoids a double
toggle). Also simplify a no-op `step={'%' ? '1' : '1'}` to step="1" on the
threshold inputs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the dormant timing data in crawl_metrics / page_analysis into "is it
healthy over time" views. New bucketed series queries (GROUP BY date_trunc,
hour|day via a closed Bucket enum so the unit can't be attacker-controlled)
behind GET /v1/admin/{crawler,analysis}/metrics/series, with a shared
SeriesParams/resolve_bucket helper (bad bucket → 400) and migration 0030
indexing page_analysis(analyzed_at) for the analysis scan.
Frontend: a dependency-free SVG TrendChart (line+area, null buckets render as
gaps, empty-state, role=img) embedded above the per-op tables in Crawler and
Analysis → Metrics, driven by each panel's existing window selector with
AbortController-cancelled fetches. A buildSeries() util fills the continuous
bucket axis (throughput 0 for empty buckets, success/duration null) — unit
tested alongside the chart and the series API client.
Closes the Phase-1 observability set (audit log · health checks · trends).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New /admin/health rolls up signals already collected — disk/memory sensors,
dead-job + missing-cover backlogs, 24h crawl/analysis failure rates, metadata
cron freshness, and the live crawler session/browser flags — into a flat
checks list (ok/warn/critical) with an overall status and remediation links.
The overview gains a compact Health summary banner linking in.
GET /v1/admin/health evaluates the checks (DB sub-queries run concurrently via
try_join; all hit existing indexes). PUT /v1/admin/health/thresholds persists
the thresholds to app_settings (merged over compiled defaults, forward-compat
via serde(default)), validates ranges (400 on bad input), and audit-logs the
change in one transaction. New repo::crawler::last_metadata_tick_at getter and
a lightweight system::memory_percent_used (no CPU settle delay).
Pure status helpers (over_limit for gauges, exceeds for backlog counts so a
0-limit doesn't false-alarm at 0) are unit-tested; endpoint tests cover shape,
gating, persistence+audit, and validation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surface the admin_audit table (written by every mutating admin action but
previously unreadable) as a new /admin/audit tab. New repo::admin_audit::list
(LEFT JOIN users for the actor's username, filter by action/target_kind/actor/
since, at DESC via admin_audit_at_idx) behind GET /v1/admin/audit, mirroring the
crawler history endpoint's paged/clamped idiom.
Frontend: a self-contained AuditTable (target-kind + window + action filters,
expandable JSON payload, AbortController-cancelled fetches, loading/error/empty
states) and shared fmtAgo() in format.ts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The interleaved metadata pass misses mangas to list drift (a title slips a
pagination slot during the slow detail walk). Add a reconcile pass: a cheap,
full, list-only walk (refs only, no detail visit, no early stop) that
set-diffs the walked keys against manga_sources and enqueues the strictly-
missing ones as SyncManga jobs. A set-diff is immune to drift — a manga only
has to appear somewhere in the list.
- Build the previously-dead SyncManga worker by extending RealChapterDispatcher
to run the shared pipeline::process_manga_ref (fetch → upsert → cover →
chapters), refactored out of run_metadata_pass so both paths stay in lockstep.
The crawl worker now leases both sync_chapter_content and sync_manga
(jobs::lease_kinds); both serialize on the single exclusive browser.
- SyncManga payload carries url + title so the worker can rebuild the ref and
the dead-jobs/history UI can label a missing manga that has no manga row yet.
- "Missing" = strict NOT EXISTS (dropped rows count as present). Re-enqueue is
skipped when a pending/running/dead SyncManga job already exists, so a gone
manga (detail 404 → retries → dead) is left dead and not retried.
- New POST /v1/admin/crawler/reconcile (fire-and-forget, shares
manual_pass_lock) + "Reconcile missing" admin button + Reconciling status
phase streamed over SSE.
- dead-jobs/history queries surface payload title/url/key; tables fall back to
them for sync_manga rows; both searches match the payload title.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The admin overview dashboard polls /admin/overview every 30s. Its
`manga_stats` aggregate evaluated MANGA_SYNC_STATE_CASE over the WHOLE
library (14.5k mangas) with no LIMIT, and that CASE runs a correlated
EXISTS over crawler_jobs. With no index on the `sync_chapter_list`
manga_id path, Postgres seqscanned all in-flight jobs *per manga* —
O(mangas x jobs). The disabled-analysis backlog (9.5k pending
`analyze_page` rows that can never match the sync-kind filter) inflated
every scan, so each call took 6-7 min. The 30s poll stacked ~10 of them
concurrently → load 11, Postgres at ~100%.
EXPLAIN ANALYZE on the live DB: the rewrite drops the query from 6-7 min
to ~1.5s (per-manga crawler_jobs seqscan → single index-backed pass).
Fixes:
- Rewrite `manga_stats` to collect the in-flight manga-id set in ONE pass
over the sync jobs (index-backed), then classify via a hash semi-join —
O(mangas + jobs), immune to the analyze_page backlog size.
- Add partial index crawler_jobs_sync_chapter_list_manga_idx (migration
0029) covering the sync_chapter_list manga_id path; mirrors the existing
sync_manga index (0020). Also speeds the per-row Mangas tab listing.
- statement_timeout backstop (5s) on the aggregate so a pathological plan
can never pin a backend for minutes again.
- Single-flight + 10s TTL cache on the /admin/overview handler so
concurrent pollers coalesce instead of stacking.
- Slow the dashboard poll 30s -> 60s (server-cached now anyway).
The in_progress rule in the rewrite is kept in lockstep with the first
arm of MANGA_SYNC_STATE_CASE (documented in both places).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Persist blob sizes (covers + chapter pages) and surface upload storage
usage to admins in two places:
- Admin System tab: total/covers/chapters totals, ratio of disk, average
image sizes, and top-5 largest mangas/chapters leaderboards, behind a
new GET /api/v1/admin/storage endpoint (separate from /admin/system so
the cheap DB aggregates aren't coupled to its 250ms CPU sample).
- Manga detail page: total chapter-content size and per-chapter size,
with an em-dash for uncrawled or not-yet-measured chapters.
Sizes are captured at write time (crawler put_stream return, uploaded
page/cover byte length) into nullable pages.size_bytes /
mangas.cover_size_bytes columns; NULL means "not yet measured", distinct
from a real 0. A one-shot, idempotent admin "Backfill sizes" action
(POST /api/v1/admin/storage/backfill) stats pre-existing blobs in
keyset-paginated, capped batches and reports more_remaining so a large
legacy library can be drained over multiple runs.
Aggregates and leaderboards treat NULL honestly (only fully-measured
entities are ranked); a dashboard banner flags unmeasured rows so partial
figures aren't read as complete. persist_pages now prunes stale page rows
on a shrinking re-crawl so totals and page_count stay consistent.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The crawler's headless Chromium advertised the default HeadlessChrome UA,
which Cloudflare's bot detection challenges on sight — over Tor that meant an
unsolvable 'Just a moment' page on every fetch, so session verification kept
classifying pages as transient and the metadata pass failed at
'acquire browser lease'. (Confirmed empirically: same Tor exit + a normal
Chrome UA returns the real page with the #logo sentinel; the HeadlessChrome
UA returns the challenge.) The existing user_agent setting only fed reqwest,
never the browser. Override the launcher's UA with a realistic non-headless
default (CRAWLER_USER_AGENT to customize); the UA may contain spaces, unlike
CRAWLER_BROWSER_ARGS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three failure-mode fixes surfaced by a crawler recovery audit:
- Graceful shutdown mid-dispatch now releases the in-flight job back to
pending without burning a retry attempt. process_lease wraps the
dispatch in a biased tokio::select! cancel arm that aborts the
heartbeat and calls jobs::release; previously a mid-job SIGTERM left
the row 'running' until lease expiry and cost one of max_attempts.
- Boot-time jobs::reclaim_orphaned resets 'running' jobs with an expired
lease back to pending (attempt refunded), run once at daemon startup
before workers start. Crash recovery is now immediate instead of
waiting a full lease window for the lazy lease-expiry path. Safe under
multi-replica: only already-expired leases are touched, which a
healthy heartbeating peer never has.
- Manga-list parsing now warns when listing anchors are dropped for a
missing/empty href or title (split into parse_manga_list_anchors so
the drop count is testable), turning silent source markup drift into
an observable signal. Returned refs are byte-identical to before.
Tests added: shutdown_mid_dispatch_releases_lease_without_burning_attempt,
reclaim_orphaned_resets_only_expired_running_jobs, and a drop-count unit
test. Patch bump 0.81.0 -> 0.81.1 (both manifests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a tiny privileged sidecar (vision-manager/) that polls the analysis
backlog in Postgres and starts/stops the mangalord-vision container by name:
start when analyze_page jobs are pending, stop after STOP_DEBOUNCE idle.
It is the single owner of the vision lifecycle and the only component with
Docker access — scoped through tecnativa/docker-socket-proxy (CONTAINERS+POST
only) on an internal-only network, so the internet-facing backend never
touches the socket.
Both helpers sit behind `profiles: [ai]` (vanilla `compose up` is
unaffected). The manager debounces the stop, gates the first request on
GET /health==200 after a cold start, honours a crawl RAM-mutex on the 8 GiB
box, and owns its own idle timer (leak-safe if the backend dies). Backlog
query uses the real schema (state + payload->>'kind'); a read-only DB role
(readonly-role.sql) keeps it off the backend creds.
Bump 0.80.0 -> 0.81.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move crawler and analysis configuration out of boot-only env vars and into
a DB-backed, admin-editable surface applied live without a restart.
- New `app_settings` table (migration 0026) holds one JSONB row per group;
env vars seed it on first boot, then the DB is the source of truth.
- `settings.rs` adds serializable Crawler/Analysis DTOs with env-base +
DB-overlay conversion and field-level validation; env-only/secret fields
(browser, proxy, TOR, vision API key, PHPSESSID) are never persisted.
- `GET/PUT /api/v1/admin/settings/{crawler,analysis}` (RequireAdmin,
cookie-only, CSRF-guarded): GET returns the editable DTO + a read-only
env-managed view + prompt defaults; PUT validates (422 + per-field
details), persists + audits in one tx, then gracefully respawns the
affected daemon via a Supervisor.
- AppState swaps the crawler control/resync handles and analysis enable
gate behind shared runtime cells so reloads take effect with no restart;
a boot-time spawn failure is logged rather than aborting startup.
- Make the three vision prompts and sampling temperature configurable
(defaults preserved); cookie_domain is admin-editable too.
- Frontend: tabbed /admin/settings page with grouped fieldsets, prompt
reset-to-default, env-managed read-only panel, save-&-apply confirm, and
per-field validation; typed client + ApiError.details.
- Bump version 0.79.1 -> 0.80.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Small vision models (qwen3-vl-4b) sometimes loop the OCR array, running
the response into the token ceiling (finish_reason: length) and failing to
parse. Layered guardrails:
- Prompts: explicit "transcribe each element once, never repeat/loop, at
most N, STOP when done" in the OCR, grounding and combined prompts.
- Schema: hard maxItems on ocr_results/tagging_results/content_type and
maxLength on text/scene — LM Studio's grammar enforces these, so a loop
is grammar-bounded rather than relying on max_tokens.
- Sampling: a configurable frequency_penalty (ANALYSIS_FREQUENCY_PENALTY,
default 0.3) sent with each request — the decode-time lever that actually
breaks loops.
- Code: sanitize() collapses runaway consecutive duplicates (same
normalized text + kind) so a loop that slips through still can't flood
the row.
Tests: schema caps present, frequency_penalty included only when nonzero,
sanitize collapses consecutive repeats; config default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Boundary text duplicated across slices (often with slightly different,
cropped transcriptions) survived the text-only merge. The OCR pass now
asks for a per-piece vertical position and the merge uses it:
- OcrResult gains optional `y` (fraction 0..1 of the slice); the Pass-A
OCR schema/prompt request it (combined path leaves it None). Not
persisted.
- merge_ocr takes each slice's working-image y-band and maps `y` to a
page-global position. A seam pair is a duplicate when position-close
(same kind) OR text-similar, so a mis-OCR'd boundary line is caught even
when the text differs. The kept copy is the one more central in its slice
(less cropped); falls back to keep-longer when positions are missing.
- Self-calibrates the model's y values (pixels vs. fraction) and ignores
degenerate columns, so a bad localizer can't over-merge.
Tests: position pairs differing texts and keeps the less-cropped copy;
text-only fallback (dedup/keep-longer/non-adjacent/order) still holds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Very tall webtoon pages were squashed by the fixed longest-edge downscale,
so OCR was garbage. The vision client now slices to the model's pixel
budget at native resolution and assembles one result:
- plan_slices: native-resolution bands sized to max_pixels (portrait OR
landscape), only when height/width > tall_aspect_threshold; min_slice_
height guards pathologically wide pages (reduce width); max_slices caps
the count (coarser fallback). Normal pages still take one combined call.
- Two-pass, OCR-first: Pass A OCRs each band (near-native), merge_ocr
stitches them with seam-scoped fuzzy dedup (keep the longer transcript,
preserve order/kind, don't collapse non-adjacent repeats); Pass B feeds
the whole downscaled image + the merged OCR text to get tags/scene/safety
grounded in the real dialogue. Only OCR is merged.
- Config: replace max_image_dim with max_pixels / min_slice_height /
slice_overlap / tall_aspect_threshold / max_slices (+ env_f64); bump
job_timeout default 180->600 (N sequential slice calls per page); raise
MAX_OCR_PIECES 60->200. New prompts/schemas: OCR_PROMPT/ocr_json_schema,
GROUNDING_PROMPT/grounding_json_schema.
Tests: plan_slices (single/portrait/landscape/pathological-wide/cap +
coverage/overlap), merge_ocr (seam dedup, keep-longer, non-adjacent
repeats, order), render_whole/render_slice, the new request builders, and
updated config defaults/env.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A text-dense page's full analysis JSON exceeded the 900-token output cap,
so the model stopped mid-object (finish_reason: length) and the truncated
response failed to parse ("EOF while parsing a list"). The page image +
prompt are only a few hundred tokens, so a larger output budget still fits
comfortably in an 8k context window. Still overridable via
ANALYSIS_MAX_TOKENS.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Analysis section now reflects worker progress in real time:
- Opens an EventSource on the live stream while mounted; a "Live" pill
reflects connection state and reconnects on drop (probes after repeated
failures so a lost session logs out).
- Applies events incrementally: enqueued marks in-scope loaded pages
queued; started flips a chip to a pulsing "analyzing"; completed flips
it green and live-bumps the manga + chapter coverage badges (guarded so
no double count); failed flips it red. A compact activity ticker shows
the last few events.
- Server numbers stay authoritative: re-loading the overview or a page
grid clears the matching live overrides.
API client: analysisStatusStreamUrl() + AnalysisEvent type.
Tests: vitest for the stream URL; Playwright asserts SSE frames flow into
the ticker (existing coverage/drill/queue specs get a default quiet
stream). svelte-check + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Broadcasts analysis progress so the dashboard updates live:
- analysis::events: AnalysisEvents broadcaster + AnalysisEvent
(Enqueued / Started / Completed / Failed), carrying the
manga/chapter/page breadcrumb.
- The worker daemon resolves each page's breadcrumb (repo::page::locate)
and publishes Started before dispatch and Completed/Failed after.
- The admin reenqueue publishes Enqueued (scoped by manga/chapter).
- GET /v1/admin/analysis/status/stream — SSE (RequireAdmin) forwarding
each event as a named `analysis` frame; broadcast lag emits a `lagged`
frame. AppState carries the always-present events bus.
Tests: worker publishes started+completed (with breadcrumb) and failed;
SSE route is admin-gated (403 non-admin) and returns text/event-stream.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworks /admin/analysis from a blind enqueue form into a coverage browser:
- Loads a paginated overview of mangas with a CoverageBadge
(Full/Partial/None, analyzed/total) per row; debounced search filters it.
- Drill manga → chapters (coverage badge each) → page grid (chips colored
done/queued/failed/none) with a legend.
- Click a page chip → Modal showing the full result: status, model, time,
NSFW + content-warning chips, scene description, tags, and OCR lines
(labeled by kind). Unanalyzed/failed pages get a "Queue this page"
action (force re-analyze).
- Keeps "Queue all" + per-manga/chapter enqueue and the include-analyzed
toggle; enqueue refreshes the open chapter's grid so queued pages show.
API client: getAnalysisMangaCoverage / ChapterCoverage / ChapterPages /
PageDetail + types. New CoverageBadge component.
Tests: vitest for the four clients; Playwright for overview+badges+queue-all,
drill+inspect, and queue-from-detail. svelte-check + build clean; 266 vitest.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The worker hardcoded response_format={type:json_object}, which LM Studio
rejects with 400 ('must be json_schema or text'); it also leaves
reasoning models (Gemma) with an empty `content`. Fix:
- Default to response_format=json_schema with the real analysis schema
(prompt::output_json_schema), which LM Studio accepts and which forces
clean JSON into message.content. Configurable via ANALYSIS_RESPONSE_FORMAT
= json_schema (default) | json_object | none (config::ResponseFormat).
- build_request_body is now a pure, unit-tested function.
- Vision errors now include the server's response body (status + text)
instead of a bare status, so a 400's reason is visible in logs.
Tests: response-format body shapes (none/json_object/json_schema), schema
shape + round-trip with the DTO, config parsing/defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the raw manga-id / chapter-id UUID inputs with a real picker:
- Search a manga (debounced, reuses listAdminMangas) → result rows show
title + chapter count; "Queue manga" enqueues the whole manga.
- Expand a result to load its chapters (listAdminChapters) and "Queue
chapter" enqueues a single chapter.
- Keeps the "Queue all (whole library)" action and a single global
"include already-analyzed" toggle applied to every action; per-action
busy state + a result message naming what was queued.
Tests: rewrote the Playwright spec to drive the search → manga/chapter
flow (whole library, search+queue manga, expand+queue chapter with
include-analyzed). svelte-check + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the scoped re-enqueue and per-page force-analyze in the UI:
- api/admin: reenqueueAnalysis({onlyUnanalyzed, mangaId, chapterId}) and
analyzePage(pageId).
- Reader PageContextMenu gains an admin-only "Queue for analysis" item
(canAnalyze/onAnalyzePage/analyzeState props) wired to the force-analyze
endpoint with inline busy/done/error feedback; reset per page.
- New /admin/analysis dashboard section + nav tab: scope selector
(whole library / one manga / one chapter), id input, and an
"include already-analyzed" toggle (default off), with busy/notice/error.
Tests: vitest for the two clients; Playwright for the context-menu action
(admin vs non-admin) and the admin section (scope + include toggle +
validation). svelte-check + build clean; 262 vitest, 10 new e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalizes the admin re-enqueue beyond global backfill:
- repo::page_analysis::enqueue_pages(scope, only_unanalyzed) with
ReenqueueScope::{All,Manga,Chapter}. Fixes include-analyzed semantics:
only_unanalyzed=false now enqueues jobs with force=true so the worker
actually re-analyzes already-done pages instead of skipping them.
- POST /v1/admin/analysis/reenqueue accepts optional manga_id / chapter_id
(mutually exclusive, 404 on unknown target) alongside only_unanalyzed.
Tests: manga/chapter scope counts, include-analyzed sets force=true on the
done page, mutual-exclusivity 422, unknown-target 404. Existing global
backfill tests still green (13 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the analysis pipeline in the UI:
- api clients: searchPages() + PageSearchItem/ContentWarning types;
listMangas cwInclude/cwExclude; MangaDetail.content_warnings.
- /search gains a content-search mode: free-text over OCR+scene and
tri-state content-warning toggles (include/exclude), driven by URL
params (text, cw_include, cw_exclude) and the new /me/page-search
endpoint. Tag browsing is preserved when no content filter is active.
- manga detail renders a deduped content-warning banner.
Tests: vitest param serialization (searchPages CSV/text/cw, listMangas
cw); Playwright text-search + cw-toggle flows (route-mocked). svelte-check
+ build clean; full vitest (258) + search e2e (7) green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces the analysis worker's NSFW moderation at the manga level:
- repo::manga FILTER_WHERE gains cw_include (AND, page-content-warning
join to mangas) and cw_exclude (NONE) clauses; ListQuery + binds
renumbered ($6/$7, LIMIT/OFFSET $8/$9).
- repo::page_analysis::warnings_for_manga: deduped, alphabetical union
across all the manga's pages.
- domain::MangaDetail gains content_warnings; get_detail populates it.
- api::mangas list accepts cw_include/cw_exclude (reusing the shared
parse_warnings_csv validator).
Tests: detail union (deduped/sorted) + empty case; list include/exclude
filter; unknown-warning 422. Existing manga tests still green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
New GET /v1/me/page-search surfacing the analysis worker's output:
- repo::page_analysis::page_search + PageSearchQuery: multi-tag AND across
(user page_tags ∪ global page_auto_tags) via the unnest double-negative
idiom, weighted OCR/scene text ranking (ts_rank over search_doc), and
content-warning include/exclude. One row per page with is_nsfw +
deduped content_warnings + rank.
- domain::PageSearchItem.
- api::page_tags: /me/page-search handler with CSV tag/warning parsing
(parse_tags_csv reuses normalize_tag; parse_warnings_csv validates the
closed vocabulary), requiring at least one positive filter (422 else).
This is where the reserved OCR text search lands for pages.
Tests: multi-tag AND user∪auto, speech>sfx ranking, cw include/exclude
(+ row flags), text-only (no tags), missing-filter 422, unknown-warning
422, auth required.
Note: the /me/page-tags/chapters|mangas aggregations keep single-tag
behavior + the reserved text=501 for now; page-level search is the
primary text surface.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The OpenAI-compatible vision client and the bounded prompt/output handling
for the analysis worker (no DB, fully unit-tested):
- analysis::prompt: terse system prompt carrying the JSON schema, the OCR
kind / content-warning vocabularies, and the output-size caps.
- analysis::vision: VisionClient (downscale via the new `image` dep →
base64 data-URL → chat/completions with an image_url part), plus pure
parse_chat_completion (fence/prose-tolerant) and sanitize (drop empty
OCR, truncate, clamp tags to 10 via the shared page-tag normalizer,
filter unknown warnings).
- config::AnalysisConfig extended with endpoint/model/api_key/timeouts/
max_tokens/max_image_dim/max_image_bytes + from_env.
- Deps: add `image` (jpeg/png/webp), reqwest `json` feature. Expose
api::page_tags::normalize_tag as pub(crate) for reuse.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wires the analyze_page job kind into the page-create paths and adds admin
controls, all gated on a new ANALYSIS_ENABLED flag (AppState.analysis_enabled,
config::AnalysisConfig):
- Chapter upload (api::chapters) enqueues one analyze_page job per page
after the tx commits (best-effort; failures logged, never fatal).
- Crawler content sync (persist_pages → RETURNING id) enqueues per
persisted page when the daemon dispatcher's analysis_enabled flag is set;
threaded through sync_chapter_content (CLI/resync pass false).
- repo::page_analysis::enqueue_all_pages: bulk backfill via INSERT..SELECT,
skipping done pages (only_unanalyzed) and existing pending jobs.
- New admin endpoints (RequireAdmin, 503 when disabled, audited):
POST /admin/analysis/reenqueue and POST /admin/pages/:id/analyze (force).
Tests: upload enqueues N jobs / no-op when disabled; crawler persist_pages
enqueue gate; admin reenqueue (backfill, idempotent, only-unanalyzed, 503,
non-admin 403) and force-analyze (force flag, 404).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the persistence foundation for the AI content-analysis worker:
- Migration 0025: page_analysis (status + scene + is_nsfw + weighted
search_doc tsvector), page_ocr_text (kind-tagged pieces), global
page_auto_tags (shared tags vocabulary, separate from per-user
page_tags), and page_content_warnings (closed vocabulary).
- domain::page_analysis: AnalysisStatus/OcrKind/ContentWarning enums with
kind->tsvector-weight mapping and lenient model-string parsing, plus the
VisionAnalysis response DTOs.
- JobPayload::AnalyzePage { page_id, force } + KIND_ANALYZE_PAGE, reusing
the existing crawler_jobs queue.
- repo::page_analysis: enqueue_for_page, load, mark_failed, and the single
transactional persist_analysis (delete+reinsert; idempotent; never
touches user page_tags; computes the A/B/C/D-weighted search_doc).
Tests: repo integration (persist/idempotency/user-tags-untouched/enqueue/
mark_failed/speech>sfx ranking) + unit (weights, parsing, DTO, job serde).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
page_tags previously stored the tag inline as free-form text — the lone
un-normalized tag concept, while authors/genres/manga-tags all went
through a lookup table + FK. Fold page tags into the SAME shared `tags`
table that manga_tags uses: `page_tags.tag` becomes `page_tags.tag_id`
referencing `tags(id)`. Manga tags and page tags now share one global
vocabulary.
The HTTP contract is unchanged — the API still speaks tag *names*; the
repo resolves name<->id via `repo::tag::upsert_by_name` (the manga-tag
helper) and keeps the stricter page-tag `normalize_tag` at the boundary.
Frontend, DTOs, and response shapes are untouched. User-visible effect:
page-tag names now appear in the manga-tag autocomplete and vice versa.
Migration 0024 backfills `tags` from existing inline values (deduping
case-insensitively), repoints rows, swaps the unique/secondary indexes
to tag_id, and drops the inline column.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add GET /v1/mangas/:id/similar returning the top 5 mangas ranked by
Jaccard tag overlap (shared / union), excluding self, as MangaCard items
in a plain { items } object. Surface them in a hidden-when-empty
"Similar" section on the manga detail page, reusing MangaCard.
Backend: new repo::manga::list_similar with a manga_tags self-join; a
shared cards_from_rows helper (also used by list_cards) that loads
authors+genres concurrently; single-sourced column list via MANGA_COLS +
manga_cols(alias) to replace the fragile SELECT_COLS string-splitting.
Frontend: getSimilarMangas client fn (guards a malformed body), loader
fetch with non-critical .catch fallback, and the catalog grid hoisted to
a shared global .manga-grid (lib/styles/tokens.css), de-duplicating the
per-route copies.
Bump version 0.62.0 -> 0.63.0 (backend + frontend in lockstep).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the /search surface (Pages / Chapters / Mangas tabs) backed by
per-user page tags and per-page collections: schema (migration 0023),
backend endpoints for page tags/collections and tagged-page aggregations
(with the OCR text-search param reserved at 501), plus the frontend API
clients, library Page-tags tab, collection page sections, page context
menu / AddTagsSheet, and reader long-press wiring. Includes the
continuous-reader navigation fixes (?page=N handling, chapter-reset
timing, back-button pops history) and tag-normalization hardening
accumulated on the branch.
Bump version 0.60.2 -> 0.62.0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reader's "back to manga" link was a naked `<a href="/manga/{id}">`
that pushed a new history entry on every tap, so browser-back kept
ping-ponging between detail and reader instead of walking out to
home:
home → detail → reader → reader-back (push detail) → detail-back
(pop reader) → reader-back (push detail) → … loop forever
The fix intercepts left-click and calls `window.history.back()`
directly when there's same-tab history to pop. Middle-click /
cmd-click / right-click pass through so "open in new tab" still
works, and a deep-link / fresh-tab visit where `history.length ===
1` falls through to the href so the button still leads somewhere
useful.
An earlier attempt gated the back on
`document.referrer.startsWith(origin)` — but SvelteKit's SPA
`pushState` doesn't update `document.referrer`, so that check was
always false in practice and the default href fired anyway. The
e2e regression added here uses real link clicks (not
`window.location.href`) so it actually exercises the SPA-nav path
the bug lives on.
Verified: home → detail → reader, then reader-back keeps
`history.length` at 4 (popped, not pushed), and a second tap on the
detail back button walks out to /.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual cleanup pass after wiring up real data — the placeholder
content used during the five mobile phases hid a few sharp edges
that became obvious as soon as user-supplied tags / author handles
/ descriptions hit the screen.
- Mobile gutter unified at 16px (was 12px) — matches iOS / Material
standard and gives chips, description text, and chapter rows
visible breathing room from the screen edge. Hero negative margin
in /manga/[id] tracks the change so the bleed still hits viewport.
- Catalog grid switched from 2 columns to 4 with
`repeat(4, minmax(0, 1fr))` (per user preference). The `minmax(0,
...)` is load-bearing — without it the implicit `minmax(auto, 1fr)`
lets a long single-word card title push the column past the
viewport edge. MangaCard's `.author` and `.genres` lines hide
below 640px so every card in the 4-col layout has identical height
(cover + 2-line title clamp).
- Same minmax(0, 1fr) trick applied to the detail page's
`.overview` grid track, plus defensive `min-width: 0` /
`max-width: 100%` on `.meta` and `.chip-row` so a long
unbreakable tag or romanized URL can't drag the metadata column
past the article gutter.
- BottomNav swapped from `grid-auto-columns` (= `minmax(auto, 1fr)`)
to an explicit `repeat(4, minmax(0, 1fr))`, plus `min-width: 0` on
`.tab` and an ellipsizing `.label` so a long tab label stays
inside the bar. The Browse placeholder is replaced with Upload —
a real action, no longer a no-op duplicate of Home.
- Hero appbar gets 16px all-around inset (was 8px) so the back / ⋯
circle buttons aren't kissing the screen edge or the hero top.
Hero content gets 20px horizontal padding so the cover thumb +
title sit visibly off the hero scrim. Description gains
`overflow-wrap: anywhere` for long URLs / romanized titles.
- Chip now allows `overflow-wrap: anywhere` on its label and uses
`max-width: 100%` + `min-width: 0` so a long token wraps to its
own line within the chip-row and ellipsizes inside the chip.
- Mobile catalog search-row gains `flex-basis: calc(100% - 36px -
--space-2)` so the search input takes its own line — the Filter
and Sort chips wrap to a second row instead of squeezing the
input down to a few characters.
- Global safety net: `html, body { overflow-x: hidden }` below
640px so any future bug that lets an element overflow the
viewport doesn't expose touch-pan that shifts the whole layout.
Fixed descendants (bottom nav, CTA bar) are unaffected.
- audit.mjs — small dev helper that walks all 12 user-facing
routes at 390px and reports horizontal overflow, internal
scrollers, and edge-bleeding controls. Used to drive these fixes
and useful for future mobile changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 5 (final) of the mobile redesign: /profile/account becomes an
inset-grouped iOS-style hub on mobile and the new /library route hosts
a SegmentedControl over Bookmarks / Collections / History. Profile-
layout horizontal tabs hide below 640px. Desktop layout is unchanged.
- New /library/+page.ts loads bookmarks, collections, and read-history
in parallel so segmented-control tabs swap instantly without a new
round trip. 401 → unauthenticated path with sign-in prompt.
- New /library/+page.svelte renders the SegmentedControl with ?tab=
as the source of truth. Bookmarks reuses the existing BookmarkList,
Collections reuses CollectionsGrid, History inlines a slim cover +
title + "Continue Ch. N" row list. `goto(..., { replaceState: true })`
drives the URL — plain `replaceState` from $app/navigation doesn't
reliably re-trigger $page-derived state.
- BottomNav's Library tab now points at /library (Phase 1 placeholder
was /bookmarks). The Phase 1 mobile-chrome spec is updated to expect
the new target and mocks the additional library data endpoints.
- /profile/+layout.svelte hides the horizontal tabs below 640px — the
bottom-nav Library/Account tabs plus the account hub carry mobile
cross-section navigation.
- /profile/account/+page.svelte gains a mobile hub: a centered avatar
+ username + "Member since" header, a row group with Profile /
Preferences / Change password rows (the last opening a bottom
Sheet hosting the existing password form snippet), and a separate
group with a red "Log out" row that reuses the layout's logout
flow (logout API → session.setUser(null) → preferences.clearForLogout
→ goto('/login')). Desktop keeps the inline card with the form.
- matchMedia gates the hub vs. desktop card so the password form
testids never duplicate on the page — the snippet is rendered in
exactly one mount at a time.
- 9 Playwright tests cover the Library nav handoff, segmented-control
sub-tab swap with URL, sign-in CTAs on both routes, account hub
composition, password sheet open flow, logout flow (POST /auth/
logout + redirect to /login), profile tabs hiding on mobile, and
the desktop regression where the inline card is the only password
surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 4 of the mobile redesign: the reader gets a mobile-native
interaction model — invisible tap zones for prev / next / toggle, a
chapter-jump bottom sheet, a reader settings sheet (mode / gap /
brightness), a fixed bottom page scrubber, a brightness overlay
driven by a CSS variable, and an idle-timer auto-hide for the chrome
after 3s of inactivity. Desktop keyboard shortcuts and chevrons are
preserved above 640px.
- New TapZone primitive: invisible 3-column grid that splits the
viewport into prev / toggle / next thirds (Mihon convention). Wired
to the existing `prev()` / `next()` / new `toggleChrome()` so
chapter-edge behavior, page preload, and read-progress tracking
all keep working. Has its own vitest coverage.
- matchMedia gates every mobile addition so the same DOM never
carries two copies of the chapter selector or two sets of nav
controls — the desktop <select>, mode toggle, and gap field stay
inline above 640px; below 640px they swap to a chapter-jump
button and a settings ⋯ button hosting the Sheets.
- Chapter jump Sheet lists every chapter with the current one
highlighted; tapping a row navigates and dismisses.
- Reader settings Sheet uses the SegmentedControl primitive for mode
and (continuous-only) page gap. Brightness lives next to them as a
range slider 0.3..1; its value publishes `--reader-dim` as a CSS
variable that drives the always-rendered fixed dim overlay
(pointer-events: none so taps fall through). Brightness is
client-side only in localStorage — the Preferences table doesn't
carry it and Phase 4 deferred a backend migration.
- Idle auto-hide: 3s timer scoped to mobile + single mode, reuses
the existing focus-mode CSS for the actual slide-off. The timer
resets on every index/mode/viewport/fullscreen change via a tracked
$effect.
- Bottom page scrubber: a styled <input type="range"> at the
viewport foot, single + multi-page only, sliding off in focus mode
alongside the chrome. Honors env(safe-area-inset-bottom).
- Reader joins the layout's `data-mobile-full-bleed` attribute so the
hero/top reader-nav can sit at viewport top under main's cleared
padding-top on mobile.
- Playwright spec covers tap left/right/center, the 3s auto-hide, the
chapter-jump sheet, the settings sheet swapping to continuous, the
brightness slider driving `--reader-dim`, and a desktop regression
for the unchanged inline controls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3 of the mobile redesign: the manga detail page gets a mobile-
native chrome — blurred-cover hero with a transparent app bar overlay,
secondary actions in an overflow Sheet, a 3-line description clamp
with Read more, and a sticky bottom CTA whose wording reflects read
progress. Desktop layout is untouched.
- /manga/[id] joins the layout's HIDE_MOBILE_CHROME route set so the
global AppBar and BottomNav step aside for the page-specific hero
chrome. A new `html[data-mobile-full-bleed]` opt-in zeroes main's
mobile top padding without touching horizontal/bottom gutters.
- The hero block (mobile-only via CSS) renders a blurred cover
backdrop, a transparent app bar (back / bookmark / overflow ⋯), and
a sharp cover thumbnail beside the title, authors, and status. The
bookmark icon swaps between Bookmark and BookmarkCheck based on the
current bookmark state — reusing the existing toggleBookmark flow.
- Secondary actions (Add to collection / Edit / Upload chapter /
Force resync) move into an overflow Sheet opened from the ⋯
button. The desktop action-row stays.
- Description gets a `.clamped` modifier (-webkit-line-clamp: 3) and
a Read more toggle on mobile, gated by a >200-char heuristic so
short blurbs don't render a dangling button. Desktop shows the
full description as before.
- Sticky bottom CTA bar reads "Continue {chapterLabel}" when
data.readProgress has a chapter_id, "Read first chapter" when
the manga has chapters but no progress, and hides otherwise. The
bar honors env(safe-area-inset-bottom) so it sits above the iOS
home indicator.
- New Playwright spec covers the CTA wording state machine
(no-progress → Read first chapter, has-progress → Continue),
Read more expand/collapse, overflow sheet contents, and a desktop
regression for the unchanged action-row.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>