Skip to content

Zimi 1.8.1 - #43

Merged
epheterson merged 59 commits into
mainfrom
v1.8.1
Jul 29, 2026
Merged

Zimi 1.8.1#43
epheterson merged 59 commits into
mainfrom
v1.8.1

Conversation

@epheterson

Copy link
Copy Markdown
Owner

Zimi 1.8.1

A polish-and-hardening release on top of the Community Edition.

Access control — a Zimi server can now be Open (everyone), Limited (a chosen set of ZIMs), or Sign-in required, with a hardened private-mode path (fail-closed gate, no identity/library caching in the service worker, an unforgeable admin session cookie so an admin still sees everything). "Did you mean?" grows into the coverage 1.8.0 promised (spread-thin words + two-typo + in-vocab-typo correction). Downloads get a nightly window, shared bandwidth caps, and bulk controls. Backup splits into My-data and Server cards with per-user server-side storage and merge-with-preview. Light mode gets a WCAG-AA contrast pass, tile redesign, and the Safari-flash fix. Plus categories (add/reorder/mobile), reader Define/overflow polish, zimgit document lists, video resume, an almanac star field, and the #38 fragment-link slow-load fix.

Notes written from the diff (shared-worktree races left several commit subjects unreliable). Full detail: release notes · CHANGELOG

Verification: 1,244 tests passing · the private/limited/open × anon/admin/user access matrix is covered by automated + Playwright e2e tests · admin session cookie, SW route classification, per-user data isolation, backup merge, download scheduling all under test · maintainer-validated on the production NAS.

Merging this cuts nothing on its own; the drafted v1.8.1 release (publish when ready) tags the merge commit and fires the pipeline (assets, appcasts, snap, brew).

🤖 Generated with Claude Code

epheterson and others added 30 commits July 28, 2026 00:41
Right-click coherence: the reader iframe no longer attaches a contextmenu
listener that intercepts right-click on in-archive links and swaps in Zimi's
own 'open article' menu. The browser's system context menu is load-bearing on
article content (copy, open-link-in-new-tab, translate, image save, look up),
so right-click inside a ZIM page now yields ONLY the system menu. Zimi's custom
link menu stays on chrome article links (search results / home tiles) in the
parent document. The 1.8.0 Define right-click suppression is unchanged and still
fires (no preventDefault) so a right-click never pops the Define trigger.
Rewrote the obsolete #17 contextmenu guard into a coherence guard.

Define discoverability, two teaching anchors on top of the select-a-word gesture
(which stays the fast path):
- One-shot tip: first article open with a wiktionary installed shows a
  dismissible bottom toast ('select any word for its definition'), once per
  browser (localStorage flag), auto-dismiss 6s, and retired the moment Define is
  ever used or lookup mode is entered.
- Reader menu entry 'Look up a word': arms a tap-to-define mode with a subtle
  'tap any word' banner; the next tap selects that word and shows the same Define
  popover, then disarms. Escape or re-tapping the entry exits; closing the reader
  disarms it. Only shown when a wiktionary is installed (else dormant).

i18n x10 (real translations): define_hint, define_lookup, define_lookup_prompt.
Extracted _defineRangeRect shared by the selection path and tap-to-define.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Replace the procedural _ensureSkyBgStars filler (220 random dots that never
moved with the sky) with 459 real HYG-v41 stars to magnitude 4.0, projected by
the same horizon geometry as the named catalog and cached with the same
discipline (rebuilt only on a time/location change, not per frame). Each field
star is tinted by its B-V colour index and twinkles on a deterministic,
RA-seeded phase. Extract the shared per-star projection into _projectStarXY and
_skyLST; _projectStars now also carries alt so the a11y star count is real.

Co-Authored-By: Claude <noreply@anthropic.com>
Server side of Users v2 access model. An anonymous visitor's ZIM view is
now governed by a public-access policy with three modes:

- open    — today's behaviour: anonymous sees the whole library
- limited — anonymous is filtered to a configurable allowlist, reusing the
            EXACT multi-user choke points (current_allow thread-local →
            get_zim_files / list_zims / zim_allowed / search-cache key), so
            no new leak surface (/languages, suggest, almanac-links, did-you-
            mean all filter automatically)
- private — anonymous gets only the login surface; every read endpoint 401s
            until a session exists (request gate in http.py). request_allow
            returns an empty set as defence in depth.

Policy lives in access.json under ZIMI_DATA_DIR; env ZIMI_PUBLIC_ACCESS
(open|limited|private) overrides the mode for docker. Fails CLOSED: a
present-but-corrupt config with no env override resolves to private, never
silently back to open. A missing file is the legacy default (open).

Admins always see everything; logged-in users keep their own allowlist.
Admin-only GET/POST /manage/public-access read + set the policy; /manage/users
also returns the policy + rich picker options (title/language/count). whoami
exposes the mode so the SPA can render the login screen.

36 new tests in test_public_access.py.

Co-Authored-By: Claude <noreply@anthropic.com>
Users panel gains a 'Public access' card above the user list with three
radio choices — Open / Limited to selected ZIMs / Sign-in required — the
client face of the anonymous-access policy. Open and Sign-in required save on
click; Limited reveals the picker and saves on an explicit button. An env
override (ZIMI_PUBLIC_ACCESS) renders the choices read-only with a note.

