Skip to content

Add creative channel engine, operator console, and review-gated video generation - #1

Open
Buckeyes22 wants to merge 44 commits into
mikemcg94:mainfrom
Buckeyes22:fix/comprehensive-review-remediation
Open

Add creative channel engine, operator console, and review-gated video generation#1
Buckeyes22 wants to merge 44 commits into
mikemcg94:mainfrom
Buckeyes22:fix/comprehensive-review-remediation

Conversation

@Buckeyes22

@Buckeyes22 Buckeyes22 commented Sep 5, 2026

Copy link
Copy Markdown

What this PR does

This PR brings Bumparr from a bumper pool with a basic dashboard toward a
coherent, operator-owned television interstitial system. It combines the
station/runtime hardening work with the creative alignment work, the
dependency-free operator console, and the opt-in generative-video workflow.

Bumparr remains an original, self-hosted project inspired by the function of
Adult Swim's interstitial grammar: a channel can have a voice, pacing, quiet,
surprise, and memory between programmes. It does not copy Adult Swim marks,
copy, footage, music, or branding. The product is still three things:

  • a bumper library for creating, ingesting, rendering, inspecting, enabling,
    disabling, and serving short playables;
  • a break composer that returns an ordered set fitting a requested duration and
    placement; and
  • a live bumper showcase plus branded standby/failover HLS channel.

It is not a long-form programme scheduler. /playlist.m3u remains an
unsequenced pool for a downstream scheduler; /api/bumpers/fill composes a
bounded break; /station/live and /station/standby schedule only Bumparr's
own bumper material.

Creative and channel system

  • Operator-owned voice and channel profiles define tone, boundaries, preferred
    subjects, prohibited habits, presentation families, roles, energy, audio,
    dayparts, and sequence preferences.
  • A single creative resolver gives new and legacy rows stable family, role,
    template, audio, brand, and provenance semantics without a schema migration.
  • Finite card templates and explicit reveal, static, and none brand modes
    keep variation intentional rather than turning every card into the same
    branded layout.
  • Break composition and station adjacency share strict selection eligibility:
    computed score must be positive, hard roles and profile gates are respected,
    and soft adjacency rules report their relaxations.
  • Music is manifest-owned, energy/family compatible, credited, loudness-shaped,
    and allowed to resolve to deliberate silence. Native, music, designed, and
    silence treatments stay distinct.
  • Channel-memory cards use station play history and local operator messages,
    with freshness and evidence metadata. They say what the channel has aired,
    never that an individual viewer watched it.
  • Simulation and review artifacts expose mix diagnostics, provenance, repeats,
    role/gate violations, duration error, and relaxations. These metrics are
    diagnostic unless explicitly listed as objective gates.

See PRODUCT_VISION.md, CREATIVE_REFERENCE.md,
ALIGNMENT_PLAN.md, CONFIG.md, and RELEASE_REVIEW.md.

Station and media correctness

  • Live and standby are separate HLS channels with channel M3U and XMLTV guide
    output for Dispatcharr and ordinary HLS players.
  • Eligible items are conformed once in a background cache; serving a playlist
    never launches an encoder. Profile-aware cache keys and a branded empty-pool
    slate preserve continuity across refreshes.
  • Playout shares scored eligibility with random/fill, records history only when
    requested timeline entries pass, and discards abandoned timelines after
    reconnect staleness. Dayparts, standby restrictions, absolute URLs, explicit
    MPEG-TS types, and status purity are covered by the station contract.
  • Source/output separation, contained filesystem resolution, atomic downloads,
    media quarantine, bounded fetches, redirect/DNS checks, stream-proxy
    allowlists, and non-root execution address media and SSRF hazards.
  • Cards remain invisible to consumers until rendered to playable MP4.

Operator console

The dashboard remains a same-origin, dependency-free operator console with
six hash-driven views: Overview, Library, Composer, Station, Operations, and
Generation. Its surfaces expose truthful loading, empty,
offline, stale, failed, parked, and unprotected-network states.

The console supports the operator's loop: inspect health, search and preview the
pool, review an ordered break, inspect station/failover status, conform the
station, run maintenance jobs, and review generated candidates. Controls are
keyboard-visible, previews bounded, polling lifecycle-aware, and generated
candidates disabled until explicit approval.

Opt-in generation (G0–G5)

Generation is off unless GENERATION_ENABLED=1; keys alone never spend. The
durable worker supports allow-listed hosted MiniMax H3 text-to-video and
OpenRouter video jobs, persisted state, budgets, preflight fingerprints,
review/approval, cancellation, retry/recovery, provenance, checksums, and
disabled-by-default output registration.

The remediation pass addresses atomic budget reservations, current-policy
submission gates, resolution-aware estimates, redirect/DNS-rebinding checks,
secret-safe errors, durable media processing, quarantine rollback, bounded
request bodies, provider discovery caching, stale-state handling, recovery
backoff, regeneration metadata, complete queue/review actions, and Compose
forwarding. It does not claim provider acceptance merely because fake transport
tests pass.

See GENERATION_PLAN.md,
GENERATION_IMPLEMENTATION_STATUS.md,
and GENERATION_REMEDIATION.md.

Verification evidence

Combined local validation (2026-09-05):

  • 729 Python tests pass, with ResourceWarning promoted to errors.
  • 326 Node tests pass, retaining all 311 operator-console tests and all
    12 generation tests, plus three integration lifecycle regressions.
  • Ruff, compileall, JavaScript syntax, and whitespace checks pass.
  • The readable, dependency-free static bundle is 216,599 bytes, below the
    unchanged 262,144-byte cap; there is no runtime build/minification step.
  • Focused backend and frontend integration re-reviews are clean.
  • Chromium drove the integrated six-view console against the real local API
    with a temporary database, synthetic credential, disabled worker/lifespan,
    and provider transport forbidden. Generation preflight displayed the exact
    submitted prompt and estimate; editing the brief disabled paid creation;
    Operations remained a separate functional view. This was not a paid call.
  • Docker build and Compose configuration validation pass. The final image
    passes a network-disabled smoke as UID 10001 with fresh anonymous volumes:
    writable /assets and /data, DB initialization, model-free card generation,
    real HTTP health and six-view dashboard, generation off, gzip delivery, and
    rejection of an oversized generation request before JSON parsing. No
    production containers or volumes were changed.

