Skip to content

v2.5.0 - Admin Defaults - #956

Merged
ajslater merged 316 commits into
mainfrom
develop
Sep 29, 2026
Merged

ajslater merged 316 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner
  • Features
    • Admin Defaults tab sets the browser and reader settings new sessions start
      with, replaces Default View, and can update existing anonymous sessions.
    • Browser and reader resets restore the admin defaults.
    • Read comics and collections show a check mark instead of dimmed titles.
    • Online tagging match review shows each file's own cover beside its name.
  • Fixes
    • A Folders or Story Arcs default view opens there instead of Publishers.
    • Reader zoom scales pages from their original size.
    • Fit to Screen fits wide pages when PDFs render as vectors.
    • Admin tables and tag write errors no longer revert to old values.
    • The reader's "b" shortcut sets bottom to top reading.
    • "Clear All Filters" no longer shows when no filter is set.
    • Anonymous browsing no longer logs a timezone error.

ajslater and others added 30 commits July 30, 2026 00:42
Tagging a comic with rename enabled produced a failed import for the
pre-rename path, and the tags that had just been written never imported
at all.

The tag write and the rename land in one watch batch: a modify naming
the old path, plus a delete+add that inode matching pairs into a move.
Move detection never looked at the modify, and the task builder pruned
modified paths only against move destinations, so the task carried
files_moved={old: new} alongside files_modified={old}. The importer
applies moves before reading, so the read opened a path that no longer
existed.