Allowlist picker rebuilt for legibility, shared by public-Limited and per-user
Limited: searchable checklist of installed ZIMs showing real titles, language
badges and article counts, select-all/none (acting on the filtered rows), a
live 'N of M selected' summary, comfortable rows, mobile-friendly. Fed by the
new zim_options payload.

Limited users' row now shows a clickable 'N ZIMs' scope that opens the
allowlist editor directly — the previously buried Edit allowlist action.

i18n: 14 new keys across all 10 locales (real translations).

Co-Authored-By: Claude <noreply@anthropic.com>
Add a session-scoped holiday scope toggle beside the calendar's region caption.
Default stays region-scoped (one national pack for the detected/chosen location);
'Worldwide' layers all 18 packs at once, each entry tagged with its ISO country
code ('Bastille Day · FR'). Clock-change entries stay region-only (18 countries'
worth would be noise), and the cell tooltip expands the ISO to the full country
name. The caption itself is now a button that scrolls to and flashes the Sun &
Daylight location map, so 'whose holidays?' leads to the control that sets it.
i18n parity x10 (+4 keys).

Co-Authored-By: Claude <noreply@anthropic.com>
Delivers the 1.8.0 release-note promise ("coverage grows with your title
indexes — a work-in-progress we're widening in 1.8.1"). Validated against
the production NAS library (68 indexes, 16.8GB, 27M sampled rows).

Vocab coverage (the core fix). The old 200k word cap saturated on ~3 of 68
files and FROZE the scan, so words that only accumulate once the
dictionary/encyclopedia indexes are reached never got in — "mitochondria"
and "photosynthesis" were both absent in the production cache. The build now:
  - never stops early: hitting the raised (4M) peak cap triggers
    non-terminating tiered eviction (_evict_to_free) that frees room and
    keeps scanning every index;
  - freezes only NEW admissions, and only if eviction genuinely can't free
    room — existing words keep counting;
  - prunes singletons then top-K caps the persisted set (1.5M safety valve;
    a normal library keeps its full count>=2 set).
Measured full build: 188s (< 5min budget), peak RSS 585MB, one clean
eviction, cache ~9-19MB. mitochondria/photosynthesis now present
(count 15/33). Builder version 3->4 so production re-scans once on upgrade.

Long-word distance-2. The corrector only did exhaustive edit-2 for words
<= 7 chars (a 13-char word generates ~450k candidates, ~700ms — over the
50ms budget), so "fotosynthesis" -> "photosynthesis" (distance 2, 14 chars)
was uncorrectable. Added a bounded character-trigram inverted index over the
long words of the vocab (selective trigrams only, shortest posting lists
first, hard scan ceiling, edit-distance-verify the best overlaps,
frequency-ranked pick), built in the background worker beside the vocab, not
persisted (~2-3s rebuild from the loaded cache). The index is itself capped
to the highest-count long words (_TRIGRAM_MAX_INDEX_WORDS) so per-query
latency stays flat as the vocab grows: measured worst-case long-word query
holds ~16-19ms whether the vocab is 600k or a synthetic 1.3M (uncapped it
drifted to ~44ms). dist-1 and short-word paths are O(1) dict lookups,
unaffected by vocab size. End-to-end through real search.py: mitochondira->
mitochondria 0.5ms, fotosynthesis->photosynthesis ~4ms, existing corrections
(einstien, watre, phlosophy, volcanoe) intact.

Tests: tests/test_did_you_mean.py, 62 green (12 new + the eviction test
rewritten for non-terminating semantics). Covers non-terminating eviction,
the admission-freeze fallback, top-K cap, bounded edit distance, the trigram
index + its word cap, and end-to-end long-word correction.

Co-Authored-By: Claude <noreply@anthropic.com>
When the public-access policy is 'private', an anonymous visitor must see
nothing but the login screen. The server already 401s every read endpoint;
this makes the CLIENT match instead of rendering a half-populated home whose
background fetches all fail.

On boot, /whoami reports login_required for an anonymous visitor in private
mode. The client then raises a non-dismissible login gate: an opaque overlay
(solid --bg, top layer, background scroll locked) over the sign-in card.
Cancel/Esc/any close path is a no-op while the gate is up — only a successful
sign-in leaves, reloading into a clean authenticated boot (a named user gets
their filtered view; an admin gets the full library). Admins and logged-in
users never see the gate.

Co-Authored-By: Claude <noreply@anthropic.com>
Widen the typeable location set from 353 to 502 cities: append 149 major world
cities (population-ranked, top-per-country for spread) with coordinates taken
straight from the GeoNames cities15000 dataset and deduped against the existing
lists. Add Adelaide to the map so every world-clock city now has a dot (28/28
parity). Click-anywhere-to-set-exact-lat/lon (off-dot clicks) already lands via
the existing handler; holidays resolve through the nearest-anchor region logic.

Co-Authored-By: Claude <noreply@anthropic.com>
Commit previews.py as it sits so HEAD (server.py re-exports extract_snippet;
tests/test_snippet_boilerplate.py imports it) no longer depends on an
uncommitted file. extract_snippet() prefers a page's own summary block
(iFixit banner-blurb / itemprop=description) over a repeated boilerplate
<meta description>, then meta description, then body prose with
nav/TOC/related-guide chrome stripped. Other preview refinements here are the
on-disk truth HEAD already builds against.

Co-Authored-By: Claude <noreply@anthropic.com>
Completes the server side of the public-access policy (companion to the
manage.py endpoints already committed). NOTE: a shared-worktree index race put
those manage.py endpoints into an earlier commit alongside another agent's
files; this commit carries the core these belong with — kept together here.

- users.py: get/set_public_access + status, access.json storage with env
  override + fail-closed load, _request_is_admin, request_allow rewritten so
  anonymous maps onto the existing allow-set choke points (open→None,
  limited→public allowlist, private→empty set).
- http.py: _private_access_block gate (private-mode login surface vs 401),
  do_GET/do_POST wiring, login-surface constants, whoami exposes the mode.
- test_public_access.py: 36 tests (storage/env/fail-closed, request_allow per
  mode×identity, the private gate, limited-mode leak checks, admin endpoints).

Co-Authored-By: Claude <noreply@anthropic.com>
Snapshot/restore the module-global state these tests touch (library._rate_cache,
the _download_throttle singleton, p2p._prefs_path) in an autouse fixture, so a
dirty rate cache or mid-flight bucket from an interleaved run can't produce
spurious "expected 0.0, got 1.0" pacing failures — and so the mirrors/bt-down
test no longer leaks _prefs_path into later suites.

Co-Authored-By: Claude <noreply@anthropic.com>
When the Almanac is open the breadcrumb now shows an almanac icon (calendar +
star) with the 'Almanac' title, mirroring how entering a ZIM shows the ZIM's
icon — instead of collapsing to a bare 'Zimi'. The tab title (_setWindowTitle)
and the search placeholder already read 'Almanac'; this completes the identity
in the topbar. bcClick is guarded so the identity icon never navigates into the
ZIM behind the overlay.

Co-Authored-By: Claude <noreply@anthropic.com>
_start_scheduled_now (server side of the download-start-now endpoint) had no
test. Covers: launches a scheduled item when a slot is free, promotes it to a
normal (non-window-gated) queue item when at cap, not_found for an unknown id,
and already_active when the id is already downloading.

Co-Authored-By: Claude <noreply@anthropic.com>
- _start_scheduled_now(): override the nightly window for one queued item
  (satisfies the pre-committed start-now test contract).
- /manage/download-start-now POST wired to it.
- /manage/backup GET builds the server half of a backup bundle (portable
  library list + collections + home layout); POST restores it (collections
  whole-doc replace, layout validated).
- Extract _validate_library_layout / _apply_library_layout so the
  library-layout endpoint and the backup importer share one code path.

Co-Authored-By: Claude <noreply@anthropic.com>
- Server settings gets a Downloads card: nightly-window toggle, start/end
  time pickers, in-window chip, env-lock notes, and the global download
  speed cap (posts download_kb). The BT sharing row becomes upload-only so
  one control owns the download cap (no duplicate writer of bt_down_kb).
- Downloads list shows a 'Scheduled · starts HH:MM' label and a 'Start now'
  override for parked items.
- Backup & export hub: Export all merges the server bundle with per-browser
  bookmarks/history/preferences into zimi-backup-YYYY-MM-DD.json; Import
  restores the server half via /manage/backup, writes localStorage back, and
  offers to queue any missing ZIMs through the normal download machinery
  (which honors the schedule).

Co-Authored-By: Claude <noreply@anthropic.com>
Audit of every rendered entity for unmatched-but-matchable Wikipedia targets.
Adds 28 holidays (4 region-pack labels — St Brigid's Day→Imbolc, Black
Consciousness Day, Youth Day CN/ZA; 24 base UN/world observances) and a new
CITIES block covering all 28 world-clock cities. Every Q-ID verified BOTH
directions against live Wikidata (title→wikibase_item and QID→enwiki sitelink),
independently re-confirmed via Special:EntityData. Five candidates dropped for
resolving to list/redirect/section targets (Constitution Day ES/MX, World
Science Day→UNESCO, World Photography Day: no article, Intl Mountain Day: dup).
Closed set, no search fallback. 431→487 curated Q-IDs.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
- Reader iframe: cap <video>/<audio> at 100% width so TED/Khan talk pages
  (fixed width=640) fit a phone viewport instead of overflowing.
- Resume: remember playback position per video (localStorage ledger keyed
  zim+path+index), restore on reopen, clear at >=95% watched or on ended.
- Discover: play badge + aria-label on cards from video ZIMs (TED/TED-Ed/
  Khan) so a random pick reads as 'play a video'. New play_video key x10.

Co-Authored-By: Claude <noreply@anthropic.com>
- Searchable, filterable document list (title, author, size, description)
  with a PDF glyph per row; filter appears once a collection exceeds 6 docs.
- Source header flags zimgit-* ZIMs as a 'Document collection' (chip).
- get_catalog now returns each PDF's byte size (cheap item.size lookup under
  the existing zim lock) for the row size hint.
