Skip to content

v1.12.0 - #756

Merged
ajslater merged 268 commits into
mainfrom
develop
May 10, 2026
Merged

ajslater merged 268 commits into
mainfrom
develop

Conversation

@ajslater

Copy link
Copy Markdown
Owner
  • Features
    • Browser Table mode shows configurable, sortable, metadata columns.
    • Favorite objects and filter on them.
    • Serve image-dominant PDF pages as just their images.

ajslater added 30 commits April 5, 2026 15:47
commit c76660006840abb36aa37d7355d5c7e242babebf
Merge: b2b5a011a be94a0a
Author: AJ Slater <aj@slater.net>
Date:   Sun Apr 5 15:49:11 2026 -0700

    Merge branch 'develop' into select-multiple

commit b2b5a011ab207a7307505b092877f789e1a66ab3
Author: AJ Slater <aj@slater.net>
Date:   Sat Apr 4 22:15:54 2026 -0700

    fix card menu hover highlighting

commit 5a98fe23d89be5e11fa46ada927100ccd3e08cda
Author: AJ Slater <aj@slater.net>
Date:   Sat Apr 4 22:13:37 2026 -0700

    new design for select many mode. no settings drawer involvement. reconfigure cards

commit 82c612e3f77eec90b02465174b49fc8552871686
Author: AJ Slater <aj@slater.net>
Date:   Sat Apr 4 01:50:11 2026 -0700

    add github config to source include. remove dockerhub config

commit b02bd450f4598300d78433a2d749741872d14894
Author: AJ Slater <aj@slater.net>
Date:   Sat Apr 4 01:42:32 2026 -0700

    update deps

commit d7bbc875e7936d808259c9ba726127fcc3af905e
Author: AJ Slater <aj@slater.net>
Date:   Fri Apr 3 23:56:12 2026 -0700

    select many toolbar

commit 246d5ecda59aaabb83e41db3ad32ec552804b9ae
Author: AJ Slater <aj@slater.net>
Date:   Fri Apr 3 22:23:24 2026 -0700

    select many feature first attempt
ajslater and others added 29 commits May 8, 2026 22:52
…743)

* reader: serve image-dominant PDF pages as <img>, drop full-PDF mode

Most "comic PDFs" are scanned-image wrappers — one full-bleed JPEG
or PNG per page, no real vector content. Previously every PDF page
was routed through ``vue-pdf-embed`` (and through pdf.js on the
client) regardless. Now the backend runs an image-dominant page
detector via ``comicbox-pdffile`` 0.6 and serves matched pages as
plain image bytes; the browser renders them through ``<img>`` like
any CBZ page. Vector-content pages keep the existing single-page-PDF
+ ``vue-pdf-embed`` path.

Backend
=======

* ``codex/views/reader/page.py``
  - ``?format=auto|pdf|image`` query parameter. ``auto`` (default)
    runs the detector. ``pdf`` skips the detector and forces the
    legacy single-page-PDF path. ``image`` always rasterizes — works
    for any PDF page but spends more CPU on vector-heavy pages.
  - ``_try_pdf_image_serve`` reaches through Comicbox to the
    underlying ``PDFFile`` (private API for now; comments mark the
    seam for a future public-getter swap) and dispatches to
    ``classify_page`` / ``read_image_if_dominant`` /
    ``read_full_pixmap_jpeg``. Per-page verdicts are memoized on the
    ``_ArchiveEntry`` so prev/curr/next prefetch is effectively free.
  - Old ``?pixmap=`` query parameter dropped — it returned PPM bytes
    with ``image/jpeg`` content-type (latent labeling bug; no caller).
  - OpenAPI schema advertises three possible response content-types:
    ``application/pdf`` (fallback), ``image/jpeg``, ``image/png``.
* ``codex/views/reader/_archive_cache.py``
  - Adds a ``verdicts: dict[int, PageVerdict]`` slot on
    ``_ArchiveEntry`` for the per-page detector cache.
  - New ``open_entry()`` context manager yields the entry directly
    (existing ``open()`` keeps yielding ``Comicbox`` for callers
    that don't need verdict state).

Frontend
========

* ``BookPage`` (``page/page.vue``) always tries ``<ImgPage>`` first.
  On ``error`` for a PDF book it sets ``pdfFallback=true`` and the
  page re-mounts as ``<PDFDoc>`` against the same URL with
  ``?format=pdf`` appended. No HEAD pre-flight, no verdict threaded
  through the API response — the browser's image-load failure on a
  ``application/pdf`` body is the natural signal.
* Drops ``PagerFullPDF`` and the whole-document-load mode it served.
  ``pager.vue`` now picks between ``PagerHorizontal`` /
  ``PagerVertical`` based purely on reading direction.
* Drops the ``cacheBook`` carve-out for vertical PDFs in
  ``stores/reader.js`` — PDFs prefetch alongside CBZ now.
* Adds a per-comic "PDF Rendering" radio in the reader settings
  drawer (Auto / Force image / Force vector) wired to a new
  ``clientSettings.pdfRenderMode`` field. Forwarded to the page
  endpoint as ``?format=``.
* ``getComicPageSource`` accepts an optional ``format`` parameter.
  Omitting it preserves the URL shape so HTTP caches don't fragment.

Sequencing
==========

This change depends on two upstream PRs:

* ``comicbox-pdffile`` 0.6 — image-dominant detector + extractors:
  ajslater/pdffile#22
* ``comicbox`` widened pdffile pin (>=0.6,<0.7):
  ajslater/comicbox#131

The ``[tool.uv.sources]`` block in ``pyproject.toml`` temporarily
points both deps at their PR branches so this branch's CI can
resolve. Once both upstreams land on PyPI, drop the sources block
and the explicit ``comicbox-pdffile`` direct dep.

The full design + empirical validation against a 14-PDF private
corpus lives in ``tasks/pdf-image-detection/``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* TEMP: debug logging for PDF page-render failure paths

Reports of 404s on PDF page requests with the new image-dominant
detector landed. The catch-all ``except Exception`` in
``ReaderPageView.get`` was logging only ``str(exc)`` — no traceback,
no path context — so the actual failure point was invisible. Hook
loguru's ``logger.exception`` to surface the traceback, plus
``logger.debug`` markers at the decision points so we can see which
branch each request took:

* image-serve auto: which verdict + xref + ext we got
* image-serve force-image: bytes/ext returned
* image-serve declined: why we fell through
* legacy PDF path: bytes/content-type served, plus a wrapper that
  surfaces ``get_page_by_index`` failures with a traceback before
  the catch-all flattens them to 404

All log lines tagged ``[pdf-debug]`` for grep + an easy revert.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* TEMP: log view entry/exit + Comic.DoesNotExist + FileNotFound

The earlier debug pass logged the catch-all exception path but
silently 404'd through the Comic.DoesNotExist / FileNotFoundError
handlers (no log line). User reports a 404 on /api/v3/c/1/0/page.jpg
that has *no* matching [pdf-debug] log line, meaning the request
either bombs out before _get_page_image (in _resolve_path_and_type's
ACL/DB lookup) or gets caught by one of the silent handlers.

Add view-entry, view-exit, and per-handler log lines so every 404
correlates to one specific [pdf-debug] line. Entry log includes the
User-Agent and Referer so we can tell whether a failing request
comes from <img>, vue-pdf-embed's fetch, a prefetch, or a 'Read in
Tab' direct nav.

All TEMP DEBUG; revert with grep -l '[pdf-debug]' once stable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: rename ?format= → ?serve= to dodge DRF's URL_FORMAT_OVERRIDE

Bug
===

PDF pages were 404'ing with ``?format=pdf`` (the ``serve as a single
single-page PDF blob to vue-pdf-embed`` path). Server logs showed the
URL request reaching ``ReaderPageView.get`` for the no-param ``<img>``
attempt and serving the PDF bytes successfully. The follow-up
``?format=pdf`` request 404'd before the view's entry-debug log
fired, with the response body shaped like DRF's
``{"detail": "Not found."}`` and ``Content-Type: application/json``.

Cause
=====

DRF reserves ``?format=`` (``REST_FRAMEWORK['URL_FORMAT_OVERRIDE']``,
default ``'format'``) as a renderer-format selector.
``DefaultContentNegotiation.filter_renderers(renderers, 'pdf')`` runs
inside ``APIView.dispatch.initial`` *before* the view's
``get`` handler. With no PDF renderer registered, that method
raises ``exceptions.NotFound`` per DRF source:

    def filter_renderers(self, renderers, format):
        renderers = [r for r in renderers if r.format == format]
        if not renderers:
            raise exceptions.NotFound(...)
        return renderers

So the request was getting a 404 from DRF's content negotiator
before the view code ran — explaining the missing entry log.

Fix
===

Rename the query parameter from ``format`` to ``serve`` end-to-end:

* Backend: ``_FORMAT_*`` constants → ``_SERVE_*``; OpenAPI schema
  parameter renamed; debug-log fields renamed.
* Frontend: ``getComicPageSource({ ..., format })`` →
  ``({ ..., serve })`` and the URL builder emits ``&serve=`` instead
  of ``&format=``. Internal field names (``pdfRenderMode`` etc.)
  unchanged — only the wire param renamed.

Verified by curl:

    HEAD ?ts=...&format=pdf  →  404 (DRF NotFound)
    HEAD ?ts=...&serve=pdf   →  200 application/pdf  (✓ fixed)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: don't cache error responses on the page endpoint

Companion to f970a3b. The ``cache_control(max_age=PAGE_MAX_AGE,
public=True)`` decorator on ``page.jpg`` was patching its
``Cache-Control`` header onto *every* response — including 4xx —
because Django's ``cache_control`` doesn't filter by status.

That turned every transient failure into a week-long cache poison.
Symptom in the field: three concurrent ``?format=pdf`` 404s in the
browser surfaced only one server-side log line; the other two were
served from the browser HTTP cache (cached from earlier ``format=pdf``
requests in prior sessions, when DRF's URL_FORMAT_OVERRIDE rejected
the param). Once a 4xx ships with ``Cache-Control: public, max-age=
604800`` the browser pins it for a week and never asks again.

Add ``codex.views.util.cache_control_2xx`` — same shape as Django's
``cache_control`` but only patches the header on responses with
status 200-299. Swap it in on the page endpoint. Other routes that
use ``cache_control`` are mostly cover endpoints + book.pdf
(server-side cached via ``cache_page``), where 4xx is rare and
short-lived; leave them alone for now.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* remove temporary [pdf-debug] logging from ReaderPageView

The PDF rendering paths are stable now (?format= → ?serve= rename
fixed the DRF NotFound issue, cache_control_2xx fixed the cached-404
issue). Strip the temporary view-entry/exit, image-serve-decision,
and exception logging that helped diagnose those.

Reverts:
* logger.exception(...) in catch-all → logger.warning(exc) (original)
* Surfacing legacy get_page_by_index failures with traceback
* All [pdf-debug] DEBUG-level decision logs

The structural fixes — ?serve= param, classify-on-cache-entry,
cache_control_2xx — stay.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* add browser table view phase 0 plan

Captures the architecture for a tabular alternative to the cover-
emphasized browser view: per-top-group column visibility, header-click
sorting reusing the existing order_by/order_reverse settings, expanded
order_by enum, single-endpoint API for v1 with column-narrowed API
flagged as Phase 7 optimization debt, and a column registry shared
between backend and frontend that future-proofs inline cell editing
and M2M sorting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: lock columns= query param into v1 scope