Remap modified paths through the move map rather than dropping
destinations. Sources become their destination (the write-then-rename
every external tagger performs, codex's own included), and destinations
survive, which the poller emits deliberately for a move whose stats
also changed.

Stop the move phase from refreshing Comic.stat. The stored stat means
"the file as of its last tag import", so refreshing it on a move erased
the only evidence the read phase had that the renamed file's contents
had changed too -- the tags were lost rather than deferred. A pure
rename leaves inode, mtime and size alone, so the preserved stat still
matches disk.

Also stop recording failed imports for files that vanished mid-import:
the row was queued before presave() stat'd the path, so the OSError
meant to drop it did not. Key the failed-import map by str so its
membership test against db paths can match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Tag Online dialog's Search and By ID tabs become one pane. Ids are
pinned per source: a pinned source is fetched by that issue id while the
unpinned ones search, in a single comicbox lookup, so merge_all_sources
merges across both. The submit button reads Search, Tag by ID & Search,
or Tag by ID accordingly, and entering an id selects its source.

Ids now ride on tag-sessions/start as {source: token}. That retires the
parallel POST /admin/tag-by-id path entirely -- AdminTagByIdView,
OnlineTagByIdTask, TagByIdRequestSerializer -- so tagging by id gains
session status, resume, and the write pipeline the scan already had.
Also drops dry_run, which the start view accepted and never read.

run_session skips the DB stored-id prepass when ids are pinned: the
prepass pops the comic out of comic_paths, which would leave the
unpinned sources nothing to search.

Needs comicbox 4.8.0 for OnlineSession(ids=...). That release is not on
PyPI yet, so the pin here is still ~=4.7.1 -- bump it after publishing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v2.2.4 (0dd3e51) removed the custom URL fields for both online
sources. Metron's was correctly retired — mokkari hardcodes
METRON_URL and comicbox warns the url is a no-op — but Comic Vine's
worked: comicbox passes it through OnlineCredentials.comicvine_url to
OnlineSourceCredentials.url and on to simyan's base_url, which is what
lets tagging run against a Comic Vine proxy or mirror.

Restores the Comic Vine half only, no comicbox change needed:

- Model field + migration 0052. A plain URLField rather than an
  EncryptedCharField, because unlike the API keys it is not a secret
  and must read back for the admin form's placeholder.
- Admin serializer (read+write), validate request serializer, and the
  validate view's _CREDENTIAL_FIELDS.
- All three librarian consumers: the scan session's OnlineCredentials,
  the explicit-id auth mapping, and the credential validator's simyan
  base_url, so Test checks the endpoint the scan will actually use.
- Backup/restore sidecar column, serializer, and restore allowlist.
  _reconcile_columns retrofits existing sidecar files; a legacy
  metron_url column in an older backup stays silently ignored.
- Telemetry reports only bool(comicvine_url) — never the value.
- The admin Tagging tab's Comic Vine panel gets the field back, its
  save button reverts to "Save Comic Vine Credentials" now that a
  URL-only save is possible again, and Clear removes both.

A URL alone is not a credential: it must not satisfy the session
manager's configured-sources gate, enable the source checkbox, or pass
validation without a key. Tests pin all three.

URLs dropped by 0051 are unrecoverable; admins re-enter them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three items `make complexity` flagged, all pure refactors.

AdminOnlineTagStartView.post (CC 11 -> C): three near-identical
"request value or stored default" blocks collapse into a
_FLAG_DEFAULT_FIELDS mapping plus a _resolve_flags helper.

test_telemeter_privacy._vocabularies (CC 12 -> C): split by vocabulary
source into _choice_vocabularies, _identifier_bucket_vocabulary and
_tagging_vocabularies, with the literal and OIDC sets hoisted to module
constants. The same string set is checked; the walk is not widened.

test_onlinetag_session_manager (MI 17.37 -> B): 916 lines in one module,
every function already rank A. Split along its seams into the scan pass,
prompt resolution and credentials, with the doubles and the comic factory
moved to tests/onlinetag_session_fakes.py. All 31 tests come across
unchanged; the move dedupes the BulkTagWriteTask filter into
write_tasks() and the prompt dict into a _prompt() factory.
test_onlinetag_tag_pass now imports the fakes from their new home.

make complexity, lint, ty clean; 815 pytest + 371 vitest pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A move whose destination path already belonged to another comic row
violated the (library, path) unique constraint inside bulk_update. The
IntegrityError aborted the whole import before the delete phase could
clear the stale row, so the poller rebuilt the same task and crashed on
every subsequent scan.

Mirror the folder guard for comics and custom covers: drop moves onto
paths an existing row holds, and moves that two sources in one batch
claim, which files_moved can express because it is a plain dict rather
than a bidict. Skipping converges. The destination row's stat refreshes,
the next scan sees two rows sharing an inode and suppresses the bogus
move, and the stale source falls through to the delete phase.

Also contain any move phase failure in all three move steps so a bad
batch degrades to a skipped phase the next scan reconciles instead of
aborting the import. This covers the same class of crash in the folder
step, where converting dirs_moved to a bidict raises on the duplicate
destinations the watcher can emit.

Fixes #807

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The self-update was not flaky, it was inert.

Nothing ever asked for an automatic update. update_latest_version()
had an `update` hook that queues JanitorCodexUpdateTask and no caller
passed it, and the update task was not in the nightly fan-out, so the
Auto Update flag promised a daily upgrade that could never happen. The
nightly check now forces a fetch and chains into the update when the
flag is on. The flag gate moves to that scheduling point, so the admin
Jobs tab button updates whether or not the flag is set -- it used to
silently no-op, since the flag defaults off and the button sends
force=False.

The installer ran `sys.executable -m pip install --upgrade codex` with
no timeout, no captured output, and a blanket except that swallowed
every failure while still logging "updated to the same version". uv and
pipx environments have no pip and the docker image uninstalls it, so
those installs always failed invisibly. Now: pip if importable, else
`uv pip install --python <sys.executable>`, else an ERROR naming both
and pointing docker at a new image. Failures log the installer's own
stderr, a hung installer times out instead of wedging the scribe queue
behind it, and the restart only fires when the install really happened.

Restarting exec'd __file__, which relied on run.py's executable bit and
its `#!/usr/bin/env python3` shebang resolving to a python that has
codex installed. Under macOS, pipx, uv, systemd and launchd that is
frequently a different interpreter and the server never came back. Exec
`sys.executable -m codex.run` instead.

_is_outdated() called Version() on unvalidated strings, raising on a
fresh install's empty cache and on a source checkout's "test" version.
Version comparison is now codex.version.is_outdated(), total by
construction, shared by the janitor and the version view.

The browser could not announce anything: semverGreaterThan(a > b) passed
one boolean into a two-argument function so `outdated` was always false,
and the comparison read 1.0.9 as newer than 1.1.0. The server ships
`outdated` and `docker` in the version payload and the javascript
comparison is gone. The footer renders a codex orange "upgrade to codex
vX.Y.Z" link to the Update Codex job, or to the image repo in docker,
where codex cannot install over itself. The Jobs tab grows section
anchors, names the version the job would install, and explains the
docker case.

While the cache is empty every /api/v4/version request queued a fetch
task, each on its own thread with a 5 second PyPI call. Add a process
wide in-flight lock and a cooldown after a failed fetch.

Tests cover the version comparison, the payload, installer selection and
failure paths, the flag gate and fetch guards, the nightly wiring, and
both frontend surfaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nginx hides the upstream Server header by default (along with Date,
X-Pad and X-Accel-*) and substitutes its own, so codex/<version> never
reaches clients behind a reverse proxy. Granian passes the header set by
CodexMiddleware through untouched; nginx is the only clobberer.

Add proxy_pass_header Server to the README's example location, plus a
subsection covering the curl verification, why server_tokens off is not
a substitute, and the array-directive inheritance trap (a location that
declares any proxy_pass_header drops the one inherited from server{}).

Set the same directive in the test-proxy harness so the documented
config is the one actually exercised.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ajslater and others added 29 commits September 28, 2026 12:37
…#935)

The reader debounces bookmark writes by a second. The pending write
read `books.current` when its timer fired, and a new write replaced a
pending one. Paging to a book's last page and moving straight on to the
next book therefore either dropped that book's final page and its
`finished` flag, or wrote them to the next book, depending on whether
the timer fired before the next book's first write.

The debounce now captures the book with the page when the write is
scheduled, and a pending write for a different book lands immediately
instead of being replaced. Page turns within one book still coalesce
into a single write.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…936)