Historical branch evidence, kept separate from these combined results:

  • generation/remediation branch: 639 Python tests passed with
    ResourceWarning promoted to errors; 87 were focused generation tests;
    33 JavaScript tests passed; compile, Ruff, diff, Docker build, and Compose
    configuration checks passed;
  • frontend branch: 311 frontend tests were reported across the operator-console
    work and its lifecycle follow-ups;
  • browser evidence covered the generation workflow, narrow 320/390 CSS
    viewports, and key operator actions, but is not a complete browser matrix.

The historical captures are archived with their scope in
evidence/frontend/README.md. Reproduce the
combined local checks with the repository CI commands:

python -W error::ResourceWarning -m unittest discover -s tests
ruff check bumparr tests
python -m compileall -q bumparr tests
node --check bumparr/web/app.js
node --test bumparr/web/app.test.js
git diff --check
docker compose config --quiet

Operating defaults and explicit limits

  • The service is unprotected by default and is intended for a trusted network;
    put authentication and a suitable proxy in front of it before internet
    exposure.
  • No paid provider acceptance, real credential run, production deployment, or
    account-pricing guarantee is included. Local estimates and preflight gates
    are safeguards, not billing guarantees.
  • The shipped generation path is hosted MiniMax H3/OpenRouter video only; it
    does not download local H3-Base weights, silently switch providers, or claim
    ZDR for video. G6–G8 remain deferred: frame/reference, image generation, and
    migration of the legacy invented-text-card path.
  • Firefox has not been verified; the browser evidence is not a substitute for
    a full cross-browser/accessibility matrix.
  • The Generation view does not imply G6–G8 frame images, local H3 hosting, or
    text migration; audiovisual output still requires human review with sound.
  • The console and HTTP API remain a trusted-operator surface: no public
    submissions, viewer accounts, or authentication are added.

@Buckeyes22
Buckeyes22 force-pushed the fix/comprehensive-review-remediation branch 3 times, most recently from 6841e9a to c6ae0a1 Compare September 5, 2026 04:31
@Buckeyes22 Buckeyes22 changed the title Harden media pipeline and resolve comprehensive audit findings Harden media pipeline, resolve audit findings, and add the station (Bumparr as a live channel) Sep 5, 2026
@Buckeyes22
Buckeyes22 force-pushed the fix/comprehensive-review-remediation branch from c6ae0a1 to 0125e29 Compare September 5, 2026 05:29
Security and filesystem safety:
- require PID-bound signed HLS tokens, validate allowed origins across redirects and DNS, block private targets by default, and cap streamed reads
- centralize media containment while preserving lexical in-root symlinks so deletion removes aliases rather than shared targets
- bound archive destinations, partial names, and quarantine names; make downloads and file/database mutations atomic or compensating
- harden generated media, background, music, dashboard, database, and subprocess failure paths

Correctness and lifecycle behavior:
- bound generation loops, isolate invalid batch items, strengthen factual and trivia validation, and make grounded seeding deterministic
- preserve weather operator fields, distinguish weather data from explicit footage requests, and parse counts without interpreting years as quantities
- stabilize camera identities, preserve disabled state, park removed or missing media, and keep scheduled work running after isolated failures
- make seasonal mutation opt-in and remove obsolete workflows

API, frontend, database, and operations:
- validate API bounds, return only playable media, preserve valid M3U attributes, aggregate status correctly, and keep errors bounded and stable
- bound asynchronous job capacity and retention while polling actions until the server reports a terminal state
- construct dashboard content safely, close database connections reliably, initialize WAL once, and guard startup/background failures
- run the container as UID/GID 10001 with writable default named volumes and a healthcheck
- expand pinned CI with compile, strict tests, Ruff, Node checks, and clean-checkout Compose smoke coverage

Live station and playout:
- expose live and standby HLS channels, a channel M3U, XMLTV guide, dashboard status, and background conform action
- conform media once into a splice-safe profile and key persistent renditions by source identity plus all output-affecting settings
- retain prior renditions until replacements land, protect on-air keys, and publish branding/profile-specific slate renditions under immutable segment URLs
- make status snapshots side-effect free and measure reconnect staleness from the last actual playlist request so monitoring cannot fabricate playback
- exclude zero and negative rotation scores, repeat the only positive item when necessary, and fall back to slate when all content is gated
- serve transport-stream station segments explicitly as video/mp2t on every supported host MIME database
- write play history only from playlist-driven playout, enabling recency, affinity, and fatigue rotation factors
- add daypart scheduling and document station operation and Dispatcharr integration

Documentation and regression coverage:
- update security, API, architecture, configuration, rendering, rotation, schema, CLI, migration, station, and changelog documentation
- cover traversal, symlink, rollback, filename-limit, weather-routing, long-polling, generator, camera, render, seed, database, and seasonal edge cases
- cover conform limit/failure/source-race retention, profile and still-duration invalidation, immutable slate refreshes, status purity, reconnect history, gated playout, and MPEG-TS HTTP metadata

Verified with 310 Python tests under ResourceWarning-as-error, 13 Node tests, compileall, Ruff F401/F821, JavaScript syntax validation, Compose configuration validation, and whitespace checks. Earlier clean-checkout Docker smoke and local HLS end-to-end checks remain covered by the consolidated change set and CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@Buckeyes22
Buckeyes22 force-pushed the fix/comprehensive-review-remediation branch from 0125e29 to c38d140 Compare September 5, 2026 06:14
@Buckeyes22 Buckeyes22 changed the title Harden media pipeline, resolve audit findings, and add the station (Bumparr as a live channel) Add live HLS station and standby channel; harden the media pipeline Sep 5, 2026
chris and others added 22 commits September 5, 2026 11:56
Phase 0 design contract only. Runtime alignment (selection, sequence
grammar, voice, music, channel memory, review) is not claimed complete.

Reconcile scheduling language: Bumparr is not a long-form programme
scheduler; /fill composes duration-bounded bumper sets; live is a
showcase; standby is failover; SQLite is authoritative and the station
cache is derived.

