Add creative channel engine, operator console, and review-gated video generation - #1
Open
Buckeyes22 wants to merge 44 commits into
Open
Add creative channel engine, operator console, and review-gated video generation#1Buckeyes22 wants to merge 44 commits into
Buckeyes22 wants to merge 44 commits into
Conversation
Buckeyes22
force-pushed
the
fix/comprehensive-review-remediation
branch
3 times, most recently
from
September 5, 2026 04:31
6841e9a to
c6ae0a1
Compare
Buckeyes22
force-pushed
the
fix/comprehensive-review-remediation
branch
from
September 5, 2026 05:29
c6ae0a1 to
0125e29
Compare
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
force-pushed
the
fix/comprehensive-review-remediation
branch
from
September 5, 2026 06:14
0125e29 to
c38d140
Compare
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
- 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
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
disabling, and serving short playables;
placement; and
It is not a long-form programme scheduler.
/playlist.m3uremains anunsequenced pool for a downstream scheduler;
/api/bumpers/fillcomposes abounded break;
/station/liveand/station/standbyschedule only Bumparr'sown bumper material.
Creative and channel system
subjects, prohibited habits, presentation families, roles, energy, audio,
dayparts, and sequence preferences.
template, audio, brand, and provenance semantics without a schema migration.
reveal,static, andnonebrand modeskeep variation intentional rather than turning every card into the same
branded layout.
computed score must be positive, hard roles and profile gates are respected,
and soft adjacency rules report their relaxations.
and allowed to resolve to deliberate silence. Native, music, designed, and
silence treatments stay distinct.
with freshness and evidence metadata. They say what the channel has aired,
never that an individual viewer watched it.
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
output for Dispatcharr and ordinary HLS players.
never launches an encoder. Profile-aware cache keys and a branded empty-pool
slate preserve continuity across refreshes.
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.
media quarantine, bounded fetches, redirect/DNS checks, stream-proxy
allowlists, and non-root execution address media and SSRF hazards.
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. Thedurable 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):
ResourceWarningpromoted to errors.12 generation tests, plus three integration lifecycle regressions.
unchanged 262,144-byte cap; there is no runtime build/minification step.
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.
passes a network-disabled smoke as UID 10001 with fresh anonymous volumes:
writable
/assetsand/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:
ResourceWarningpromoted to errors; 87 were focused generation tests;33 JavaScript tests passed; compile, Ruff, diff, Docker build, and Compose
configuration checks passed;
work and its lifecycle follow-ups;
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:
Operating defaults and explicit limits
put authentication and a suitable proxy in front of it before internet
exposure.
account-pricing guarantee is included. Local estimates and preflight gates
are safeguards, not billing guarantees.
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.
a full cross-browser/accessibility matrix.
text migration; audiovisual output still requires human review with sound.
submissions, viewer accounts, or authentication are added.