Every reader settings action awaits the server and then writes to
whatever book or series scope is open at that moment. Changing a setting
and turning to the next book before the save returned wrote the old
book's settings onto the new one. A slow settings load for a book the
reader had already left could overwrite the settings of the book now
open, including its series settings.

updateComicSettings and clearComicSettings now apply only if the book
they saved is still current. loadAllSettings drops a response for a book
no longer open, since loadBooks starts a fresh load for every new book.
updateIntermediateSettings and clearIntermediateSettings apply only if
the same series, folder or arc scope is still open. Clearing the merged
settings cache stays unconditional, because it is keyed by book.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
loadGlobalSettings started its request in a fire-and-forget async IIFE
and resolved at once. reloadOnDefaultsChange awaits it when the admin
defaults change under an open reader, so it reported the reload done
before the new settings had arrived.

The action now awaits its own request. Callers that don't await it, the
reader view and loadBooks, are unaffected. Errors are still logged and
never reject.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…uests (#938)

_filterSettings built a pruned copy of `filters` and then returned the
original: Array#filter only reads its callback's result as a keep/drop
flag, so the pruned object was thrown away. Mark Read, Force Update and
metadata loads sent every empty filter list along with the ones in use.

It now builds the result directly, so `filters` carries only the filters
in use, and is still dropped when none are. The server reads every filter
with a default, and the filter serializer's fields are optional with no
default, so a missing filter already meant the same as an empty one.
Results don't change; the request gets smaller.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The dialog re-validates the form on every edit and set the submit button
from whichever async form.validate() settled last. It also validated
before the inputs re-rendered, so the check read each field's value from
before the edit: a group name entered in one input event was checked as
blank ("Name is required") and "Add Group" stayed disabled on a valid
form.

Wait a tick so the fields settle, and apply only the newest validation.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Anonymous browsing with Non-Users on logged "Authentication credentials
were not provided." on every load: the SPA sends the browser timezone
for kiosk sessions on purpose (app.vue's nonUsers watcher), but the
v4 cutover folded the v1.7 PUT /auth/timezone/ into PATCH /auth/profile,
whose IsAuthenticated policy 403s anonymous callers. The client was
right; the server regressed. CodexMiddleware activates the session
timezone on every request, anonymous or not, so the value is used.

Restore a dedicated PUT /api/v4/auth/timezone on the default
IsAuthenticatedOrEnabledNonUsers policy (what v1.7 used), point the
client at it, and drop timezone from the profile PATCH so there is one
write path. Relaxing ProfileView instead would have opened its GET and
username/email edits to anonymous callers.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The admin store's generic table engine (loadTable, loadTables, the row
writes and pending-delete revival) backs every admin tab, but the only
store specs covered OIDC and the site defaults. This adds a
characterization spec for it, with every TABLES request function mocked
and each table's real stateField kept.

It covers both response shapes loadTable accepts (a bare array and a
cursor page) and what happens to anything else, the 5 s sticky cache
per table, AgeRatingMetron never expiring, force bypassing the cache,
and loadTables filling each table's own field and resolving only when
all have landed.

It also pins which tables reload after a write: create, update and
delete force-reload only their own table, and a revive force-reloads
PendingDelete and Library, with nothing reloaded when the request
fails. The librarian-status diff keeps unchanged rows (and their
watchers) untouched, and non-staff visitors get no request and no
state change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
_validateTopCollection has needed five fixes and no test reached it: the
existing pipeline specs enter through _validateSearch and never take its
top-collection branches. This spec drives the live store through each of
them, with every case named for the route it runs on.

It covers the root guard and its folders/arcs exception, the switches that
keep the route, the in-place redirect for a parent top collection (whose
numeric page is what keeps _validateAndSaveSettings' "already there" check
from dropping it), the root redirect for a child, the moves to and from
folders and arcs, and the case where no browser route exists yet.

getTopCollection's walk up the hierarchy is pinned against settings.show,
and lowestShownCollection against both browse and non-browse top
collections. No production code changes.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The browser store kept a top-level `zeroPad` that nothing reads; the
card subtitle reads the server's `page.zeroPad`. `deleteSavedSettings`
has had no caller since the saved-settings UI landed without a delete
button; the API function it wrapped stays for when one is added. The
misc settings drawer mapped `twentyFourHourTimeTitle`, which its
template never uses.

No behavior change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…om to top (#943)

* refactor(reader): drop reader store bindings no component uses

Eight reader components mapped store members they never read. Two of
them pointed at nothing: the pager mapped an `isBookVertical` action the
store doesn't have, and the books window defined `bookChangePrev` and
`bookChangeNext` computeds that called an unmapped `bookChangeShow` and
would have thrown if anything had rendered them. The rest were real
members left behind by earlier moves: the top toolbar's four navigation
actions, the download panel's `routeParams`, `isBTT` in the book-change
drawer and nav button, and the horizontal pager's `nextBook` and
`setBookChangeFlag`.

The vertical pager only reads `reactWithScroll`, so it maps it with
`mapState` instead of `mapWritableState`.

No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(reader): make the "b" shortcut set bottom to top reading

The reader settings drawer's "b" key wrote the reading direction "bbt",
which isn't a direction the server accepts, so the shortcut never
switched a book to bottom to top. It now writes "btt".

A new spec presses each direction and fit key and checks the values
sent against the reader choices the server generates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…944)

* fix(reader): stop a redundant settings request and a stale zoom fit

Opening a book asked the server for the global reader settings twice.
loadBooks fetched them on its own behind a settingsLoaded flag that
nothing has set since the flag's setter was removed, and then
loadAllSettings fetched the global, series and comic settings for the
same book. Every book open made the extra request. loadBooks now relies
on loadAllSettings for the global settings, and the dead flag is gone.
The reader view still loads the global settings on its own before a
book arrives.

loadAllSettings now also marks the global settings as loaded when they
arrive. The dropped request used to do that, and without it a site
default seed landing after the book's settings could overwrite the
stored global settings until the reader view's own load arrived.

Zooming a page with a double-click did not switch the page to its
original-size fit. The fit class is cached with each book's settings
and depends on the zoom level, but the zoom was the one reader setting
that did not drop that cache. A zoomed page kept its screen, width or
height fit and was magnified from the fitted size, off centre and
larger or smaller than the scroll area sized for it. The zoom now drops
the cache like every other settings change, so zooming in shows the
original size and resetting the zoom restores the chosen fit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(reader): pin how book settings are masked and cached

Cover getBookSettings: the book over the series over the global scope,
empty values never overriding a lower scope while false and 0 do, two
pages forced off in vertical reading, the direction flags for all four
directions, the fit class when zoomed, the per-book cache, and every
action that must drop that cache, zoom included. Also pin that opening
a book makes one settings request that includes the global scope, and
that the settings it loads stop a later site default seed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
)