Reclassify the bumper catalog with state/mechanism/implementation/gap
and add tests/test_catalog.py.
Random, fill, and station playout now share scored_candidates so a
weight or computed score <= 0 cannot be revived. Explain is additive
and read-only; python -m bumparr.simulate reports mix without writing
history.
Store optional payload.creative metadata and load an operator channel
profile without a schema migration or sequence grammar. New writers persist
inferred family/roles/energy/audio via merge_creative; legacy rows resolve
at read time. Invalid profiles warn once and fall back to the full shipped
default. API list/random/fill/detail add resolved creative; /api/status
reports profile version/valid/source.
Make music and silence first-class editorial data: a validated manifest,
durable credit snapshots, compatibility-gated legacy paths, and one
offline ffmpeg loudness policy. Missing or unreadable beds become
explicit silence.
Uncredited directory-scan beds have unspecified energy so they attach to
typical produced clips under ALLOW_UNMANIFESTED_MUSIC=1. Manifest beds
still exact-match energy; credits stay empty.
Committed as found at the start of the frontend plan execution so that
subsequent frontend-plan commits carry only their own changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Add station:live history-backed cards (statistics, previously-on,
achievements) and validated local operator YAML. Claims record evidence
and freshness, say this channel has aired rather than you watched, and
never use preview, status, or simulation as a writer.
The dashboard could report a failure as healthy emptiness: a failed read
cleared the grid, an offline status pill said "offline" and nothing else,
and a rejected fetch left the preview panel blank. It also polled while the
tab was hidden, fired a request per keystroke, and hid the only delete and
enable controls behind :hover.

Every panel now renders exactly one explicit state — loading, populated,
useful empty, error with Retry, or last-known content marked stale with its
age — and a failed read never clears content that is still true. One api()
wrapper owns response checking, safe JSON parsing, the server's `error`
field, a bounded single-line human message with a status, an AbortSignal,
and a 15 s clock that job POSTs opt out of. Library search is debounced
250 ms, superseded reads are aborted, and answers older than the current
filter generation are dropped. The 20 s refresh does nothing while the tab
is hidden and runs at once on return.

Client state is one explicit object divided by concern, so rendering is
repeatable from state alone rather than read back out of the DOM. app.js is
laid out in the twelve documented sections. Accessibility: skip link,
landmarks with one <h1>, visible labels, an aria-live status region, focus
rings, reduced motion, and per-card controls that are always visible, named,
and 44px. Styling moves to the documented token set.

The fake DOM in app.test.js grows classList, attributes/dataset, dispatchable
listeners, hidden/focus and selector matching, plus a stand-in for
index.html, so the new tests exercise real behaviour rather than mocks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
The operator frontend now has its own execution specification, and nothing
pointed at it. Add a row to the docs index, a pointer in the alignment plan's
"How to use this document", and one in the API dashboard section, plus the
Unreleased entry for the states, api() wrapper, refresh and accessibility work.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Two paths reported an unreachable server as a verdict on the work.

`pollJob`'s default status read carried the ordinary 15 s timeout, so one
blip during a long generate, starter or conform run rejected, escaped the
poll loop and landed in doAction's catch as "✗ generate trivia failed:
Bumparr could not be reached" — while the job was very likely still running
server-side. It now keeps `status unknown`, announces it, backs off to ten
seconds and keeps asking. Only a 404 ends the poll, and it ends as unknown,
not failed; doAction marks that "▲ … — run it again to check", with the
Actions buttons re-enabled as the retry.

The ask bar's own poller rescheduled itself every ten seconds forever with
the input and button still disabled, so a sustained outage left the operator
with no way to type, cancel or retry short of reloading the page. It now
hands the controls back on the first lost poll and offers "Check now" and
"Stop checking" beside the unknown badge, while continuing to check in the
background. Because the form is usable again mid-poll, an ask generation
counter keeps a superseded job from writing over the newer request's result.

Seven tests cover the branches, including the default getStatus path that
the injected-getStatus test never reached; all seven fail against the
previous implementation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Making pollJob survive a lost status read moved the defect rather than
removing it: under sustained failure the loop never returned, so
`await pollJob(r)` in doAction never resolved and the re-enable below it was
never reached. Every `.actions button` — not just the one clicked — stayed
disabled with no escape, exactly what the previous round fixed for the ask
bar. The comment claiming the buttons come back enabled was false.

Both surfaces now share one poller. pollJob keeps the state machine and
gains a `stopped()` hook, so the loop always terminates; watchJob owns the
pause, which "Check now" can cut short and "Stop checking" can abandon, and
hands the surface's controls back the moment a read is lost. doAction renders
into a new #actions-state region — progress while working, the unknown badge
with both escape controls when a read is lost — and the region returns to the
panel-state vocabulary when the job ends. An action generation counter makes a
superseded wait abandon its poll instead of polling forever or overwriting
newer feedback, matching the ask bar.

The ask bar's bespoke poll loop is gone; submitAsk now awaits watchJob and
reports the outcome once, so "unknown" reads as attention rather than failure
there too.

Covered by a test that drives doAction through two consecutive failed status
reads and asserts the buttons are usable, the escape controls exist, checking
continues, and Stop actually stops. It fails against the previous commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Additive API surface for the operator dashboard's Overview/Library slices:

- GET /api/status gains parked/dead/unrendered counts, computed with the
  same SQL definitions GET /api/bumpers?state= uses (shared helper
  _state_filter_sql) so the two can never disagree. No version key is
  added: nothing in the package exposes a version anywhere.
- GET /api/bumpers gains total (rows matching filters before
  limit/offset) and an optional state filter (all|playable|parked|dead|
  unrendered, FastAPI Literal, composes with existing filters via AND).
- POST /api/pool/disable?bumper_id= mirrors enable_playable: sets
  enabled=0 only, never health/uri/files. Warns only when the code
  actually reverses it -- verified by reading jobs.py::_rotate_dated_cards,
  on_this_day.py, and live_cams.py::load_cams: only an on_this_day card
  belonging to today is re-enabled by the next rotation pass; config-owned
  cams never re-enable a row they find. Shares _on_this_day_status with
  the enable warning so both agree with the rotation's own predicate.
- POST /api/render/cards accepts an optional bumper_id (max 200 chars):
  404 unknown, 400 non-card, otherwise starts a job labelled
  "render card <id>" via a new --id flag on render_cards.py's CLI
  (render_all(ids=...), bypassing the render-pending filter). Batch
  behaviour is unchanged when bumper_id is absent.
- docs/API.md, docs/CLI.md, CHANGELOG.md updated for all of the above.