- Pin the database.js parsing quirk with tests: parse_catalog must use
  ast.literal_eval (single-quoted Python literals JSON rejects), and fail
  closed on missing/malformed payloads. New filter/collection keys x10.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds app_theme, app_theme_hint, theme_auto/dark/light, darken_articles,
darken_articles_hint to all ten locale files (1119 keys each).

Co-Authored-By: Claude <noreply@anthropic.com>
Add a warm-paper light palette as [data-theme=light] token overrides on
<html>: bg #f2efe8, near-black text, and a burnt-amber accent (#b45309) so
every var(--amber*) reference keeps AA contrast on paper without per-selector
churn. Consolidate scattered literals into tokens (--topbar-bg, --card-bg,
--success/--success-strong) and darken two single-use status inks under light.

Intentional seam: the almanac overlay + home Today card keep their dark cosmic
identity in both themes by re-establishing the dark token set on .almanac-view
(custom properties inherit to the whole subtree).

Also style the Auto/Dark/Light segmented control in Display settings.

Co-Authored-By: Claude <noreply@anthropic.com>
Three-state app theme persisted in localStorage: Auto follows the OS via a live
prefers-color-scheme listener (dark fallback), Dark/Light are explicit. A head
bootstrap stamps data-theme before first paint so a light visitor never flashes
dark. Control lives in Display settings and flips the app in place.