The admin store's folder picker state used the key `root`, but the
server sends `rootFolder` and the picker reads `rootFolder`. Clearing
the Library path field reset the picker to `{ root, folders }`, so the
root folder read as empty until the follow-up folder load replaced the
whole object. The state and `clearFolders` now use `rootFolder`.

The libraries table also mapped a `clearErrors` action the admin store
doesn't have; that lives in the common store. It was never called, and
would have thrown if it had been.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ndles (#946)

The socket client lazy-imports the admin store so non-admins never
download it, but the metadata dialog's controls imported the online-tag
launcher statically, and the launcher imports the admin store. That put
the store in every browser and reader page load.

The controls now load the launcher with defineAsyncComponent, the same
way the metadata dialog already loads the tag edit panel. The launcher
only renders for admins, so only they fetch it.

Checked in the build manifest: the reader entry no longer reaches any
admin chunk, and the browser entry no longer reaches the admin store.
The browser still reaches the admin API client, which the card menu's
custom cover buttons import directly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…948)

* test(admin): cover the table engine

The admin store's generic table engine (loadTable, loadTables, the row
writes and pending-delete revival) backs every admin tab, but the only
store specs covered OIDC and the site defaults. This adds a
characterization spec for it, with every TABLES request function mocked
and each table's real stateField kept.