Tests (TDD, all new coverage green): status counts with a seeded mix;
total vs count across pages; every state value's semantics including
playable's stream-inclusion/unrendered-card-exclusion; invalid state as
4xx over real HTTP; disable's 404/changed/health-uri-untouched/no-file-
deleted/warning-present-or-absent (plus a test that actually runs the
rotation to confirm the warning's claim); render-one's 404/400/job-id/
label; --id's row selection with render_one mocked (no ffmpeg); hostile
literal strings surviving disable and state-filtered list as JSON data.

Full verification clean: node --check, node --test (57/57, unchanged),
python -W error::ResourceWarning -m unittest discover (575/575), ruff,
compileall, git diff --check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Review finding: enable_playable and disable_playable duplicated the row
lookup and conditional UPDATE almost verbatim, differing only in the
target enabled value and the sense of `changed`. Extracted
_set_enabled(bumper_id, want_enabled) -> (row, changed), used by both
routes. No response shape, status code, or warning logic changes --
changed = bool(row["enabled"]) != bool(want_enabled) is exactly
equivalent to each route's previous bespoke condition.

Covering tests unchanged and green: tests.test_app_api (39),
tests.test_pool_recovery (98, including PoolEnable/PoolDisable/
CalendarParkWarning/CalendarDisableWarning/HostileStrings), full suite
(575), ruff, git diff --check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Five hash-routed views replace the single column of panels: Overview,
Library, Composer, Station, Operations. Every capability of the old page
keeps exactly one home — ask bar, actions and log to Operations, pool
counts to Overview, browse to Library, item/pack preview to Composer, the
station panel and Conform now to Station. An unknown or empty hash is
replaced (not pushed) with #/overview, the skip link's #main stays an
ordinary in-page jump, and deep links and back/forward work because the
hash picks the view while page state alone decides what it holds.

Library filters travel in the hash query and are read on entry, so an
overview warning links straight to the rows it counted; `state` and `type`
are checked against what /api/bumpers accepts and an unknown value is
dropped rather than forwarded. The listing now uses the server's own
`state` filter — the same SQL /api/status counts with — instead of
enabled=false.

Overview is triage: warnings before healthy detail, each derived from an
explicit field (no playable items, unrendered cards, conform backlog,
missing ffmpeg, invalid/fallen-back profile or music, a failed job) and
each linking to the view that can fix it. A count this build of the server
does not report reads "Not available in this version.", never a zero.
Overview reads GET /api/status and GET /api/station only, so opening it
cannot create or advance a station timeline.

The 20-second refresh now belongs to the two views that show live figures;
leaving a view stops its clock and aborts the reads it left in flight, and
a cancelled read no longer leaves a panel waiting on a request that does
not exist. Below 760px the sidebar becomes a scrolling tab row, below
480px controls go to one column and ids and URLs wrap.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
"Not available in this version." was printed whenever there was no
/api/status body at all, which made a first paint flash it and a failed
first read state it permanently: the Overview showed the pool error with
Retry while the Configuration panel underneath claimed the server does not
report a profile or a music manifest. statusGap() now names why there is no
body — not read yet, reading, or the last try failed — and the header,
configuration and service rows say that instead. NOT_AVAILABLE is reachable
only after a read lands whose payload really lacks the field. The warnings
region had the same fault in a quieter form and said "loading" over two
failed reads, which read as an all-clear; it now says nothing could be read.

A route change during an in-flight status read also left the destination
view with no status at all. abortReads() cancelled synchronously but the
`loading` flag was cleared a microtask later in the read's catch, while the
next view's enter runs in the same turn — so ensureStatus() saw a read still
on its way and issued none, leaving Library without kind chips and the
header claiming the profile was unavailable until the operator navigated
again. abortReads() now settles the flags with the cancel, and loadStatus /
loadStation write only while they are still the current read, so the late
rejection of an abandoned read cannot clear its replacement's flags.

The teardown test asserted every recorded signal was aborted, which only
held because the destination started no read; it now snapshots the reads
that existed before the route change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Curating the pool meant hovering a card until a ✕ appeared. That control did
not exist at all by keyboard or on a touch screen, it was the most destructive
thing on the page, and it was the *only* thing on the page: to see why an item
was or was not airing you read the JSON.

The Library toolbar is now labelled controls — search, type, kind (from the
status counts, with numbers), state, page size, grid/list layout, Clear
filters — and every change is written back to the hash with location.replace,
so the address bar is always a deep link and filtering costs no history
entries. Results say "Showing N of TOTAL" from the `total` the API reports, and
a build that reports none says so rather than letting the loaded count stand in
for the matched count.

Cards state their pool state in words and carry one always-visible control:
Inspect. It opens a native <dialog> (with a fallback panel where the browser
has no HTMLDialogElement) that reads the row once with explain=true — a listing
of 24 rows must never carry 24 explanations — and shows the preview and card
answer, identity, state, creative, the eligibility verdict with its ordered
reasons, every selection factor, provenance and credits, history, and a
copyable media URL. Anything this build of the server does not send reads
"Not available in this version.".

The primary action is the reversible one for the state: Disable from rotation,
Enable for a parked row, Render card for an unrendered one, and — because there
is no per-item recheck endpoint — Run revive (all retired) for a dead one,
named for the pool-wide sweep it actually is rather than pretending to be
per-item. A mutation updates only the row it changed and refreshes the counts;
it never resets filters, offset or scroll.

Deletion moved to a danger zone in the inspector and one in the Library.
Both confirmations name the item, state the file consequence in the endpoint's
own terms, offer the keep_file the API documents, put Cancel first and focus
it, and do not take Escape for an answer; deleting a whole kind needs the kind
typed exactly first.

Media: preload="metadata", muted, controls visible, one preview playing at a
time, and a live stream that is a badge and a Play button — no element holds
the stream URL until someone presses it, under a note saying that doing so
makes this page a real client of the station. Leaving the view closes the
modal and detaches every media element.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…he modal

Three findings from the F2 review.

Escape swallowed. confirmDialog opened danger dialogs with escapes:false, so
both the keydown handler and the native cancel event dropped Escape on the
floor. Cancelling is the non-destructive direction — the rule forbids Escape
from confirming, not from refusing — and a modal you cannot dismiss with
Escape is the worse failure by far. Escape now runs the same finish(false)
Cancel does, and the flag is gone rather than flipped.

Teardown belonged to one view. cardEl puts Inspect on every card and the
Composer builds its preview grid with it, but closeAllDialogs() and the
inspector's abort were wired into exitLibrary alone. Inspect from a Composer
card, click Overview, and the <dialog> stayed in the top layer over a view that
never opened it, its explain read stayed in flight and could still write, the
Composer's video kept playing and inspectorOnMutate stayed pointed at a
torn-down surface. Dialogs close in exitRoute and the inspector's read is
cancelled in abortReads, where every other route-level read already is;
exitComposer releases its grid; and every grid rebuild — library, pack preview,
one-item preview — now goes through fillGrid, which lets go of the media it is
replacing so activeMedia cannot point outside the document.

Warnings never reached the operator. enable/disable relayed the server's
warning only through announce(), into a live region that sits outside the modal
and is inert under it: the one place the operator was looking was the one place
it did not appear. The response's warning now lands in #inspector-state as an
attention badge as well as being announced, and a delete that answers
cleanup_failed keeps the inspector open to carry that news rather than closing
the only surface that said a file is still on disk.

The static bundle is at 153 587 of 153 600 bytes. Making room cost the media
URL's Copy button — the read-only field selects itself on focus, the same copy
affordance the station's handoff URLs already use — and a pass over this file's
comments. There is no room left for another slice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…copied

Round 1 traded the Copy button away for bytes, on the reading that a read-only
field selecting itself on focus is already "copyable". That was a capability
removed to fit a limit, which is the wrong trade in both directions: the limit
is audited at F6, and selecting text is not copying it — it leaves the operator
to know the keyboard shortcut and gives them nothing to confirm against.

Copy is back, through the Clipboard API where the browser grants it. Every path
ends in a sentence the operator can see, because a Copy button that does
nothing cannot be told from one that is broken: success says the URL is on the
clipboard, and a browser that refuses — a denied permission, or no Clipboard
API at all — selects the field and says to copy it by hand. Both are a
statusBadge, so the outcome carries an icon and a word rather than a colour,
and both go to the live region as well.

The result lives in STATE.inspector.copied, not in the node: a redraw repeats
it, and inspecting a second row does not inherit the first row's "copied". It
is written straight into its own element rather than through renderInspector,
so pressing Copy does not rebuild the dialog out from under the button that was
just pressed.

Static bundle: 155 296 bytes (151.7 KiB), over the 150 KiB the plan audits at
F6 and reported rather than paid for.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
chris and others added 21 commits September 5, 2026 18:00
- GET /api/jobs?limit=20: pure read-only view of the job registry
  (newest first, bounded request/result, no internal keys).
- GET /api/station: per-channel state/reason, last_playlist_request,
  lookahead_seconds; top-level last_conform from the most recent
  completed conform sweep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6

# Conflicts:
#	CHANGELOG.md
The Composer was a pack preview: four fixed durations, a grid of thumbnails,
and relaxed rules printed as raw tokens. A break is an editorial unit, and
none of that let an operator judge one.

It is now a break composer. Labelled controls — presets, a custom duration,
tolerance, maximum items, placement and optional type checkboxes — build one
GET /api/bumpers/fill. A control outside the range the endpoint documents
disables Compose break and says what is wrong in words, so an invalid request
is never sent, and composition is never reproduced in JavaScript.

The answer is rendered in the server's order and never re-sorted: a horizontal
timeline on desktop, an ordered stack below 760px, each item carrying its
order, title, kind, family, duration, audio, role and brand mode, and the same
inspector every other card opens. One summary line reads

  Requested 30.0s | Composed 29.4s | Gap +0.6s | Within tolerance

with the sign always written out and "within tolerance" taken from the
server's `exact` rather than a comparison with zero, which would call a
perfectly good break a bad one. Every relaxed rule is a sentence in an
Attention panel instead of a token in a tooltip.

Play sequence / Previous / Next / Stop preview the break locally: one medium
at a time, advancing on the medium's own `ended` and on the declared duration
for a payload-only card, showing which item is on screen and how much of it is
left. A live stream keeps its own Play button, so the sequence never opens one
by itself. A new composition and a route change both stop it and take its
timers and its media with it.

Disabling, enabling, rendering or deleting an item through the inspector marks
the break "Stale — recompose to reflect changes" and disables Play. Nothing is
substituted client-side: what is on screen stays exactly what the server
composed until it is composed again.

Nothing here writes: the whole view is GET-only, so reviewing a break never
advances the live channels or touches play history.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
… list

The Station view could not tell four different problems apart. It now maps
the API's own state/reason to one operator sentence each: no playlist client
has asked, nothing is conformed, everything playable is gated behind the
slate, or ffmpeg is simply absent. A read that failed says how old the last
good one was instead of blanking the panel. Each channel shows now/next with
times and remaining duration, plus the last playlist request and lookahead
the channel reports - reading them never sets them.

All four handoff URLs are copyable through the Clipboard API with a selection
fallback and a visible result either way, because a Copy that silently did
nothing cannot be told from a broken one. A channel opens in a video element
only where the browser reports native HLS, only on an explicit press, and
only under a note saying that doing so makes this page a real playlist
client; browsers that cannot play HLS get the URL and an external-player
suggestion rather than a player that would never work. No remote HLS library
is loaded either way, and the preview lives outside the region the 20-second
refresh redraws so it is not reopened every cycle.

Operations now leads with the unauthenticated-API warning and groups every
action by what it costs, stating each group's requirements before it runs.
Both housekeeping passes lead with the endpoint's own dry run, and
destructive work is linked rather than duplicated. Only the starter run stops
to confirm.

Recent jobs is one list from two registries - GET /api/jobs merged by id with
the jobs this page started - with the bounded raw result in an expandable
block and Retry only where repeating is genuinely safe. Every working job is
followed to a terminal state whoever started it, and no poll outlives the
view that began it.

Fixes three deferred items: the failed-job warning scanned all twenty
registry entries and could not be cleared, so it is now bound to the five
rows the panel actually shows; a lost poll re-enabled every action button, so
locking is now per action and only the duplicate is held; and the station URL
fields offered select-on-focus alone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…poser

A 200 carrying no object took the last good break with it: `result` was set to
null with no error beside it, so the panel fell into "Nothing composed yet —
choose a duration and press Compose break." That clears known-good content
without a successful replacement, and says something untrue on the way — a
break had been composed, and the server had just failed to send one.

An empty or non-object body is now a failure like any other. The previous break
and its timestamp are left alone, so the shared state ladder marks it stale with
its age and a Retry; with nothing to fall back on the same path renders the
error state and its Retry instead.

The guard the removed pack preview had ("preview failed: empty response") and
its test came out with it. Both are back: an empty answer over a good break
keeps the break and says "empty response", and a failed compose with nothing to
keep is an error with a Retry that composes again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
… an action

Review round 1. The Overview's "recent jobs" claimed to be the merged list but
never read GET /api/jobs, so it showed only what this tab had started and a job
that failed elsewhere raised no warning. It reads the list on entry and keeps it
current on its own clock. Reading it was not enough on its own: the warning is
derived from that list and was only ever drawn by a status repaint, so the read
now redraws it. With the registry in view the warning can no longer claim the
job was started from this page, because it may not have been.

Retry was a way around the action lock - no job key, no status guard - so a
running job showed an enabled Retry for the very action the lock had just
disabled. Retry is now withheld while a job is working, and otherwise carries
that action's key so one lock covers the panel button and the Retry together.
The lock counts holders rather than being a flag: the server runs two blocking
actions at a time, and the first to finish was handing back a control the second
still held. That made a run's own release unsafe to call twice, which a lost
poll does, so it is now idempotent per run.

The escape from a lost poll is Check now, which asks the server again, rather
than a Retry that would start a second copy of work that is probably still
running.

A jobs read that failed after a good one left the rows looking current with the
failure nowhere on screen. That region now runs the same state ladder as every
other read-backed region, so a failed refresh is stale with its age and a Retry.

Which panel reports a running action is decided by the view the operator started
it from, not by the action: a conform retried from Operations reported into the
Station's panel, which is hidden at the time, so the run was watched in a blank
region.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…from

The inspector could list a row's factors but not say why it was gated, and
its provenance block showed four of the seven fields a credits snapshot
actually carries. Neither is a display detail: an operator deciding whether
to park an item needs to know which term of the product is zero, and anyone
shipping a bumper needs the attribution its licence requires.

Selection now draws the score as the product it is —
base × season × daypart × recency × affinity × fatigue = score — with every
term in the page's own monospace. A term the server sent as 0, or as null
(its way of saying "not finite"), is marked and named as the zero gate under
an Attention badge, with the matching `reasons` token read out in words;
recency, affinity and fatigue have no token of their own, and the block says
so rather than inventing one. A factor this build does not send reads
"Not available in this version." instead of being shown as a zero, because a
key that is absent and a key that is null are two different answers.

Provenance & rights carries every field of bumparr.music's CREDITS_KEYS plus
the payload's background attribution and its licence links. A field the
snapshot carries but left empty reads "not recorded" — not the same claim as
a build that lacks it. A row that records nothing at all says "No provenance
recorded" under an Attention badge and says out loud that this is a note and
not a block: missing provenance is editorial news, and hiding a Disable
button behind it would be pretending the UI is an authorization boundary.

The Station view gains a read-only Configuration block — profile, music
manifest and channel memory, each with its source, version, validity and own
counts under one badge, an invalid file or a fallback-after-error source
being an Attention rather than a silent default. It holds no input, select,
textarea, form or button, and says in words that these files are edited on
the server: configuration is file-owned, and browser editing without auth,
locking and rollback is out of scope. Because the station's 20-second clock
re-reads the station and not the status, the block says how old its answer
is instead of letting an hour-old read look current.

Library cards trade the run-on creative line for compact family and audio
chips, the two labels a shelf of cards is actually scanned by; the rest of
the vocabulary stays one press away in the inspector, and a row the server
resolved neither for still says so.

fieldText/facts/yesNo/num move from the inspector up to the shared
components section — the composer and the new configuration block were
already reaching across for them — and the Overview's compact config line
and the Station's full block now share configLevel/configSay, so the two
surfaces cannot drift apart about when a file counts as fallen back.

node --test bumparr/web/app.test.js: 251 pass, 0 fail (19 new, all red first).
Every new component has a hostile-string test: the equation, the rights
block, the card chips and the configuration block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…he token map

Review found that the Station's Configuration panel claims more than it can
keep. renderStationConfig hands readState to renderPanelState, which in the
error and stale states appends a Retry button into #station-config-state —
inside the panel that the test, docs/API.md and the slice report all said
contained no button at all.

The behaviour is right and stays: every region on this page owes the operator
an error state with a way out, and this Retry re-reads /api/status and nothing
else. What was wrong was the claim. The test only ever drove the populated
state and then asserted over the whole panel, so a "no controls anywhere"
assertion ran exclusively in the one state that has no controls.

The no-write assertion (input, select, textarea, form) now still covers the
whole panel, because that one is true in every state; the no-button assertion
narrows to the configuration content region, where it belongs. A new test
drives the error state directly and pins what is actually there: exactly one
button, class panel-retry, and pressing it issues one more GET /api/status and
no other request — so the only control on the panel is provably a read. It also
checks the content region says "not read: the last try failed" rather than
"Not available in this version.", because a read that failed has told us
nothing about what the build supports. docs/API.md and CHANGELOG.md now say
"no control that can write configuration", with the Retry named.

Two cheap fixes in the same area. inspectorSelection looked up
REASON_TEXT[reason] with a bare index, so a `reasons` entry of "constructor"
or "toString" resolved up the prototype chain to a function and would have
rendered "function Object() { [native code] }" as that token's plain reading;
it is now an own-property check, with a test that pins the list text to the
words the server sent. And .pv-creative went dead when creativeLine became
creativeChips, so the selector is gone from style.css.

node --test bumparr/web/app.test.js: 253 pass, 0 fail (2 new; the token test
verified red first by reverting the guard).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
The F6 audit found two things genuinely broken and a run of smaller ones.

A job poll started from Operations or the Station outlived its view: four
GET /api/request calls in the twelve seconds after the route was left, because
only watchListedJob registered its watch. Every watchJob now joins the same
registry, the shared route teardown stops all of them, and the ask/action
counters move with it so an abandoned wait writes nothing into a view that
never started it. pollJob's default pause — a setTimeout nothing could clear —
is gone; a caller has to supply one its own Stop can reach.

The modal focus trap collected only form controls, so the <video controls> in
the inspector was never a Tab stop: a keyboard operator could not play the item
they had opened. It now collects what the browser really does — media with
controls, <summary>, an explicit non-negative tabindex — and the busy lock uses
a separate list, because the focus list skips a disabled control and could
never hand one back.

Also: 44x44 for #shuffle, the duration presets, Close, the Consume links and
both checkboxes; interactive borders in --muted (--border is 1.64:1 on a panel,
which fails 1.4.11); main:focus scoped to :not(:focus-visible); the current tab
scrolled into view once per change so aria-current is visible at 320px; hover
preview gated on prefers-reduced-motion; decorative glyphs and the action-group
headings fixed; a favicon; and every map keyed by a server string read through
own().

Curation and composition: a dead row shows words instead of a 404'd <video>;
deleting the last row says the listing is empty and hands focus somewhere real;
a route change clears the live region and lands focus on <main>; the empty
library points at Operations; an empty array reads as absent rather than "";
Previous from stopped plays the end of the break; a staged medium that errors
moves on and one that stalls says so; a library mutation marks a break that
holds that row stale.

Lean pass: the two clipboard implementations and the two pause closures are one
each, the dead CSS is gone, the section banners are one line, and the comments
that repeated themselves are shorter. 256,333 -> 253,405 B for the three files,
asserted by a test against the revised 262,144 B budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
The dashboard has no build step by design: index.html, style.css and app.js
ship as the readable sources they are, and the plan's 150 KiB uncompressed
budget was measured unreachable — stripping every comment and all indentation
from all three still landed at 156 KiB before F5 shipped.

GZipMiddleware(minimum_size=1000) is the answer to the real question, which is
what the browser downloads: app.js drops from ~194 KB to ~55 KB. Small answers
stay uncompressed, where the header costs more than it saves, and already
compressed media is skipped by the middleware's own negotiation. No route
behaviour changes.

Both directions are asserted over a real HTTP server, following the
HttpValidation conventions already in tests/test_app_api.py.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
The plan's status line stops saying "proposed": F0-F6 are implemented, and it
records exactly what the manual matrix covered — Chromium at five viewports plus
a real 200% zoom, keyboard-only order, dialogs, reduced motion, offline, an
empty DB, a 304-row pool and a hostile title — and says out loud that Firefox
was not run.

Performance limits carries the measurement rather than a number nobody checked:
226,286 B at the audit base; 169,722 B with every comment stripped; 156,526 B
with the indentation and blank lines gone too, which is still over 150 KiB and
has by then destroyed the invariant documentation the same plan requires. The
cap is 256 KiB uncompressed, asserted by a test, with gzip named as what
actually crosses the wire.

API.md's Dashboard section is corrected where F6 moved the behaviour: gzip on
the static assets, the shared teardown (polls and the live region included, and
where focus goes), the card's accessible name and its reduced-motion hover, the
sentence a dead row shows instead of an element pointing at unreadable media,
the empty pool pointing at Operations, the focus trap reaching the media
preview, where focus lands after a deletion, the composer's agreed lower bound
and its error/stalled handling, the action groups as headings, and the poll
lifetime claim, which is now true of every surface rather than only Operations.
It also gains the accessibility paragraph the section never had.

README gains a real dashboard paragraph — the five views, what it is a client
of, and what it will not do — and CHANGELOG an Unreleased entry for the console
as a whole.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…riants

Two sweeps rather than another per-feature assertion.

The first enters every route in turn, starts the things each view can start —
a playing sequence, a search mid-debounce, a card preview — ends on the Station
because it is one of the two views with a clock (ending on the Library made the
old shape unfalsifiable: refreshTick is a no-op there, so a surviving interval
would have asked for nothing), exits it, and ticks two minutes. Nothing may be
requested, no medium may be playing, and the sequence must be stopped with its
stage empty. Verified by mutation: removing stopRefresh() from exitRoute fails
it, and so does removing both mechanisms that end an action's job poll.

The second walks the five routes and asserts the shell shows exactly one view
and marks exactly one nav link current, which is the thing every deep link,
back button and first paint depends on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
Starlette's GZipMiddleware decides on Accept-Encoding, minimum_size and an
existing Content-Encoding alone. It has no content-type rule and no idea what a
Range request means, so installing it bare did two wrong things here.

A plain GET of a file under /media came back content-encoding: gzip and LARGER
than the file, having spent compresslevel 9 to get there — 50,000 B of media
becomes 50,038 B. And a Range request got a 206 whose Content-Range described
the decoded bytes while the body was a gzip stream of another length: a player
cannot seek with that, which is the entire job of /media, /station/seg and the
proxied segments.

SelectiveGZip hands both cases straight to the app and delegates everything else
to GZipMiddleware. The path list is /media, /station/seg and /api/stream —
stream_seg relays MPEG-TS byte-for-byte from the origin, which is the same class
of thing as the two static mounts — matched exactly rather than by loose prefix.
The Range rule applies on every path, because a compressed 206 is unusable
wherever it comes from. /, /web/*, /api/*, the playlists and the guide keep
compression.

My comment and docs/API.md both claimed a content negotiation the middleware
does not have, and the CHANGELOG understated the scope. All three now describe
what actually happens, with the measurement in them.

Five tests over a real HTTP server, against fixtures the static mounts really
serve: media and a station segment come back uncompressed and byte-for-byte; a
range to media is a 206 with Content-Length 1024, the right Content-Range and no
encoding; the same range on /web/app.js likewise; /openapi.json still gzips and
still parses. Verified by mutation — putting the bare middleware back fails four
of them.

Also: submitAsk takes its job over from the background watch with
stopJobWatch(record.id), as doAction already did.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
CHANGELOG.md's Unreleased section had stacked eight per-slice blocks through
two merges into a single self-contradicting release note: cards both did and
did not carry a delete control, the Overview both did and did not read the
jobs list, and the API both did and did not ship one. Collapsed the seven
detail blocks below the top-level summary into one non-overlapping,
thematically ordered account of the console as it now is — Views and
navigation, Overview, Library and the item inspector, Composer, Station,
Operations, API additions, Foundations — keeping every fact from the eight
originals except the ones a later slice superseded, and adding the byte
budget's revision (150 KiB proved unreachable; 256 KiB is what the design
supports, asserted by a test) as its own fact rather than leaving it
undocumented. The one heading with no blank line before it (a merge artifact
that rendered as a bullet continuation) went with the block it was in.

docs/API.md claimed every working job is polled at three seconds "whoever
started it". Reading syncJobWatches in bumparr/web/app.js: a job started from
any view is watched from wherever it started until that view is left, but a
job this page did NOT start is folded into the three-second watch only while
Operations is open — everywhere else (the Overview) such a job is only as
fresh as that view's own periodic read. Corrected the sentence to say so, and
tightened the adjacent claim that a terminal state "refreshes ... the library
listing" to say it does so only when the library is the view on screen,
matching the app.js fix in the paired commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
- app.js:1479 hard-coded "Not available in this version." where NOT_AVAILABLE
  exists precisely to stop that literal drifting from the constant.
- refreshAfterJob() re-read the library on every job completion regardless of
  route; gated loadGrid(true) on STATE.route === "library", matching the
  loadJobs() sibling already gated the same way. The pool counts still refresh
  via loadStatus() and the station via loadStation() on every route.
- boot() dereferenced five selectors ($("#ask-go"), $("#ask"), $("#shuffle"),
  $("#more"), $("#search")) unguarded while every other wiring site in the
  same function used the guarded on() helper; moved on() to the top of boot()
  and used it everywhere, so a renamed id degrades the page instead of
  throwing before applyHash() runs.
- heldFocus() (#inspector-body) and previewFocusHeld() (#station) were the
  same "is the active element inside this root" check written twice from an
  earlier merge. Merged into one focusInside(rootSelector), used at all four
  call sites; behaviour unchanged.
- _bound_result's docstring said a list value becomes its str(), clipped, at
  every level. True at the top level, not for a dict value: out[k] = v passes
  a nested list through untouched. No job in the tree returns that shape
  today, so this is a docstring correction, not a new bound.
- index.html's two Consume links (/playlist.m3u, /api/bumpers/random) carried
  target="_blank" without rel="noopener", unlike the other two such links in
  the file. Added it for consistency; both links are same-origin.

No API behaviour, capability, or test assertion changed. Verified:
node --check + node --test bumparr/web/app.test.js (294/294), python -W
error::ResourceWarning -m unittest discover -s tests (605/605), ruff check
bumparr tests (clean), python -m compileall -q bumparr (clean), git diff
--check (clean), byte budget 253,770 / 262,144 B.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
…overs the body

Two defects an operator can hit in one session.

The ask bar disabled its input and button on the elements themselves. Route
teardown bumps askGeneration, so the ask was superseded before finish() could
re-enable anything and release() was gated the same way; the disabled state
lived only in the DOM, so returning to Operations redrew a form nobody could
type into, with no way out short of a reload. STATE.ops.ask now holds whether
the controls are taken, the job record they were taken for, and the ask's own
last word. renderAsk() derives the controls from it, enterOperations draws them
on entry and again once the jobs read has answered, and exitOperations hands
them back with the poll it has just stopped. The ingest is untouched: it carries
on server-side, stays on the merged jobs list, and the re-entry line is read off
that row — still running, or its actual outcome, or nothing at all — rather than
an ending nobody watched.

api()'s 15 s clock only ever covered fetch() resolving with headers: the timer
was cleared and the caller's abort relay removed in the finally attached to that
await, before readBody(). A reply whose body then stalled had nothing left to
cut it off, so the call never settled, `loading` stayed on and any poll waiting
on it never came back. The teardown and the reason-for-ending are now two named
closures shared by both phases, and the body read is raced against the inner
signal, so a deadline during the body throws the same "did not answer in time"
and a caller's cancel during the body throws the AbortError-flavoured one.
readBody itself is unchanged.

8 tests added (302 pass). CHANGELOG: two Unreleased sentences that had become
inaccurate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
… of its own entry

Two ways a poll or a field could be taken from the surface that owned it.

submitAsk ran its takeover block whatever had happened while the POST was in
flight. Leaving Operations and coming back re-enables the form; typing into it
and then letting the late POST land cleared the field (inp.value = "") and
stopped the background watch the re-entry had just started, so nothing polled
the job afterwards. The id is still recorded — the row is the server's row and
the jobs list follows it from there — and then a superseded ask returns before
touching the field or any watch. The record is left working, which is the
truth: syncJobWatches and applyJobResult settle it from the registry exactly as
they do for a job another tab started.

watchListedJob's settle deleted its watch by id. Route teardown drops a watch
from the map while its status read is still in flight, and the re-entry's
loadJobs registers a fresh watch of the same job before that read lands; the old
settle then deleted the new entry, orphaning a poll no teardown could reach. It
now deletes only its own entry, the identity guard watchJob's release already
had — checked, and unchanged there.

2 tests added (304 pass), both driving the reported reproduction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hmc2DhBEj4RpHPhCDZcvY6
… gaps

Preserve late accepted jobs across navigation without replacing newer watches or drafts. Reconcile terminal feedback and unique running counts, prevent stale working snapshots from reviving completed jobs, and make Stop checking durable within its view with explicit resumption. Add seven lifecycle regressions and boot-selector coverage; document semantics and browser evidence. Verified 311 Node and 605 Python tests, lint/compile checks, and the unchanged bundle budget.
…llowups

Implement opt-in durable MiniMax H3 and OpenRouter text-to-video jobs, model capabilities, atomic budget reservations, preflight-bound paid creation, safe provider transport, durable local processing and recovery, and review-gated outputs with operator controls. Close all seventeen generation audit findings with concurrency, transport, rollback, lifecycle, UI, and Compose regressions. Preserve channel-profile, music, selection, experience-report, and release-review followups from the concurrent alignment work. Document local verification and explicitly deferred paid-provider acceptance and G6-G8 work.
… video generation

Merge the frontend enhancement branch with the creative alignment and provider-neutral generation implementation, retaining the six-view operator console and review-gated MiniMax/OpenRouter workflows.

Resolve integration and job lifecycle races, preserve backend safety guards, reconcile README and design/API/operations documentation, and add a comprehensive PR summary and reusable PR template. Archive historical browser evidence with explicit provenance and verification limits.

Validated the combined tree with 729 Python tests, 326 Node tests, ruff, compileall, diff checks, Compose validation, a final Docker build and non-root offline container smoke, and a focused integrated browser check. Static assets total 216,599 bytes. Live paid-provider acceptance and Firefox remain unverified.
@Buckeyes22 Buckeyes22 changed the title Add live HLS station and standby channel; harden the media pipeline Add creative channel engine, operator console, and review-gated video generation Sep 6, 2026
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