Rather than accept the optimization debt of returning all column data
on every table-view request, narrow annotations and M2M aggregates by
a columns= query param up-front. The frontend reads the persisted
table_columns setting and translates it to the param on each request.
Cover view stays unchanged. Eliminates what was tracked as Phase 7
optimization debt and avoids paying GROUP_CONCAT cost on every page
load for users who never enable any tag-style columns.

Also updates Phase 1 plan with the corresponding step changes (Step 5
adds columns= validation; Step 6 wires JsonGroupArray narrowed by the
requested set).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: add view_mode + table_columns settings

Step 1 of Phase 1: introduce three new fields on SettingsBrowser to
persist the user's table-view preferences:

- view_mode (cover|table, default cover) - chooses which presentation
  the frontend renders.
- table_columns (JSON map keyed by top-group) - the visible column
  set per top-group; falls back to defaults from the column registry
  when missing or empty.
- table_cover_size (xs|sm, default sm) - thumbnail size for the cover
  column when displayed in table mode.

DIRECT_KEYS gains all three so they round-trip through
browser_instance_to_dict and get_browser_default_params automatically.
Schema-only migration; existing rows backfill with model defaults.
BROWSER_DEFAULTS updated for frontend choice consistency.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: round-trip new settings through serializer

Step 2 of Phase 1: BrowserSettingsSerializer accepts the three new
fields added in Step 1 (view_mode, table_columns, table_cover_size).
Persistence and reset already work since DIRECT_KEYS drives them
automatically.

table_columns is declared as DictField[ListField[CharField]] with a
custom validator that rejects keys outside BROWSER_TOP_GROUP_CHOICES.
Column-key validation against the registry waits for Step 3.

Tests cover the model-level wiring, the serializer accept/reject
matrix, and the HTTP PATCH/GET/DELETE round-trip including the
camelCase response shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: add column registry

Step 3 of Phase 1: define the column registry that drives every
table-view feature downstream (column picker, columns= validator,
row serializer field generation, header-click sort key resolution).

The registry data lives in codex.choices.browser so the existing JSON
build pipeline (choices_to_json.py, browser-table-columns.json,
browser-table-default-columns.json) can hand it to the frontend
without circular imports. Helper functions
(default_columns_for / is_sortable / is_m2m / sort_key_for) live in
codex.views.browser.columns and import the data downward.

Tightens validate_table_columns to reject unknown column keys per
top-group. Some sortable columns reference order_by enum keys that
aren't yet defined (Step 4 expands the enum); the runtime stays
consistent because the order_by ChoiceField rejects them until then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: expand order_by enum with 20 new keys

Step 4 of Phase 1: enlarge BROWSER_ORDER_BY_CHOICES from 13 to 33
entries so the table view can offer header-click sorting on every
column the user is likely to expose.

The new keys split into two categories:

- direct Comic fields (year, month, day, issue_number, issue_suffix,
  file_type, monochrome, reading_direction, metadata_mtime). These
  use the existing pipeline unchanged: Comic queries order on the
  indexed field directly; group queries aggregate via Min through
  the relation prefix.

- FK-name keys (series_name, volume_name, publisher_name,
  imprint_name, country, language, original_format, tagger,
  scan_info, main_character, main_team). These need ORM-path
  translation since Comic carries FK objects, not denormalized name
  fields. The new `comic_order_path` helper maps each enum key to
  its `__name` path; both `_add_comic_order_by` and
  `annotate_order_value` route through it.

Adjusts the column registry so `issue_count` is sort_key=None (the
sortable promise needs a Count annotation that is out of scope for
Step 4). The new `test_registry_sortable_columns_resolve_to_enum`
catches future drift between the registry and the enum.

Tests cover serializer acceptance for all 20 new keys plus
representative end-to-end ordering paths (year, issue_number,
issue_number reverse, series_name FK).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: emit table rows narrowed by columns= param

Step 5 of Phase 1: the browser endpoint now branches on the user's
view_mode setting and produces either the existing card grid (cover
mode) or a table-row response (table mode).

Three pieces:

- BrowserPageInputSerializer extends BrowserSettingsSerializer with a
  comma-separated columns= query param. Unknown column keys cause a
  400. The validated tuple flows through BrowserParamsView into
  self.params["columns"].

- BrowserTablePageSerializer mirrors BrowserPageSerializer's metadata
  fields but emits a single rows list (groups + books concatenated)
  projected through whichever columns the request asked for. The
  projection lives in _row_repr; getattr is intentionally tolerant
  so columns whose value source isn't annotated yet just render as
  None.

- BrowserView now exposes input_serializer_class explicitly and
  branches in get(): table mode picks the column set
  (explicit columns= -> stored table_columns[top_group] -> registry
  defaults) and instantiates the table page serializer.

For Step 5 the columns wired through end-to-end are the ones already
annotated by the cover pipeline (name, issue_number, year,
page_count, size, series_name, volume_name, publisher_name,
volume_number_to). Other registry columns (imprint_name, country,
language, FK chains, M2M aggregates) come back as null in v1; the
annotation extensions land in Step 6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: M2M aggregates for requested columns

Step 6 of Phase 1: when a table-view request asks for an M2M column
(genres, tags, characters, etc.), the queryset gains a JsonGroupArray
aggregate over the related ``__name`` path; otherwise no M2M
machinery runs.

Implementation:

- ``_M2M_COLUMN_PATHS`` in views.browser.columns maps the simple M2M
  keys to their ORM paths (characters, genres, locations,
  series_groups, stories, tags, teams, universes). The complex ones
  (credits with person/role, identifiers with source/key, story_arcs
  with through-model) stay null in v1; their aggregation needs custom
  ORM construction that's deferred.

- ``m2m_annotations_for`` returns a dict of prefixed aliases. The
  prefix (``_table_m2m_``) avoids the conflict between an annotation
  named ``genres`` and ``Comic.genres``-the-M2M-field-attribute.

- ``BrowserView._get_group_and_books`` calls
  ``m2m_annotations_for`` only when ``view_mode == "table"`` and only
  applies the annotations to Comic book querysets; group rows skip
  M2M entirely.

- ``_row_repr`` recognizes M2M column keys and reads the value from
  the prefixed alias. JsonGroupArray's JSONField output_field
  ensures the value comes through as a Python list.