It covers both response shapes loadTable accepts (a bare array and a
cursor page) and what happens to anything else, the 5 s sticky cache
per table, AgeRatingMetron never expiring, force bypassing the cache,
and loadTables filling each table's own field and resolving only when
all have landed.

It also pins which tables reload after a write: create, update and
delete force-reload only their own table, and a revive force-reloads
PendingDelete and Library, with nothing reloaded when the request
fails. The librarian-status diff keeps unchanged rows (and their
watchers) untouched, and non-staff visitors get no request and no
state change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(admin): share one sticky-cache test across the admin loads

The admin store's table loader and its six settings loaders each
repeated the same five-line check of a timestamp against the cache
window. They now call one isFresh helper, so the window's edges (a
never-loaded stamp is stale, and so is data exactly one window old) are
defined once.

No behavior change. A flat spec pins the helper. The table engine spec
and the OIDC and site defaults specs exercise three of the seven gates
through the store; the tagging defaults, tag-write errors, email and
throttle gates are the same one-line call.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…950)

The browser card menu's Upload Cover and Remove Cover buttons imported
uploadCustomCover/removeCustomCover from @/api/v4/admin statically, so
the browser entry pulled the whole admin API chunk (~4.8 kB) on first
load for every user. Both buttons are admin-only (isUserAdmin gate,
AdminAPIView on the server), so load the client with a dynamic import
inside their handlers instead. The menu stays synchronous; the chunk is
fetched only when an admin actually uploads or removes a cover.

Adds unit tests for both buttons' admin flow and non-admin hiding.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
loadTable didn't check the order its responses came back in, and it
stamped each table when the response arrived. If a tab mounted with an
empty or expired cache, its read could still be in flight when the admin
saved a row. The save's forced reload landed the new rows, then the
older read landed late and put the pre-save rows back. It also restamped
the table, so every unforced read for the next five seconds was served
those stale rows from cache. A WebSocket-forced reload overlapping a
slower unforced read did the same thing.

Every loadTable call now takes a request number. Rows land only if no
later request for the same table has landed first. A later request that
fails doesn't block an earlier one. The table is stamped with the time
its request went out, because the rows are only as fresh as that moment.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
FIT_TO_CLASSES mapped "O" to "Original", so the Original Size setting
at the default zoom produced fitToOriginal / fitToOriginalTwo /
fitToOriginalVertical while page-img.vue and pdf-doc.vue only style
fitToOrig*. The zoomed-in path already used "Orig". Map "O" to "Orig"
and have the zoom path read the same entry so the two can't drift.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
None of pdf-doc.vue's four canvas rules had matched anything since the
Vuetify 3 port (Dec 2022). That port moved them from an unscoped block
into top-level :deep() selectors, which compile to
`[data-v-x] .vue-pdf-embed.X > div > canvas` and need a scoped ancestor.
The embed is the component's root, so it has none. vue-pdf-embed 2.x
also nests the canvas one div deeper than `> div > canvas`.

With the selectors fixed (`.pdfDoc.X :deep(canvas)`), each rule was
checked in a browser with Force vector at DPR 2:

- Fit to Height/Screen two-page `width: inherit` and Fit to Width
  two-page `height: inherit` did nothing. vue-pdf-embed already gives
  the canvas an aspect-correct inline size. Deleted.
- Original two-page `width/height: inherit` resolved to auto, so each
  canvas showed at its bitmap size, 2x on HiDPI (1023x1581 instead of
  512x790). Zoom uses these classes too. Deleted.