Auto-darken adapts raw (non-Reader-View) ZIM pages when the app is dark, via an
invert(1) hue-rotate(180deg) filter with media/math counter-inverted; skips
pdf.js viewers and pages that already declare a dark scheme. Default follows the
app theme; overridable in Display settings and via a quick toggle in the reader
… menu. Reader View strips the filter (it owns its own themes) and restores it
on exit.

Co-Authored-By: Claude <noreply@anthropic.com>
… config to tests/

Repo-root slimming. Relocate the desktop build files out of the repo root:
  zimi_desktop.py, zimi_desktop.spec, zimi_winsparkle.py,
  requirements-desktop.txt -> desktop/
  playwright.config.mjs -> tests/

Every reference updated: CI workflow (pyinstaller + pip paths), deploy.sh,
CONTRIBUTING, test_winsparkle (spec-read + import path). The PyInstaller spec
now anchors all paths (entry script, datas, icon, WinSparkle.dll) to REPO_ROOT/
DESKTOP_DIR via SPECPATH so the build is CWD-independent. zimi_desktop.py adds a
dev-mode repo-root sys.path bootstrap (skipped when frozen) and fixes its
Sparkle.framework dev path; server.py adds desktop/ to sys.path before importing
zimi_desktop. playwright.config.mjs testDir/outputDir re-anchored for its new
home; spec run-command comments now pass --config=tests/playwright.config.mjs.

Proven locally: pyinstaller desktop/zimi_desktop.spec builds Zimi.app, smoke
test passes all endpoints incl. live libtorrent engine; pytest 1146 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
One 28s crossfaded walkthrough (820x512, 3.5MB) captured from the
production library: home library, cross-source search with language
pills, clean Reader View with A+ sizing, live interlang article switch
(English -> French), and an almanac glimpse. Replaces the five static
stills in the README Screenshots section. The five PNGs are kept on
disk pending maintainer review (still referenced by main's README).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
The release-published event fires before Desktop Release finishes
building, so the snap-publish job raced it and silently skipped —
1.8.0 never reached the Snap Store until a manual rerun. Poll for the
asset (60m cap) and pass the tag via env per Actions injection guidance.

Co-Authored-By: Claude <noreply@anthropic.com>
_moveZimTo() always called renderHome(), which paints into the same
#output element renderManage()/renderInstalled() use — so moving a ZIM
to a category from the Installed tab or Catalog tab blew away the whole
Manage overlay DOM and replaced it with the home grid, reading to the
user as being bounced to the home screen for a settings action. Route
the post-move refresh to whichever pane is actually active instead, and
toast the existing generic "Saved" confirmation on success.

Separately, the Move to… (and Collections) submenu only ever opened
downward from its trigger's top edge with a static 60vh cap, so a
trigger near the bottom of a short viewport — e.g. the last row of a
long Installed-tab list — ran the category list off screen with no way
to reach the lower items. posMenu() now measures each trigger and flips
its submenu to hang upward with a height capped to the room actually
available whenever there isn't enough space below, benefiting every
current and future submenu through the one shared positioning helper.

--

Root cause & fix locations:
- zimi/static/app.js:3285 _refreshZimListView() (new) + _moveZimTo() — mode/manageTab-aware refresh instead of unconditional renderHome()
- zimi/static/app.js:3407 posMenu() — per-trigger flip-up + max-height clamp for every .ctx-sub
- zimi/static/app.css:1839 .ctx-sub.flip-up rule

Verified: node --check zimi/static/app.js; pytest (1156 passed, 3 skipped,
no failures); Playwright against a local dev server (ZIM_DIR=./zims,
900x320 viewport) — Move to… from the Installed tab stayed on
/?manage, updated the row into the new category group in place, and
surfaced the "Saved" toast; the submenu on a trigger near the bottom
gained flip-left + flip-up classes and rendered fully inside the
viewport (top 34.5px / bottom 301.5px in a 320px-tall window).

Co-Authored-By: Claude <noreply@anthropic.com>
Programmatic audit of every light-theme token pairing found 10 sub-AA
pairs. Fixes, all now >=4.5:1 text / >=3:1 UI:

- --amber deepened #b45309 -> #9d4a08: amber links (4.37->5.35) and the
  amber-on-glow badges (updated/installed/multilingual, 3.84->4.70) clear AA.
- --on-amber token (dark #000 / light #fff) replaces 15 hardcoded black-ink
  sites that sat on amber fills (pills, primary buttons, NEW badge, role
  badge, tab badges); black-on-deep-amber (4.18) -> white (6.15).
- --border-strong token (dark = --border / light #8c826f) for controls whose
  outline is their only affordance: form fields (1.16->3.30 on bg) and toggle
  tracks.
- Toggle OFF track (the BitTorrent switch the maintainer flagged): white-on-
  white (1.04) -> warm-grey fill + AA outline; ON knob flips to white so it
  reads on the deep-amber track. Same fix for the reader-view switch.
- --text3 deepened #7c786e -> #6f6b60 so muted 10-12px labels clear AA (3.83->4.63).

Co-Authored-By: Claude <noreply@anthropic.com>
epheterson and others added 28 commits July 28, 2026 09:48
…n toggle

Touch devices now get extra clearance below the selection, a settle delay
before the chip appears, and a flip above the selection near the viewport
top — mirroring where iOS puts its own Copy/Look Up callout so the two
never overlap. Desktop positioning/timing is unchanged.

The darken-articles toggle now lives only in Display settings; the
duplicate quick-toggle in the reader ⋯ menu is removed (its CSS selector
and icon constant were already gone from a prior commit).

Co-Authored-By: Claude <noreply@anthropic.com>
Make BitTorrent work for free across install flows. libtorrent becomes a
default dependency gated `python_version < "3.14"` (wheels exist for
CPython 3.9-3.13 on manylinux incl. aarch64, macOS, Windows; none for 3.14
yet, where an unmarked hard dep would fail the whole pip install and break
the HTTP soft-fallback contract). Adds a `bt` extra (`pip install zimi[bt]`)
to force it anywhere, folds it into `all`, and moves it into requirements.txt
so Docker (incl. arm64) and a plain requirements install both get it — the
redundant explicit Dockerfile step is dropped.

README documents the auto-pull + HTTP fallback and the new ZIMI_BT
`active=`/`conns=` sub-keys.

Co-Authored-By: Claude <noreply@anthropic.com>
…ull reload

Single-page docs (devdocs) whose in-page TOC links are page-qualified
('index#anchor', not a bare '#anchor') routed every anchor click through
openArticle -> openReader, which called location.replace() to the same
document with a new #fragment. That is a same-document navigation: the
browser scrolls but fires NO load event, so the reader's loading overlay —
shown unconditionally — hung until the 15s safety timeout. Users saw a
10-30s 'page load' on every in-page jump (the section had already scrolled
underneath the spinner). The server was never involved: a 225KB single-page
doc serves in ~4ms (0.9ms on a 304).

openReader now detects a target that differs from the loaded document only
by fragment, scrolls the anchor into view explicitly (id, then name), and
returns without the reload cycle or the overlay. Cross-page navigation is
unchanged (full load, onload fires). _titleFromPath drops the fragment so
in-page jumps no longer title the tab 'page#anchor'.

Co-Authored-By: Claude <noreply@anthropic.com>
Compact tiles read as one family with the detail cards now, in both themes:

- Anchor the title: reserve two lines (min-height 2.7em) so a one-line title
  lands at the same Y as a two-line one. Short titles no longer float in the
  middle of the tile; icon->title gap is constant on a 4px rhythm.
- Deterministic badges that never collide at any title length: status chip
  (NEW/UPDATED) top-left, star top-right (opposite corners), language chip on
  its own line under the title. The old layout pinned the language badge
  bottom-left where it overlapped any title that wrapped to two lines, and
  clamped it inside the title so long titles lost it entirely.
- Title text wrapped in <span class="zt"> so the tile can clamp the title
  independently and flow the language chip below it; in the list the span is
  inline, so list rendering is unchanged.

Verified via card harness (long title + NEW + multilingual, plus RTL Arabic
with UPDATED) in light and dark: no collision, uniform tile heights.

Co-Authored-By: Claude <noreply@anthropic.com>
Adds regression coverage for the private (sign-in-required) mode boot/auth flow:

- test_public_access.py: TestSessionCookieAttributes pins that the zimi_session
  cookie carries Secure ONLY behind an HTTPS proxy (a Secure cookie over plain
  http is dropped by the browser, which read as 'login won't stick' on the LAN),
  with HttpOnly + SameSite=Lax always and Max-Age gated on 'remember'.
  TestWhoamiAdminToken pins that /whoami with the admin Bearer token answers
  role=admin (not anonymous+login_required) — the server contract the client
  boot gate depends on.
- test_private_mode_login.spec.mjs (webkit): login form is the first paint (no
  empty-library flash), and both a named user (session cookie) and the admin
  (Bearer token) stay signed in across a reload over plain http. Registered in
  playwright.config testMatch.

Co-Authored-By: Claude <noreply@anthropic.com>
Backend for the two BitTorrent settings-card knobs whose UI + i18n already
landed (commit e8fb821 swept them in), so HEAD is coherent again:

- p2p: get_max_active_downloads()/get_bt_max_connections() — ZIMI_BT active=/
  conns= sub-keys, persisted prefs, env-lock predicates. connections_limit is
  set on the libtorrent session and applied live (apply_session_limits). The
  active_* queue settings are deliberately NOT used: libtorrent only queues
  auto-managed torrents and Zimi manages every torrent by hand, so library.py's
  own download queue is the real concurrency enforcer (HTTP + BT alike).
- library: _max_concurrent() delegates to p2p (legacy
  ZIMI_MAX_CONCURRENT_DOWNLOADS still wins + locks); drain_download_queue()
  promotes queued items when the cap is raised live.
- manage: /manage/bt-settings handles both keys (env-lock guarded, applied
  live); the bt-status 'unavailable' hint now names the exact pip/Python fix.
- tests: getters/env-lock/clamps/pref, live connection-limit application,
  pref-driven queueing + drain.

Rides alongside concurrent download-window + backup work already present in
library.py/manage.py (shared worktree). Full suite green: 1205 passed.

Co-Authored-By: Claude <noreply@anthropic.com>
Safari flash: a light-app user on a dark-mode OS saw a dark compositor frame
when returning to the tab, because the UA painted its backdrop from the system
scheme, not the page's. Now the resolved scheme is pinned three ways:
- <meta name="color-scheme" content="light dark"> declares theme-awareness.
- The head boot and _applyAppTheme() set documentElement.style.colorScheme
  alongside data-theme, so the very first paint and every runtime flip keep the
  UA surface in sync.
- pageshow(persisted) re-asserts after a bfcache restore (both in the inline
  boot and in init), where no other script re-runs.

Reader View Auto: adds an "Auto" reader theme that follows the app theme
(app dark -> reader dark, app light -> reader light); sepia stays a manual
choice. Auto is the new default; users who already picked a reader theme keep
it. Stored value is now the mode (auto|dark|light|sepia); _readerTheme()
resolves it to a concrete palette so all downstream (body class, chrome tint)
is unchanged. An app-theme change re-stamps an open Auto reader. The Auto swatch
reuses the existing localized "Auto" label (no new i18n key). Verified: default
= auto, resolves dark/light with the app, sepia sticky across app flips.

Co-Authored-By: Claude <noreply@anthropic.com>
libtorrent ships musllinux wheels (Alpine gets BT for free) and NO sdist, so
the only marker-skipped gap is 3.14+; a py<=3.13 arch with no wheel would fail
the install (no sdist to fall back to) — noted for the rare exotic-arch case.
No behavior change; comment accuracy only.

Co-Authored-By: Claude <noreply@anthropic.com>
…gin gate

Bug 3 (iOS zoom on Add User): iOS Safari zooms the page when a focused
text-entry control has font-size < 16px. The login modal was already 16px,
but Add User (13px), the allowlist/zimgit search fields, and the share/port
fields were denser. Adds a touch-device (`@media (pointer: coarse)`) floor of
16px for every text-entry input/select/textarea; desktop keeps the compact
sizes. Verified on iPhone-emulated webkit: every real text input now computes
16px (native <select> is exempt — it opens a picker, not a text-entry zoom).

UX: the private-mode login gate is non-dismissible (closePwModal no-ops while
_loginRequired), so its Cancel button did nothing. Hide it in the gate; it
still shows in every other (dismissible) use of the modal.

Co-Authored-By: Claude <noreply@anthropic.com>
…lish

Residual working-tree hunks from the coordinated feedback drop (backup
scopes/preview UI, schedule-card recomposition, tests). Commit subjects
on this branch are unreliable due to shared-index races — release notes
are written from diffs; the PR squash-merges.

Co-Authored-By: Claude <noreply@anthropic.com>
…bility

Follow-ups from the maintainer's light-theme screenshots:

- Card definition: on the cream background the 1px card border is ~1.2:1 and
  tiles/detail cards read as loose icon+text. Added a light-only soft shadow
  (--card-shadow) + firmer edge (--card-border) so they lift into real cards;
  dark keeps its flat surface-contrast look. Applied via tokens on the base
  .stat-card rule so hover still overrides border + shadow.
- Blank icon: a source whose favicon is white/transparent (Army Publishing
  Directorate) rendered as an invisible white block on the white card. An inset
  ring on .card-icon in light gives the block a defined edge regardless of art.
- Disabled port field: opacity:0.5 made the env-locked value illegible
  grey-on-grey. Now a muted-but-readable colour on a recessed fill (still clearly
  inactive). Status dot gets a ring so a low-key grey reachability dot stays
  visible on a same-tone field.

Verified at 402px (iPhone) in both themes: tiles read as cards, blank icon
bounded, port value legible, dot visible; dark unchanged (no shadow/ring).
The toggle-knob and catalog pill-count contrast from the same screenshots were
already handled in d172882 (confirmed: BT on = white knob, pill count = white).

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Two field-reported private-mode enforcement bugs, both client-side (the server
already 401s every anonymous read in private mode — verified end to end):

BUG 1 "logged out and saw everything": manageLogout/userLogout dropped the
credential but never re-ran the boot gate, so the full library the session had
already loaded stayed painted client-side. Both logouts now POST /logout (drops
a secondary-admin's server session + expires the cookie) then reload, so the
boot gate re-runs anonymous — private re-shows the login gate, open/limited
reboot into the correct scope. No stale library survives a logout.

BUG 2 "sign in twice and saw nothing": the service worker routed /whoami through
stale-while-revalidate and /list,/search through network-first (which writes the
cache). After the post-login reload the boot gate read a STALE anonymous /whoami
and re-showed the gate; a cached full-library /list could also be served on a
network blip to a now-anonymous visitor. The SW fetch router is now a single
testable routeStrategy(): identity/auth-scoped endpoints (/whoami,/login,
/logout,/list,/search,/suggest,/random) are network-only — never cached, never
served stale, fail closed to the offline page.

Tests: new tests/test_sw_route_classification.py drives the real sw.js through
node to assert those endpoints are network-only and never touch the cache;
test_public_access.py gate loop expanded to the full read-endpoint table;
test_private_mode_login.spec.mjs gains single-sign-in (live access) and
logout-re-gates (401) specs, verified on chromium + webkit. Full suite 1228
passed, 4 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
…s polish

Round-3 on library sections v2, all UI-level:
- Rename user-facing sections -> categories across the reorder panel,
  add/remove controls and hints (i18n x10). Reorder title is now the
  complete "Customize categories". Internal code names unchanged.
- "+ Add category" expands an inline input row in place (autofocus,
  Enter commits, Esc cancels) instead of a prompt() dialog. Enter/Esc
  stopPropagation so cancelling never closes the whole manage overlay.
- Reorder rows are reorderable by touch: long-press-drag on the grip
  (touch-action:none) locks scroll while dragging and persists on drop;
  the up/down keyboard fallback is unchanged.
- The "Customize categories" pointer scrolls the panel into view and
  flashes it (same scroll+flash the almanac holidays caption uses).
- Long-press menu: suppress text selection on the pressed card while a
  press is armed, dismiss the menu on scroll like a native menu, and
  clamp it fully on-screen (both axes for the menu, per-submenu flip +
  width/height cap) on small viewports.

Co-Authored-By: Claude <noreply@anthropic.com>
The Regional/Worldwide segmented pill now sits centered directly under
the calendar grid instead of above it, and the 'Showing X holidays'
caption/link is gone entirely — the location affordance already lives on
the sun-map. Removes the now-dead _almScrollToLocation helper and its
.alm-hol-cap / .alm-loc-flash styles.

Co-Authored-By: Claude <noreply@anthropic.com>
…ctivity cap

- Compact tiles show the native full language name ("Français") on the chip's
  own line; dense list rows keep the terse two-letter code.
- Tighten the tile top band (28px -> 20px pad); status/star chips lifted to
  slightly overlap the icon corners, reclaiming height.
- Catalog checkbox: light-theme unchecked box gets --border-strong + a warm
  tinted fill (>=3:1 on the near-white card) matching the fixed-toggle family.
- Kill theme-sensitive transitions during theme (re)apply via an html.theme-boot
  guard, removing the residual Safari dark->light flash on pills/cards across
  bfcache restore / tab return.
- Downloads: top-level Pause all / Resume all / Delete all (confirmed), driving
  the existing per-item endpoints.
- Activity tab caps rendering at the most recent 50 with a "Show all (N)"
  expander; resets to capped on tab re-entry.
- Discover strip preserves its horizontal scroll offset across a home
  re-render (Back from an opened card) instead of snapping to the first card.
- i18n x10 for the new download/activity strings.

Co-Authored-By: Claude <noreply@anthropic.com>
A ZIM can open fine, report the right entry count, and match its catalog
size while still being a broken scrape: media entries with zero bytes, or
articles that are all empty shells. Neither was caught before.

- _sample_media: walks (<=120k entries) or strided-samples a ZIM for
  media by file extension, flagging 0-byte / application/x-empty entries.
  Catches partial breakage like ted_en_technology (26 empty .mp4 among
  1184 real .webm) that a small strided/mimetype sample would miss.
- _sample_text: a bounded strided probe for a few text/html articles,
  flagging a ZIM whose sampled articles are all empty even when it ships
  no media (~60 metadata-only probes, <1ms/ZIM).

Both run under the existing per-ZIM _zim_lock while the archive is open,
metadata-only reads (size/mimetype), never the blob. Adds a synthetic
media fixture and an empty-article fixture with tests for each path.

Co-Authored-By: Claude <noreply@anthropic.com>
…ema v3)

- Per-user My-data storage: ZIMI_DATA_DIR/userdata/<casefold>.json, atomic
  writes, deleted with the account. New /userdata GET/POST gated to the SESSION
  user (never a name from the body) — a user only ever touches their own;
  anonymous / admin-without-a-user stay file-only. Body cap raised for backup +
  /userdata routes (MAX_BACKUP_BODY).
- Full-server backup now also covers the hot list, auto-update config, server
  event history, and every per-user data blob. Env-locked hot/auto-update are
  never clobbered; history merge is a no-op into a live log (overwrite replaces).

Tests: per-user isolation + endpoint gating (test_userdata.py); v3 field
round-trips, env-lock, and history rules (test_backup.py). Full suite 1228/4.

Co-Authored-By: Claude <noreply@anthropic.com>
Two clearly separated cards, each with its own Export + Import:
- "My data" (bookmarks/history/preferences) — client-file by default; a signed-in
  named user also gets Save-to / Restore-from their server account (/userdata).
  It's the only card a signed-in non-admin sees (in their account view).
- "Server backup" (admin) — the full bundle, preview-then-apply, with new
  preview lines for per-user data and event history.
Imports validate the bundle's scope against the card and point a mis-dropped
file at the right one. Adds i18n (×10) and a Playwright spec covering the two
cards, scope-mismatch errors, and the signed-in save/restore round-trip.

node --check + staged i18n parity green.

Co-Authored-By: Claude <noreply@anthropic.com>
… settings grid, copy trims

Final tree state after the coordinated round-3 drop: removes the dead
lookup-mode code (menu entry gone; gesture + hint remain), Define clamp
+ scroll-dismiss, raw-view overflow containment, reader sepia-in-light
default, BT settings field grid + one-line hints, i18n trims. Verified
additive against all seven landed lane commits (marker audit).

Co-Authored-By: Claude <noreply@anthropic.com>
Ship blocker: an authenticated admin saw an EMPTY library (home "No knowledge
sources", Library "No ZIMs installed") in private OR limited public-access mode,
even though Settings showed all ZIM files.

Root cause: the admin's credential is a password Bearer token that only rides on
/manage/* calls. The DATA endpoints (/list, /search, /suggest, /random, …) are
plain fetch() with no Authorization header, and the /w/ reader iframe is a
browser navigation that CANNOT send one. So server-side those requests resolved
as neither user nor admin -> request_allow returned an empty/limited set ->
empty library and blank article iframes.

Fix (mirrors the user-session model, solves BOTH transports at once): mint an
HttpOnly zimi_session admin cookie when the admin password verifies. It rides
the plain-fetch data endpoints and the /w/ iframe alike.

- users.create_admin_session / is_admin_session: reuse the session machinery
  (random token, hashed at rest, TTL expiry, logout drop) under a NUL-sentinel
  user key no real username can produce. Never resolves as a named user.
- manage._primary_admin_authorized accepts a valid admin session from the cookie
  (checked before the Bearer-format gate) or a Bearer, as unforgeable as the
  password Bearer.
- http: /login sets the cookie on admin auth; /whoami mints/refreshes it for a
  Bearer-authed admin lacking one (boot awaits /whoami before /list, so it lands
  in time); /logout drops both bearer and cookie sessions server-side.

Security guarantee preserved: anonymous-in-private still 401s on every read
(gate unchanged for cookie-less requests); a logged-in USER still sees only
their allowlist; the admin cookie is as unforgeable as the password Bearer.

14 new tests in test_public_access.py cover admin-session recognition on both
transports, the private-mode gate, login/whoami/logout wiring, and the
anonymous-still-blocked regression guard. Full suite: 1242 passed, 4 skipped.

Co-Authored-By: Claude <noreply@anthropic.com>
Playwright spec proving the ship-blocker fix in a real browser, closing the gap
the existing private-mode spec left (it only checked the admin GATE clears, never
that the library was populated or an article rendered):
- private-mode admin's /list returns the full library (not the empty set)
- admin can OPEN an article: the /w/ reader iframe (a cookie-only navigation)
  renders non-empty content — the exact transport that was blank before
- admin logout returns to the gate and cuts off access (/list 401)

Logout hardening (app.js): userLogout/manageLogout now navigate to '/' instead
of reloading the CURRENT url. The fix newly lets an admin open articles in
private mode, so the current url can be a /w/ article — reloading that anonymous
renders raw 401 JSON as the page (SW navigateShell passthrough) instead of the
login gate. Rebooting at the root always boots the SPA and re-runs the gate.

Verified live (local server, all three public-access modes):
  open    → anon/admin/user all see the library, can open articles
  limited → anon gets public allowlist, admin sees all, user sees own allowlist
  private → anon 401 on /list AND /w/; admin sees all + opens articles; user
            sees own allowlist; logout → gate. Mode toggle persists to access.json.

Co-Authored-By: Claude <noreply@anthropic.com>
Adversarial hardening for the admin-session-cookie fix. Before it, the admin's
credential WAS the password (Bearer), so changing the manage password locked out
the old one instantly. An admin session cookie would otherwise survive a password
rotation for up to the 30-day session TTL — a regression in the "rotate the
password to revoke access" property.

_set_manage_password now calls users.drop_admin_sessions() after writing the new
hash. The rotating admin's own password Bearer still authenticates and /whoami
re-mints a fresh cookie, so the person making the change is never locked out.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Write the 1.8.1 release docs from the branch diff (commit subjects on
this branch are unreliable): access modes (open/limited/private) and the
private-mode security hardening lead; did-you-mean coverage widened;
Categories, light-mode contrast, backup hub, scheduled/throttled
downloads, BT-by-default install. README tests badge 1042 -> 1244.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
…ting

When a human has already written the GitHub release body (the curated
draft for this tag), the post-merge auto-drafter must not replace it
with the CHANGELOG extract. Tag creation is unchanged; the draft binds
to it on publish.

Co-Authored-By: Claude <noreply@anthropic.com>
@epheterson
epheterson merged commit b2d8206 into main Jul 29, 2026
5 checks passed
@epheterson
epheterson deleted the v1.8.1 branch July 29, 2026 04:41
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