Tests: requesting columns=cover,name,genres on a comic with two
genres returns those names; requesting only scalar columns produces
a row dict with no M2M keys at all (verifying the skip path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add browser table view phase 2 frontend plan

Captures the scaffold-level UI work for the table-view alternative:
view-mode toggle, BrowserTable component using v-table (not
v-data-table-server, so URL pagination stays intact), header-click
sorting wired to the existing orderBy/orderReverse settings, and
BrowserMain branching on viewMode.

Out of scope for Phase 2: column picker dialog (Phase 4),
bulk-selection in table (Phase 3), mobile fallback (Phase 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 2 frontend scaffold

End-to-end UI for the table-view alternative. A user can now toggle
into table mode from the top toolbar, see comics rendered as rows
using the registry's per-top-group default columns, click headers
to sort, and toggle back to cover view.

What's wired:

- BrowserViewModeToggle next to the settings drawer button. The
  order-by select and reverse button hide when viewMode === "table"
  since header clicks own that affordance there.
- BrowserTable + BrowserTableCell render the rows array returned by
  the Phase 1 backend. v-table (not v-data-table-server) so the
  existing BrowserNavToolbar keeps owning URL pagination. Cell types
  recognized in v1: cover (img sized by tableCoverSize), m2m list
  (comma-joined), bool (Yes/No), fallback string.
- BrowserMain branches on viewMode to render BrowserTable or the
  card grid.
- The Pinia store gains viewMode/tableColumns/tableCoverSize in
  state.settings (round-tripped through loadSettings), and
  loadBrowserPage injects ``columns=`` from the registered defaults
  (or persisted overrides) before each table-mode request.

Side fix: choices_to_json.py's _make_json_serializable now
propagates jsonize_keys through nested mappings. Without this, the
new browser-table-columns.json would have its column keys
camelCased (issue_number -> issueNumber) and break the columns=
contract with the backend. search-map.json is the only existing
file affected by the propagation; its nested keys are all
single-word lowercase, so the output is identical.

Out of scope (later phases per 00-plan.md):
- bulk-selection wiring inside the table (Phase 3)
- column-picker dialog (Phase 4)
- mobile auto-fallback (Phase 5)
- per-type cell components (Phase 6 polish)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: fix tableColumns query-param parse + filters PATCH

GET requests carry the user's settings as URL-encoded JSON values in
query params. The browser endpoint receives ``?tableColumns=%7B%7D``
and the DictField on the serializer sees the literal string ``"{}"``,
returning 400 ``Expected a dictionary of items but got type "str".``.

Two fixes in JSONFieldSerializer:

- ``_parse_json_field`` now passes through non-string values. The
  previous unquote_plus(dict) call raised AttributeError, was
  swallowed by ``except Exception``, and silently returned None. PATCH
  bodies arrive as already-parsed dicts; the only path that needs
  decoding is GET query strings. As a side effect this also fixes a
  latent bug: PATCH /r/settings with a non-empty ``filters`` dict was
  returning 400 ``This field may not be null.`` because filters went
  through the same null-on-error path.

- ``_is_json_field`` normalizes the incoming key (camelCase -> snake)
  before checking ``JSON_FIELDS``. The check ran before the
  underscoreize step, so single-word names like "filters"/"show"
  matched in both PATCH and GET paths but multi-word names like
  "table_columns" only matched on PATCH (CamelCaseJSONParser already
  underscored the body) — the GET path saw "tableColumns" and skipped
  parsing.

Adds ``table_columns`` to ``BrowserSettingsSerializer.JSON_FIELDS``.

Tests: GET ?tableColumns={...} round-trips, and PATCH filters with a
non-empty payload persists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: fix row routing, scroll layout, drop xs cover size

Three fixes from frontend testing:

- Row navigation routed to the wrong destination because _row_repr
  only emitted ``pk`` and the requested columns. The frontend's
  onRowClick fell back to ``c`` + ``[row.pk]`` when ``group``/``ids``
  were missing, sending Publisher rows to /c/<publisher_pk>/1
  instead of /p/<publisher_pk>/1. ``group`` and ``ids`` are now
  always emitted alongside ``pk`` regardless of the requested column
  set; they're routing metadata, not user-selectable columns.

- Table content scrolled past the toolbar instead of inside its own
  container. ``height: 100%`` doesn't compose with the flex-column
  parent (#browsePane) — switched to ``flex: 1; min-height: 0;``
  with ``overflow-y: auto`` on the same div so v-table's fixed-
  header sticky thead pins to this scroller.

- Removed ``xs`` (~16px) from BROWSER_TABLE_COVER_SIZE_CHOICES.
  Only ``sm`` (~32px) remains in v1 per user feedback. The field
  is kept for future md/lg expansion. Migration 0042 captures the
  choice update and folds in the 20 new order_by keys from Step 4
  that should have been migrated alongside the model field default;
  Django emits no SQL for choice-only AlterField ops.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: replace v-table with plain table for reliable sticky header

The previous v-table+fixed-header combination put overflow:auto on
v-table's own wrapper, which competes with our outer scroll container
and lets the whole table scroll past the toolbar instead of scrolling
internally with a pinned thead. Switch to a plain <table>:

- #browserTable stays the scroll container (flex:1 + min-height:0 +
  overflow-y:auto inside the flex-column #browsePane).
- thead th has position:sticky; top:0 in scoped css, which binds to
  the nearest scrolling ancestor — #browserTable — so the headers
  stay visible regardless of viewport height.

Replicates the few v-table styles we actually need (surface
background, padding, border-bottom, hover affordance) without the
internal-wrapper compositing surprises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 3 bulk selection

Add a leftmost checkbox column to BrowserTable wired to the existing
useBrowserSelectManyStore. The same toolbar that appears in cover
view (BrowserToolbarSelectMany) shows up automatically when the
table activates the store.

- Per-row checkbox toggles via the store's toggleItem; an empty <td>
  catches the click so the row's onClick doesn't double-fire.
- Header checkbox is indeterminate when some rows on the page are
  selected, full when all, empty when none. Click flips between
  selectAll and clearSelection.
- Row click respects selectManyActive: if active, toggle selection;
  otherwise navigate (matches the cover overlay's contract).
- Selected rows get a subtle primary-color tint with a slightly
  stronger hover state.

Side fix: useBrowserSelectManyStore.selectAll() previously read from
page.groups + page.books only, so select-all-on-page was a no-op in
table mode. It now prefers page.rows when present and falls back to
the cover-view lists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 4 column picker dialog

Modal dialog for choosing which columns the table view shows. The
existing cover-view PATCH path was already enough to set
``tableColumns`` directly, but until now there was no UI for it.

Pieces:

- BrowserTableColumnPicker: v-dialog with category-grouped checkbox
  lists (Identity / Publishing / Counts / Files / Dates / Tagging /
  Reader / Tags & People). Categories are hardcoded in the picker;
  the registry doesn't carry a category field and the categories
  haven't yet earned the right to be backend-configured. Any
  registry column not categorized is surfaced under "Other" so
  future additions don't silently disappear.
- BrowserColumnsButton: top-toolbar icon button that mounts the
  picker. Visible only in table mode. The dialog snapshots the
  current selection on open, edits a draft, and commits via
  setSettings on save; cancel just closes.
- Reset-to-defaults wires to the registry's per-top-group default
  list (browser-table-default-columns.json).

The picker preserves the registry's natural column order rather
than the user's click order so the table layout stays visually
stable across edits. Drag-to-reorder is deferred — when the
registry order doesn't suit a user, we'll surface that in feedback
and act on it then.

Per-top-group: the dialog edits the column set for whatever
top_group is currently active. Switching top-groups closes the
dialog (via the regular page reload).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: cover column shrinks to thumbnail, falls back on error

Two cover-column polish items:

- Width: the cover column was inheriting the regular cell padding,
  so the cover thumbnail sat in a wide column. ``width: 1%`` +
  ``white-space: nowrap`` is the standard idiom for "as narrow as
  content allows" in a fluid-width table; combined with reduced
  right padding the column collapses to the thumbnail's footprint.
  Wired via a new cellClasses helper on the table so the same
  class drops on both <th> and <td>.

- Broken-image fallback: the <img> tag previously rendered the
  browser's broken-image icon when the cover URL 404'd (covers
  haven't been generated yet, etc.). Added an @error handler that
  flips an imgErrored flag and falls back to getPlaceholderSrc()
  for the row's group — the same svg used for rows that don't
  carry coverPk at all. The flag resets when the row identity
  changes so a transient error doesn't stick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 5 mobile auto-fallback + unified serializer

The user's persisted view_mode is now honored only on viewports
≥ 960px (Vuetify smAndDown). On narrower screens BrowserMain renders
the cover grid even when viewMode === "table" — a multi-column
table is unusable on phone-sized devices. The setting itself
isn't touched; rotate / widen and the table reappears.

For this to work without a second round-trip on viewport changes,
the response shape changed: BrowserPageSerializer now always emits
``groups``/``books`` and additionally emits a ``rows`` list when
the request carries ``columns=`` (i.e. when in table mode). Mobile
fallback reads ``books``; table mode reads ``rows``. Drops
BrowserTablePageSerializer in favor of the unified shape.

Cost trade-off: table-mode responses now also serialize the cards
(adds ~14 fields per row). Acceptable in v1 — pagination caps the
row count, the duplication is a flat overhead, and the alternative
(re-requesting on viewport rotation) is a worse user experience.
If profiling later shows this matters, a ``shape=`` query param
can opt in/out.

Tests updated to expect both shapes in table-mode responses, and
to expect ``rows: []`` in cover-mode responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 6 fk-name annotations + complex m2m paths

Fills in the remaining columns that were returning null in v1:

- FK-name columns: imprint_name, country, language, original_format,
  tagger, scan_info, age_rating, main_character, main_team. Each is
  annotated via F() with a prefixed alias (``_table_fk_<col>``) so
  it doesn't clash with the matching Comic FK attribute. ``_row_repr``
  reads via the alias.
- Complex M2M aggregations: credits (-> person.name), identifiers
  (-> key), story_arcs (-> story_arc_numbers.story_arc.name).
  All routed through the existing JsonGroupArray helper.

Both annotation sets are narrowed by the request's columns= so a
user with only scalar columns visible pays no JOIN cost for any of
this. Tests verify country/language and story_arcs aggregate
correctly end-to-end; the 12 cards remaining (imprint, tagger,
scan_info, age_rating, etc.) follow the identical code path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 6 polish — sticky leading columns + tooltips

- Sticky leading columns: the checkbox cell pins to left:0 during
  horizontal scroll; the cover column pins immediately after it.
  Both get an explicit surface background so scrolled content stays
  hidden underneath, and a higher z-index in thead so the corner
  cell sits above both the tbody and the rest of the thead. Selected
  rows override the sticky bg with surface-light so the selection
  tint is visible across the full row.

- Tooltips on truncated cells: list cells (genres / tags / etc.)
  and generic text cells get ``title`` attributes with the full
  string. The browser shows native tooltips when text is truncated;
  no JS needed. Generic text cells gain the same max-width +
  ellipsis treatment that list cells already had.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: type-aware formatting for size and date cells

Generic stringification of raw values produced an ugly readout —
``size`` rendered as bytes (``1234567``), date-only columns as ISO
strings (``2024-05-23``), and timestamp columns with milliseconds.
Adds three formatters dispatched by column key:

- ``size`` -> ``prettyBytes`` (already used by metadata-header /
  order-by-caption for the same job).
- ``date`` -> ``DATE_FORMAT`` from datetime.js (sv-SE YYYY-MM-DD).
- ``created_at`` / ``updated_at`` / ``metadata_mtime`` /
  ``bookmark_updated_at`` -> ``getDateTime`` (date + 24h time).

24-hour time is hardcoded in the table cell for now; respecting
the user's ``twentyFourHourTime`` setting needs a store-aware
formatter and is a small follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: toolbar respects mobile auto-fallback

The view-mode-aware toolbar bits (column picker button, hidden
order-by/reverse) were keying on the persisted ``viewMode`` setting
alone. On mobile with table mode set, the cover grid actually
renders (Phase 5 fallback), so the picker button was offering
table-only controls that didn't apply, and the cover view's
order-by + reverse buttons were missing.

Both toggles now use the same effective check:
``viewMode === "table" && !$vuetify.display.smAndDown``. When the
viewport is too narrow to render the table, the toolbar reverts to
its cover-view shape regardless of the persisted setting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: mark phases done; add overnight handoff doc

Updates the phase checklist in 00-plan.md to reflect everything
landed in the autonomous overnight run. Adds HANDOFF.md as a quick
read-this-when-you-wake-up summary covering branch state,
verification spots, decisions made autonomously, and open questions
for the user to weigh in on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: respect twentyFourHourTime setting in datetime cells

The cell formatter was hardcoding ``true`` (24h) for datetime columns
(created_at / updated_at / metadata_mtime / bookmark_updated_at),
ignoring the user's persisted ``twentyFourHourTime`` toggle. Pulls
the flag from the browser store and threads it into ``getDateTime``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: test credits m2m aggregation end-to-end

The credits column resolves through Credit -> CreditPerson.name. The
existing M2M test covered story_arcs (through-model) and the simple
N-to-N keys; this adds the missing case so the full
``_M2M_COLUMN_PATHS`` map is exercised by integration tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: All / None / Defaults shortcuts in column picker

Three quick-action buttons in the picker's action row:

- ``All`` — selects every registry column.
- ``None`` — clears the selection.
- ``Defaults`` (renamed from "Reset to Defaults" for compactness) —
  restores the registry's per-top-group default set.

Useful for users who want to A/B different column profiles without
clicking through 40+ checkboxes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser: include metadata_mtime in cover-card datetime captions

The OrderByCaption component dispatches by orderBy to format the
``order_value`` overlay shown on each browser card. With Step 4's
enum expansion ``metadata_mtime`` was a valid sort key but fell
through the type dispatch and rendered as a raw ISO timestamp. Now
treated like the other datetime sorts (bookmark_updated_at /
created_at / updated_at) so the caption formats with the locale's
date + time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: cover-friendly order_by, composite credits + identifiers

Three follow-ups from the morning review:

1. Cover view's order-by dropdown filters down to a curated subset.
   The Step 4 enum expansion went from 13 to 33 keys; most of the
   table-only additions (reading_direction, monochrome, country,
   language, FK-name keys) don't drive a useful cover-grid sort.
   ``BROWSER_COVER_ORDER_BY_KEYS`` (in choices/browser.py) lists the
   13 keys that survived; the frontend's ``orderByChoices`` getter
   filters the dropdown to this set. The full enum is still valid
   for ``order_by`` server-side (table-view header clicks use it).

2. ``credits`` aggregation surfaces the role: ``Person Name (Role)``
   when role is set, just ``Person Name`` when null. Implemented as
   a ``Case`` expression passed into ``JsonGroupArray``.

3. ``identifiers`` aggregation renders ``[source:]type:key``: a full
   composite when source is present, type:key when source is null.
   Same Case-expression pattern.

Tests cover both with-source and without-source paths for credits
and identifiers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: drag-to-reorder columns in the picker

The picker now leads with an "Order" section: an ordered list of the
currently-selected columns, each draggable. Drag a row to a new
position — the drop target shows a 2px primary-color line above or
below depending on which half of the row the cursor is in, and on
release the column moves to that position. Each row also has a close
button so the user can remove a column without scrolling down to
the category list.

Below the order section is the unchanged category-grouped checkbox
list. Toggling a checkbox on appends the column to the end of the
order list (drag from there to the desired position); toggling off
removes it. Save / Cancel / All / None / Defaults work the same.

Native HTML5 drag-and-drop — no library dependency. Firefox needs
``setData`` set during dragstart for the drag to actually start;
that's wired. ``effectAllowed = "move"`` and ``dropEffect = "move"``
give the user a move-cursor instead of the default copy-cursor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: mark four open questions resolved in handoff doc

The morning review answered all five outstanding questions; four
landed as implementations (cover-friendly dropdown subset, composite
identifiers, role-suffixed credits, drag-to-reorder columns) and one
(per-top-group view mode) is intentionally deferred until you've
used the table view for real sessions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: filter empty m2m rows out of credits + identifiers

Identifiers can carry source=null, id_type="", key="" — placeholder
rows that rendered as just ":" in the aggregated list and polluted
the cell. Same shape for credits where a CreditPerson exists with an
empty name.

Adds per-column ``filter=`` Q expressions to ``m2m_annotations_for``;
identifiers require id_type or key non-empty, credits require
person.name non-empty. SQL filters before aggregation, so the JSON
list never sees the empty composite.

Tests cover both the placeholder-identifier and unnamed-credit paths
returning ``[]`` (or absent) from the row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: full country/language names + rename Name to Title

Two display polish items:

- The Country and Language tables store ISO-2 codes in their
  ``name`` field (``us``, ``en``), but the column should show the
  full readable name. Adds pycountry-backed resolvers in the
  serializer applied via a per-column transform table; unrecognized
  codes pass through unchanged so we never crash on dirty data.

- Renames the ``name`` column's label from "Name" to "Title" in
  the registry. The column key stays ``name`` (still the
  protocol identifier and the implicit ``sort_name`` sort target);
  only the user-facing label changes.

Tests cover the US/English happy path and the unknown-code
fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* typecheck

* update deps

* browser table view: click-to-zoom cover popup

Clicking the cover thumbnail in a table row now opens a borderless
popup of the full-resolution cover. The click is captured at the
cell level so it doesn't bubble up to the row-click handler (which
would otherwise navigate to the reader).

Implementation:

- v-menu with ``transition="scale-transition"`` and
  ``origin="overlap"`` so the popup grows from the thumbnail's
  position rather than animating in from elsewhere.
- ``location="end center"`` puts the popup to the right of the
  thumbnail with an 8px offset, so the cursor can transition from
  thumb to popup without leaving either.
- ``@click.stop`` on the cover-cell ``<span>`` blocks the row's
  navigation handler whenever the user is interacting with the
  cover (clicking the v-menu activator triggers the menu via the
  spread ``v-bind="props"`` from the activator slot).
- ``@mouseleave`` on the popup div sets ``popupOpen = false``,
  matching the dismissal model the user asked for.
- The popup img uses ``max-height: 70vh; max-width: 60vw`` so it
  fits any viewport, with a 4px radius and soft drop-shadow as
  the only chrome — borderless per the spec.
- ``cursor: zoom-in`` on the thumb / ``zoom-out`` on the popup as
  affordances.

The popup styles live in a second, intentionally-unscoped style
block because v-menu teleports its content out of the component's
scoped DOM. ``row.pk`` watcher resets ``popupOpen`` so a stale
popup never lingers when the underlying queryset shifts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 7 m2m column sorting

M2M columns (genres, tags, characters, credits, identifiers,
locations, series_groups, stories, story_arcs, teams, universes)
now sort on the table-view header click. Per the plan, the property
we care about is "rows with the same M2M set sort adjacent" —
exact alphabetic order between distinct sets is a side effect.

Mechanism:

- ``JsonGroupArray`` calls now pass ``order_by=path`` so each row's
  array elements are alphabetized; identical sets render to
  identical JSON literals (``["Action", "Drama"]``). Sorting then
  groups equivalent rows together.
- Each aggregate gains a ``filter=`` Q so LEFT-JOIN NULL rows
  don't fold into the array as JSON nulls. Comics with no genres
  now produce ``[]`` instead of ``[null]``, which gives the
  empty-cell equivalence class the user wanted.
- Comic ordering: ``_add_comic_order_by`` emits the prefixed
  alias (``_table_m2m_<col>``) when an M2M sort key is selected;
  ``annotate_order_value`` does the same on the order_value
  annotation.
- Group ordering (Publisher / Series / Volume / etc.): falls back
  to ``sort_name`` when an M2M sort is requested. Same shape as
  ``child_count`` falling back to ``sort_name`` for Comic
  querysets. v1 doesn't aggregate M2M across child comics for
  group rows; user-requested follow-up.
- BrowserView hoists the FK / M2M annotations into
  ``_get_common_queryset`` (before ORDER BY) so the alias is on
  the queryset when the sort clause is built. Sort-by-not-
  displayed: the order_by key is added to the annotation set so
  hidden M2M columns can still drive a sort.
- Registry flips M2M ``sort_key`` from null to the column key.
  ``BROWSER_ORDER_BY_CHOICES`` gains 11 new entries; cover view's
  curated subset doesn't include them.

Tests cover the equivalence-class grouping, empty-cell placement,
and the registry / enum consistency.

Plan: tasks/browser-table-view/03-phase-7-m2m-sort.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: phase 7 group-row intersection display

Group rows in the table (Series, Publisher, Volume, Imprint, Folder,
Story Arc) now show the intersection of column values across their
child comics — values present in *every* child for M2M, the value
itself when *every* child shares it for scalars / FK-name columns.
Same semantic as MetadataQueryIntersectionsView's M2M path; reuses
the same "what's shared" question, surfaced as table cells.

Architecture:

- New module ``codex/views/browser/intersections.py``:
  - ``compute_group_intersections(group_qs, columns)`` runs after
    pagination so the work is bounded to the visible page. Returns
    ``{group_pk: {column_key: value}}``.
  - One batched query per visible column (M2M and scalar both):
    GROUP BY (group_pk, value), distinct comic count = group's
    total comic count → value is in the intersection.
  - Composite columns (credits, identifiers) keep the same display
    formatting users see on Comic rows: "Person (Role)",
    "[source:]type:key".
  - ``MODEL_REL_MAP`` from views.const provides the comic→group
    field path (``series`` for Series, ``parent_folder`` for
    Folder, ``story_arc_numbers__story_arc`` for StoryArc).

- ``BrowserView.get`` invokes the helper for the table-view path
  with the resolved column set, stashes the dict on the response
  data under ``group_intersections``.

- ``_row_repr`` checks the intersections dict first for any
  matching group row; falls back to the existing alias / direct-
  attribute paths for Comic rows or unrecognized columns. Country
  / language transforms apply to intersection results too.

Sort behavior unchanged: group rows still fall back to sort_name
when an M2M sort key is active (Phase 7 M2M-sort experiment kept
this fallback). The intersection helper drives display only;
intersection-based sort is a future iteration if you want it.

Plan: tasks/browser-table-view/04-phase-7-group-m2m-sort.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: fix cover-subquery m2m crash + push display annotations post-pagination

Two issues from the morning testing:

1. M2M sort while browsing groups crashed with ``Cannot resolve
   keyword '_table_m2m_universes' into field``. The cover subquery
   is a correlated Comic query that doesn't carry the outer view's
   M2M alias annotation, so an M2M ORDER BY would reference a
   nonexistent column. ``add_order_by(qs, for_cover=True)`` now
   falls back to sort_name when an M2M sort is selected — pickling
   "the first comic" by alphabetical title is what the cover
   subquery wants regardless. Threaded ``for_cover`` through
   ``_add_comic_order_by`` and the cover subquery call site.

2. Issues view in table mode felt slower than cover view. The
   previous landing put *all* table-view annotations
   (FK-name + M2M) before pagination — convenient, but the M2M
   JsonGroupArray aggregates ran over the full filtered queryset
   even when the user wasn't sorting by them. Split into two
   passes: ``_add_table_view_sort_annotations`` upstream (only the
   order_by key's annotation, needed for ORDER BY), and
   ``_add_table_view_display_annotations`` downstream of pagination
   (every other visible column, so M2M aggregates only run over
   the visible page). When the order_by key is also displayed it's
   already annotated upstream and gets skipped to avoid duplicate-
   annotation errors.

For typical browsing (no M2M sort), the issues path now annotates
M2M only for the visible 100 rows instead of every comic in the
filter — which was the previous behavior and what the user was
expecting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: group-row m2m sort via correlated intersection subquery

Group rows (Series, Publisher, Volume, Imprint, Folder) now sort
on M2M columns by the intersection-of-child-comics set: identical
intersection sets cluster together. Same equivalence-class property
the user validated for the Comic-row M2M-sort experiment, plus the
display side that landed in ec1073a7.

Mechanism — pure-SQL, evaluated per outer row by the database:

- ``m2m_intersection_sort_expr(group_model, column)`` returns a
  RawSQL fragment for the order_value annotation:

      (SELECT COALESCE(GROUP_CONCAT(name, X'1F'), '')
       FROM (
           SELECT t.name AS name
           FROM <target_table> t
           INNER JOIN <through_table> th ON th.<id_col> = t.id
           INNER JOIN codex_comic c ON c.id = th.comic_id
           WHERE c.<group_fk> = <group_table>.id
             AND t.name IS NOT NULL
             AND t.name != ''
           GROUP BY t.id, t.name
           HAVING COUNT(DISTINCT c.id) = (
               SELECT COUNT(*) FROM codex_comic
               WHERE <group_fk> = <group_table>.id
           )
           ORDER BY t.name
       ) AS isect)

  Identifiers come from Django metadata (db_table,
  m2m_reverse_field_name) bounded by the registry's M2M column set
  and a Comic-FK whitelist; no user input touches the SQL.

- ``annotate_order_value``: when group + M2M sort, the order_value
  annotation is the RawSQL above. ORDER BY this string clusters
  identical intersection sets (same set → identical concatenated
  string under SQLite's binary collation). Unsupported combinations
  (StoryArc, credits/identifiers — composite expressions / through-
  model traversal differ) fall back to sort_name as before.

Performance: the subquery executes once per row in the outer
queryset's ORDER BY phase, evaluated by the database. With FK
indexes on codex_comic and the through tables (already present)
the per-row cost is bounded by the group's comic × m2m fan-out.
The user's 18k-comic library is the target; profile-and-tune is
their call.

Test: 3 series — A and B with identical intersection {Action,
Drama}, C with {Comedy} — sort A and B adjacent regardless of name
order.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: compound intersection sort for credits / identifiers / story_arcs / universes

The Comic-row M2M-sort path treats four columns as compound (display
isn't simply the related ``name`` field):

- ``credits``     →  ``Person (Role)``  (or ``Person`` when role null)
- ``identifiers`` →  ``[source:]type:key``
- ``story_arcs``  →  ``story_arc.name``  (through ``StoryArcNumber``)
- ``universes``   →  ``name:designation``  (or just ``name``)

The first three were ``return None`` from
``m2m_intersection_sort_expr`` and fell back to ``sort_name``;
``universes`` slipped into the simple-field path that grouped on
``name`` only, so universes whose ``designation`` actually
distinguishes them got bucketed together.

Adds four typed builder functions, each producing a correlated
RawSQL subquery whose inner SELECT yields a per-row
``(target_id, display_name)`` pair. A shared
``_wrap_intersection_sort`` envelope applies the group filter,
HAVING-equals-comic-count check, and X'1F'-joined GROUP_CONCAT.

Story arcs intentionally group by ``StoryArc`` rather than
``StoryArcNumber`` — the user wants "this group all features arc
X" not "this group all features arc X with the same issue
number". The display side is unchanged (uses
``story_arc_numbers__story_arc__name``); only sort gets the new
template.

Universes intentionally diverge from the M2M display path's
``universes__name`` — the cell shows just the name today, but the
sort key includes designation. Inconsistent in v1 but matches the
user's expectation that two distinct universes with the same name
shouldn't bucket together. Display-side composite is a follow-up
if it bothers anyone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: combine issue_number + issue_suffix into a single 'issue' column

Table view's ``issue_number`` and ``issue_suffix`` collapse into one
``issue`` cell rendered as ``"1.5a"``. Sort still uses the existing
``issue_number`` enum entry; ``_add_comic_order_by`` now emits
``issue_suffix`` as the secondary ORDER BY field whenever sorting on
``issue_number``, so clicking the Issue column header gives the
natural number-then-suffix ordering.

No migration: the order_by enum is unchanged. The existing
``issue_number`` and ``issue_suffix`` enum values stay valid for
direct API access; the registry just stops surfacing them as
separate columns.

Changes:

- ``codex/choices/browser.py``: replace the two registry entries
  with a single ``issue`` entry (sort_key=``issue_number``).
  Update the ``c`` default columns to use ``issue``.
- ``codex/views/browser/order_by.py``: special-case
  ``order_key == "issue_number"`` in ``_add_comic_order_by`` to
  emit ``[issue_number, issue_suffix]``. Cover-view's "Issue
  Number" dropdown picks up the same multi-field secondary too.
- ``codex/serializers/browser/page.py``: ``_format_issue`` trims
  trailing zeros on the Decimal ("1.00" → "1", "1.50" → "1.5")
  and concatenates the suffix without a separator.
- ``frontend/.../browser-table-column-picker.vue``: Identity
  category now lists just ``issue`` (no separate suffix entry).
- Tests updated to use the new column key.

Group rows leave the issue cell empty — intersection of distinct
issue numbers across child comics rarely has a meaningful value,
so the helper doesn't compute it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: silently migrate deprecated issue_number/issue_suffix column keys

Stored settings predating the column registry's collapse of
``issue_number`` and ``issue_suffix`` into the compound ``issue``
column were 400-ing on every page load. Both validators now run
user-supplied column lists through ``coerce_columns`` before
checking against the registry, so old data round-trips cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: split-justify the compound issue cell

The issue column stays one cell, but the backend now emits its
value as ``{number, suffix}`` so the frontend can render the two
halves side-by-side: number right-justified in the left half,
suffix left-justified in the right. Digit columns line up at the
boundary regardless of suffix presence (1, 2a, 100, 25.5b align
on the number's right edge).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* remove old coerce columns function. no longer needed after dev

* browser table view: drop issue_count, rename child_count label to Children

The ``issue_count`` registry entry was a v1 default for series and
volume top-groups but never had its value plumbed (the cell rendered
empty). Drop it rather than wire it up; the existing ``child_count``
already covers the per-group count semantics for those top-groups.
Series and volume defaults now use ``child_count``.

Renamed ``child_count``'s column label from "Count" to "Children"
for clarity. The order_by enum entry stays "Child Count" — that's
the cover-view sort dropdown text and is already explicit.

Stored settings carrying ``issue_count`` will 400 until re-saved
(consistent with the no-coercion-shim policy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: drop the leftover coerce_columns calls in settings serializers

The coerce_columns helper was removed from codex.views.browser.columns
in 6fd104e4 but the import + two callers in
codex/serializers/browser/settings.py weren't updated, leaving the
serializer module broken at import (ImportError on app load). Match
the cleanup intent: drop the import, drop the calls, and drop the
test that exercised the shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: virtualize the compound issue order_by enum entry

The compound issue column previously surfaced its sort behind
``issue_number`` (the existing enum entry); ``_add_comic_order_by``
hardcoded the ``issue_suffix`` secondary. Replace the indirection
with a single virtual enum value:

- ``BROWSER_ORDER_BY_CHOICES`` drops ``issue_number`` and
  ``issue_suffix``, adds ``"issue": "Issue"``
- ``BROWSER_TABLE_COLUMNS["issue"]["sort_key"]`` flips to
  ``"issue"``
- ``COMIC_ORDER_FIELD_PATHS`` translates the virtual key to the
  underlying ``issue_number`` field (Comic-row annotation +
  group-row aggregate both pick this up)
- ``_add_comic_order_by`` matches on ``"issue"`` and still emits
  ``[issue_number, issue_suffix]`` for the actual ORDER BY
- ``_ORDER_AGGREGATE_FUNCS`` collapses two entries into one
- Dead ``issue_number`` / ``issue_suffix`` entries removed from
  ``_SCALAR_FIELD_PATHS`` (the registry no longer ships them as
  columns; intersection display never ran for them).

Migration 0044 updates ``SettingsBrowser.order_by`` choices.
Stored settings carrying ``order_by="issue_number"`` /
``"issue_suffix"`` will 400 until re-saved (consistent with the
no-shim policy).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: multi-column sort experiment via shift-click

Adds an opt-in multi-column sort mode to the table view. The
primary ``order_by`` + ``order_reverse`` scalars stay; a new
``order_extra_keys`` JSON list on SettingsBrowser carries
secondary entries ``[{"key": <enum>, "reverse": <bool>}, ...]``.

Frontend (table only):
- Plain click on a header: replace primary, clear extras (today's
  behavior).
- Shift-click: add the column as an extra. Cycle is asc → desc →
  off (matches Excel / Sheets). Shift-click on the current
  primary degrades to a plain direction toggle.
- Visual: primary header keeps the existing arrow + colored
  title (no badge). Each extra header gets the same arrow plus a
  tiny superscript priority number (², ³, ⁴ …). With no extras,
  the UI is identical to v1 — anti-clutter by default.

Backend pipeline (``add_order_by``):
- Appends extras between the primary's ``order_fields_head`` and
  the ``pk`` tiebreaker. Each entry carries its own direction so
  primary-asc + extra-desc mixes work.
- Cover subqueries (for_cover=True) skip extras. Non-Comic group
  queries also skip — those sort on the precomputed
  ``order_value`` aggregate and the extras path would need a
  per-key aggregation pipeline the experiment doesn't ship.

Migration 0045 adds the field. Validation rejects unknown order_by
keys and dedupes by key (first occurrence wins). New settings,
ordering, and validation tests; 118 backend tests pass; lint
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: fix cover-dropdown after multi-sort + tooltip the shift-click hint

Two follow-ups for the multi-column sort experiment.

Cover-mode transition: when the user toggles from table → cover
while their primary ``orderBy`` is a key the cover-view dropdown
doesn't surface (e.g. ``genres``, ``issue``, ``year``), the
dropdown rendered blank because the value wasn't in its choices.
The view-mode toggle now walks ``[orderBy, ...orderExtraKeys]``
in priority order and promotes the first cover-friendly entry
(member of ``BROWSER_COVER_ORDER_BY_KEYS``) to primary, removing
it from extras. If none match, falls back to ``sort_name``. The
remaining extras stay around — inert in cover mode but flip back
when the user returns to table view, so the multi-sort isn't
destroyed by the round-trip.

Discoverability: every sortable ``<th>`` gets a native browser
tooltip — ``"Click to sort. Shift+click to chain a secondary
sort."`` — surfaced only on hover, no visible chrome. The
lowest-friction option for hinting at the otherwise-hidden
gesture without crowding the table header row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: multi-sort works on group rows + fix M2M-extras crash

Two follow-ups for the multi-column sort experiment.

M2M extras on Comic-row queries crashed with
``FieldError: Cannot resolve keyword '_table_m2m_genres' into
field`` because ``_add_table_view_sort_annotations`` only annotated
the alias for the primary ``order_by`` key. The new
``_table_view_sort_keys`` helper unions primary + every multi-sort
extra; the sort-annotation pass annotates the full set, the
display-annotation pass skips that set to avoid Django's
duplicate-annotation error. M2M keys can now appear at any
position in the multi-sort.

Group-row queries (Series, Volume, Publisher, …) now honor extras.
``annotate_extra_order_values`` annotates a per-extra
``_table_extra_value_<idx>`` alias on non-Comic querysets; the
expression mirrors ``annotate_order_value``'s primary-sort
dispatch — direct ``sort_name``, lazy ``Count`` for ``child_count``,
``Min`` / ``Max`` aggregate over a child-comic field for direct
keys, and the M2M intersection-sort RawSQL for M2M keys. Each
extra picks its own directional aggregate (``Min``/``Max``) from
its own ``reverse`` flag. ``_add_extra_order_by`` references those
aliases for non-Comic models and keeps the existing direct-field
path for Comic.

Extras are gated to table-view mode (``view_mode == "table"``).
The cover dropdown can't edit extras, so honoring stored extras
in cover view would silently change order based on a hidden
setting. Toggling table → cover preserves the extras list (inert
in cover), so flipping back restores the multi-sort.

Adds three integration tests:
- M2M extra on a Comic-rows view doesn't crash (regression).
- Group-row extras change row order in the expected direction
  (and flip when the extra's ``reverse`` flag flips).
- Cover-view requests ignore stored extras.

156 tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: wire bookmark_updated_at + filename as multi-sort extras

Three of the four annotated-only sort keys can be expressed as
extras with a small extension to the per-extra pipeline. The
last (story_arc_number, search_score) need StoryArc-context /
FTS-subquery plumbing that the pipeline can't reproduce.

Wired up:
- ``bookmark_updated_at`` extra: Comic queries get an upstream
  ``annotate(bookmark_updated_at=...)`` via the new
  ``annotate_comic_extra_specials`` hook (idempotent against the
  primary's annotator). Group queries route through the
  ``_extra_group_special`` branch, building an aggregate with the
  per-extra ``reverse`` flag picking ``Max`` / ``Min``.
- ``filename`` extra: Comic queries get a ``alias(filename=...)``
  via the same hook. Group queries (non-Folder) build an
  aggregate; Folder uses ``F("name")`` directly.

Unsupported:
- ``story_arc_number``, ``search_score`` — listed in
  ``BROWSER_EXTRA_SORT_UNSUPPORTED_KEYS`` (exported to the
  frontend). The table headers gray out the column label and
  refuse the shift-click; tooltip explains. The backend remains
  defensive — a stored payload with these keys falls back to
  ``sort_name`` so the ORDER BY tail still binds.

Frontend: imports the new constant from build-choices, adds an
``isExtraSortable(column)`` predicate, applies an
``.extraIncompatible`` class to the ``<th>`` (60% opacity on the
label) for unsupported columns, and ignores shift-clicks on them.
Tooltip messaging is split: regular sortable headers say
"Click to sort. Shift+click to chain a secondary sort.";
unsupported-as-extra headers say "Click to sort. Can't be used
as a secondary sort."

Adds three integration smoke tests for the new behavior.
159 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* saved settings: clone through DIRECT_KEYS so new columns ride along

The create branch in ``SavedBrowserSettingsListView.post`` enumerated
the fields it copied from the current row by hand, silently dropping
the table-view columns added later (``view_mode``, ``table_columns``,
``table_cover_size``, ``order_extra_keys``). The other two paths
through saved settings — load (``browser_instance_to_dict``) and
overwrite (``_copy_settings``) — already iterate ``DIRECT_KEYS``,
so they captured the new fields automatically.

Route the create branch through the same set so all three paths stay
in sync and future column additions don't have to remember to update
two sites.

Adds a regression test that customizes the table-view fields, saves
a *new* preset, resets current settings, loads the preset, and
asserts every field round-trips. Pre-fix the test would have shown
``view_mode='cover'`` / empty columns / empty extras (model defaults)
because the create branch dropped them.

160 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: wrap-and-grow cells with a 3-line clamp

Long text and M2M list cells used to truncate at one line via
``white-space: nowrap`` + ellipsis. Now they wrap to as many as
three lines and clamp with an ellipsis at the third — using
``-webkit-line-clamp`` (the de-facto cross-browser path for
multi-line ellipsis) plus ``overflow-wrap: break-word`` so
unbreakable tokens (long identifier URLs, no-space file names)
wrap rather than blow the column wide.

Columns still auto-fit content up to a 360px max-width; wrapping
happens within that width. The native ``:title`` tooltip keeps
serving as the disclosure surface for cells that did clamp at
the third line.

Resolves the "long values get hidden behind hover" complaint
without introducing column drag-resize chrome.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: hide view-mode toggle on tiny viewports

The view-mode toggle button (the table↔cover switcher) showed up
even on viewports below ``smAndDown`` where mobile auto-fallback
already forces the cover grid regardless of the persisted
``viewMode``. Toggling there changed state without any visible
effect, mirroring the pattern the columns picker already uses.
Hide the button when ``$vuetify.display.smAndDown`` so the
toolbar matches what the user can actually do at that size.
The user can flip the mode from a wider viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* change column picker categories

* browser table view: expand reading_direction codes + group-sort regression tests

Reading direction cells now display the readable label
(``"Left to Right"`` etc.) instead of the stored enum code (``ltr``,
``rtl``, ``ttb``, ``btt``). The cell's ``textValue`` consults a new
``ENUM_COLUMN_LABELS`` map keyed on the column registry name; the
``READING_DIRECTION`` map is imported from build-choices' shared
``reader-map.json`` so backend and frontend stay in sync. Falls
through to the raw code if a future addition lacks a map entry.

Adds five integration tests exercising primary sort by direct-field
and FK-name keys on group querysets (Series and Publisher rows):
``tagger`` (FK-name), ``page_count`` (Sum), ``year`` (Min), at the
publisher root, and with a frontend-shape ``columns=`` query param.
All pass on this branch — the user's reported "Tagger / size /
page_count / country / language don't sort on group views" doesn't
reproduce in the test suite, so the regression tests pin the
correctness as a backstop. If users hit this in production it's
likely a browser-cache or stale-session issue, not a backend bug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: scalar intersection sort matches the displayed value

Group-row cells for scalar / FK-name columns (year, country,
language, tagger, age_rating, page_count, size, …) display via
intersection — a value renders only when every child comic
agrees, blank otherwise. The sort key was a plain
``Min`` / ``Avg`` / ``Sum`` aggregate, so a series whose children
mostly lack a year but one happens to be 2024 would sort as if
year=2024 and yet display blank — the user's reported "rows with
the same intersected year don't cluster" bug.

New ``scalar_intersection_sort_expr`` builds a correlated-subquery
RawSQL that mirrors ``_compute_scalar_intersection``: returns the
shared value when every child agrees on a non-NULL value, NULL
otherwise. ``annotate_order_value`` and ``_extra_group_expr``
(multi-sort extras) dispatch through it for all
``_SCALAR_FIELD_PATHS`` entries, falling back to the legacy
aggregate so registry additions don't silently regress.

Behavior change: ASC-sorted groups whose cells display blank
cluster at the start (SQLite NULLS FIRST in ASC); DESC clusters
them at the end. Matches the user's CSV reproducer where 2024
years now group together rather than interleaving with
mixed-year-but-Min=2024 series.

Refactored ``annotate_order_value`` into per-shape helpers
(``_comic_order_value`` / ``_group_m2m_order_value`` /
``_group_scalar_order_value``) to satisfy the complexity lint
after adding the new branch. Two existing aggregate-semantic
tests updated for the intersection rule (``page_count`` /
``columns=`` requests). New
``test_year_sort_matches_intersection_display`` reproduces the
user's exact "2024-then-blanks-then-2024" pattern via a "Mixed"
series with two different child years and pins the new behavior.
166 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: folder rows aggregate / sort through ancestor M2M

Folder rows in table mode displayed blank for almost every cell
and sorted as NULL for almost every order_by — the user's
"Folder view doesn't aggregate years at all" report.

Root cause: ``Comic.parent_folder`` is the *direct*-parent FK,
but most folders in a real library hold only sub-folders. The
intersection (display) and intersection-sort paths both filtered
through ``parent_folder``, returning zero comics for any folder
whose content lives one or more levels deeper. The
``Comic.folders`` M2M includes every ancestor folder — the same
relation the cover-pick subquery already uses for the same
reason — and the aggregate path through ``rel_prefix = "comic__"``
already traversed it correctly. The intersection / sort path now
matches.

- ``_intersection_relation(group_model)`` returns the right ORM
  lookup: ``MODEL_REL_MAP`` for the FK groups, ``"folders"`` for
  Folder. ``compute_group_intersections`` (the display-side
  computer) routes through it so descendants count.
- ``_comic_correlation_sql(group_model)`` returns the SQL
  fragments that correlate Comic rows to the outer group row:
  for FK groups it stays ``c.<fk> = <group>.id``; for Folder it
  injects ``INNER JOIN codex_comic_folders cf ON cf.comic_id =
  c.id`` and ``cf.folder_id = <folder>.id``. The total-count
  sub-select gets an analogous shape with ``DISTINCT c2.id`` for
  the M2M case.
- ``scalar_intersection_sort_expr`` and the four M2M-intersection
  builders (simple, universes, credits, identifiers, story_arcs)
  all delegate to ``_comic_correlation_sql`` instead of hand-
  splicing ``c.{comic_group_col} = {group_table}.id``. The
  ``_wrap_intersection_sort`` envelope now takes the correlation
  tuple directly.

New ``test_folder_view_aggregates_through_ancestor_m2m``
reproduces the user's pattern: two top folders containing
sub-folders only, one with all-2024 grandchildren and one with
mixed years. DESC year sort on the folder-table view now ranks
the 2024-cluster folder before the mixed one. Pre-fix both
sorted as NULL → tied → arbitrary.

167 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser table view: page_count and size sort/display by Sum, matching cover view

Cumulative scalars want a different group-row rule than categorical
ones. Cover-view cards already show ``Sum(comic__page_count)`` /
``Sum(comic__size)`` for group rows; the table view now matches.

- New ``_CUMULATIVE_SCALAR_FIELDS`` set lists ``page_count`` and
  ``size``.
- ``compute_group_intersections`` dispatches those columns to a new
  ``_compute_scalar_sum`` helper that returns the per-group sum
  rather than the intersected value. ``row[col]`` ends up as the
  total instead of NULL when children differ.
- ``scalar_intersection_sort_expr`` returns ``None`` for cumulative
  fields so the caller falls back to ``_ORDER_AGGREGATE_FUNCS``,
  which already maps both keys to ``Sum``. Display + sort both now
  use Sum end-to-end.

Categorical scalars (year, country, language, age_rating, tagger,
…) keep the intersection rule from the previous commit.

Tests:
- ``test_primary_sort_by_page_count_on_group_rows`` reverts to the
  cumulative semantic — ASC by page_count puts Beta (smaller sum)
  before Alpha.
- New ``test_table_view_group_cumulative_page_count`` verifies the
  series-row cell displays the total (22 + 18 = 40) when the two
  children disagree, instead of an intersected NULL.

168 backend tests pass; lint clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser: cancel button on the loading spinner after a 10s grace period

Backend table-view queries can take a long time on large libraries
(intersection sort + multi-column extras + M2M aggregates). Without
an escape hatch the user is stuck watching the indeterminate
spinner with no way back to the previous results.

After the spinner has been showing for 10 seconds, a "Cancel"
button surfaces below it. Clicking it aborts the in-flight
``getBrowserPage`` request via the abortable API and flips
``browserPageLoaded`` back to true so the prior page's cards /
table rows reappear from the unchanged ``state.page``.

- New ``abortKey(key)`` in ``api/v3/abortable.js`` looks up the
  controller for a single-flight key, calls its ``abort()``, and
  removes it from the registry. Returns whether anything was
  actually pending so the caller can decide what to do.
- New ``cancelBrowserPage()`` action in the browser store calls
  ``abortKey("browser:loadBrowserPage")`` and toggles
  ``browserPageLoaded = true`` only when an abort actually fired.
  ``loadBrowserPage``'s catch already swallows ``AbortError`` so
  state.page stays untouched.
- ``BrowserMain`` watches ``showPlaceHolder``; when it goes true
  it arms a 10s timeout that flips ``cancelButtonReady`` true.
  When the placeholder hides (success / error / cancel) the
  watcher clears the timer and resets the flag so the next
  request gets its own 10s grace. The button is gated on a real
  prior page existing (``state.page.mtime > 0``) so a brand-new
  session — where the spinner is just the initial libraries-fetch
  and there's nothing to fall back to — doesn't get a useless
  cancel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* move cancel timeout to a const

* change cancel timeout to 5 secs

* browser: cancel button resets settings to defaults instead of restoring stale state

Restoring the prior page on cancel left the route in the same heavy
settings configuration that just timed out, so the next load would
hit the same wall. Cancel now follows the "Clear All Filters" path:
DELETE /r/settings to reset server-side, ``_validateAndSaveSettings``
to pull the response back into local state, then a fresh
``loadBrowserPage`` against the defaulted settings.

The user lands on a route's baseline view (cover mode, sort_name,
no extras, no filters) — guaranteed to render — instead of being
stuck on a configuration the backend can't serve in a reasonable
time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser: cancel button targets the heavy bits, keeps the rest

Previous behavior reset every setting via DELETE /r/settings, which
threw away the user's filters, search, view-mode and any custom
columns for other top-groups along with whatever made the query
expensive. Now the cancel scopes the reset:

- ``orderBy`` → ``sort_name`` and ``orderReverse`` → false (drops
  aggregate / intersection sorts).
- ``orderExtraKeys`` → ``[]`` (drops the multi-sort chain).
- ``tableColumns[<topGroup>]`` cleared so the resolver falls back
  to the registry defaults for *this* top-group only — other
  top-groups' custom column sets stay intact.

Filters, search, view-mode, top-group and route stay on the user's
current values. Implementation routes through the existing
``setSettings`` action, which PATCHes the partial-reset payload,
re-loads, and persists.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* browser: cancel-button tooltip explains what gets reset

Title attribute branches on the active view: ``"Reset order"`` in
cover view (where the cancel onl…
Both ``_row_repr`` (page serializer) and ``_add_extra_order_by``
(order-by view) sat at cognitive complexity 22, well above the
project's threshold of 15. Refactor was pure decomposition — no
behavior change:

- ``_row_repr``: extract per-column dispatch into ``_emit_column``
  (elif chain over column type), with ``_emit_cover`` and
  ``_emit_issue`` for the two-key / compound cases. Add
  ``_apply_transform`` to dedupe the transform-or-not pattern that
  ran on both the intersection branch and the FK-name branch.
  Routing-column shortcut moves to a module-level ``_ROUTING_COLUMNS``
  frozenset.
- ``_add_extra_order_by``: split the Comic-row and group-row branches
  into ``_comic_extra_order_by`` / ``_group_extra_order_by`` helpers,
  with the unsupported-key fallback hoisted into
  ``_comic_extra_fields``. The dispatcher itself is now three lines.

193 backend tests + 27 frontend tests still pass; complexipy now
reports no functions over the threshold.
…grations (#747)

The original 0041_browser_table_view.py was a consolidation born of
develop and browser-table-view both adding a migration numbered 0041
to a shared 0040 parent — the consolidation merged the phantom-Comic
cleanup ``RunPython`` (originally develop's standalone 0041) into the
table-view schema migration to keep history linear.

Re-split now that main has shipped 0041_cleanup_phantom_comic_as_folder_rows
in its own right. develop should mirror main on 0041 verbatim, then
add the table-view operations as 0042 on top:

- 0041_cleanup_phantom_comic_as_folder_rows.py is now byte-identical
  to main's copy. Same docstring, same helpers (``_is_comic_path``,
  ``_comics_fs_reachable``, ``_stat_says_directory``,
  ``_path_says_directory``, ``_find_corrupt_comic_pks``,
  ``_cleanup_phantom_comic_as_folder_rows``, ``_noop``), and the
  single ``RunPython`` operation.
- 0042_browser_table_view.py carries only the table-view schema work:
  the four ``AddField``s (``view_mode``, ``table_columns``,
  ``table_cover_size``, ``order_extra_keys``) and the ``order_by``
  ``AlterField`` that refreshes the choice list to the post-
  table-view set. Depends on 0041_cleanup.

Verified: ``makemigrations --check --dry-run`` reports no drift,
``showmigrations`` prints 0040 → 0041 → 0042 linearly, and the full
test suite (193 tests) still passes.
…748)

All five files were in the radon ``cc --min C`` output (or for one
test file, ``mi --min B``). Pure decomposition / consolidation, no
behavior change.

Production code:
- ``intersections._compute_simple_m2m_intersections_batched`` (CC 12 → 6):
  extract ``_initialize_simple_m2m_buckets``,
  ``_union_simple_m2m_queries``, and
  ``_collect_simple_m2m_intersections``. Main function now reads as
  a 6-line pipeline.
- ``order_by.BrowserOrderByView._add_comic_order_by`` (CC 11 → 1):
  split the order-key normalization out into
  ``_normalize_comic_order_key`` and the head-building branches into
  ``_comic_order_fields_head`` / ``_comic_sort_name_head`` /
  ``_comic_indexed_head``. Top-level method is now a 2-line orchestrator.
- ``annotate.order.BrowserAnnotateOrderView.annotate_extra_order_values``
  (CC 11 → 6): hoist the early-return predicate into
  ``_should_annotate_extras`` and the per-extra annotation map into
  ``_build_extra_annotations``. The applier function now reads top-
  to-bottom with no nested branches.

Tests:
- ``test_save_view_round_trips_table_fields`` (CC 11 → 4): extract
  ``_save_named_view`` and ``_load_saved_settings`` round-trip
  helpers; collapse four field-by-field assertions into a single
  dict-equality check against the live patch payload.
- ``test_table_view_m2m_sort_groups_identical_sets`` (CC 19 → 5):
  extract ``_create_sibling_comic`` factory (replaces three
  ~12-line ``Comic.objects.create`` blocks) and
  ``_assert_genre_sort_classes`` for the equivalence-class
  verification. The class-counts assertion uses ``frozenset`` +
  ``sorted`` for a single equality check.
- ``test_table_view_simple_m2m_intersections_share_one_union_query``
  (CC 11 → 5): extract the seven-table ``or`` chain into a module-
  level ``_SIMPLE_M2M_THROUGH_TABLES`` tuple + a
  ``_count_through_table_queries`` helper that uses ``any()``.
- ``tests/test_browser_table_response.py`` MI: B 17.08 → A 19.66
  (driven by the function-level fixes above).

Verified: ``bin/lint-complexity.sh`` clean, ``ruff check`` clean,
193 backend tests pass.
Five diagnostics. All worth fixing — none required ``# ty: ignore``
fallbacks except for two lines that already carried matching
``# pyright: ignore`` comments for the same Django-field shape:

- ``columns.default_columns_filtered`` — annotate ``show_map: dict``
  so ty doesn't infer ``dict[Never, Never]`` from the
  ``isinstance(show, dict) else {}`` ternary. The ``.get(flag)``
  call now type-checks cleanly.
- ``intersections._intersection_relation`` and
  ``_comic_correlation_sql`` (plus the four
  ``_build_*_intersection_sort_sql`` helpers,
  ``_build_simple_m2m_intersection_sort_sql``,
  ``scalar_intersection_sort_expr``, and
  ``m2m_intersection_sort_expr``) — tighten ``group_model: type``
  to ``group_model: type[BrowserGroupModel]``. The narrowing after
  ``if group_model is Folder`` then preserves the BrowserGroupModel
  bound, so ``MODEL_REL_MAP.get(...)`` and ``group_model._meta``
  type-check. ``BrowserGroupModel`` was already exported from
  ``codex.models.groups``.
- Two ``field.remote_field.through`` accesses (lines 540 and 736)
  pick up ``# ty: ignore[unresolved-attribute]`` to match the
  existing ``# pyright: ignore[reportAttributeAccessIssue]``
  comments. ``ManyToManyRel.through`` is exposed at runtime but
  not in stubs — same reason pyright already ignored it.

Verified: ``make ty`` clean, ``ruff check`` clean, 193 backend
tests pass.
* add Favorite model + migration for per-user favorites

Phase 1 of the favorites feature — see tasks/favorites/00-plan.md for
the full design. Adds a single Favorite table keyed by
(user, group, target_id) where group is the same single-letter code
used in browser URLs (p|i|s|v|f|a|c). The composite unique constraint
doubles as the lookup index for both the upcoming "favorites only"
filter and the per-row Exists() annotation in table view.

Authenticated users only — favorites are persistent intent and don't
need the session fallback Bookmark uses for in-flight reading state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* cascade favorites on target-row delete via signals + nightly sweep

Phase 2 of the favorites feature.

Primary path: a `post_delete` handler in `codex/signals/django_signals.py`
catches deletes of any of the 7 favorite-able target models (Publisher,
Imprint, Series, Volume, Folder, StoryArc, Comic) and drops matching
rows from the `Favorite` table. A module-level map populated in
`connect_signals()` translates model class to single-letter group code.

Backstop: `JanitorCleanupFavoritesTask` runs nightly alongside the
existing orphan-bookmark/sessions/settings sweeps, deleting favorites
whose `target_id` no longer matches a real row. Catches paths that
bypass Django ORM signals (raw-SQL migrations, etc.).

Test coverage extended with `test_target_delete_cascades_via_signal`,
including a distractor row to confirm the (group, target_id) match is
exact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorites HTTP API at /api/v3/favorites/

Phase 3 of the favorites feature.

  PUT    /api/v3/favorites/<group>/<target_id>/  201 new / 200 existing
  DELETE /api/v3/favorites/<group>/<target_id>/  204 always (idempotent)
  GET    /api/v3/favorites/                      {group: [ids...]}

Authenticated only - permission_classes pinned to IsAuthenticated so
neither an enabled-non-users install nor an anonymous session can
mutate or list favorites.

Detail PUT performs an ACL check via get_acl_filter(model, user) and
returns 404 (not 403) when the target is hidden, so an attacker can't
probe the existence of a row they aren't allowed to see.

GET emits a complete dict keyed by every group code (with empty arrays
where appropriate) so the frontend store can hydrate per-group Sets
without a second roundtrip per group.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorites-only filter to browser pipeline

Phase 4 of the favorites feature.

`SettingsBrowserFilters.favorite` (BooleanField, default False) joins
the existing per-user filter row, with a scalar branch added to
`_save_browser_filters` so the boolean isn't list-coerced through the
existing pk-list path. The serializer accepts `filters.favorite` as a
boolean.

Migration 0044 adds the column; it also incorporates the JRF
librarian-status choice deferred from Phase 2's signal/cron landing.

`BrowserFilterView.get_favorite_filter(model)` returns
`Q(pk__in=Favorite.objects.filter(user, group).values("target_id"))`
when the filter is set, and a no-op Q for unauthenticated users or
models that aren't favorite-able. The chain in `_get_query_filters`
gets one new line right after the bookmark filter.

Tests cover settings round-trip, default false, and a direct-ORM
proof that the subquery narrows a Series queryset to only favorited
rows. Full suite stays green at 209/209.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorite as editable table-view column

Phase 5 of the favorites feature.

`BROWSER_TABLE_COLUMNS` gains a `favorite` entry — the first editable
column in the registry. Sort key is `favorite`, edit widget is
`checkbox`. The order-by enum (`BROWSER_ORDER_BY_CHOICES` and the
matching `SettingsBrowser.order_by` choices via migration 0045)
gets the new key so users can sort by favorited state.

`favorite_annotation_for(model, user)` in
`codex/views/browser/columns.py` builds the queryset annotation:

  Exists(Favorite.objects.filter(user, group, target_id=OuterRef('pk')))

for authenticated users on a favorite-able model, a `Value(False)`
constant for anonymous sessions, or an empty dict for models outside
the favorite group map. The alias is just `favorite` — no Comic-field
collision.

`BrowserView._add_table_view_favorite_annotation` runs the annotator
unconditionally on model (groups + Comic) and unconditionally on
column selection — the per-row Exists is cheap and being always-on
lets sort-by-favorite work without gating on which columns the user
toggled. It runs right before the existing Comic-gated sort
annotators in `_get_common_queryset` so `add_order_by` sees the
alias.

Test suite extends the registry-invariant test to assert favorite is
the *only* editable column, plus new annotation-shape tests covering
authenticated, anonymous, and unmapped-model cases. 212/212 backend
tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* surface a Favorites preview on the OPDS v2 start page

Phase 6 of the favorites feature.

The favorite browser filter from Phase 4 already flows through OPDS
unchanged (BrowserSettingsFilterInputSerializer is shared), so any
client can request /api/v3/<group>/<page> with
?filters={"favorite": true} and the existing pipeline narrows the
feed.

The new visible bit is a "Favorites" preview LinkGroup on the v2
start page, conditionally appended in
OPDS2FeedGroupsView.get_ordered_groups when the requesting user has
at least one favorite. Empty-favorite users (and anonymous sessions)
see the unchanged Keep Reading / Latest Unread / Oldest Unread set.

FavoriteFilters.ONLY constant added in codex/views/opds/const.py for
symmetry with BookmarkFilters. OPDS v1 is symmetric but uses a
different facet structure; deferred to a follow-up if v1 clients
need the surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorites handoff doc for the frontend session

Phases 1-6 done end-to-end on this branch. The handoff captures the
REST/filter/column/OPDS contracts the frontend will consume, plus
notes on judgement calls (404-on-hidden-target, always-on table
annotation, scalar-filter coercion fix, etc.) so the next agent can
dive into Vue without re-deriving the design.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* seed favorite=False in BROWSER_DEFAULTS filters

Phase 4 added the model field and serializer entry but missed the
``_DEFAULT_FILTERS`` const, so the regenerated ``browser-defaults.json``
omitted the key and the frontend's initial ``state.settings.filters``
held an undefined favorite. Mirror the bookmark scalar entry so the
default round-trips through the build-choices script.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorites Pinia store + xior client

Per-user favorites cache fed by ``GET /api/v3/favorites/`` and
flipped via ``PUT|DELETE /api/v3/favorites/<group>/<pk>/``. The
store keeps a Set per group code (p|i|s|v|f|a|c) so the
``isFavorite(group, pk)`` getter is constant-time, and ``toggle``
optimistically flips the local Set with rollback on API error so
the UI never lies about persisted state.

App-boot wiring: the existing ``user`` watcher in App.vue gains a
``hydrate()`` call so the cache is warm before browser cards
render their stars. Logout clears the cache so a different account
signing in next doesn't see the previous user's favorites.

Vitest covers hydrate (full + partial payload + error), toggle
both directions, optimistic rollback for both verbs, unknown
group, and clear.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorite star UI across browser, reader, and table views

A single ``FavoriteToggle.vue`` component (props: group, pk) drives
every mount site. It reads ``isFavorite`` from the favorites store,
fires ``toggle`` on click, and stays disabled for anonymous
sessions (the API rejects them with 403).

Mount sites:

- Browser cards: peer of the existing select-many checkbox so the
  lit star sits outside the controls' opacity-fade subtree and
  stays visible when set, only fading in on hover when off. Hidden
  for multi-pk aggregate cards (folder rollups, mixed-publisher
  story arcs) since favorites are per-row.
- Reader header: alongside the metadata dialog button, scoped to
  the current comic.
- Table view ``favorite`` column: the ``v-else-if`` branch in
  ``browser-table-cell.vue`` renders the toggle inline; the cell
  resolves ``(group, pk)`` from ``row.group``/``row.ids[0]`` for
  group rows and ``row.pk`` for comic rows.

Filter UI: a "Show Favorites Only" prepend-item in the existing
``BrowserFilterBySelect`` menu writes ``filters.favorite`` into
the browser store, with the icon flipping between outlined and
filled to mirror state. ``isFiltersClearable`` and the
``onClear`` fallback now know about the favorite filter so the
clear-all path resets it alongside bookmark and the dynamic list
filters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* add favorites bullet to readme features list

Mirrors the table-view bullet placement; both surfaces landed in
the same release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* make favorites filter transitive across the comic chain

Treat "favorites only" as a navigation tree: a row passes if it is
itself favorited, an ancestor of a favorite (so descending into a
starred Publisher reveals its Imprints / Series / Volumes / Comics),
or a descendant (so favoriting a Comic keeps every ancestor visible
all the way back to the root publisher list). Previously the filter
was a flat ``pk__in=<my favorites for this group>``, which made
favoriting a Publisher show the Publisher row but break navigation
into it — the Series list narrowed to favorited Series only, of
which there were none.

Each OR clause traces ``rel_prefix + comic_field`` to a per-group
favorited-id subquery. ``folders`` is the m2m of every ancestor
folder for a comic (the importer adds the full path chain), so a
favorited folder lights up every descendant folder + comic in one
clause; ``story_arc_numbers__story_arc`` is the analogous m2m to
StoryArc. A trailing self-clause (``pk__in=<my favorites for this
model>``) covers the "favorited group with no comics yet" edge
and lets the planner skip the Comic join when the row matches by
identity alone.

The filter always pulls in ``folders`` and
``story_arc_numbers__story_arc`` m2m joins on Comic, so
``comic_filter_uses_m2m`` learns to flag the favorite filter so
the Comic queryset gets ``.distinct()`` to dedup.

New ``FavoriteFilterTransitivityTestCase`` exercises both
directions end-to-end through the browser endpoint with two
parallel hierarchies (P1/I1/S1/V1/C1 vs. P2/I2/S2/V2/C2):

- Favoriting P1 → I1, S1, V1, C1 all surface in their lists; P2's
  branch is filtered out.
- Favoriting C1 → P1, I1, S1, V1 all remain navigable.
- Favoriting S1 → P2's subtree stays out, C1 still reachable.
- Filter off → both branches show (sanity check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix favorite filter rel for sub-queries against Comic

The transitive favorite filter computed its rel from
``self.rel_prefix``, which is cached against the BROWSE model (set
once via ``self.model``). The browser pipeline runs sub-queries on
Comic during a group browse — annotation rebuilds, intersection
calculations — and applying the cached browse-model rel there
produced ``comic__pk`` against Comic and a ``Cannot resolve
keyword 'comic' into field`` FieldError as soon as a Folder browse
exercised the path.

Compute ``rel`` per-call from the queryset's actual model via
``self.get_rel_prefix(model)`` — same pattern as
``get_bm_rel(model)`` and ``get_acl_filter(model, user)``.

Pinned with a new test that walks an F1/F2/F3 folder chain where
F2 has no direct comic children: favoriting F1 must surface F2 in
the inner-folder list (drives the regression that exposed the
original bug, and confirms ``Folder.comic`` traverses the
``Comic.folders`` m2m as expected for empty-intermediate cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* tweak favorites UI placement

Three feedback-driven adjustments:

- **Card star**: stack the toggle below the existing childCount badge
  in the top-right (the previous shared ``top: 0; right: 0`` slot
  was occluding the count circle on group cards).
- **Filter menu**: move "Favorites Only" out of the prepend-item
  slot and into the append-item slot, between the bookmark choices
  and the dynamic filter sub-menus, with ``v-divider``s on each
  side. Title text stays left-justified per v-list-item default;
  the star moves from ``prepend-icon`` to ``append-icon`` so it
  renders on the right. The lit state tints the icon primary so a
  user can read the row's state at a glance.
- **Metadata dialog**: add the toggle to the metadata-controls row
  alongside Download / Mark Read / Read so the star is reachable
  from the per-item detail view, not just the card grid.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* support sort by favorite without crashing

Three fixes to keep ``order_by=favorite`` from KeyError-ing on
``_ORDER_AGGREGATE_FUNCS``:

1. Add ``favorite`` to ``_ANNOTATED_ORDER_FIELDS``. The favorite
   column carries its own ``Exists`` annotation rather than an
   aggregate, so ``annotate_order_value`` should reference it via
   ``F("favorite")`` (matching ``bookmark_updated_at`` /
   ``sort_name``) instead of dispatching to the per-column
   aggregate map.
2. Annotate the favorite Exists *before* ``annotate_order_aggregates``
   in ``_get_common_queryset``. Django resolves ``F()`` targets at
   ``annotate()`` call time, not at SQL compile time — adding the
   ordering annotation first raises ``Cannot resolve keyword
   'favorite' into field`` because the named annotation isn't on
   the queryset yet.
3. Fall back to ``sort_name`` for the cover-pk subquery's
   ``order_by`` when the outer key is ``favorite``. The cover
   subquery is a fresh, correlated Comic queryset that doesn't
   carry the outer view's annotations, mirroring the existing
   m2m_columns fallback in ``_normalize_comic_order_key``.

Multi-sort extras: ``_extra_group_special`` now returns
``F("favorite")`` for the same reason — direct annotation, no
aggregation. Combined with ``sort_name`` into a small
``_EXTRA_GROUP_F_KEYS`` set so the dispatch stays under
``ruff PLR0911``'s return-count limit.

Pinned with ``test_table_view_sort_by_favorite_does_not_crash``
walking every group level under table view with
``order_by=favorite, order_reverse=true``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* rename first column section

* update deps

* format

* optimize favorites filter + annotation; consolidate shared const

Three intertwined changes that share a single common-const refactor:

* Move the model→group-letter dispatch into ``codex.models.favorite``
  as ``FAVORITE_MODEL_GROUP_CODES`` (and the reverse map
  ``FAVORITE_GROUP_CODE_MODELS``). Four near-identical copies (filter,
  columns, view, signal/cleanup) now consume the single source of
  truth, so adding an eighth group code in the future is a one-line
  change instead of a four-place sweep.

* ``BrowserFilterView.get_favorite_filter`` only emits OR clauses for
  groups the current user actually has favorites under. Two new
  ``cached_property``s — ``_active_favorite_group_codes`` (one
  ``DISTINCT`` query per request) and ``_favorite_subqueries``
  (per-group ``target_id`` subquery, materialized once) — drive both
  the per-clause skip and the m2m-JOIN gating in
  ``comic_filter_uses_m2m``. The previous Q always carried
  ``folders__in`` and ``story_arc_numbers__story_arc__in`` clauses,
  forcing m2m JOINs on every favorite-filtered Comic browse even when
  the user had only favorited a publisher / series. Now those JOINs
  only land when ``"f"`` or ``"a"`` is in the active set, and Comic
  queries skip ``.distinct()`` when no m2m clause is in play.

* ``favorite_annotation_for`` switches from ``Exists`` (correlated
  per-row subquery) to ``Case(When(pk__in=Subquery(...)), ...)``.
  SQLite materializes the favorited-id subquery once and runs a
  hash-set membership check per outer row, which is cheaper than the
  Exists's per-row indexed lookup. The unique index on
  ``(user, group, target_id)`` covers both forms, but the IN form
  saves a JOIN per row and lets the planner cache the subquery
  result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* expose cleanup_orphan_favorites in the admin Jobs tab

``JanitorCleanupFavoritesTask`` already runs nightly via
``_NIGHTLY_TASK_CLASSES``, but the admin Jobs UI had no surface for
the on-demand variant. Mirror ``cleanup_bookmarks``: register
``cleanup_favorites`` in ``_TASK_MAP`` so the POST endpoint can
queue the task, add the matching entry to ``codex.choices.jobs``
under the existing Cleanup section, and include the ``JRF`` status
code in ``_JANITOR_NIGHTLY_STATUSES`` so the Run Nightly Maintenance
button surfaces the favorite-cleanup substatus alongside the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fold 0043-0045 schema migrations into 0042

Pre-release consolidation. Both feature tracks (browser table view
and per-user favorites) ship in the same release, and the four
migrations chained 0042 → 0043 → 0044 → 0045 walked the same
``SettingsBrowser.order_by`` choice list twice (0042 and 0045)
along with three separate ``ALTER TABLE`` blocks for additions to
``SettingsBrowserFilters``, ``LibrarianStatus``, and the new
``codex_favorite``. Folding everything into 0042 means a fresh
install applies one migration with the final shape directly,
including the post-table-view + favorite ``order_by`` choice set,
the ``Favorite`` model, the ``SettingsBrowserFilters.favorite``
boolean, and the ``JRF`` librarian-status entry.

The 0042 migration name is preserved (not renamed) since 0042 has
not shipped to a tagged release; deployments still on the
in-flight branch will need a fresh DB or a manual
``django_migrations`` adjustment to pick up the merged operations.

``manage.py makemigrations --dry-run --check`` reports no drift;
the full backend test suite (218 tests) passes against the
consolidated schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* rename migration

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cker (#751)

Previously the column picker always appended newly-toggled columns to
the end of the order list. This adds canonical-rank-aware insertion: if
the existing draft is in strictly-increasing canonical order (the order
implicit in `_CATEGORIES`), splice the new column into the unique slot
that keeps the sequence sorted. If the user has manually rearranged the
draft out of canonical order, fall back to appending so we don't reshuffle
their layout.

Examples (against the default "p" group `[cover, name, child_count]`):
  toggle favorite  → [cover, favorite, name, child_count]
  toggle imprint   → [cover, imprint_name, name, child_count]
  toggle publisher → [cover, publisher_name, name, child_count]

Covered by 16 new pure-function vitest cases.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BROWSER_CHOICES is dumped to both browser-choices.json (Vuetify list)
and browser-map.json (raw map), but each format is consumed
selectively by the frontend. Add per-file include-key frozensets in
codex/choices/browser.py and look them up from choices_to_json.py so
the generator skips keys the frontend never reads.

Removed orphans:
- browser-choices.json: IDENTIFIER_SOURCES (frontend uses map form)
- browser-map.json: BOOKMARK_FILTER, VUETIFY_NULL_CODE, SETTINGS_GROUP
  (frontend uses Vuetify form)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The `.*` glob in `[tool.codespell].skip` matched every walked path
because `os.walk('.')` prefixes paths with `./`, so codespell scanned
nothing during `make lint` while nvim per-file checks still flagged
typos. Replace `.*` with explicit hidden-dir paths, fix the
`/uv.lock` typo, and add legitimate technical terms (wan, crate, iff,
ser, etc.) to ignore-words-list.

Also wire codespell into the frontend lint script so frontend code
gets spell-checked too, and fix one real typo uncovered (secifies →
specifies in opds/v2/progression.py).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* audit and fix tool ignore/skip configs

- fix eslint **/*min.css and **/*min.js globs that over-matched (e.g. admin.css)
- fix [tool.vulture] test_results/ -> test-results/ typo
- move stray builtin = "clear,code,rare" into [tool.codespell]
- fix uv build-backend source-include typos (mkdocks.yml, .circlci/**) and drop dead entries (ci/**, top-level strange.jpg)
- remove duplicate "site" in [tool.basedpyright] exclude
- drop stale codex/_vendor references from all tool configs
- simplify [tool.complexipy] exclude (paths gating made entries unreachable)
- drop redundant entries from [tool.ruff] (dist already in defaults) and [tool.djlint] (covered by use_gitignore = true)
- normalize codespell skip prefixes; replace bare "coverage" with htmlcov + .coverage*; add *.svg, frontend/src/choices
- add coverage, htmlcov, .eslintcache to ESLint base ignores
- add comics/* and vulture_ignorelist.py to radon exclude
- normalize vulture *​/X* patterns to **/X so root-level matches work; align ty exclude with basedpyright
- delete unused [tool.typos] block

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* update deps

* fix remark for claude

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* bump version to alpha

* bump news for favorites
@ajslater
ajslater merged commit 680fbc8 into main May 10, 2026
4 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