Skip to content

Releases: esagduyu/tiro

0.9.0 sync-beta — BYO multi-device sync (Phase 7a)

Choose a tag to compare

@esagduyu esagduyu released this 18 Jul 19:39

Multi-device sync where the user owns the storage: state-diff capture against
a shadow manifest, LWW + both-versions-kept conflicts (no CRDT), age
encryption over an Argon2id-derived identity, filesystem/S3/WebDAV backends.
Phase 2b (Obsidian bidirectional reconciliation) shipped absorbed as S1.
The v1.0.0 go/no-go gate — S2's hypothesis property suite + S5's
multi-device integration suite — is GREEN at this tip.
The physical
2-laptops-+-phone owner acceptance matrix is still pending — machine-verified
analogues exist for every criterion, but the tag is the owner's call after
that run.

Sync S6 — hardening & acceptance

  • Failure drills (tests/test_sync_drills.py): corrupted-snapshot bootstrap
    recovery (newest→oldest fallback, clean refusal naming tiro sync repair,
    never a half-materialized library), journal-truncation reachability
    refusal (a device doc ahead of present segments can no longer produce a
    silently-stale "ok" — bootstrap AND steady-state), stale-lock steal/skip
    pins, mass-delete-guard e2e extras (trip persistence across cycles, the
    real-argv CLI acceptance path, the annotations-guard equivalent with
    rows+sidecars surviving the trip, one-shot acceptance across two guarded
    segments), library re-pair chain against naturally compacted backends,
    CRLF newline-posture pin, lock-renewal and compaction-corruption drills.
  • Doctor: new report-only sync section (backend reachability, stale lock,
    needs-attention reason, conflict-file census, cycle warnings, clock skew)
    — exit-code neutral, offline-safe, no device-identity mint from a scan;
    the ONE --fix action is clearing a provably stale backend lock.
  • Clock-skew warnings (>24h, spec §10): pull-time both-directions detection
    (behind-check bounded by the previous successful cycle so an
    offline-weekend reunion never false-fires) + a live ahead-check in
    load_sync_status["warnings"]; surfaced in tiro sync status, the
    settings card, and doctor.
  • library_id pinning: the backend's format.json identity is pinned in
    sync_shadow and enforced every cycle — pointing sync_path at another
    library's backend refuses (needs_attention) instead of cross-merging;
    the setup ceremony re-pins deliberately (plaintext join included);
    plaintext auto-init refuses when a pin exists (typo protection).
  • Same-device two-process race closed: a per-library flock (kernel-released
    on process death) makes a concurrent server cycle + tiro sync --now
    skip instead of double-writing a journal seq lockless.
  • Long-upload protection: mid-push/compaction advisory-lock renewal (never
    clobbers a stolen lock; compaction additionally skips after an observed
    mid-cycle theft).
  • Merge core hardened by the property gate itself — fresh hypothesis
    exploration during S6's own gate runs falsified order-independence three
    times (counterexamples #5–#7, all latent since S2); root-fixed
    culminating in an atom-based canonical note form: freshly minted conflict
    blocks now use a fixed dateless > [conflict] header (block dates and
    intermediate-winner filtering were provably fold-order-dependent), legacy
    dated blocks re-mint canonically at first touch. Note texts are never
    lost (verbatim per atom); assertions never weakened; the generator was
    extended to reach the legacy/header regime.
  • Sync UI pass (Playwright): warning lines now render in an actual warning
    color in both themes (was a muted grey variable), interval-0 reads
    "manual only"; needs_attention flips the sidebar + card dots (verified
    in-browser).
  • Docs: README "Sync across devices (beta)" (setup, recovery-code warning,
    backends table, honest limitations incl. device-local reading stats,
    per-device vectors/audio, LF rewriting, the re-pair/repair story),
    SECURITY.md sync-encryption posture (what bucket access reveals, what
    the passphrase does not protect).
  • Accepted risks carried for owner ratification (decisions log D-S6-*):
    memory-only emitted-alias window, line/link ops not alias-remapped,
    repair drops in-flight tombstones, repair state reaches other
    established devices only via re-bootstrap (cross-library re-pair edge).

Sync S2 — pure merge core

  • tiro/sync/{journal,manifest,merge}.py: HLC + the eight journal op kinds
    (spec §5, wire format frozen by tests/fixtures/sync-journal-golden.jsonl,
    sync_format 1), whole-library manifest build + sync_shadow store
    (migration 016), state-diff op derivation, and full op application per the
    spec §4 merge-rules table — LWW files with conflict-file preservation,
    per-uid JSONL merge with a canonical note algebra that never drops a note
    body (losers append verbatim under sorted [conflict {date}] blocks),
    per-field meta LWW with per-field clocks (sync_shadow kind='metats') and
    max()-merged opened_count, link add-wins-over-concurrent-remove, article
    tombstones through delete_article with edit-wins resurrection, URL
    dedupe keeping the ULID-older uid + alias repointing, tombstone TTL 90d,
    unreadable-file protection (a transiently unreadable file is UNKNOWN,
    never deleted), and a pull-side mass-delete guard (max(10, 20%),
    whole-batch refusal).
  • THE 1.0 HARD GATE: hypothesis property suite (commutativity, idempotence,
    no-note-loss, diff∘apply round-trip, HLC monotonicity) + test-enforced
    zero-network check on the pure modules. Gate command:
    for i in 1 2 3; do uv run pytest tests/test_sync_properties.py -q --hypothesis-seed=random || exit 1; done
  • New test-only dependency: hypothesis (MPL-2.0, license verified at add).
  • No product surface yet: no routes, no scheduler, no UI, no transport —
    S3 (crypto/blobs) and S5 (engine loop) build on these interfaces.
  • Known owner-review items (decisions log D26): highlight deletes preserve
    non-empty notes as conflict files unconditionally (plan-property-vs-spec
    resolution); conflict blockquote headers carry the date only (device
    labels are not byte-convergent at line level).

Sync S1 — local reconcile engine (absorbed Phase 2b)

  • External edits to the library (Obsidian et al.) now reconcile into SQLite/
    ChromaDB/anchors: changed bodies re-index + re-embed and re-check highlight
    anchors; new .md files in articles/ ingest as ingestion_method='external'
    (file never rewritten — frontmatter stays user-owned); deleted files complete
    deletion through delete_article, guarded against directory mishaps
    (all-missing or > max(10, 20%) refusals).
  • Two-poll hash-settle defeats editor temp+rename/partial writes; unsettled
    files retry next pass.
  • Notes prefer the external version when ambiguous; the losing DB version is
    preserved as notes/{stem}.conflict-local-{yyyymmdd}.md — never silently
    dropped. tiro doctor gains a report-only conflict_files census and no
    longer treats conflict files as orphans.
  • New: migration 015 (articles.body_hash backfilled, articles.meta_updated_at,
    sources.uid + stamped at creation), config reconcile_interval_s: 30
    (0 = off), scheduler task reconcile, CLI tiro reconcile [--dry-run|--json].
  • PATCH .../rate|read|snooze (including the M3.2 unmark paths) now bump
    articles.meta_updated_at (UTC) — the LWW clock for the coming sync merge.

Sync S3 — encryption + backend blob format

  • Sync engine S3 (internal, no user-facing surface yet): age encryption layer
    (passphrase → Argon2id → X25519 identity via pyrage; recovery code = Bech32
    age secret key), frozen cross-port crypto parity fixture, sync backend blob
    format (format.json with version refusal, journal segments, content-addressed
    objects, snapshot manifests, device registry docs), compaction/GC planning
    (500-op/7-day cadence, 90-day dead-device rule), and corrupted-blob
    quarantine semantics. New runtime deps: pyrage (MIT), argon2-cffi (MIT).

Sync engine (S4 — storage adapters)

  • Storage adapter contract (tiro/sync/adapters/): async put/get/list/delete/lock/unlock
    byte stores with TTL-honoring advisory locks and a shared jittered retry policy
    (transient faults only, 3 attempts).
  • Three adapters: filesystem (atomic temp+rename writes, O_EXCL lock, encryption
    default off), S3-compatible (boto3, paginated listing, conditional-PUT lock,
    encryption default on), WebDAV (hand-rolled on httpx: Depth-1 PROPFIND walk,
    MKCOL parent creation, percent-encoded keys, encryption default on).
  • One shared conformance suite runs against all three: tempdir filesystem and
    an in-memory WebDAV fake always; real MinIO/Nextcloud via
    deploy/docker/docker-compose.sync-test.yml, auto-skipped when unavailable. Failure injection (5xx retry, list pagination, partial upload,
    lock contention/steal) runs fully offline via Stubber/MockTransport.
  • New dependency: boto3 (Apache-2.0, license re-verified). No migration, no routes,
    no UI — the engine loop and settings surface land in S5.

Sync engine (S5)

  • The full pull-merge-push cycle (tiro/sync/engine.py): S1 reconcile first,
    pull per-device journal segments on watermarks and apply through the S2
    merge core, push local changes in the FROZEN crash-safety order (objects →
    journal segment → device doc → local last_seq THEN shadow LAST — a crash
    anywhere leaves a duplicate, never a loss), then lock-gated compaction/GC.
  • Safety semantics: per-cycle format.json encryption pinning — the local
    pin is the authority, a disagreeing backend doc is refused before any
    codec is built (downgrade refusal); corrupted/undecryptable/malformed
    remote data quarantines the cycle as needs_attention with the watermark
    held — never a half-apply; a journal gap (missing segment) refuses the
    pull the same way; the S2 pull-side mass-delete guard gains a ONE-SHOT
    acceptance (tiro sync --now --accept-mass-delete / {accept_mass_delete}
    on the API), consumed by a single guard trip.
  • Empty-library auto-bootstrap: a zero-article, never-synced device pointed
    at a populated backend materializes the latest snapshot before its first
    ...
Read more

0.8.0 agents-beta — Agent runtime (Phase 6)

Choose a tag to compare

@esagduyu esagduyu released this 18 Jul 19:39

Every AI feature now executes as a recorded, replayable agent run; personas
and the ContradictionDetector build on the frozen kernel contract.

Agent runtime (Phase 6 K1–K2)

  • New tiro/agents/ kernel: TiroAgent/AgentContext contract, run_agent() with per-run JSONL traces ({library}/agents/traces/{run_uid}.jsonl), agent_runs index table (migration 014), one-agent-at-a-time run lock, typed AgentRunError.
  • All four AI features migrated behavior-identically (golden transcript tests pin prompt bytes): MetadataExtractor (Haiku extraction — every web/email/imap/rss/import ingest is now a recorded run), PreferenceClassifier, DigestWriter (scheduler untouched), IngenuityAnalyst. extract_metadata/classify_articles/generate_digest/analyze_article remain as compat wrappers — zero call-site churn.
  • /agents page: agent cards, filterable paginated run history, collapsible trace viewer, replay with cost-note confirm. Routes: GET /api/agents, GET /api/agents/runs, GET /api/agents/runs/{run_uid} (?trace=1 streams JSONL), POST /api/agents/runs/{run_uid}/replay, POST /api/agents/{name}/run.
  • Trace retention (agent_trace_retention_days/agent_trace_max_mb, files pruned, rows kept); doctor gains orphan-trace vacuum + stuck-run sweep.
  • Evals harness: tiro/evals/ fixtures + tiro evals run [agent] [--real] (structural mode is free/CI-gated via pytest); tiro agent list|run.
  • STATIC_VERSION 69.

Agent runtime — personas (Phase 6 K3)

  • Persona files ({library}/personas/*.md): community-shareable prompt templates over a closed placeholder set, running through the agent runtime on scope-derived read-only contexts. Untrusted by construction: suggest-only writes, no network tool, fenced interpolation with a fixed preamble, forced output kind, adversarially tested.
  • suggestions table (migration 017) + accept/dismiss flow running the standard validated writes; suggestion chips in inbox/reader and a queue + persona management on /agents.
  • Three forkable default personas: devils-advocate, daily-themes, research-brief. personas_disabled config (API-toggled). Manual runs only in K3; on-ingest/cron dispatch arrives with K4's hook infrastructure.
  • Routes: GET /api/personas, POST /api/personas/{slug}/enable, POST /api/personas/{slug}/disable, GET /api/suggestions?status=&article_id=, POST /api/suggestions/{uid}/accept, POST /api/suggestions/{uid}/dismiss.
  • Backup snapshots now include personas/ (an export/backup posture fix landed alongside K3 closeout — agents/traces/ remains deliberately excluded, per the K1–K2 D15 owner-ratification item).
  • STATIC_VERSION 70.

Agent runtime — ContradictionDetector & on-ingest hooks (Phase 6 K4)

  • New contradiction-detector code agent runs on ingest (post-save hook, never
    inside the rollback window; hook failures can never fail a save): finds up to
    8 similar articles, keeps the trusted set (rating > 0 or must-read), gets one
    light-tier claims-level verdict per candidate, and files confidence-gated
    contradiction suggestions ("challenges something you trusted"). Accepting
    appends the contradiction to the article's note. Empty trusted set = zero
    LLM calls.
  • contradiction_detector_enabled kill-switch (default on, config/env only —
    gates the on-ingest dispatch, never manual runs); tiro agent run contradiction-detector --backfill [--limit] for existing libraries
    (resumable, newest-first; bulk imports are hook-exempt by design).
  • On-ingest personas (article scope) now dispatch through the same hook —
    K3's schedule: on-ingest value is live; cron dispatch still deferred.
  • Six-pair eval fixture set added to the harness (fake_similars +
    ai_tier seed support). No new migration, no frontend change.

v0.7.0 — desktop-beta (Phase 5: desktop packaging)

Choose a tag to compare

@esagduyu esagduyu released this 11 Jul 00:58

[0.7.0] — desktop-beta (Phase 5: Installable personal app)

Tiro becomes something you install, not just something you run from a terminal:
a double-clickable macOS app that bundles the whole server, a first-run
onboarding wizard, platform-standard library locations with a safe migration
tool, a background service for run-at-login, and an official multi-arch Docker
image on GitHub Container Registry. STATIC_VERSION 67 → 68.

Added — Desktop app (macOS beta)

  • PyInstaller server binary (desktop/pyinstaller/) — the full FastAPI
    server frozen as a onedir bundle (tiro-server), embedding-model included so
    the first launch reaches a working, offline vector store with no unexplained
    Hugging Face download. The all-MiniLM-L6-v2 snapshot is staged into the
    bundle and copied into the standard HF hub cache on first launch iff
    absent
    (tiro/model_cache.py); overriding default_embedding_model still
    downloads normally. The .spec stages the snapshot under its true file
    names
    (not blob hashes), so it loads fully offline — enforced by
    desktop/pyinstaller/smoke.sh, which boots the binary with
    HF_HUB_OFFLINE=1 and an empty HF_HOME and round-trips an ingest +
    semantic search.
  • Tauri desktop shell (desktop/tauri/) — a native window wrapping the
    bundled server as a sidecar. It bootstraps a platform config, prefers port
    8000 (falls back to a free port if occupied — the one case the Chrome
    extension's hardcoded localhost:8000 can't reach, documented), polls
    /healthz before opening the window, and kills the sidecar's whole process
    group on quit. Built unsigned for 0.7.0 (Gatekeeper right-click-Open);
    Developer ID signing + notarization ship as an owner runbook.
  • First-run onboarding wizard (GET /welcome, js/welcome.js,
    welcome.html) — a web-served, skippable wizard that extends the existing
    /login setup spine: Welcome → Library location → Password (required) → AI
    provider + key → Email → Remote-access pointer → Chrome-extension card →
    Sample articles → Finish. New setup routes under tiro/api/routes_setup.py
    (POST /api/setup/library-path, /api/setup/ai, /api/setup/samples) share
    an unconfigured-OR-authenticated gate. Unconfigured page-auth redirects now
    land at /welcome rather than a /login that would only bounce.

Added — Library location & migration

  • Platform-default library paths (tiro/paths.py) — tiro init and the
    desktop app write a platform-standard library_path into newly-created
    config files (macOS ~/Library/Application Support/Tiro, Linux
    $XDG_DATA_HOME/tiro, Windows %APPDATA%\Tiro). The dataclass default stays
    ./tiro-library, so no existing install is ever silently re-pointed.
  • tiro migrate-library [dest] (tiro/library_move.py) — copy-then-
    confirm, never-remove migration: auto_backup first (a failed backup
    aborts), copy every store, verify path-set + per-file size equality, then the
    single persist_config write re-pointing library_path. Interrupted runs
    leave a marker and restart cleanly from scratch; the source is never touched.
    A dismissible inbox banner points legacy-default installs at the command.

Added — Migrate-on-start & update check

  • Migrate-on-start hardening — before applying schema migrations, a start
    that crosses a schema version on a library with real data takes an
    auto_backup("pre-migrate") snapshot and logs the vN → vM transition.
    Non-interactive by design (a double-clicked app has no TTY); tiro migrate
    gained the same snapshot for symmetry.
  • Notify-only update check (tiro/update_check.py) — a scheduler-registered
    daily PeriodicTask queries the GitHub Releases API (ETag-cached, 10s
    timeout, one audit line per check), compares against tiro.__version__, and
    surfaces a dismissible "update available" banner (per-version dismissal),
    plus the authenticated /healthz detail and tiro status. Notify-only — it
    never downloads. Kill switch: update_check_enabled: false (default True);
    documented as the app's only phone-home.

Added — Service & distribution

  • tiro service install|uninstall|status|logs — run Tiro at login as a
    background service: a launchd user agent (macOS), a systemd user unit
    (Linux), and a printed nssm recipe (Windows, documented-not-built). Targets
    the resolved absolute executable + absolute --config, so it's
    working-directory-independent.
  • Official Docker image on ghcr (.github/workflows/docker.yml) — a
    tag-triggered (push: tags: ["v*"], plus workflow_dispatch for the owner's
    supervised first push) multi-arch (amd64 + arm64) build publishing
    ghcr.io/esagduyu/tiro with X.Y.Z / X.Y / latest tags. ci.yml
    untouched; the compose template now references the registry image (build-it-
    yourself still works). Container auth without a TTY: set
    TIRO_AUTH_PASSWORD_HASH (a bcrypt hash) via the TIRO_* env overlay.

Changed

  • Version 0.6.00.7.0; STATIC_VERSION 67 → 68 (the phase's one bump).
  • README gained "Install Tiro", "Where your library lives", "Run at login", and
    "Updates" sections; CLAUDE.md gained a Desktop packaging (Phase 5) conventions
    bullet, the new endpoint/CLI rows, and a 0.7.0 status line.

Fixed

  • Long-standing test_backup flake resolved (tests/conftest.py) — the
    intermittent mid-suite ChromaDB failure was rooted in chromadb's
    process-wide SharedSystemClient cache holding System objects past a test's
    lifetime and hitting a file-descriptor ceiling. The fixture now clears the
    cache after each test (SharedSystemClient.clear_system_cache(), guarded),
    freeing the held clients — a real test-durability win, not just a re-roll.

v0.6.0 — feeds-beta (Phase 4: RSS & imports)

Choose a tag to compare

@esagduyu esagduyu released this 10 Jul 19:18

[0.6.0] — feeds-beta (Phase 4: RSS & imports)

Recurring ingestion arrives: subscribe to RSS/Atom feeds, bulk-import an
existing reading library, and save straight from the browser with a selection
captured as a highlight. STATIC_VERSION 66 → 67.

Added — RSS & feeds

  • Feed subscriptions (migration 013: feeds + feed_entries). Subscribe
    by feed URL or page URL — POST /api/feeds autodiscovers a
    <link rel="alternate" type="application/rss+xml|atom+xml"> when the URL is
    an HTML page (30s timeout, 5-redirect cap, 10 MB body cap; 409
    already_subscribed on a duplicate feed URL). Feeds carry a per-feed
    fetch_interval_minutes, conditional-GET validators (etag/last-modified),
    and an error_count backoff.
  • Recurring poll loop on the new PeriodicTask scheduler registry
    (tiro/scheduler.py, extended not replaced — the imap/digest/vector loops
    were refactored onto it). Each poll cycle writes one audit line. New articles
    land with ingestion_method="rss"; the feed_entries dedup ledger keeps a
    deleted article from being resurrected by the next poll (its article_id is
    nulled, the ledger row survives).
  • Feed management (GET/POST /api/feeds, PATCH/DELETE /api/feeds/{id},
    POST /api/feeds/{id}/check, POST /api/feeds/check-all) and a /feeds
    management page (grouped by folder, status pills, check-now / pause / rename /
    delete-with-or-without-articles). DELETE ?delete_articles=true takes an
    auto_backup first, then loops the delete_article lifecycle coordinator
    per article — never a raw cascade. Sidebar Library entry + Shift+F
    keyboard shortcut (inbox & reader; n was already taken).
  • OPML round-trip: GET /api/feeds/export (standalone OPML 2.0, nested one
    level by folder) and POST /api/feeds/import (multipart upload, flattens
    nested outlines into a folder path, dedupes by url, returns
    {added, skipped, errors}; rejects >5 MB or unparseable).

Added — library importers

  • Three importers — Readwise JSON, Instapaper CSV, Omnivore zip — via
    POST /api/import/{kind} (single-slot background job; 409 import_running
    when one is active; GET /api/import/status polls progress) and CLI verbs
    tiro import-readwise|import-instapaper|import-omnivore (always skip
    existing). Content is re-fetched where possible; a paywalled/failed re-fetch
    falls back to a stub article tagged import-stub. Original timestamps are
    preserved. Imported articles use ingestion_method="import".
  • Anchored highlight import: Readwise highlights are anchored against the
    re-fetched markdown body with the same D7.4 machinery the reader uses;
    unlocatable highlights are skipped and counted, never hand-placed. A
    Settings "Import library" card drives it with a live progress bar.

Added — Chrome extension advanced save

  • Background service worker registers three context-menu items (Save /
    Save as VIP / Save with selection as highlight) and the popup gains a
    save-all-open-tabs action. Selecting text and saving anchors it as a
    highlight server-side via highlight_text on POST /api/ingest/url
    (soft-fails to no-highlight, still 200, if the selection can't be located).

Added — owner UX wave

  • Reading progress bar in the reader (fixed, accent fill, both themes; a
    ResizeObserver re-measures when late-loading images reflow the body).
  • Unread-first inbox with a Library view toggle (a key / ?view=library)
    that reveals read + archived rows — read/unread and active/decayed are
    treated as orthogonal axes.

Added — export & backup

  • metadata.json gains an additive feeds key (durable subscription columns
    only; transient fetch state and the feed_entries ledger excluded).
    sources.opml marks feed-backed sources with type="rss" + xmlUrl.
    tiro import merges bundle feeds by url; tiro backup/restore round-trips
    feed rows wholesale. See EXPORT_SCHEMA.md.

Fixed

  • load_config() now honors TIRO_CONFIG (ON-8 root-cause hardening).
    A bare load_config() (from tiro/app.py, scripts/, or any script run
    from the repo root) previously ignored the TIRO_CONFIG env var and
    defaulted to CWD-relative ./config.yaml, so a load→persist round-trip
    could silently corrupt the owner's real config. Path precedence is now
    explicit-arg > TIRO_CONFIG > ./config.yaml, matching run.py/cli.py/
    the MCP server. No signature change (additive default → None).

Added — iOS device pairing

  • Device pairing for the iOS client (/setup/qr?mode=device,
    POST /api/auth/pair). The /setup/qr page becomes two labeled panels —
    browser sign-in (unchanged login/qr QR) and app pairing — the latter
    encoding a tiro://pair?url=…&code=… QR the native app scans in-app to
    exchange a one-time code for a long-lived ios:<device_name> API token.
    Mirrors the QR-login token machinery exactly (sha256-only storage,
    15-minute TTL, atomic single-use consume, generic-400 failures, no-store);
    new device_pair_codes table (migration 012); doctor purges expired/used
    codes in the same housekeeping bucket as login tokens.

Design pass — full frontend redesign (design/codex-pass, STATIC_VERSION 66)

Whole-app visual and interaction redesign, landed as a sequence of
self-contained tasks (icons/tokens/chrome first, then a page-by-page pass,
then motion/dark-theme/glyph closeout). No backend or API surface changed.

  • Icons: a single canonical SVG icon set (tiro/frontend/static/js/icons.js
    for JS call sites, _icons.html's icon() Jinja macro for templates, kept
    in sync by a dedicated test) replaces every emoji/dingbat/HTML-entity glyph
    across the app — nav, toolbars, modals, cards, empty states, close buttons.
  • Chrome: CSS custom-property token layer (color/spacing/radius/type
    scale, light + dark) and shared component primitives (modal, overlay,
    banner surfaces); rebuilt 240px sidebar with an icon rail; phone chrome
    replaced with a bottom tab bar + Library/More sheets (hamburger menu
    removed).
  • Per-page pass: inbox (icon cards, toolbar, serif pagination, empty
    states), reader (chrome, audio player, callouts, phone action bar), digest,
    stats, graph, sources, wiki, highlights, settings, login, QR/remote-access
    wizards, and the offline fallback page all restyled on the new token/
    component layer.
  • Motion: a consistent transition/easing pass across interactive surfaces
    (cards, modals, sheets, toasts) with prefers-reduced-motion: reduce
    support throughout, plus a dark-theme (Roman Night) contrast audit.
  • Closeout sweep: final glyph audit — remaining literal &times; close
    buttons (graph.html's node panel, base.html's LAN-over-HTTP banner
    dismiss, reader.html's analysis/highlights panel close buttons) converted
    to the canonical close icon; orphaned .shortcuts-close and
    .graph-node-panel-close CSS rules removed (both close buttons already
    used .modal-close); LAN-banner phone padding constant aligned to the
    real phone-header height. STATIC_VERSION 65 → 66.