- Fit to Screen `object-fit: contain` did nothing, because the box
  already matched the bitmap. Kept, and paired with max-width
  100vw/50vw as page-img.vue does for images. Wide pages now fit the
  screen instead of overflowing it: a spread plus a page in two-page
  mode at 1024px went from a 1491px scroll width to 1024px, and a wide
  page on a 390px phone from 1092px to 390px.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…errors (#952)

loadTagWriteErrors had the race loadTable had before #949. The Tagging
tab and the settings button read the list unforced when they mount, and
the WebSocket forces a reload whenever it changes. If a slow mount read
landed after the forced reload, it put the older list back and stamped
it when it arrived, so every unforced read for the next 5 s was served
the stale list. clearTagWriteErrors wrote [] directly, so a read that
went out before the clear could put the cleared errors back the same way.

Both now use loadTable's request counter. #949's landing check moves into
a claimLanding(key, request) helper that all three share, keyed like
timestamps. The tag-write reads take a number when they go out, land only
if no later request has landed, and stamp the time they went out. The
clear takes a number when its DELETE goes out, so an older read can't
undo it, and a read that went out after it and already landed isn't
wiped by it.

The singleton settings loads (tagging defaults, email, OIDC, site
defaults, throttling) are unchanged. Nothing forces them, and their only
writer is the same tab's own Save. That can't realistically fire before
the tab's mount read comes back.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* chore: update devenv

- Retire the dead .circleci ignore lines (devenv's new retirement lists).
- eslint base config: presets apply again and Markdown code blocks are
  linted, which reformats the compose YAML example in docs/DOCKER.md.
- pyproject template: T201 exemption for bin/release_info.py.
- eslint-plugin-package-json bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: compose CI from devenv's building blocks

ci.yml is now a caller of devenv's CI building blocks (copied in by
`make update-devenv`) plus codex's own image jobs:

- ci: devenv-check.yml with ci-target codex-ci and the same four checks.
  Its gate runs the Release Preflight and, on a main push, reuses the
  python-dist of an earlier run that passed on the same git tree (keyed
  by tree hash, replacing the "last merged PR" lookup). One image job
  builds codex-ci and pushes it by digest; each check pulls it instead of
  building and loading its own. The required check becomes
  "CI / Lint, Test & Build Dist".
- build, deploy, deploy-hub: unchanged in substance, but they read
  deploy/version/final from ci's outputs instead of testing the event or
  grepping pyproject.toml. deploy publishes to PyPI through devenv-pypi
  (uv publish --check-url) after the manifest. deploy-hub now skips at the
  job level for alphas.
- release: devenv-release.yml (tag, GitHub Release, merge-back), after
  deploy and deploy-hub.

Removes .github/actions/ci-container (now devenv-ci-container) and the
release-engine tests, which devenv owns now; codex keeps its golden
NEWS.md tests. tests/test_ci_workflow.py pins how codex wires the blocks.
CLAUDE.md describes the new graph and the current Dockerfile stages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* update deps

* fix(make): build the choices JSON before lint and fix

Since the ESLint preset fix (#931, #932), import-x/no-unresolved resolves
the frontend's `@/choices/*.json` imports, which bin/build-choices.sh
generates and git ignores. A checkout that never built them, such as CI's
fresh codex-ci container, fails `make lint` and `make fix` with 19
unresolved-import errors. Give lint and fix the same build-choices
prerequisite test-frontend and build already have.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…955)

* feat(browser): mark finished items with a check cap, not a thick bar

Issue 856 feedback: the 6px grey finished bar was hard to tell from the
3px reading bar when scanning a grid, and only really read when two cards
sat side by side. The dimmed caption was a second cue that made read
titles harder to read.

Every bar is now 3px. A finished comic or all-read collection ends its
bar in a 10px grey check circle, the same grey as the fill. The bar runs
under the cap to its centre so the two join with no gap. The check is
stroked in the page background instead of cut out, because a cut-out
would show the track through it. The fill is still the real bookmark
position, so a comic marked read but never opened is an empty track and
its cap.

Read titles and subtitles keep their normal colour.

The bar is now one component, ReadStateBar, used by both the browser
card and the metadata dialog. They had duplicated its CSS, and the cap
would have been a third copy to keep in sync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(metadata): draw the read state under the cover, not behind it

The read state bar in the metadata dialog has been invisible since the
read state landed in v2.4.0. It sat 15px up over the cover's bottom edge,
where the old v-progress-linear used to be. That component is
positioned, so it painted over the cover. The plain div that replaced it
is not, and the absolutely positioned cover image painted over the bar.

It now sits 1px under the cover at every breakpoint, as on the browser
card, which moves the dialog's buttons down about 16px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ajslater
ajslater merged commit 5e60155 into main Sep 29, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant