Skip to content

Auto-mark a script read in the library when its read-view reaches 100% #145

Description

@prekabreki

⚠ Worktree rule (added 2026-08-02)

Every path you read or write must be under your worktree. A path outside it is
auto-rejected by the harness and that rejection ends your session instantly — no
commit, no PR, work lost. This already killed one run in this batch (#138).

In particular: do not use /tmp. Write scratch output under your cwd (e.g.
./.scratch/) or use pytest's tmp_path fixture, which resolves inside the sandbox.
Do not look for bop-scripty, CassetteLore, or global-memory — not on this machine.

Context

The read-view now knows how far through the script it is (commit 3656c15 added
percent() and the HUD's completeness readout). That makes "mark this script read
in the library when I actually finish it" cheap, where before it needed a scroll
heuristic invented from scratch.

Both design docs deliberately parked this — see "Out of scope" in
docs/superpowers/specs/2026-07-31-library-index-design.md and
docs/superpowers/specs/2026-07-31-readview-session-affordances-design.md. The
reason was that inferring completion from scroll position has its own failure
modes. Those failure modes are now known and named below, which is what makes this
issue closeable rather than open-ended.

The swipe/tap path in the library stays exactly as it is. This adds an automatic
path alongside it, not a replacement.

Acceptance criteria

  • When a read-view reaches 100%, it marks itself read in the library's
    storage: localStorage["coldread-library:read"] is an object, and the
    script's key is set to true (merging into whatever is already there —
    never replacing the object).
  • The key used is the read-view's own served filename, obtained from
    decodeURIComponent(location.pathname.split("/").pop()). See the danger
    zone below — this is the one detail that silently produces a no-op if done
    the other plausible way.
  • A script shorter than the viewport does not mark itself read on
    open. percent() returns 100 when maxScroll() <= 0; a naive === 100
    check therefore marks such a script read the instant it loads.
  • Reaching 100% by any route works: autoscrolling to the end, the End key,
    a drag, and a momentum fling.
  • Marking is idempotent and does not fight the user: once the read-view
    has auto-marked in a given page load it must not re-mark, so a user who
    returns to the library, unmarks the row by hand, and comes back to a page
    still sitting at 100% does not get it silently re-marked.
  • The library page reflects it on next load without any change to
    library.py's prune logic — the existing prune keeps entries whose filename
    is in the current index, and a correct key satisfies that untouched.
  • All storage access goes through the read-view's existing store-style
    try/catch contract. Losing the write is acceptable; throwing is not.
  • A test covers the short-script case (the maxScroll() <= 0 → 100% trap) at
    whatever level is testable, or — if it genuinely is not unit-testable —
    the PR states how it was verified in a browser instead. Do not silently skip
    it.

Files / where this lives

  • vo_format/readview/reader.jspercent() and paintHud() are the existing
    completeness code; seek() is the single funnel every scroll route passes
    through. The store object at the top shows the required try/catch shape,
    but note it prefixes keys with coldread:<title> — this write must not use
    that prefix (see danger zones).
  • vo_format/readview/library.py — read for context only. _JS's PREFIX is
    "coldread-library" and the read map is stored under the read key, so the
    full key is coldread-library:read. Rows carry data-key="<filename>".
  • tests/test_readview_render.py — where read-view render assertions live.
  • docs/superpowers/specs/2026-07-31-readview-session-affordances-design.md
    update its "Out of scope" section, since this issue reverses that decision.

How to verify

  • ./.venv/bin/python -m pytest tests/ -q → 233 passing before this issue; must
    rise.
  • End to end in a browser, which is the only check that proves the cross-page
    coupling:
    1. ./.venv/bin/python -m vo_format.cli vo_format/samples/single_narrator_sample.md --no-preflight --archetype single_narrator --non-interactive -o ./.scratch/t.pdf
    2. ./.venv/bin/python -m vo_format.readview.cli ./.scratch/t.pdf --library index.html
    3. ./.venv/bin/python vo_format/readview/library.py ./.scratch to build the index
      over the same directory
    4. Open the read-view, press End, then open index.html and confirm the row
      shows as read.
    5. In devtools, confirm localStorage["coldread-library:read"] contains the
      read-view's exact filename as a key, matching the row's data-key byte for
      byte.
  • Confirm the short-script case: a read-view whose content is shorter than the
    window must not appear read after merely being opened.

Constraints

  • Preserve: the library's swipe-to-mark and tap-the-✓ paths, unchanged.
  • Preserve: library.py's prune-on-load behaviour. Do not weaken it to accommodate
    a mismatched key — fix the key instead.
  • Do not touch: percent()'s existing return contract, including its
    maxScroll() <= 0 → 100 branch. The HUD depends on it; guard at the call site.
  • Do not: introduce a new storage key or a new prefix. Write the key the library
    already reads.
  • Match: the existing try/catch-wrapped storage style in both files.
  • No new dependencies; the read-view must stay self-contained and offline.

⚠ Danger zones (review focus)

  • The key. data-title on the read-view happens to equal the filename minus
    - readview.html, because the push script stages PDFs as
    Channel — Title - variant.pdf. Deriving the key from data-title therefore
    appears to work on the Pi and breaks anywhere the HTML was renamed — and its
    failure is silent in the worst way: the write succeeds, the library's prune drops
    the unknown filename on next load, and nothing ever shows as read. Verify the
    implementation reads location.pathname, and verify the resulting key matches a
    row's data-key exactly
    , including the em-dash and spaces after
    decodeURIComponent.
  • The short-script trap. percent() returns 100 for any script that fits the
    viewport. Confirm by test or by browser that opening such a script does not mark
    it read. A cheap executor will write if (percent() === 100) in paintHud() and
    it will look correct.
  • Clobbering the map. store.set("read", {key: true}) instead of a merge
    wipes every other script's read state. Verify with two marked scripts that
    marking a third preserves both.
  • Write frequency. seek() runs every animation frame. A write placed there
    without a guard hits localStorage at 60Hz, which is a synchronous main-thread
    write on the device this is for. Verify the write happens once per completion,
    not per frame.

Out of scope

  • Un-marking automatically when the user scrolls back up. Completion is a
    one-way signal here; reversing it is the manual path that already exists.
  • Any change to how the library renders or orders rows.
  • Syncing read state between devices.

Notes

  • Executor tier: cheap (DeepSeek), but this is a cross-page storage coupling
    with four named silent-failure modes — consider exec:pro if the wave has
    capacity, and review the diff against all four danger zones rather than the
    acceptance list alone.
  • Reverses an explicit "out of scope" in two design docs; the PR must update the
    newer one rather than leaving it contradicting the code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestscopedIssue drafted and specced

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions