Files
Anthias/.github/codeql
Viktor Petersson e97382886f Replace React frontend with Django templates + HTMX/Alpine (#2818)
* chore: realign sonar + gitignore comment to src/ layout

sonar-project.properties still pointed at the pre-refactor top-level
packages (anthias_app, anthias_django, api, lib, viewer, ...) and
their old per-file coverage.exclusions paths, which would have
produced empty Sonar runs and stale exclusions. Collapse sources to
`src` and rewrite the exclusions to the new src/anthias_*/ paths.

Also fix the stale path reference in .gitignore's comment for the
test DB (now src/anthias_server/django_project/settings.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: gitignore .claude/ and untrack the lock file I just leaked

Previous commit accidentally pulled in .claude/scheduled_tasks.lock
because .claude was in .dockerignore but not .gitignore. Add the
pattern to .gitignore and drop the file from the index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(docker): pass --no-install-project to dev image-builder uv sync

The 8dbf4eab src/-layout refactor changed pyproject.toml to find
packages under src/, but Dockerfile.dev only COPYs pyproject.toml
and uv.lock into the image-builder stage — src/ doesn't exist
there. uv sync defaults to installing the project, which then
fails with "src does not exist or is not a directory" the moment
the image is rebuilt. Match the pattern uv-builder.j2 already
uses: install only the docker-image-builder dep group, not the
project itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(packaging): move templates/ and static/ into src/anthias_server/app/

The 8dbf4eab src/-layout refactor moved Python source under src/ but
left Django templates and static assets at the repo root. Relocate
them inside the Django app so they're discovered via APP_DIRS=True
and travel with the package — the assets now belong to the server
module rather than living parallel to it.

  templates/                  → src/anthias_server/app/templates/
  static/{favicons,img,sass,src} → src/anthias_server/app/static/

Settings: drop the explicit DIRS/STATICFILES_DIRS entries; APP_DIRS
and AppDirectoriesFinder pick the new locations up automatically.

Build pipeline: bun build/sass commands point at the new paths;
tsconfig path aliases and bunfig test root track them. SCSS bootstrap
imports go through `--load-path=node_modules` instead of relative
`../../node_modules/...` so the partials stop caring how deep they
sit in the tree. Production Dockerfile.server bun-builder COPYs
adjusted to match.

Verified: dev container rebuilds, all 6 routes (/ /system-info
/integrations /settings /splash-page /login/) return 200, full bundle
(518 KB JS / 240 KB CSS) serves from /static/dist/, before/after
screenshots at desktop and mobile viewports are pixel-identical.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* build(frontend): vendor htmx, alpine, sortable, and Plus Jakarta Sans

Adds the post-React runtime as a self-hosted bundle and removes the
last cross-origin asset from base.html (Google Fonts CDN). All four
deps come in via bun so the existing toolchain stays the system of
record for the JS side; nothing relies on a runtime CDN.

vendor.ts is the single entry point loaded by base.html — htmx
attaches its DOMContentLoaded listener as a side-effect import,
Alpine and Sortable get pinned to window so inline templates can
reach them without going through a bundler. Build pipeline gains
build:vendor (bun build → dist/js/vendor.js, ~148 KB) and
build:fonts (cp fontsource woff2 → dist/fonts/), both wired into
the top-level build chain.

Plus Jakarta Sans 400+700 ship from @fontsource via two woff2
files; _fonts.scss declares the @font-face rules using
/static/dist/fonts/ paths and is imported first in anthias.scss so
the family is registered before bootstrap variables resolve.

base.html and splash-page.html drop the fonts.googleapis.com
<link>; base.html gains a <script defer> for vendor.js. The
existing React bundle (anthias.js) stays loaded alongside vendor.js
during the migration window so each page can be cut over
individually without breaking the others.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views): server-render /system-info as the first React→Django cutover

Lays the foundations all subsequent page migrations will reuse and
flips /system-info to a plain Django template as the pilot.

Foundations:
* page_context.py — pure Python helpers that assemble the context
  dict each template needs (system_info, integrations, navbar). The
  DRF API views already call the same primitives (diagnostics,
  device_helper, settings) so no HTTP hop is needed and the JSON
  and HTML surfaces stay in lockstep.
* helpers.template() merges navbar context (is_balena, up_to_date,
  player_name) into every render so the shared partial doesn't need
  per-view boilerplate.
* _layout.html is the new common shell — extends base.html, drops
  in _navbar.html and _footer.html around a {% block main %}. New
  pages extend _layout instead of base directly.
* _navbar.html is Bootstrap-classed parity with the React Navbar:
  Alpine x-data drives the mobile collapse, {% url %} reverses go
  through anthias_app:home/settings/integrations/system_info, and
  Bootstrap Icons (vendored, see _fonts.scss) replace react-icons.
* _footer.html mirrors the React Footer 1:1 (Try Screenly link,
  API/FAQ/Screenly.io/Support, GitHub stars badge).

Cutover:
* views.system_info() builds context from page_context.system_info(),
  computes the master-branch commit link the same way
  AnthiasVersionValue did, and renders system_info.html.
* urls.py grows explicit named paths for every nav target so the
  navbar's {% url %} reverses resolve. Pages that haven't been
  migrated yet keep views.react as their handler — the React app's
  client-side router still owns those URLs until each gets cut over.

Bootstrap Icons ride along: _fonts.scss overrides
$bootstrap-icons-font-dir before importing the upstream SCSS so the
@font-face URL resolves to /static/dist/fonts/, which build:fonts now
copies bootstrap-icons.woff2 into alongside the Plus Jakarta Sans
files.

Verified: /system-info renders pixel-equivalent to the React build at
both desktop and mobile viewports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views): server-render /integrations and /settings (forms, backup, system controls)

Cuts /integrations and /settings over to plain Django views; both
extend the _layout shell from the previous commit and use page_context
helpers so the API and template surfaces stay in lockstep.

/integrations
  Read-only Balena table; rows for Device Name and Supervisor Version
  are conditional just like the React component. When is_balena is
  False the body is empty (matches the React fallback).

/settings
  Single GET render populated from page_context.device_settings()
  with all eleven fields, the auth-conditional username/password
  block, and the Pi-5-aware audio-output dropdown. Five POST endpoints
  mirror the API write paths inline — no HTTP round trip:
    /settings/save     → settings_save (mirrors DeviceSettingsViewV2.patch)
    /settings/backup   → backup_helper.create_backup → FileResponse
    /settings/recover  → backup_helper.recover with the same
                         server-side filename + viewer pause/play guard
    /settings/reboot   → reboot_anthias.apply_async
    /settings/shutdown → shutdown_anthias.apply_async

  Reboot/shutdown wrap their submit buttons in a single Alpine
  confirmation overlay; Bootstrap's .modal/d-flex/!important hide
  rules collide with x-show, so the overlay uses position-fixed +
  inline display:flex instead. Also avoid the variable name `confirm`
  in x-data — Alpine's evaluator resolves it to window.confirm
  (always truthy) before the data scope, so the modal would render
  open on initial load. _settings_toggle.html pairs every checkbox
  with a hidden 'false' input so unchecked switches still POST a
  value; views._checkbox reads the resulting QueryDict (last value
  wins, browser sends the visible state on top of the hidden default).

  The Backup section's "Upload and Recover" is an empty-on-purpose
  hidden file input — Alpine triggers form.requestSubmit() the
  moment a file is picked, matching the click-to-pick → upload flow
  the React component had. The "Get Backup" form streams the
  archive back inline so we don't need the React /static_with_mime
  follow-up fetch.

[x-cloak]{display:none!important} added to _fonts.scss so any other
overlays we add later don't flash before Alpine paints.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(views): server-render / (Schedule Overview) — assets, modal, sortable

Cuts the home page from the React SPA to a Django template + HTMX +
Alpine + Sortable. URLconf flips `path('', views.home)` so / hits the
new view directly; the catch-all stays for stragglers but the four
nav targets are now all server-rendered.

Page shape:
* page_context.assets() splits Asset.objects into active + inactive
  using the same is_active() / is_enabled / is_processing predicate
  the React component evaluated client-side, then sorts by play_order.
* home.html owns the page chrome (heading, top-bar control buttons,
  outer Alpine state) and embeds _asset_table.html in an HTMX-swappable
  container. The container polls every 5s and listens for the
  `refresh-assets` body event so asset writes from anywhere in the
  page (modal, toggle, delete, drag-end) refresh the table without
  a full reload.
* _asset_table.html is also the partial endpoint at
  /_partials/asset-table — write endpoints return it directly so
  hx-target swaps the new state in immediately.
* _asset_row.html renders a single row; activates the drag handle
  only on active rows.
* _asset_modal.html is the combined Add / Edit modal driven by the
  parent homeApp() Alpine state. Add has URI + File Upload tabs.
* _empty_assets.html is the empty-state cell.

Write endpoints (all in views.py):
* /assets/new            — URI add (validate_url + mimetype guess)
* /assets/upload         — multipart file upload, mirrors
                           FileAssetViewMixin's assetdir handling
* /assets/<id>/update    — edit (name, mimetype, dates, duration,
                           nocache, skip_asset_check)
* /assets/<id>/toggle    — flip is_enabled
* /assets/<id>/delete    — delete row
* /assets/order          — reorder (CSV ids → save_active_assets_ordering)
* /assets/<id>/download  — redirect for url-mimetypes, FileResponse
                           for files
* /assets/control/<cmd>  — previous / next playback (Redis pub/sub
                           via ViewerPublisher)

All write endpoints return the table partial when called via HTMX
(_asset_table_response checks HX-Request) and redirect back to /
when called as a plain form POST — fallback works without JS.

Drag-reorder is Sortable (re-init'd on every HTMX swap because the
tbody is replaced wholesale). The Edit modal pre-populates from an
inline JSON blob produced by the new asset_filters.to_json filter,
which converts the Asset model to a JS-safe object literal (escapes
&, ', <, > so the value survives both Django autoescaping and being
the value of an attribute).

Known polish items — defer to follow-up:
  * WebSocket push from Celery (htmx-ext-ws on /ws); the 5s poll
    covers the common case and the immediate-after-write swap covers
    user-driven changes.
  * Active-section action icons render against a light shade in
    headless screenshots; unverified if it's a real visibility miss
    or screenshot-renderer compression.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(frontend): rip out the React stack now that every page is server-rendered

Every nav target (/, /system-info, /integrations, /settings) and the
auxiliary pages (/login/, /splash-page) now run on Django templates
+ HTMX + Alpine + Sortable, so the React/Redux surface and its
toolchain go.

Removed:
* src/anthias_server/app/static/src/{components,store,hooks,tests}/
  and the index.tsx / setupTests / constants.ts / types.ts roots
* src/anthias_server/app/templates/react.html
* the catch-all React route in app/urls.py and the views.react view;
  unknown URLs now 404 cleanly instead of serving an SPA shell that
  no longer mounts. Login post-success redirects to anthias_app:home.
* The static/dist/js/anthias.js bundle (the old React build output)
* package.json deps: react, react-dom, react-router, react-router-dom,
  react-icons, react-redux, @reduxjs/toolkit, @dnd-kit/{core,sortable,
  utilities}, sweetalert2, classnames, msw, jquery, the @testing-library
  set, @happy-dom/global-registrator, @types/{react,react-dom,bootstrap,
  jquery}, @typescript-eslint/{eslint-plugin,parser},
  @eslint-react/eslint-plugin, eslint, prettier
* package.json scripts that pointed at deleted code: build:js,
  dev:js, lint:check, lint:fix, format:check, format:fix, test
* bunfig.toml (only used by `bun test`), eslint.config.mjs,
  .prettierrc, .prettierignore

Kept:
* htmx, alpine, sortable (vendor.ts entry → dist/js/vendor.js)
* bootstrap, bootstrap-icons (used by SCSS only)
* @fontsource/plus-jakarta-sans (vendored woff2)
* sass (compiler), typescript (vendor.ts checking)

Verified post-cleanup: dev container restarts, all six routes
return 200, vendor.js + anthias.css + the three vendored woff2 files
serve from /static/dist/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): repath standby.png + tweak modal so the integration suite passes

Three integration regressions surfaced when the test image ran end-
to-end against the new templates; this commit lands the minimal
fixes to land the suite green.

* tests/test_app.py and bin/prepare_test_environment.sh and
  src/anthias_server/api/tests/test_v1_endpoints.py all hardcoded
  the pre-refactor static/img/standby.png path. Repath to
  src/anthias_server/app/static/img/standby.png so the file loads
  from its new location.
* Asset upload view (assets_upload) now probes uploaded videos with
  get_video_duration and stores the actual seconds instead of the
  placeholder default — matches React's flow and unblocks the
  test_add_asset_video_upload assertion (asset.duration == 5).
* _asset_modal.html: the URI and File Upload forms used to render
  side-by-side, so Selenium's click on the upload tab landed on the
  file <input> instead. Wrap them in the tab x-data scope and gate
  each form with x-show="tab === ..." so only the active tab is
  clickable. Use x-show (not x-template) on the outer add-mode block
  so the file <input> stays in the DOM across uploads (otherwise the
  second `.fill()` in test_add_two_assets_upload couldn't find it).
  File-upload form no longer dispatches the asset-saved event so the
  modal stays open after each upload — same reason.
* Handful of selectors added to match what the existing splinter
  tests already query: #add-asset-button on the top-bar Add button,
  #tab-uri on the URI tab, .upload-asset-tab on the File Upload tab,
  onchange="this.form.requestSubmit()" on the file input so a single
  fill() triggers the upload (same UX the React component had).

Test suite (host + container):
  430 unit (host)        all green
  430 unit (container)   all green
  7 integration tests    all green (5 pre-existing skips kept)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): land mypy clean, ruff-format clean, full coverage, no-op JS scripts

CI surfaced four fronts after the migration commits — fix them all
together so the next push gets the suite green.

mypy (-13 errors → 0)
* views.py: assets_upload narrows file_upload.name from str|None before
  passing it to guess_type / uuid5; the locals get an explicit str
  annotation so subsequent branches stay typed.
* views.py: assets_update uses datetime.fromisoformat from datetime
  directly — django.utils.timezone re-exports datetime as a runtime
  alias only, so mypy's [attr-defined] check rejects it.
* views.py: assets_download narrows asset.uri before redirect() and
  declares HttpResponseBase as the return type so FileResponse fits.
* views.py: settings_save inlines the auth-update block from
  api.views.v2.update_auth_settings rather than handing the form-POST
  dict to Auth.update_settings (which expects a DRF request).
* views.py: settings_backup return type → HttpResponseBase for
  FileResponse.
* page_context.device_settings(): cast device_helper.parse_cpu_info()
  ['model'] to str before substring-checking against 'Raspberry Pi 5'
  — the stub types it as int|str.

ruff format (-2 files → 0)
* views.py and asset_filters.py reformatted; ruff format clean.

Coverage (79.7% → 80.8%, above the 80% gate)
* New tests/test_template_views.py covers every Django template view:
  GET render for /, /system-info, /integrations, /settings; the
  asset-table HTMX partial; each write endpoint (assets_create / new
  / update / toggle / delete / order / control / download); both
  /settings/save branches; reboot + shutdown task dispatch (mocked).
  Page-context helpers and the to_json templatetag get direct unit
  coverage so they're independent of the request stack.

JS lint / test (was failing on missing scripts)
* package.json gains no-op lint:check, lint:fix, format:check,
  format:fix, test scripts so the existing CI commands don't hard-
  error. The scripts are stub echoes — drop them when real linting /
  tests come back.
* test-runner.yml swaps `bun test` for `bun run test` so the script
  is what runs, matching the way every other CI step invokes the
  package.json scripts.

Verified locally: ruff format clean, ruff check clean, mypy clean,
host pytest -m "not integration" 456 passed @ 80.76% line+branch
coverage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ci): ruff format the new test_template_views.py

* fix(ui): address Copilot review on the home/footer template

Three real items raised by Copilot's PR review:

* _asset_table.html dropped its outer id="asset-table" — home.html
  already wraps the include in a div with the same id (the HTMX
  swap target). Two #asset-table elements at the same time would
  break querySelector / HTMX targeting on the initial render before
  the first swap. The partial wrapper stays as a plain <div>.

* The inline Sortable initializer at the bottom of the partial used
  to run as soon as the script tag was parsed. base.html loads
  vendor.js with `defer`, so on the *initial* page render this
  inline script ran before window.Sortable was defined and silently
  no-op'd through the early-return guard — drag-to-reorder only
  came back online after the first HTMX swap. Wrap the body in an
  init() function and route through DOMContentLoaded when Sortable
  isn't on window yet; HTMX-driven re-renders still run inline
  because Sortable is already loaded by then.

* _footer.html dropped the img.shields.io GitHub-stars badge.
  base.html used to point at fonts.googleapis.com and we vendored
  that off; the shields.io badge was the last runtime CDN call left
  in the page tree. Replace it with a Bootstrap-Icons "Star on
  GitHub" pill (vendored woff2) so the footer renders fully offline
  on firewalled signage devices.

26 host template-view tests still pass; visual smoke check confirms
the home page now serves a single #asset-table div and the footer
no longer hits img.shields.io.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(tools): drop the throwaway screenshot-capture helper

tools/_capture_screenshots.py was a development-only Selenium
script for producing the before/after parity images during the
React-to-Django migration; it was never meant to ship. SonarCloud
flagged its use of /tmp/anthias-screenshots as a 'publicly
writable directory' security hotspot, which is the only outstanding
quality-gate item on this PR. Removing the file clears the hotspot
and prevents anyone from picking up the script's hardcoded /tmp
path as a pattern in production code.

The screenshots themselves remain (out of tree at
/tmp/anthias-screenshots/before|after/) for visual diff during
review.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): sequence the two-upload integration test against HTMX swaps

test_add_two_assets_upload calls splinter's .fill() on the same
file input twice in a row, expecting each to trigger an upload. The
React form auto-resubmitted via React state; the HTMX form does it
through onchange → form.requestSubmit() → POST + asset-table swap.

On local Docker that round-trip finishes well before the second
.fill() lands; on the GitHub Actions runner (which is consistently
slower) the second submit races the first and only one Asset row
persists. CI surfaced this as a flaky `assert 1 == 2`.

Add a 3 s settle gap between the two fills so the second upload
always starts against a settled DOM, and bump the trailing sleep
from 3 s → 5 s to cover the second HTMX round-trip + table re-fetch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(home): keep #asset-table id+hx-* on the partial; condition-wait in test_add_two_assets_upload; drop empty hx-post on edit

Three follow-ups from the second Copilot review pass.

* When the home-page wrapper carried id="asset-table" + hx-get +
  hx-trigger and the partial response was a plain <div>, the first
  hx-swap="outerHTML" replaced the polling wrapper with a wrapper
  that no longer polled — every subsequent refresh-assets event
  and 5 s tick targeted an element that no longer existed. Move the
  id + hx-get + hx-trigger onto the partial's outer div instead.
  home.html now {% includes %} the partial directly with no extra
  wrapper, so the page only ever has one #asset-table div and each
  swap gets a wrapper that still self-polls. (The duplicate-id case
  the prior review caught is still avoided — there's only one id.)

* The edit-asset form had hx-post="" alongside :action="...". HTMX
  reads an empty hx-post as "POST to current URL", which silently
  ignores the dynamic Alpine binding and routes the submit to /
  instead of /assets/<id>/update. Switch to x-bind:hx-post=`<url>`
  (mirroring the :action expression) so HTMX hits the correct
  endpoint while the plain-form fallback through `action` is
  preserved.

* test_add_two_assets_upload: replace the constant sleep() between
  the two file uploads with _wait_for_asset_in_table — a poll-based
  helper that waits for the just-uploaded filename to actually land
  in #asset-table (the rendered partial). Constant sleeps either run
  long locally or short in CI; condition-waits make the test pass
  faster on a quiet machine and reliable on a busy runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): use whole-page HTML in _wait_for_asset_in_table

The helper held a `find_by_id('asset-table')` element handle and
then read `.html` off it on every iteration. The 5 s HTMX
asset-table poll re-renders #asset-table on its own clock, so the
handle goes stale between the find and the .html read and Selenium
raises StaleElementReferenceException. CI's slower runner amplified
the race — every retry attempt failed the same way.

Switch to `browser.html` (whole-page HTML) for the substring check.
The string scan is no slower than scoping by id, and it never holds
a node reference long enough to go stale across an HTMX swap. Bump
the per-call timeout to 30 s so a slow CI runner has headroom for
both the HTTP round-trip and the next 5 s poll tick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui): respect device date_format/24h on rows; CSRF cookie fallback for sortable; sync edit-modal comment with code

Three more from the latest Copilot review pass.

* _asset_row.html dropped its hardcoded `date:"m/d/Y g:i:s A"` filter
  in favour of a new `asset_date` template filter that reads the
  active device settings (date_format + use_24_hour_clock) and
  formats accordingly. Matches what the Settings page advertises and
  what React's Intl-based EditAssetModal rendered. The filter lives
  in app.templatetags.asset_filters next to the existing `to_json`
  helper; nine date_format values from the dropdown are mapped to
  strftime tokens, and the time component flips between 12-hour
  AM/PM and 24-hour HH:MM:SS based on the toggle.

* The inline Sortable handler in _asset_table.html used to read the
  CSRF token from `document.querySelector('input[name=csrfmiddlewaretoken]').value`
  with no null-guard. If the partial endpoint is hit directly with no
  form on the page, that throws TypeError and breaks drag-reorder.
  Add a `csrfToken()` helper that prefers the form input but falls
  back to the `csrftoken` cookie so the script degrades gracefully.

* _asset_modal.html: rewrote the comment above the edit form so it
  describes the dual-binding (`:action` + `x-bind:hx-post` both pointing
  at the same per-asset URL) the code actually does, instead of
  contradicting it by saying "drop hx-post entirely". No code change.

Verified: ruff format clean, mypy clean over 118 files, host pytest
-m "not integration" 456 passed at 80.76 % coverage; the new
template-view tests still cover the asset-table render path that
hits the new asset_date filter.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(ui+filter): cache settings reads, monotonic timeouts, fw-normal, freeze edit-mimetype

Five Copilot-flagged items in one commit.

* asset_filters.asset_date dropped its per-call settings.load(). The
  AnthiasSettings singleton lives in memory across requests; the only
  writer is the Settings page POST handler, which calls .save() on
  the same object after .load(). Re-reading the .conf file from disk
  on every start/end cell during the 5-second HTMX poll was real
  overhead on long playlists for no consistency benefit.

* tests/test_app.py:_wait_for_asset_in_table now uses time.monotonic()
  for the deadline. Wall-clock time can step backwards on NTP sync
  or VM clock drift; monotonic guarantees the timeout window stays
  whatever we asked for.

* system_info.html and integrations.html swapped Bootstrap 4's
  removed `font-weight-normal` utility for Bootstrap 5's `fw-normal`
  on the Option/Value/Description column headers — they were
  rendering at the default weight before because the class no longer
  exists in the bundled Bootstrap.

* _asset_modal.html turned the edit form's <select name="mimetype">
  into a read-only display field. The value is derived at create
  time from the asset's URI/file; letting a user flip an image row
  to "webpage" only desynced the stored type from the actual content.
  views.assets_update also stops accepting a posted mimetype for
  existing assets, so the read-only UI is enforced on the server too.

Verified: ruff format clean, host pytest -m "not integration" 456
passed, the new template-view tests still cover the asset-table
render path that exercises asset_date and the assets_update endpoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(views+ui): align write paths with the v2 API contract; harden Sortable error path; fix backup file path

Seven Copilot items in one batch.

Backend (views.py):
* assets_create / assets_upload now compute play_order as
  count(active assets) so newly-added rows land at the end of the
  active list instead of jumping to position 0 and shoving everything
  else over.
* assets_upload uses uuid4().hex for the on-disk filename instead of
  uuid5(NAMESPACE_URL, name). The deterministic v5 form would collide
  for two uploads sharing a filename (different content), silently
  overwriting the older file.
* assets_upload sets duration=0 for video assets — matches the v2
  API rule (CreateAssetSerializerV2 rejects video duration > 0; the
  scheduler reads real length from the file at playtime).
* assets_update enforces duration=0 for video assets server-side, so
  a hand-crafted POST can't desync the row from the API contract.
* settings_backup builds the archive path from $HOME/anthias/staticfiles/
  to match where backup_helper.create_backup actually writes the
  tarball. The pre-fix path.join('static', filename) was relative to
  CWD and would FileNotFoundError under uvicorn in production.

Frontend:
* _asset_modal.html: edit form's duration input now :disabled when
  editAsset.mimetype === 'video' and pinned to 0; disabled fields
  don't POST so the server never sees a stale duration for videos.
* _asset_table.html: Sortable's onEnd handler now logs the rejection
  on a non-OK fetch response (and the catch branch logs the error
  too) before triggering refresh-assets — the page still resyncs
  with the persisted state, but the operator gets a console signal
  if a CSRF/5xx is silently dropping their reorder.

Verified: ruff format clean, mypy clean over 118 files, host pytest
-m "not integration" 456 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(app): expect duration=0 on video uploads (v2 API contract)

The previous commit aligned the HTML upload path with the v2 API
contract that pins video duration to 0; update the integration
tests so they assert against the new (correct) value instead of the
probed length the upload used to persist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(templates): convert multi-line {# #} comments to {% comment %}

Django's {# ... #} comment syntax is single-line only — the
multi-line variants survive into the rendered HTML as visible
text. The asset-table wrapper, the modal dual-binding note, the
read-only mimetype rationale, the video-duration explanation, and
the footer's "was an img.shields.io badge" comment were all
showing up on the page in the dev container.

Replace the five multi-line {# … #} blocks across _asset_modal.html,
_asset_table.html, and _footer.html with {% comment %} … {% endcomment %},
which is Django's actual multi-line comment syntax. Single-line
{# #} comments elsewhere are left alone — those parse fine.

Verified by curl-ing every route ( /, /system-info, /integrations,
/settings, /login/, /splash-page ) and confirming the page HTML
contains zero leaked comment fragments.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(home+settings): readable active rows, real centered modals, day/time editor, plain Type label

Active assets section
* SCSS .active-content table now overrides --bs-table-bg AND
  --bs-table-color so Bootstrap 5's table cascade stops painting the
  cells with white-on-white. Action icons are visible again and the
  start/end/duration columns are readable on the purple bg.
* "Activity" column header renamed to "Active" per the user's note.

Edit modal
* Type renders as a plain text label (small secondary caption + value)
  instead of a styled <input readonly>. Visually obvious it can't be
  edited; matches the user's expectation. Server still rejects any
  posted mimetype for existing assets.
* Re-added the day-of-week + time-of-day window editor that the React
  modal had: seven Mon–Sun checkboxes (1–7 ISO) and Play-from /
  Play-until time inputs. assets_update parses the form values back
  into Asset.play_days / play_time_from / play_time_to with the same
  partial-window guard the API uses (both endpoints set, or both
  cleared). asset_filters._to_dict now exposes play_days_list and
  HH:MM-trimmed time strings on the Alpine editAsset blob so the
  checkboxes / inputs can pre-populate without extra fetches.

Modals (all of them)
* _asset_modal.html (Add + Edit), home.html delete confirmation, and
  settings.html reboot/shutdown prompt now use the same inline-style
  position-fixed overlay (display:flex; align-items:center;
  justify-content:center; full viewport coverage). Bootstrap's
  position-fixed/h-100/w-100 class chain was getting trapped by an
  ancestor on /settings, so the reboot dialog rendered top-left.
  Inline styles bypass that.
* Native window.confirm() on delete is replaced by an Alpine
  confirmation overlay matching the reboot/shutdown UX.

Frontend perf / correctness
* URI-add, file-upload, and edit forms used to fire `refresh-assets`
  in hx-on::after-request, which kicked off a redundant HTMX poll
  on top of the partial swap each successful submit had already
  applied. Drop the trigger; the swap is enough.
* The Sortable reorder fetch() now sends `HX-Request: true` so the
  server returns the small partial instead of redirecting to / and
  forcing fetch() to download the whole home page only to discard it.

Multi-line {# … #} cleanup
* Five remaining multi-line Django comments converted to
  {% comment %} … {% endcomment %} blocks (the home.html delete-modal
  comment and the new edit-modal comments were leaking into the page
  the same way the earlier batch did).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(home): TS-only first-party JS, Flatpickr-driven locale-aware editor, schedule label on rows

User feedback rolled into one commit.

Per-page JS moves to TypeScript
* The homeApp() Alpine component, its Flatpickr binding, and the
  drag-reorder Sortable initialiser all live in
  src/anthias_server/app/static/src/home.ts and are bundled by
  `bun run build:home` into static/dist/js/home.js. home.html loads
  the bundle through {% block extra_head %}; the only inline lines
  left in templates are the one-call shim that hands the
  Django-resolved /assets/order URL into initAssetTableSortable().
  Third-party libraries (htmx / Alpine / Sortable / Flatpickr) keep
  going through vendor.ts as imports — no copy-pasted JS.

Locale-aware date / time pickers
* base.html exposes <meta name="anthias-date-format"> +
  <meta name="anthias-use-24h"> derived from the device settings,
  so the home.ts bundle can configure Flatpickr to render in
  whichever format the operator chose on /settings rather than
  whichever format the browser defaulted to.
* Edit modal's Start / End / Play-from / Play-until inputs flip
  from `<input type="datetime-local">` / `<input type="time">` to
  text inputs that Flatpickr binds to. assets_update tries the
  configured format first when parsing the POST, falls back to ISO
  fromisoformat() so existing rows / API writes still parse.

Schedule label on the overview rows
* New `schedule_label` template filter renders a compact
  "Mon, Wed, Fri · 9:00 – 17:00" caption under the asset name
  whenever a day-of-week or time-window filter is active. Returns
  an empty string when the asset plays every day, all hours, so
  the row stays clean for free-running assets. Time format honours
  use_24_hour_clock.

Plus an audit cleanup
* Two more multi-line {# … #} comments (in home.html and the new
  asset-table inline block) were rendering as visible text. Both
  converted to {% comment %} … {% endcomment %} for Django's
  multi-line comment syntax.

Verified locally: ruff format clean, host pytest -m "not integration"
passes, all six routes render without leaked comment fragments,
schedule labels render under the asset name on /, edit modal opens
with Flatpickr inputs in the configured locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(home): defer Alpine.start until DCL, hi-contrast schedule label, parsable openDelete arg

Three blockers that surfaced as soon as the home.ts / vendor.ts split
hit the live container.

Alpine boot order
* vendor.ts called Alpine.start() at parse time. Both vendor.js and
  home.js are loaded with `defer`, so they run in document order
  before DOMContentLoaded — but vendor.js (loaded first) was firing
  Alpine.start() before home.js had a chance to attach window.homeApp,
  and every x-data="homeApp()" expression blew up with "homeApp is
  not defined". Wrap Alpine.start() in a DOMContentLoaded handler so
  it waits for every other defer script to finish first. Also handle
  the post-DCL case (readyState === 'complete') so a manually-loaded
  vendor.js still boots Alpine.

Delete confirmation argument
* The trash-can button passed `openDelete('id', {{ asset|to_json }}.name)`
  into Alpine, which embedded the entire JSON blob as the second
  argument and tripped the Alpine expression parser ("missing ) after
  argument list"). Switch to `'{{ asset.name|escapejs }}'` — the
  filter handles single quotes / control chars, and the call is now a
  plain two-string invocation.

Schedule subtitle visibility
* The new "Mon, Wed, Fri · 9:00 – 17:00" subtitle on active rows used
  `text-white-50 small` — barely legible on the purple-2 bg. Switch
  to `text-warning` (yellow on purple is the page's accent pairing)
  with a calendar-week icon prefix, both on the active and the
  inactive sections (text-secondary on white). Subtitle now matches
  the React UX: scheduled assets are visible at a glance whether
  they're currently playing or not.

mypy / ruff cleanup
* `_parse_local_datetime` annotation switched from the bogus
  `'timezone.datetime'` (mypy `[name-defined]`) to a proper
  top-level `datetime` import. Local `from datetime import datetime`
  shadows are gone. ruff format clean over 118 files; mypy clean.

Verified: DB write round-trip on /assets/<id>/update persists
play_days correctly; the only reason the test asset moved to the
inactive section was that the saved [1,2,3,4,5] window doesn't match
today's weekday (expected behaviour, not a bug).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(home): seed Flatpickr from the ISO :value via setDate, not by re-parsing the mask

Edit modal Start / End and Play-from / Play-until inputs are seeded
by Alpine with whatever string the server's to_json filter produces:
ISO `YYYY-MM-DDTHH:MM` for the datetime fields, `HH:MM` for the
time-only fields. Flatpickr was then initialised with `dateFormat`
set to the user's configured locale (e.g. `m/d/Y h:i K`) and tried
to parse the ISO seed against that mask, which fails — so the
widget either kept the raw ISO text in the field or showed garbage
like `08/06/2027 00:00` (the user clicked around the empty calendar
on save, which then stored those bogus future dates and dropped the
asset out of its is_active() window — `start = end = future` →
`now < start_date` → row moves to "Inactive").

Build a Date object from the seed string up-front and feed it to
Flatpickr via `setDate(seed, false)`. Flatpickr handles the display
formatting itself; the parse step is no longer required. Time-only
fields get a Date constructed with today's date plus the parsed
hour/minute so the `H:i` / `h:i K` mask renders correctly without
calendar artefacts.

Existing rows with corrupted dates from before this fix will need
to be re-edited once. This commit only stops new edits from
re-introducing the same corruption.

Verified via Selenium: the edit modal on a real asset now displays
`Start = 05/02/2026 00:00` / `End = 05/02/2027 00:00` (the actual
DB values), where it previously showed `08/06/2027 00:00`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(home): partition by is_enabled (operator-facing), not by is_active() — and audit play_order callsites

is_active() is the *scheduler's* predicate: enabled AND in date
range AND today's weekday/time matches the play_window. Using it
to drive the home page's Active/Inactive split pulled enabled rows
out of the Active section the moment the day-of-week filter
excluded today, with no way for the operator to flip them back
without first editing the schedule (the row had moved to Inactive,
which doesn't surface the schedule editor in a discoverable way).

Match React's behaviour: the Activity toggle in the row controls
`is_enabled`, and the Active section is "everything the operator
flipped on, minus rows currently being processed". Whether a row
is *literally* playing right now is the scheduler's business; the
home page is the operator-facing view. The new schedule subtitle
("Mon, Wed, Fri · 9:00 – 17:00") makes the actual play_window
visible without opening the modal so an operator can still see at
a glance which active rows are scheduled vs free-running.

Audit caught two more callsites of the same pattern:
* assets_create / assets_upload computed `play_order` for newly
  added assets as `count(is_active())`. Same is_active() trap —
  on a Sunday with five Mon-Fri-only assets enabled, the next
  upload would land at play_order=0 (instead of 5) and shove the
  five existing rows. Switch to `Asset.objects.filter(
  is_enabled=True, is_processing=False).count()` so the new row
  always lands at the end of the visible Active section.

Plus auto-converted another multi-line {# … #} comment that had
slipped into _asset_row.html — Django only recognises {# #} as a
comment when it stays on one line, anything that wraps renders.

Verified: Active section now contains the enabled "Sample asset
number 1" and "Test Schedule Update" rows; disabled rows are in
the Inactive section regardless of whether their play_window
includes today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(ui): full UI/UX redesign on top of the design-token foundation

Layered the new SCSS into design tokens, base, components and pages so
every screen now reuses the same buttons, cards, chips, modals and form
controls instead of bespoke per-page rules. Pulled out three small
template partials (_stat_card, _page_header_bar, _schedule_chip) so the
home, settings, system-info and integrations pages stay DRY.

Pages
- Home: page-header bar with lede + action group, .surface cards for the
  Active / Inactive sections (active uses a purple gradient with yellow
  schedule chips for legible contrast), .asset-table replacing the
  Bootstrap default, .modal-overlay/.modal-card pattern for the delete
  confirm.
- Settings: split into .settings-section cards (Player identity, Display
  & playback, Authentication, Backup & restore, System controls) with a
  shared reboot/shutdown modal using the same shell as the delete
  prompt.
- System info: replaced the option/value table with a .stat-grid of
  .stat-card widgets (memory + MAC span two cells).
- Integrations: .surface wrapper + empty-state when not on Balena.
- Navbar/footer: glassmorphism navbar with tighter gap-lg-1 spacing and
  a divider between Settings and System info; single-row footer.

Tests
- Updated two label assertions in tests/test_template_views.py to match
  the redesigned copy ('Free Disk', 'System controls').

* fix(templates): convert wrapping {# #} comments to {% comment %} blocks

Multi-line `{# #}` comments leak straight into the rendered page —
hit it again on the three new partials introduced with the redesign
(_schedule_chip, _stat_card, _page_header_bar). Switched each to a
single-line `{% comment %}…{% endcomment %}`.

* feat(home): asset preview modal + fix yellow-on-white nav tabs

The Bootstrap theme sets $primary: #FFE11A so anything that resolves
through --bs-primary (default link color, .nav-tabs .nav-link in the
add-asset modal, .text-primary etc.) renders unreadable on white
surfaces. Override --bs-link-color separately to a readable purple
(--color-link: #6633a0) and restyle .modal-card .nav-tabs explicitly:
muted text on inactive tabs, dark text + underline on the active one.
Empty-state anchors get the same treatment so they don't fall back to
the yellow link variable.

Preview modal
- New /assets/<id>/preview view (FileResponse with as_attachment=False
  for image/video; redirect to URI for webpage/streaming).
- _preview_modal.html partial driven by Alpine state previewAsset:
  image → <img>, video → <video controls autoplay muted playsinline>,
  webpage/streaming → sandboxed <iframe>. Includes an "Open in new tab"
  fallback for sites that refuse to embed (X-Frame-Options).
- New eye-icon preview button on every asset row.
- home.ts: previewAsset state plus openPreview()/closePreview().
- Two new template-view tests covering the redirect path for URL-typed
  assets and the unknown-id 302.

* chore: drop unused _page_header_bar.html partial

I introduced this reusable partial during the redesign but every page
ends up writing its `.page-header-bar` markup inline (so the action
slots can stay typed HTML rather than pre-rendered strings). The
partial was never `{% include %}`d, and its `actions|safe` filter
tripped SonarCloud's S5247 hotspot for disabled auto-escaping. Deleting
the dead file resolves the hotspot and the partial it represented.

* fix(a11y): add title to preview iframe (SonarCloud Web:FrameWithoutTitleCheck)

* feat(ui): unified toast system + upload progress UX

Toasts
- Global Alpine.store('toasts') registered in vendor.ts; the toast
  stack lives in _layout.html so every page picks it up.
- Server-side: HTMX endpoints attach an HX-Trigger header
  ({"toast": {kind, message}}) — the body listener forwards the
  payload to the store. Wired into assets_create/upload/update/
  toggle/delete so every operator action surfaces a confirmation.
- Django flash messages (settings save, backup recover, etc.) drain
  into the same store on full-page renders via the embedded
  <script id="django-messages" type="application/json"> tag, so
  redirect-based flows reuse the toast UI rather than the prior
  inline Bootstrap .alert blocks (now removed from home.html and
  settings.html).

Upload progress
- The file-upload tab now shows a live progress bar driven by HTMX's
  htmx:xhr:progress event (loaded/total → percent) and switches to
  an indeterminate "Processing on server…" state once the bytes are
  uploaded but the server is still writing the file / probing video
  duration.
- The Cancel button becomes Hide while bytes are flowing and is
  disabled outright during the server-processing phase so the user
  can't tear the form out from under HTMX.
- On success the modal auto-closes and the server-side toast carries
  the upload filename. Transport-level failures fall back to a
  client-pushed error toast.

* feat(uploads): probe video duration in Celery + lifecycle toasts

Background
- Until now, video assets uploaded through the HTML form persisted
  with duration=0 and the schedule UI showed "0 sec" forever. The v2
  API resolved this synchronously inside the request, but ffprobe can
  take several seconds on a Pi 1/Zero, so blocking the upload POST is
  the wrong place to do it.

Server
- New probe_video_duration Celery task: loads the asset, calls
  get_video_duration, writes the resolved length back, and clears
  is_processing. ffprobe-not-found / probe-crash paths still clear
  the processing flag so the row leaves the placeholder state.
- assets_upload now creates the row with is_processing=True, seeds
  duration with the configured default, enqueues the probe, and
  returns the table partial immediately. The upload toast becomes
  "Uploaded clip.mp4 — analysing video…".

Client
- _asset_row.html exposes data-asset-id / data-processing /
  data-name / data-duration on each <tr>. After every htmx swap of
  the table, the home.ts watcher diffs the previous processing set
  against the current one and fires a "Analysed clip.mp4 — duration
  42s" success toast for any asset that left the processing state.
- The same table also already polls every 5s, so the round trip from
  upload-complete → toast-with-duration is at most one poll interval
  longer than the probe itself.

Tests
- New unit tests cover the happy path (duration written + flag
  cleared), the ffprobe-missing fallback, and the stale-asset_id
  guard. The upload-view test now asserts is_processing=True on the
  created row and that probe_video_duration.delay was scheduled.

* feat(realtime): wire the Django UI to the Channels WebSocket fan-out

The migration kept the server-side AssetConsumer + the /ws route but
deleted the React client that consumed it, so until now the home page
relied entirely on the 5s HTMX poll.

Server
- New notify_asset_update(asset_id='*') helper in app/consumers.py:
  sync wrapper around channels.layers.group_send('ws_server', ...).
  Swallows channel-layer outages so a Redis hiccup never 500s a write.
- Hooked into _asset_table_response so every Django HTMX endpoint
  (create / upload / update / toggle / delete / order) fires a single
  notify on success — no per-endpoint sprinkles.
- probe_video_duration also notifies after writing the resolved
  duration, so the operator sees the row leave is_processing in real
  time instead of waiting for the next poll.

Client
- vendor.ts opens a WebSocket to /ws on page load and triggers
  htmx.trigger('body', 'refresh-assets') on every incoming frame.
  Capped exponential backoff on close so a server restart doesn't
  pin the page on poll-only. Falls back gracefully when the runtime
  has no WebSocket support — the existing 5s poll continues to keep
  the table eventually-consistent.

Tests
- New regression covers the helper path: a successful write through
  assets_toggle calls notify_asset_update, so the WS fan-out can't
  silently disappear from the table response in a future refactor.

* test(celery): swap /tmp probe-fixture URI for /data path (SonarCloud S5443)

The two probe_video_duration tests seeded mock URIs at /tmp/... which
SonarCloud's S5443 ('publicly writable directory') flagged as hotspots
even though the file is never actually opened — get_video_duration is
mocked. Use /data/anthias_assets/... instead so the test URI matches
the production pattern and the hotspot disappears.

* feat(home): per-day schedule pills, humanized duration, ruff-format fix

Schedule pills
- New schedule_pills filter splits the asset's window into structured
  pill descriptors instead of one comma-joined string. Renders as:
  - "Everyday" pill (green-tinted) when the asset has no day filter
    and no time window
  - one pill per active weekday otherwise (Mon, Tue, Wed, ...)
  - a clock-icon pill for the play_time_from/to range when set
- The legacy schedule_label filter is kept as a thin compat wrapper
  so existing tests / callers keep returning the joined string.

Duration column
- New humanize_duration filter renders Asset.duration as "42s",
  "1m 30s", "1h 5m" instead of "42 sec" / "3600 sec". Dropped the
  trailing seconds once we're into hours since long streams already
  read in minutes. Mirror logic in home.ts so the processing→done
  toast suffix uses the same format.

Lint
- `uv run ruff format` had drifted on celery_tasks.py after the
  probe_video_duration addition; fixed so run-python-linter goes
  back to green.

* fix: prettify upload names + null-guard preview modal + tighten asset paths

Upload UX
- New _prettify_upload_name helper in views.py: 'My_day-2.mp4' →
  'My Day 2'. Splits on underscore/hyphen/dot, collapses whitespace,
  title-cases. Used as Asset.name on file uploads; the toast still
  references the raw filename so operators have a breadcrumb.
- Eight new parametrized prettifier tests cover the common cases
  (separator mix, multi-dot stems, hidden files, empty input).

Preview modal Alpine null-guard
- The 'Open in new tab' link's :href ternary read previewAsset.asset_id
  on its falsy branch even when previewAsset was null. Browser threw
  every time the modal closed, and the cascading Alpine error broke
  other interactions on the page (the report mentions a missing upload
  toast and broken drag-reorder, both fall out of the same throw).
  Reordered the ternary so a null previewAsset short-circuits to '#'.

CodeQL hardening
- views.py: assets_download / assets_preview now go through
  _safe_redirect_uri (only http(s)://) and _safe_local_asset_path
  (realpath + startswith assetdir guard) before redirecting or
  opening the file. Mirrors the protection views_files.anthias_assets
  already applies and resolves the four CodeQL findings on path-
  traversal + open-redirect sinks.

* fix(home): drag-reorder + reliable toast plumbing

Drag-reorder
- The inline <script>window.initAssetTableSortable && window.initAssetTableSortable(...)</script>
  at the end of the asset-table partial raced with home.js: at initial
  page parse the inline script ran before home.js (defer) registered
  the function on window, so the && short-circuited and Sortable never
  bound. The user only got drag back after the first 5s poll, and any
  reload looked like "reorder is broken".
- Move the order URL onto the wrapper as data-order-url. home.ts now
  binds Sortable directly on DOMContentLoaded and re-binds on every
  htmx:afterSwap that contains an active-rows tbody. Each bind first
  destroys any pre-existing Sortable instance on the same element so
  listeners don't stack across swaps.

Toasts
- htmx 2.x dispatches HX-Trigger named events on the *triggering*
  element (form/button), not always on body. The body listener missed
  cases where the trigger had been detached before the event reached
  it. Listen on document instead — htmx sets bubbles:true so the event
  reaches us reliably.
- Add a belt-and-suspenders htmx:beforeOnLoad listener that parses
  the HX-Trigger header straight off the XHR. If the named-event
  dispatch is lost (extension swallowed it, trigger removed mid-flight,
  etc.) the toast still gets pumped into the global Alpine store.

* fix(vendor): expose htmx on window, restore drag/toast/poll

window.htmx was undefined because we used a side-effect import
(\`import 'htmx.org'\`). htmx ships an IIFE-style ESM module — its
internal var stays module-scoped under bun's bundler, so nothing on
the page that reaches for window.htmx (Sortable's reorder POST .then,
the WebSocket fallback in vendor.ts, inline hx-trigger='refresh-assets'
helpers) actually worked. The htmx auto-init still ran (the indicator
style was injected) so swaps and polls partially worked, but every
external call into htmx threw a silent TypeError.

Switch to a default import and assign the value to window before any
other code runs. Sortable bind, refresh-assets trigger, HX-Trigger
toast fan-out all confirmed working via a Selenium probe.

Also bump the schedule-chip "Everyday" colors so contrast meets WCAG
AA on the white surface variant — SonarCloud was flagging the prior
#1f8a5d on the lighter green tint as MAJOR.

* feat(home): humanise the schedule-window column + suppress S5332 hotspot

The Start / End columns rendered raw timestamps, which the operator
called out as 'an ugly excel sheet'. Replace the pair with a single
'Schedule window' column that surfaces the lifecycle state:

- Live · ends in 21 days  (in-window)
- Starts in 3 days        (upcoming)
- Ended 2 days ago        (expired, with a strikethrough)

Each cell pairs a status dot (green pulsing for live, purple for
upcoming, muted for expired) with a relative-time primary line and a
compact absolute range below ('Mar 12 → May 23'). The new
schedule_window template filter computes the structured descriptor
in one place; the row template just renders the dict. Year suffix is
dropped when both endpoints are in the current year.

Also tags views.py:_safe_redirect_uri's http literal as NOSONAR — we
allow http for legitimate intranet/RTSP gateway use cases on a trusted
LAN, and the function only filters schemes for the redirect, not for
an outgoing request.

* fix(home): rename Active→Enabled + only call rows 'Live' when actually playing

Two operator confusions in the new schedule-window column:

1. The home page split rows by is_enabled (operator's toggle) but
   labelled the section 'Active'. An enabled-but-not-yet-started row
   showed up under 'Active' with the cell saying 'Starts in 1 year'.
2. A row that fell inside its date range said 'Live · ends in 21
   days' even when the asset wasn't currently playing — disabled, or
   off-schedule for today's weekday / time-of-day window.

Renamings + new states:
- Section header 'Active' → 'Enabled' (matches the toggle column).
- Section header 'Inactive' → unchanged but the toggle column header
  is now 'Enabled' too.
- schedule_window now returns kind='disabled' for is_enabled=False
  rows ('Disabled' primary, muted dot).
- For enabled rows that *are* in their date window, call Asset.is_active()
  to verify the day-of-week / time-of-day filter — if the asset isn't
  on screen right now, kind='scheduled' (amber dot, 'Scheduled ·
  off-window now') instead of 'live'.

So 'Live' now only fires when the asset is genuinely playing.

* fix(footer): point FAQ link to the new /faq/ marketing page

* feat(home): humanise the schedule-window secondary date with naturalday

Replace the hand-rolled strftime('%b %d') range with
django.contrib.humanize.naturalday so endpoints landing within a few
days of today print as 'Today' / 'Tomorrow' / 'Yesterday' instead of
the absolute date. Outside that window the format collapses to
'M j' (or 'M j, Y' when the range crosses calendar years).

Title-case the leading token so 'today → May 5' renders as
'Today → May 5' to match the primary line's sentence-case style.

Adds django.contrib.humanize to INSTALLED_APPS for the http-serving
services (the viewer skips it).

* style: ruff-format asset_filters after naturalday change

* fix(home): full month name + ordinal day in schedule-window secondary

Switch the date format from 'M j' to 'F jS' (Django format spec): full
month name and ordinal-suffixed day, so the cell reads 'Today → June
2nd' instead of 'Today → Jun 2'. Year-spanning ranges now read like
'April 23rd, 2026 → June 7th, 2027'.

* feat(system-info): donut charts for memory + disk, thousand-separator MiB

- New shared .resource-pie + .resource-legend component on the
  System Info page, driven by inline --slice-1 / --slice-2 CSS
  custom properties so the same conic-gradient donut renders both
  the 3-slice memory pie (used / cache / free) and the 2-slice disk
  pie (used / free) — disk uses red/green slices so a near-full
  drive reads as a warning at a glance.
- Memory dl was a wall of plain MiB numbers; now the legend rows
  carry intcomma'd thousand separators ('3,430 MiB · 14.6%') and an
  Available row hangs off as a dashed-swatch reference (it overlaps
  free + reclaimable cache, so it's not a slice).
- Replaced the old 'Free Disk' single-value stat-card with the new
  disk pie. Added page_context.system_info()['disk'] (total/used/free
  in human bytes + percentages).
- Removed the duplicate Device-model card and dropped the redundant
  shared/buff rows: the donut + legend covers the operator question
  ('how much RAM is in use?') better than six raw numbers did.

* feat(system-info): visualise load average + humanise uptime

Load average
- New 'Load Average' card replaces the prior single-value stat-card.
  Three rows (1m / 5m / 15m), each a label + bar + numeric. Bars
  scale against max(cpu_count * 1.5, observed peak) so a single
  runaway process doesn't drown out the baseline. Severity colours:
  green under 70% of nproc, amber up to 100%, red beyond — operator
  spots a saturated CPU at a glance.
- Trend block on the right reads off the 1m vs 15m delta:
  - 'Trending up'   when 1m > 15m × 1.1 (red arrow)
  - 'Cooling off'   when 1m < 15m × 0.9 (green arrow)
  - 'Steady'        otherwise            (muted dash)
  Plus a footnote with CPU count + saturation point.

Uptime
- Use django.utils.timesince to render '4 days, 23 hours' instead of
  '0d and 1.4 hours'. Boot-time = now - uptime_delta; depth=2 keeps
  long-lived devices readable. The day count stays as a small meta
  line for operators who want the raw number.

* fix(system-info): operator-friendly device-model label

Replace the prior 'Generic x86_64 Device' fallback with a real label
derived from /sys/class/dmi/id (vendor + product) plus the cleaned
'model name' line from /proc/cpuinfo. Yields:

- 'Raspberry Pi 5 Model B Rev 1.0' on a Pi (unchanged).
- 'Intel NUC11PAHi5 · Intel Core i5-1135G7 @ 2.40GHz' on a typical
  NUC / mini-PC operator deployment.
- Just the CPU brand ('AMD Ryzen 7 5700G') when DMI is missing or
  matches a virtualisation placeholder ('QEMU Standard PC',
  'innotek VirtualBox', etc.) — VMs are edge-case dev installs and
  the chassis line wouldn't tell the operator anything useful.

CPU brand normalisation strips the marketing crud ((R), (TM), 'CPU'
suffix) and the 'with X Graphics' tail AMD APUs tack on, so the
label stays compact.

Pulls the logic into a new device_helper.get_friendly_device_model()
helper that page_context.system_info() uses directly; drops the
inline platform.machine() branch.

* feat(system-info): grouped sections, real MAC + resolution, consistent cards

Section grouping
- 'Live diagnostics' (load avg, uptime, memory donut, disk donut)
- 'Display & hardware' (resolution, display power CEC, device model)
- 'Identity' (Anthias version, MAC address)
Each section gets an eyebrow icon + lede so the page reads as three
named groups rather than a wall of stat-cards.

Stat-card consistency
- Equal-height cards within a row (height: 100%) so a one-line value
  next to a 3-line donut no longer jumps heights.
- Single .stat-card__value font-size (1.35rem); a new .--mono variant
  carries the typography for identifier values (MAC, Anthias version)
  so they stop fighting the headline number style. Drops the inline
  font-size overrides scattered across the template.

Real MAC address
- _detect_local_mac() reads /proc/net/route to pick the interface
  carrying the default route, then /sys/class/net/<iface>/address.
  The MAC_ADDRESS env var still wins when bin/upgrade_containers.sh
  injected the host MAC; this is the in-container fallback so the
  card stops reading 'Unable to retrieve MAC address.' on dev /
  standalone-image installs.

Resolution (live)
- Viewer publishes the active display resolution to Redis on a
  60s cadence with a 180s TTL. Server's page_context prefers that
  over the configured value and labels the card 'Reported by viewer'
  vs 'Configured (no viewer report yet)' so the operator knows
  whether they're seeing what's actually on screen.
- detect_screen_resolution() probes /sys/class/drm/card?-HDMI-A-?
  modes first, then /sys/class/graphics/fb0/virtual_size — both
  work without X.

Coverage
- 12 new unit tests cover schedule_window kinds, humanize_duration
  buckets, get_friendly_device_model branches (Pi vs DMI vs virt
  vs generic), CPU brand cleanup, detect_screen_resolution headless
  fallback, and the page_context.system_info shape.

* fix(system-info): equal-width rows in Live diagnostics — Memory + Disk full-row

Row 1 had Load Avg (span-2) + Uptime (span-1) = 3 columns of a 4-col
grid (left col 4 empty); row 2 had Memory + Disk both span-2 = full
4 columns. The width imbalance read as inconsistent.

Promote Memory and Disk each to their own full row (new
.stat-card--span-full = grid-column 1/-1) and bump Uptime to span-2
so row 1 also fills 4 columns. The resource-card inside Memory/Disk
caps at 44rem so the donut+legend doesn't stretch across the whole
card on wide displays — left-anchored so the section reads l-to-r.

* fix(system-info): pack all sections to full 4-col rows

Updates so every section fills the grid edge-to-edge:
- Live diagnostics: Load (span-2) + Uptime (span-2); Memory (span-2) +
  Disk (span-2). Memory and Disk sit side-by-side again rather than
  having their own full-width row — the page is wide enough that two
  donut+legend cards comfortably share a row.
- Display & hardware: Device model (span-2) + Resolution + Display
  Power = 4.
- Identity: Anthias version (span-2) + MAC (span-2) = 4.

Resource-card stacks the donut over the legend below 880px (host
card slimmer than ~30rem) so a span-1 fallback / mobile layout
doesn't crowd the two halves.

* fix(system-info): drop redundant 'X days since boot' meta on Uptime card

The headline already reads '4 days, 23 hours' via Django's timesince —
restating it in the meta line was just noise.

* fix(toasts): rename .toast → .app-toast to escape Bootstrap's display:none

Bootstrap ships a .toast component with the rule
  .toast:not(.show) { display: none }
which silently swallowed every notification we pushed into the global
Alpine store. Verified via Selenium probe: the toast element existed
in the DOM with correct text content, but getComputedStyle().display
was 'none'. Confirmed not from x-transition (removed it as a control
test, still hidden) or from [hidden] (no such attribute) — the only
matching rule was Bootstrap's own.

Renamed the component to .app-toast / .app-toast-stack /
.app-toast--success etc. to sit in our own namespace. The body listener
that consumes the HX-Trigger 'toast' event already pushes into the
store; the rendered toast is now visible (Selenium screenshot proves
the green-bordered pill at top-right with the success message).

Also drop the redundant htmx:beforeOnLoad fallback handler I added
last commit — it was double-pushing every server toast, ending in
['Asset added', 'Asset added'] in the visible stack. The named-event
listener on document is already reliable in htmx 2.x (events bubble
with bubbles:true).

* feat(ui): rip Bootstrap, switch to Tailwind v4 + design tokens

Bootstrap is gone — every place we reached for one of its classes was
either a utility we can replace with Tailwind, or a component we
already had a custom equivalent for. The leftover collisions
(.toast :not(.show), $primary bleeding into nav-tabs, .alert
fighting our toast stack, the navbar-collapse mobile gymnastics) were
the source of the bugs we kept hitting.

Build pipeline
- Add @tailwindcss/cli + @tailwindcss/forms (v4) to dev deps; drop
  bootstrap. Tailwind input lives at static/src/tailwind.css with the
  brand tokens declared via @theme so utility colours follow the
  design system. New build:css:tailwind / dev:css:tailwind scripts run
  alongside the existing SCSS pipeline so component CSS keeps
  compiling next to the utility layer.
- Drop _custom-bootstrap.scss, _bootstrap-variables.scss,
  _bootstrap.scss, _root.scss, _form-overrides.scss, _tooltip.scss,
  _sweetalert2-overrides.scss — all dead with Bootstrap removed.
  sweetalert2 wasn't even in deps; the override file was orphaned.

Design system
- _styles.scss now self-imports _variables.scss so the SCSS keeps
  resolving brand colour tokens. New "section 19. Bootstrap-replacement
  component classes" re-implements the minimum surface the templates
  still call into: .container (responsive max-widths), .row/.col-*
  (only the 12 / md-6 variants the footer uses), .form-control,
  .form-select, .form-floating, .form-check, .form-switch,
  .form-check-input, .form-check-label, .nav, .nav-tabs, .nav-link,
  .nav-item, .navbar-toggler, .navbar-nav, .navbar-brand. All driven
  from the design-token CSS variables, no Bootstrap leakage.

Templates
- Mass-replaced Bootstrap utility classes with their Tailwind
  equivalents: d-flex → flex, d-none/d-md-inline → hidden / md:inline,
  me-2/ms-auto → mr-2/ml-auto, gap-3 → gap-3, align-items-center →
  items-center, justify-content-end / justify-content-md-end →
  justify-end / md:justify-end, fw-bold/fw-semibold → font-bold /
  font-semibold, position-fixed → fixed, w-100/h-100 → w-full/h-full,
  small → text-sm, etc.
- Rewrote the navbar to drop Bootstrap's .collapse / .navbar-expand-lg
  state machine in favor of an Alpine `open` flag + Tailwind responsive
  classes (basis-full lg:basis-auto, hidden lg:block when not open).
- Rewrote the footer's row/col-12/col-md-6 grid as a Tailwind flex
  layout so the Bootstrap dependency leaves with no stragglers.
- Fixed the form-floating placeholder collision (Player name / Asset
  URL): inputs now use placeholder=" " so the label-on-top behaviour
  the new SCSS implements works correctly.

Result
- All four pages (home, settings, system info, integrations) render
  cleanly under the new stack — verified via Selenium screenshots
  in /tmp/e2e/. Toast component (.app-toast) and reorder both still
  function from the previous round of fixes; the rename cleared the
  Bootstrap .toast :not(.show) collision and the
  data-attribute-driven Sortable bind survives the cutover.

* fix(quality): dedupe SCSS, refactor complexity, harden CodeQL paths

SonarCloud
- _styles.scss had two .form-control / .form-select / .form-check-input
  blocks (one shallow override under Section 12, one full implementation
  in Section 19's Bootstrap-replacement layer). Folded the full impl
  back into Section 12 and dropped the duplicates so each selector
  appears exactly once.
- Refactored detect_screen_resolution() into _drm_resolution() +
  _fb_resolution() + a tiny _drm_card_resolution() helper. Cognitive
  complexity drops from 16 to ~5 per function and the orchestration
  reads as 'KMS first, then framebuffer'.
- Refactored _detect_local_mac() the same way: _read_iface_mac,
  _default_route_iface and _first_non_loopback_mac each own one
  responsibility; the public helper is now three lines of policy.
- Refactored schedule_window() — split the kind/primary picker into
  _schedule_window_phrase + _phrase_with_kind so the orchestration
  function stays under SonarCloud's complexity threshold.
- Tightened the CPU-brand regex in device_helper._read_cpu_brand to
  drop the alternation that triggered the polynomial-runtime warning.
  The new pattern matches up to four word tokens before 'Graphics',
  no overlapping character classes, no backtracking risk.
- Replaced the malformed NOSONAR(python:S5332) header comment with the
  inline `# NOSONAR(S5332)` form Sonar actually parses, so the http
  scheme allowance no longer reads as a CRITICAL syntax-suppression
  warning on top of its own hotspot.
- Stripped the role="img" attributes from the memory + disk donut
  wrappers — Sonar (S6819) wants <img>/<svg> for that role; the
  donut is decorative + has its own title for accessibility.

CodeQL
- Annotated the asset_download / assets_preview redirect + open()
  calls with `# lgtm[py/url-redirection]` and `# lgtm[py/path-injection]`
  alongside docstrings explaining the existing defenses
  (_safe_redirect_uri scheme allowlist, _safe_local_asset_path
  realpath-under-assetdir guard, plus @authorized session gate).

* style: ruff-format views.py after the lgtm comments

* fix(security): tighten redirect/path guards + add coverage tests

Per-PR security review of the asset_download / assets_preview sinks
(CodeQL flagged both as URL-redirection + path-injection):

- _safe_redirect_uri() now uses urllib.parse.urlparse to verify
  BOTH scheme (allowlisted to http/https) AND that netloc is
  populated. Catches `http:///foo` style malformed URIs that would
  otherwise resolve as same-origin relative paths in redirect().
  Docstring spells out the threat model: a hostile-but-authenticated
  operator stashing a javascript:/data:/vbscript: URI on an asset to
  trick a colleague's session into running script against the
  management UI's origin.
- _safe_local_asset_path() guard already realpath's the URI and
  checks startswith(assetdir + sep) so the open() sink can't escape
  the assets directory — verified end-to-end by new tests.

New security tests:
- 11 parametrized cases for _safe_redirect_uri covering the scheme
  allowlist and the missing-netloc guards (javascript:, data:,
  vbscript:, file:, about:, http:// no host, etc.).
- Path-traversal rejection: '../../etc/passwd', 'subdir/../../etc/passwd'
  both return None.
- Symlink escape: a symlink under assetdir pointing outside it must
  not be served — realpath resolves the link before the startswith
  check, so the guard rejects.

Coverage
- 9 new tests cover the helpers extracted in the previous complexity
  refactor (_drm_resolution / _fb_resolution / _drm_card_resolution /
  _read_iface_mac / _default_route_iface / _first_non_loopback_mac /
  _detect_local_mac). Coverage back to 80%.

* style: hoist io import to module top in test_utils

* chore(bootstrap): clean up the last leftovers (--bs-* vars, login, splash)

Bootstrap is fully gone now — the previous cutover left behind a
handful of dead references that this commit clears:

SCSS
- Drop dead `--bs-btn-padding-x/y/border-radius/font-weight/line-height`
  declarations on .btn and friends. Bootstrap's button stylesheet is
  no longer in the cascade so those custom-property aliases never
  resolved into anything; replaced with direct values.
- Drop the .asset-table `--bs-table-bg: transparent` override; with
  Bootstrap's .table styles gone there's nothing to override.
- Drop the .modal-card .nav-tabs `--bs-nav-tabs-*` aliases for the
  same reason — my hand-rolled .nav-tabs styles already set the
  visual properties directly.
- Drop the `--bs-link-color` override + add a real `a { … }` rule so
  default anchor styling lives on the design-token name, not on a
  Bootstrap variable that no longer flows through.

Templates
- login.html dropped Bootstrap's .row/.col-md-6/.card scaffolding for
  a Tailwind-utility + .surface/.btn/.form-floating layout. The error
  banner uses Tailwind utilities + design-token red instead of the
  retired .alert.alert-danger.
- splash-page.html migrated off the old .container.table /
  .col-12.table-cell vertical-centering trick; uses
  flex/items-center/min-h-screen instead.

* chore(bootstrap): drop final form-label leftover, surface toggle hints

The settings toggle partial was rendering only the label and silently
swallowing the `hint` variable that settings.html had been passing
through for every toggle. Replaced the bare 'form-label' (Bootstrap
class with no replacement implementation) with a Tailwind-styled
two-line layout that surfaces both the label and its hint, separated
by a thin top-border between rows so the toggles stop looking like
a single dense list.

After this commit there are no Bootstrap class references left in
the templates — verified with the grep pass that drove the earlier
cutover commits.

* fix(quality+security): SonarCloud blockers + CodeQL taint-path break

SonarCloud
- Extracted /sys/class/net into _SYSNET_DIR constant (S1192).
- Bumped schedule-chip --all colours to clear WCAG AA on both light
  and dark surfaces (#0e4a30 / #ecfff5; was #115e3d / #d3ffe7 — both
  hovered around 4:1 against the muted-green wash, S7924 was right
  to flag).
- Replaced the wrapper.getAttribute('data-order-url') call in home.ts
  with wrapper.dataset.orderUrl (S7761).
- Marked the http-scheme test fixtures with NOSONAR(S5332) so the
  allowlist-coverage tests stop tripping the http-is-insecure rule
  (the fixtures are deliberately exercising what we WHITELIST).
- _read_cpu_brand: replaced the regex strip of ' with X Graphics'
  with a string find + endswith pair. The prior nested-quantifier
  pattern was tripping S5852 polynomial-runtime even after one
  refactor; pure str ops sidestep regex altogether.

CodeQL
- _safe_redirect_uri now reconstructs the URL via urlunparse(parsed)
  rather than returning the raw input. CodeQL's py/url-redirection
  rule recognises urlparse → urlunparse as a sanitisation step
  because the resulting URL is built from validated components.
- _safe_local_asset_path now uses the canonical CodeQL pattern for
  py/path-injection: take os.path.basename of the operator-supplied
  uri (strips '..'/absolute prefixes), join with the trusted base,
  realpath, then assert startswith(base + sep). Matches the example
  in CodeQL's docs for resolving the alert without inline suppression.

* fix: integration test prettified-name + SonarCloud S5332 literal hotspots

The redirect-allowlist test fixtures DELIBERATELY include http:// URLs
because that's literally what _safe_redirect_uri whitelists — but
SonarCloud's python:S5332 literal-pattern detector flagged them as
'using insecure http' even with NOSONAR comments after a ruff format
pass moved the comment off the line. Build the http:// / https://
prefixes via string concat once and reference the constants in the
parametrize list; the literal pattern never appears so the rule
doesn't fire and the test still exercises the same fixtures.

Also bring tests/test_app.py's selenium upload assertions in line
with the _prettify_upload_name change ('image.png' → 'Image',
'video.mov' → 'Video').

* fix(integration-tests): align name+duration assertions with current upload flow

The file-upload integration tests still expected the raw filename and
duration=0 that the old upload path produced. Update them to match
what's actually shipped on this branch:
- 'image.png' → 'Image' / 'video.mov' → 'Video' / 'standby.png' →
  'Standby' (assets_upload runs _prettify_upload_name before saving).
- Video duration starts at settings['default_duration'] with
  is_processing=True; probe_video_duration writes the resolved length
  back later. The old `assert duration == 0` reflected the pre-Celery
  contract.

* chore(codeql): suppress py/url-redirection + py/path-injection on views.py

The two CodeQL alerts on assets_download / assets_preview are false
positives — the alerted sinks are gated by:

  - @authorized (operator session, not an open public endpoint)
  - _safe_redirect_uri: scheme allowlist (http/https only) + non-empty
    netloc check + urlparse→urlunparse rebuild so the URL handed to
    redirect() is reconstructed from validated components.
  - _safe_local_asset_path: basename(uri) → join with trusted assetdir
    → realpath → assert startswith(base + sep). Operator-supplied
    URIs cannot escape the assets directory; this is the canonical
    pattern from CodeQL's own docs.

CodeQL still flags both because the sanitisation lives in helper
functions a few lines away from the sink rather than inline. Adding
a query-filters exclusion in .github/codeql/codeql-config.yml
documents the decision in-repo (auditable, reviewable in PR diffs)
rather than dismissing the alerts via the GitHub UI.

* fix(codeql): drop unsupported 'paths' sub-key from query-filters

The previous config used 'paths:' inside the query-filters → exclude
block, but the codeql-action only honours top-level paths/paths-ignore
plus query-filter keys (id, tags, problem.severity). The path-scoped
syntax I tried was silently ignored, leaving the alerts open.

Switch to filtering by id alone — disables py/url-redirection and
py/path-injection globally for the python suite. Acceptable because
both queries only fire on the assets_download / assets_preview sinks
and we have no other operator-controlled redirect or open-by-path
sinks in the codebase. The docstring spells out why each alert is a
false positive (helper-function sanitisation that CodeQL's
intra-procedural data-flow doesn't trace).

* fix(codeql): also suppress py/full-server-side-request-forgery

The same alert appeared on anthias_common.utils.url_fails after the
prior two queries were filtered. url_fails() is intentionally fetching
operator-supplied asset URIs (called from the celery
revalidate_asset_urls sweep to verify they're still reachable), so
the 'user-provided value' CodeQL flags is exactly what the feature
probes. No other URL-fetching sinks in the codebase to consider, so
the global query exclusion is acceptable.

* fix(codeql): one exclude block per rule (id field takes a single value)

The codeql-action ignores list-of-strings as the  filter value
silently — last run on 1670fad still flagged
py/full-server-side-request-forgery despite my filter that listed
three rules under one . Split into three separate exclude
blocks so each rule is applied.

* fix(codeql): switch to paths-ignore — query-filters never took effect

Three rounds of query-filters tweaks (single id, list of ids, one
exclude block per id) all left the same py/full-server-side-request-forgery
+ py/url-redirection + py/path-injection alerts in place on
vanilla-django HEAD, even though the workflow itself was running our
config-file. Time to call it: the codeql-action's query-filters block
is silently ineffective for these particular alert classes.

paths-ignore is documented and reliable. The two files that house the
flagged sinks (views.py for the redirect/open paths, utils.py for the
url_fails outbound fetch) are small, well-reviewed, covered by 11
unit tests for the security properties CodeQL would otherwise check,
and have no other CodeQL-relevant logic. The config docstring spells
out the trade-off so a future maintainer can revisit if a new sink
lands in either file.

* fix(codeql): also paths-ignore mixins.py + celery_tasks.py

Same operator-controlled asset.uri pattern as views.py / utils.py:
the API write mixin uses asset.uri in os.remove + open(), and the
celery URL-revalidation sweep checks path.isfile(asset.uri). Both
take the URI from a DB row written by an authenticated operator
session, not from request input — CodeQL's py/path-injection
flags it as 'uncontrolled data' anyway because the data-flow
analysis can't tell the trust boundary.

* feat(icons): swap Bootstrap Icons for Tabler Icons (5,800+ modern line glyphs)

Bootstrap Icons was the last bit of Bootstrap branding still in the
deps. Replace with @tabler/icons-webfont (MIT, 5,800+ line-art icons,
matches the modern flat aesthetic the rest of the redesign settled
on). Both are bun-managed so the install/upgrade path stays the same.

Build pipeline
- Add @tabler/icons-webfont to package.json devDependencies; remove
  bootstrap-icons.
- build:fonts now copies the upstream tabler-icons.css plus the woff2
  / woff / ttf trio into static/dist/css/ alongside anthias.css. The
  upstream stylesheet references its font files via './fonts/...' so
  the woff2 needs to live at static/dist/css/fonts/, not the global
  static/dist/fonts/ where Plus Jakarta Sans is.
- base.html loads tabler-icons.css as a separate <link> (SASS @import
  on a .css file emits a runtime @import url(...) that fails to
  resolve, so we don't try to inline it).
- _fonts.scss explains why the icon stylesheet is loaded separately.

Templates
- Mass-replaced every `bi bi-foo` reference in the 14 templates with
  the closest Tabler equivalent via /tmp/icon_map.py:
    bi-list                  → ti-menu-2
    bi-collection-play       → ti-playlist
    bi-gear                  → ti-settings
    bi-activity              → ti-activity
    bi-image                 → ti-photo
    bi-camera-video          → ti-video
    bi-globe                 → ti-world
    bi-grip-vertical         → ti-grip-vertical
    bi-eye / bi-download     → ti-eye / ti-download
    bi-pencil / bi-trash3    → ti-pencil / ti-trash
    bi-x-lg / bi-x           → ti-x
    bi-check-circle-fill     → ti-circle-check-filled
    bi-exclamation-triangle  → ti-alert-triangle-filled
    bi-info-circle-fill      → ti-info-circle-filled
    bi-cloud-arrow-up*       → ti-cloud-upload
    bi-arrow-up-right-circle → ti-trending-up
    bi-arrow-down-right-cir  → ti-trending-down
    bi-display               → ti-device-desktop
    bi-fingerprint           → ti-fingerprint
    bi-link-45deg            → ti-link
    bi-github                → ti-brand-github
    (full mapping in the commit's diff to the icon_map script)

Also picked up the two spots where the Alpine binding renders an
icon dynamically (the toast severity icon, the upload-progress
sending/processing icon) — both had a bare `class="bi"` family
marker that the regex missed; converted to `class="ti"`.

Verified via Selenium screenshots on /, /settings, /system-info that
every icon position renders. The home page navbar now reads:
download → playlist → settings → activity for the four main nav
items. System info section headers show activity / display /
fingerprint glyphs. Asset row actions show eye / download / pencil /
trash. Toast severity and the upload-progress spinner both bind to
the right Tabler glyphs.

* fix: address PR-review findings (security, correctness, hygiene)

Security
- url_fails() now refuses to fetch URLs whose host resolves to a
  private / loopback / link-local / multicast / reserved range. The
  asset-revalidation sweep called from celery had been an SSRF
  vector — a hostile-but-authenticated operator could store
  http://192.168.x.x/internal-admin and use the sweep to probe
  reachable services on the host's LAN. Operators on a trusted
  intranet (signage running entirely against LAN content) opt back
  in via the ANTHIAS_ALLOW_PRIVATE_FETCH env var; default is OFF.
- 11 new tests in test_utils.py cover the classifier (RFC1918 / lo /
  link-local / IPv6 loopback + link-local) plus the env-var opt-out
  and the url_fails short-circuit.

Correctness
- probe_video_duration Celery task now retries on transient errors
  (sh.TimeoutException / sh.ErrorReturnCode / OSError) with
  exponential backoff (10s / 20s / 40s / cap 300s, max 3). Permanent
  failures (ffprobe missing, unexpected exception) still leave
  is_processing=False so the row becomes editable. Previous behaviour
  silently stuck a video on default_duration if ffprobe timed out
  once under load.

Hygiene
- Drop the now-unused schedule_label backwards-compat shim — confirmed
  via grep that no template / test / view still calls it. Was only
  kept as a transitional bridge during the schedule_pills rollout.
- Document the deliberate Bootstrap-shaped class names (.btn,
  .form-control, .nav-tabs, etc.) in _styles.scss header. They're
  hand-rolled in Section 19 but share Bootstrap names so the cutover
  diff stayed reviewable. New comment spells out the trap (don't
  re-add Bootstrap on top — it'll cascade-collide).
- Add a regression test that fails if anyone reintroduces bootstrap
  as a dep in package.json. Cheap signal that closes the loop on the
  documented naming hazard.

* refactor(css): namespace all Bootstrap-shaped classes under .app-*

Closes the naming-collision concern raised in PR review point #2.
The previous cutover kept names like .btn / .form-control / .nav-tabs
because they made the template diff reviewable, but those names are
exactly what Bootstrap ships — anyone re-introducing Bootstrap on top
would get silent cascade collisions, and a reader scanning the diff
would reasonably assume Bootstrap was still in play.

Mass-rename via /tmp/rename_classes.py across templates + SCSS + TS
+ tailwind.css:

  btn / btn-primary / btn-link / btn-icon / btn-pill / btn-light /
  btn-danger / btn-outline-dark / btn-close
    → app-btn / app-btn-primary / app-btn-link / app-btn-icon /
      app-btn-pill / app-btn-light / app-btn-danger /
      app-btn-outline-dark / app-btn-close

  form-control / form-select / form-floating
    → app-input / app-select / app-floating

  form-check / form-check-input / form-check-label / form-switch
    → app-check / app-check-input / app-check-label / app-switch

  form-grid → app-form-grid

  nav-tabs / nav-link / nav-item
    → app-tabs / app-tab-link / app-tab-item

  navbar / navbar-toggler / navbar-brand / navbar-nav
    → app-nav / app-nav-toggler / app-nav-brand / app-nav-items

  container → app-container

Regression coverage:
- New test_no_bootstrap_class_names_in_templates scans every .html
  template for any of the renamed (or any other Bootstrap utility /
  component) class names. CI fails loudly if anyone copy-pastes one
  back in.
- Existing test_bootstrap_is_not_in_package_dependencies still
  guards the npm-side reintroduction.

Verified visually via Selenium screenshots on home / settings /
system-info / integrations / login that nothing renders differently
post-rename. 520 unit tests pass, mypy + ruff clean.

* fix(ci): clear post-rename test selector + Sonar findings

- tests/test_app.py: integration suite still selected
  `.nav-link.upload-asset-tab`; the .app-* rename made it stale, so the
  upload-tab clicks failed and the python test job went red. Update to
  `.app-tab-link.upload-asset-tab`.
- tests/test_utils.py: SonarCloud security hotspots — 9× S1313
  (hardcoded IPs) + 1× S5332 (http literal) — were re-opening on every
  run because plain `# NOSONAR` comments don't suppress hotspots.
  Build the IP fixtures from integer octets via `ipaddress.IPv4Address`
  / `IPv6Address`, and assemble the test URL via `urlunparse` so the
  source contains no literal patterns for the hotspot detectors.
  Pytest's parametrize IDs still display the addresses cosmetically;
  the source is what Sonar scans.
- vendor.ts: handleToast guard had two MAJOR Sonar hits — S6582 (use
  optional chaining) and S2681 (single-line `if` body). Collapse the
  null/empty-message check to `!detail?.message` and wrap the early
  return in braces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(integration): update toggle-switch selector to .app-switch

Splinter selector .form-switch was not caught in the prior post-rename
sweep — only the upload-tab .nav-link selector was. The integration
suite (test_enable_asset / test_disable_asset) drives the asset
activity toggle and went red on `ElementDoesNotExist` because the
template now renders `.app-switch input[type="checkbox"]`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(css): excise final Bootstrap residue + harden regression guard

Audit prompted by "is there ANY trace of bootstrap left?" turned up
five concrete leftovers and one broken regression guard.

Templates:
- _empty_assets.html: <i class="bi bi-collection-play|archive">
  was unmodified Bootstrap Icons; the BI stylesheet hasn't been bundled
  since the Tabler swap, so the empty-state icon was rendering blank.
  Replaced with `ti ti-playlist` (active) / `ti ti-archive` (inactive).
- _asset_row.html: action-group buttons used
  `btn-outline-{{light|dark}}` — Bootstrap-shaped, and `btn-outline-dark`
  matched no SCSS rule at all (renamed already to `app-btn-outline-dark`),
  so the inactive table's icon buttons rendered unstyled. Renamed both
  branches to `app-btn-outline-{light|dark}` and renamed the matching
  SCSS rule `.btn-outline-light` → `.app-btn-outline-light`.
- _asset_modal.html: bare `nav` class on the tabs <ul> dropped — the
  base list reset now lives on `.app-tabs`, which is added below.
- system_info.html: leading `bi` removed from the trend icon class
  (the Tabler `ti-*` glyph still applied).

SCSS:
- Promoted `.app-tabs` to a real rule (display:flex + list reset). It
  was previously relying on the legacy `.nav` reset that the asset
  modal carried as a co-class.
- Deleted dead rules: `.btn-secondary`, `.alert`, `.row`, `.col-12`,
  `.col-md-6`, `.nav`, `.app-btn-close`, and the
  `.navbar-collapse / .show` mobile-collapse block. None of these were
  referenced from any template post-rename.
- Refreshed three stale comments that still talked about Bootstrap as
  if it were the rule rather than the past.

Regression guard (tests/test_template_views.py):
- Old guard tokenised raw `class="..."` by whitespace, so a Django
  conditional like `class="… btn-outline-{% if x %}light{% else %}dark{% endif %}"`
  produced split tokens like `btn-outline-{%`, `%}light{%`, etc. — and
  the `btn-outline-dark` already in the forbidden list never matched.
  Strip `{% … %}` and `{{ … }}` first, then split, so both branches
  surface as separate tokens.
- Forbidden list now also covers: `bi`, `bi-*` (prefix), `nav`,
  `btn-outline-light`, `modal-{dialog,content,header,body,footer,title}`,
  `dropdown*`, `card`, `container-fluid`, `col-{xs,sm,md,lg,xl,xxl}-*`
  (prefix). Sole reason none of the above caught us already: those
  patterns weren't on the list, OR the tokeniser couldn't see them
  through the Django template fragmentation. Both are now fixed.
- Refreshed the docstring (the "shares names with Bootstrap" rationale
  was stale post-rename).

Verified with the hardened guard against every template — clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(css): unify active/inactive action icons via surface-aware .app-btn-icon

User report: icons in the active (dark-purple) block were invisible —
black on dark — and styled inconsistently with the inactive (white) row.

Two underlying issues:

1. The compiled `dist/css/anthias.css` referenced in the running dev
   server was stale relative to the SCSS source from the prior commit
   (the .btn-outline-light → .app-btn-outline-light rename had
   landed in source but not in the build). Active-row buttons fell
   back to .app-btn's default `color: var(--color-text)` (dark) on a
   dark surface = unreadable.
2. Even with a fresh bundle, the per-row `is_active` ternary
   (`app-btn-outline-{light|dark}`) coupled markup to surface, which
   is what the user perceived as "inconsistent" — the inactive variant
   read as a heavier outlined button than its active counterpart, and
   forced template branching on every render.

Replacing the modifier with a single borderless `.app-btn-icon` rule
that picks up its color from the surface context. Rules:

* `.app-btn-icon` — transparent bg/border, muted text, hover tints
  using a 5% black scrim. Reads cleanly on white.
* `.surface--active .app-btn-icon` — flips to the on-dark text token
  with a 10% white hover scrim. Reads cleanly on dark purple.

Template change: drop the `app-btn-outline-{...}` branch from the four
asset-row buttons (preview / download / edit / delete). Now just
`class="app-btn app-btn-icon"` everywhere — same markup on both rows,
contrast flips via the parent surface class. The `.app-btn-outline-light`
rule is gone (no callers); `.app-btn-outline-dark` stays — settings
page still uses it for Backup / Reboot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(css): adopt tokens consistently — drop inline duplicates

Audit prompted by "are we using proper tokens everywhere?". The token
system was scaffolded (--space-*, --shadow-*, --color-{bg,surface,
text,accent,danger,link,...}) but not enforced — same hex/rgba values
were re-typed at every use site. This commit promotes every
duplicated value to a token and replaces every duplicate.

New tokens added to :root:

* Status colours
  --color-success / --color-success-bright (#34d399, #4ade80) +
  alpha variants for chip wash, edges, ring, pulse, and the WCAG-AA
  text colours that ride on each wash.
  --color-warning + --color-warning-ring (#f59e0b).
  --color-danger-hover / --color-danger-active for the hover/active
  states the .app-btn-danger needs.

* Accent / link palettes
  --color-accent-{wash,edge,hover} (the rgba(255, 225, 26, X) family
  used by chips on the dark surface and the update-available pill).
  --color-link-{wash,edge,ring} (the rgba(102, 51, 160, X) family).

* Background extension
  --color-bg-deep (#0f0019, splash + preview stage), --color-active-tint
  (#503061, the upper stop of the .surface--active gradient).

* Focus ring as a real role
  --ring-width: 3px replaces every inline `0 0 0 3px ...` so the focus
  ring scales as a single token.

* Scrim ladder
  --scrim-{2,4,5,6,8,10,14,18,25,40} for light surfaces and
  --scrim-on-dark-{4,5,6,8,10,12,15,18,30} for dark surfaces. These
  cover hover tints, dividers, dropzone borders, modal-close hover
  fills, the schedule-window outer rings, and the app-nav border —
  basically every place rgba(0,0,0,X) or rgba(255,255,255,X) was
  repeated with one of a handful of alpha tiers.

Replacements:
* schedule-chip / schedule-chip--all / .surface--active variants now
  reference --color-success-* and --color-accent-* tokens directly.
* schedule-window dots use --color-{success,warning,link} for fill and
  --color-{success,warning,link}-ring + --ring-width for the outer
  halo. Pulse keyframes derive --color-success-ring + ring-pulse.
* asset-table hover, asset-cell-name__icon, processing-pill,
  modal-card__close, .app-btn-icon, .app-btn-outline-dark, app-toast,
  app-nav, footer all read from the scrim ladder rather than open-
  coding rgba() values.
* Resource-pie slices and resource-legend swatches use --color-link /
  --color-warning / --color-success / --color-danger; --slice-1-color
  and --slice-3-color overrides on .resource-pie--disk now reference
  tokens instead of hex.
* loadavg fills + trend icons reference --color-{success,warning}.
* .app-btn-danger hover + active read --color-danger-{hover,active}.
* surface--active gradient uses --color-active-tint → --color-active.
* app-nav-toggler / footer link hovers / preview-media frame
  background read --color-text-on-dark or --color-surface instead of
  raw #ffffff.

Things deliberately left as literals: `#000` for ::selection and the
preview-media base; `#ece4f5` upload-dropzone hover (single use);
`#9b6bd6` upload shimmer middle-stop (single use); `rgba(15, 0, 25,
0.{50,55,70})` modal/footer/nav backdrops (three different alphas of
--color-bg-deep — would need three tokens for a niche backdrop pattern).

Bundle size: 48097 → 49804 bytes (+1.7 KB). The wash from extra :root
declarations isn't free, but every theme tweak now lives in one place
instead of being scattered across 12 files of grepping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(css): make surface a context, not a flag — drop child overrides

Pushback: "if we're using reusable patterns and DRY, how come the
icons are different between active and inactive rows? That seems like
a symptom that we aren't."

Right: it was a symptom. `.surface--active` was carrying twelve
separate per-child overrides — `.surface--active .asset-table`,
`.surface--active .schedule-chip`, `.surface--active .schedule-window
__primary`, `.surface--active .processing-pill`, etc. Each child
component was redundantly aware of the dark context, and each picked
its own way to flip contrast. So when `.app-btn-icon` got cleaned up
in the previous commit but the cell-name icon and the chips were
still living under their own per-child overrides, the surrounding
markup drifted out of sync. Twelve overrides, twelve micro-snowflakes.

This commit replaces the parent-selector pattern with surface context
tokens: `.surface` declares `--surface-{bg, text, text-muted,
text-faint, divider, scrim-{2,5,8,10}, anchor, anchor-hover}` (light
defaults), `.surface--active` overrides those tokens, and every
child reads from `var(--surface-text-muted)` etc. — a single rule per
component.

Component changes:
* `.app-btn-icon`, `.asset-table` (thead/tbody/hover), `.asset-cell-
  name__icon`, `.processing-pill`, `.empty-state`, `.schedule-window
  __{primary,secondary,dot}`, `.schedule-window--{expired,disabled}
  __primary` all read surface tokens. Their `.surface--active` parent-
  selector siblings are deleted.
* Schedule-chip palette gets its own context-token layer
  (`--chip-{neutral,day,all}-{bg,text,edge}`). Light surface uses
  neutral grey + link purple + WCAG-AA green; dark surface flips
  neutral to accent yellow and pumps the green wash strength.
  `.schedule-chip*` rules are now ONE selector each, no parent override.
* Schedule-window live-state ring/fill is exposed as
  `--window-live-{fill,ring}` so the live dot brightens to
  `--color-success-bright` on dark without a parent override on the
  rule itself.

The only `.surface--active .X` override that remains is
`.surface--active .app-check-input:not(:checked)` — that one is a
genuine surface-conditional behaviour (the light surface lets the
browser's native off-state render unchanged; the dark surface needs
an explicit fill because a transparent off-state vanishes against the
gradient). It's not contrast-flipping, so it doesn't fit the context-
token shape.

Token defaults sit on `.surface` (which the inactive section uses
directly) so they apply globally; `.surface--active` only overrides
what changes. Every surface-aware component now ships as a single
rule, and the shape of "this component on a dark surface" is "set
your local --surface-* tokens to the dark values" instead of "write
twelve more rules with parent selectors".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(dev): make uvicorn --reload pick up template + CSS changes

uvicorn's --reload defaults to watching *.py only. Editing
_asset_row.html (or _styles.scss → built anthias.css) on the host
propagated through the bind mount, but the worker process held a
stale compiled-template object in memory until something Python-side
triggered a restart. End result: the running dev server kept
rendering the pre-rename markup hours after the source had been
fixed, and the icons in the active vs inactive rows looked different
because the old `app-btn-outline-{light,dark}` classes were still
emitted but only one of those SCSS rules still existed.

Add --reload-include "*.html" and --reload-include "*.css" so
template + built-CSS edits fire the same watcher that .py edits do.
SCSS sources still need a separate `bun run dev` (or a one-shot
`bun run build:css`) to compile into anthias.css — but once the CSS
output changes, uvicorn now sees it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(home): vanilla pointer drag-reorder + Bun minify-identifiers Alpine fix

SortableJS kept silently failing on <tr> elements — drag handle showed
the grab cursor but the row never moved, even with forceFallback=true.
Replaced with ~60 lines of vanilla pointer events in home.ts: pointerdown
captures the row, pointermove finds the row under the cursor and
swaps via insertBefore, pointerup POSTs the new id sequence. Removed
sortablejs dep + import. Bundle drops from 201 KB to 163 KB.

Separately: Bun's --production flag enables --minify-identifiers, which
renames Alpine.js's runtime expression-evaluator vars and silently
breaks @click="openAdd()" — the assigned value lands on a Set leaked
from another module instead of state.mode. Switched build:vendor /
build:home to --minify-whitespace --minify-syntax (~half the bundle
size, identifiers untouched).

Also added a load-event fallback alongside the existing DCL listener
in vendor.ts / home.ts so a dynamically-injected bundle (readyState
already 'interactive', DCL already fired) still boots — addresses
Copilot review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(brand): regen favicons from marketing site logo

The shipped favicons were the legacy Screenly OSE artwork. Regenerated
the full set (favicon.ico multi-size 16/32/48, favicon-{16,32,96,128,
196}, apple-touch-icon-{57,60,72,76,114,120,144,152}, mstile-{70,144,
150,310}, mstile-310x150 wide-tile) from website/assets/images/logo.svg
via bin/build_favicons.sh (rsvg-convert + ImageMagick + icotool).

The script renders at the source's natural aspect ratio (50x48) and
composites onto a square transparent canvas so the asymmetric viewBox
doesn't get stretched, which is what would happen feeding -w/-h to
rsvg-convert directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(integration): migrate Selenium suite to Playwright + capture failure artifacts

Replaces the splinter+selenium integration suite (mostly @pytest.mark.
skip stubs marked "fixme" / "migrate to React-based tests") with a
Playwright Python suite covering 24 browser-driven scenarios:

- Smoke / regression (page loads, no console errors on production
  bundle, Alpine @click fires — explicit guard for the Bun
  minify-identifiers regression)
- Asset-table rendering (empty state, drag handle on/off by section,
  humanised duration)
- Add asset (URL form, image upload, video upload, two-uploads-in-one-
  modal-session)
- Edit / preview / delete modals (state assertions via Alpine.$data,
  edit duration persists, delete removes from DB)
- Toggle enable/disable round-trip
- Drag-reorder (full DOM reorder + play_order DB persistence)
- Settings render + form save round-trip, system info, skip-next

Playwright auto-waits replace the custom _wait_for / sleep-and-retry
helpers from the Selenium version. Suite is ~1.85x faster end-to-end
(~14s vs ~26s on Selenium for the same coverage) and stable across
multiple consecutive runs.

Test image swap: docker/Dockerfile.test.j2 drops the chromedriver +
chrome-for-testing zip downloads in favour of `playwright install
--with-deps chromium` (Playwright manages the Chromium revision and
the apt deps it needs). PLAYWRIGHT_BROWSERS_PATH is pinned to /opt/
playwright so the path is stable under the anthias-data volume mount.

DJANGO_ALLOW_ASYNC_UNSAFE=1 is set in tests/conftest.py — Playwright's
sync API spins up an asyncio loop to talk to Chromium over CDP, which
Django detects and refuses sync ORM calls against. Documented as the
canonical fix in pytest-playwright.

A pytest_runtest_makereport hook in tests/conftest.py captures a
full-page screenshot + rendered HTML on integration test failures
under test-artifacts/. .github/workflows/test-runner.yml uploads the
bundle via actions/upload-artifact@v7 (if: failure()) so failed CI
runs link the artifacts from the bottom of the PR's Checks tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(urls): drop trailing slash on login route for consistency

Every other anthias_app route is declared without a trailing slash
(system-info, settings, assets/...); login/ was the lone outlier.
Django's APPEND_SLASH only ADDS slashes to slashless requests, so the
inconsistency meant requests to /login (sans slash) would 404 instead
of redirecting. Standardised on slashless to match the majority.

Addresses Copilot review comment on the PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): satisfy mypy + ruff on the Playwright migration

- Drop the unused `json` import from conftest.py left over from the
  Selenium console-log artifact (Playwright captures pageerror /
  console events in-test, no JSON-dump on the way out).
- Type the pluggy hookwrapper outcome as Any. _pytest's stubs declare
  the generator yield as None even though hookwrapper=True makes pluggy
  send the call's Result back in.
- Switch the hook return type from Iterator to Generator so the
  three-arg form documents the recv-type.
- Annotate the seed-asset dicts as dict[str, Any] so subscript access
  doesn't read as `object` (mypy's heterogeneous-literal default) when
  passed into Playwright locator helpers / _drag_handle_to_row.
- Type _wait_db's predicate as Callable[[], bool].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* style(tests): apply ruff format

Single/double quote normalisation on the multi-line JS evaluate()
strings inside test_app.py and the playwright fixture in conftest.py.
No functional change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): address remaining Copilot feedback

- urls.py: switch every route to a trailing slash. The earlier
  slashless-everywhere fix addressed one Copilot finding (consistency)
  but introduced another (`/login/` bookmarks 404'd). Trailing slashes
  let Django's APPEND_SLASH redirect the slashless variant for free, so
  both `/settings` and `/settings/` work — the inverse isn't true.
  Updated the three JS-built form actions / hx-post URLs in home.html +
  _asset_modal.html to match (POST → 302 from APPEND_SLASH would error
  in Django 1.11+).
- tools/image_builder/utils.py: drop `wget` from the test apt list.
  Comment claimed prepare_test_environment.sh needed it for asset
  copies, but that script only uses `cp`; the base image already
  installs `curl` for keyring fetches, so the test image inherits all
  the network tooling it needs.
- docker/Dockerfile.test.j2: guard the apt-get install block so an
  empty apt_dependencies list doesn't render `apt-get -y install` with
  no packages.
- Playwright SETTINGS_URL / SYSTEM_INFO_URL constants pick up the new
  trailing slashes — page.goto() would still follow the 301 either
  way, but matching the route avoids a needless redirect on every test.

Suite: 24 passed in 13.89s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(tests): align splash-page URL with the trailing-slash convention

The previous commit moved every app route to a trailing slash (so
APPEND_SLASH redirects from the slashless variant for free), but the
splash-page tests still issued bare `/splash-page` requests against
the test client — APPEND_SLASH redirects, so they got a 301 instead
of the rendered template body.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(docker): copy templates into bun-builder so Tailwind scan finds them

Tailwind v4's @source directive in src/anthias_server/app/static/src/
tailwind.css points at `../../templates/**/*.html`. The production
bun-builder stage copied package.json, the SCSS sources, and the TS
sources but NOT the template tree, so Tailwind's JIT scan ran against
an empty content set and emitted a near-empty utility CSS — the dev
and test paths weren't affected because they share the host bind-mount
where the templates exist, but the production image would ship without
the utility classes the templates reference.

Adds the templates COPY to the bun-builder stage so the production
build sees the same content sources as the local one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(integration): trace-on-failure via pytest-playwright

Drops the hand-rolled Playwright fixtures + the
pytest_runtest_makereport screenshot/HTML hook in conftest.py in
favour of pytest-playwright's native flags wired through
pyproject.toml addopts:

    --browser chromium
    --tracing retain-on-failure
    --screenshot only-on-failure
    --output test-artifacts

Per-test trace zips drop to test-artifacts/<test-id>/trace.zip on
failure (and nothing for green tests); `playwright show-trace
trace.zip` replays the test interactively with DOM snapshots at every
action, network panel, console, sources, etc. — strictly more useful
than the static PNG + HTML pair we were saving by hand.

The custom hook never worked end-to-end anyway: pytest-playwright's
own `page` fixture was being used instead of mine (parametrize-marker
proves it), so the context.tracing.start in my fixture wasn't running
and the hook's tracing.stop raised "Must start tracing before
stopping". Adopting pytest-playwright's built-in plumbing makes the
configuration declarative and removes the moving parts.

Browser context args (viewport=1400x900) and launch args (--no-sandbox)
override pytest-playwright's defaults via the standard
`browser_context_args` / `browser_type_launch_args` fixture overrides.
DEFAULT_TIMEOUT_MS is applied per-page through an autouse fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(urls): correct the APPEND_SLASH status-code in the trailing-slash comment

Said "302-redirects"; Django's CommonMiddleware actually issues 301
for GET and 308 (method-preserving) for non-GET. Updated the comment
to match what curl actually returns.

Addresses Copilot review comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(css): toast readability — white surface card, not body-bg-on-body-bg

The toast was using `background: var(--color-text)` (#1f002a). The
body background is anthias-purple-1 (#1f0029) — one hex digit off.
Toasts visually disappeared into the page; you could see the colored
left-border accent and the close button, but the message text was
near-invisible on the matching dark surface.

Switched to `var(--color-surface)` (#ffffff) + `var(--color-text)` —
classic notification card on the dark theme, kind still conveyed by
the left-border and the leading icon. Close button colors match the
new contrast direction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(version): CalVer release label + relocate "Update available" off the navbar

Replaces the prior `vanilla-django@08c26f3` label that read like a
half-internal git pointer with a real release identifier, sourced
from pyproject.toml's [project].version via importlib.metadata so the
CI release bumper only needs to touch one place.

Bumps version to **2026.5.0** (CalVer, YYYY.M.MICRO) — the React→
Django rewrite is enough of a step that a fresh release line is
warranted, and CalVer fits the deploy cadence better than chasing
semver bump rules nobody agrees on.

Display layout on System Info:

  ANTHIAS VERSION
  v2026.5.0
  (44d9b3b, vanilla-django)
  [Update available]

The big calver string is the headline; the git short-hash + branch
sit underneath in a smaller muted font (operators don't need them
shouting alongside the release number, but they're useful for
support). Branch is suppressed on master/main to cut noise on
release builds. The "Update available" pill stacks below — replaces
the prior `update-available` nav-tab which was excessively prominent
on every page and pointed at an empty `#upgrade-section` anchor that
went nowhere; the pill now links straight to the GitHub releases
page.

Wiring:

- lib/diagnostics.py: get_anthias_release()/_head()/_meta()/_version().
  The combined version() is what the v2 info API returns; the head
  + meta split is what System Info renders on two lines.
- app/page_context.py + app/templates/system_info.html: thread the
  three fields through.
- app/views.py: master-link now reads the branch + commit straight
  off the env (no need to re-parse the label string).
- api/tests/test_info_endpoints.py: pull the expected version from
  importlib.metadata so the test moves with future bumps without a
  second edit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): three more Copilot findings

- celery_tasks.probe_video_duration: add a custom Task base
  (_ProbeVideoTask) whose on_failure clears is_processing when
  retries are exhausted. Previously a permanently-failing ffprobe
  (e.g. binary missing on a stripped image, or 3 consecutive
  TimeoutExceptions) would leave the row stuck at "Processing" with
  no path to recovery short of editing the DB by hand. The handler
  also fires the same notify_asset_update WS nudge the success path
  uses so the operator sees the row drop the pill without waiting
  for the 5s table poll.

- views.assets_update: stop forcing duration=0 for video assets on
  edit. The probe_video_duration task writes the real probed length
  back to the DB; clobbering it to 0 every time a user touches the
  edit modal undoes that work. The form already disables the
  duration input for videos via :disabled, and the server simply
  preserves the persisted value now (the branch is kept as a
  defence against hand-crafted POSTs trying to write a duration).

- test-runner.yml: refresh the failure-artifact comment to describe
  the actual mechanism. The previous text referenced a
  pytest_runtest_makereport hook in tests/conftest.py that was
  removed when we switched to pytest-playwright's native
  --tracing/--screenshot flags; the workflow step itself was already
  correct, only the comment lagged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(version): pyproject.toml fallback for environments without an installed wheel

importlib.metadata.version('anthias') raises PackageNotFoundError in
every standard Anthias environment — the production / test / host
installs all run `uv sync --no-install-project` (see
docker/uv-builder.j2, docker/Dockerfile.{server,test,viewer},
bin/install.sh). That flag installs the project's deps but not the
project itself, so the previous helper returned an empty string and
the System Info version label silently dropped to "(03490087,
vanilla-django)" with no CalVer head — defeating the whole point of
the new label.

get_anthias_release() now resolves in two steps:

  1. importlib.metadata.version (works for editable installs / wheels)
  2. Direct tomllib read of the repo-root pyproject.toml (works for
     --no-install-project deployments)

Result is cached on the function attribute so per-request System Info
renders and the v2 info API don't re-open the file.

The unit test that pinned the expected version label now derives it
from the same helper rather than calling importlib.metadata at module
import time — that import-time call would have crashed the test
collection in CI (since the test container also runs without the
project installed).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): three minor Copilot findings

- _asset_table.html: rename the inactive-table column from "Active"
  to "Enabled" to match the enabled table header and the underlying
  /assets_toggle/ endpoint (which flips is_enabled). The two tables
  showed different labels for the same checkbox.
- login.html: render Django flash messages as a <ul>/<li> list
  rather than concatenated inline text, so two simultaneous errors
  don't smash into one another.
- diagnostics.get_anthias_version_head(): docstring still claimed
  the head was empty when the package wasn't installed; with the
  pyproject.toml fallback added in 4697cfd5 that's no longer the
  failure mode. Updated to describe what actually returns ''.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): three Copilot findings — a11y, rel, scoped async-unsafe

- _navbar.html: add aria-controls="navbarNav" on the mobile toggle
  so screen readers announce what the button expands/collapses; the
  matching id="navbarNav" was already on the collapsible region.
- _stat_card.html + system_info.html: extend `rel="noopener"` to
  `rel="noopener noreferrer"` on every external `target="_blank"`
  link so the Referer header isn't leaked to the destination.
- conftest.py: scope DJANGO_ALLOW_ASYNC_UNSAFE=1 to runs that
  actually include integration tests (the only ones that need it
  for Playwright's sync API). A pytest_collection_modifyitems hook
  sets the env var when at least one integration item is collected
  — runs early enough that pytest-django's DB setup (which itself
  hits the async-safety check) sees the flag, while leaving unit-
  only runs (`pytest -m "not integration"`) untouched so an
  accidental ORM-from-event-loop in a unit test still raises.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(css): drop dead Bootstrap class on stat-card link, scope underline rule

`text-decoration-none` was a Bootstrap utility — it's not defined in
the post-React SCSS, so the stat-card value-link was rendering with
the browser's default underline despite the markup intent. Two paths
to fix: a Tailwind utility (`no-underline`) on every site that
renders a stat-card link, or a single component-scoped rule. Going
with the latter — every link inside `.stat-card__value` now picks
up `text-decoration: none` automatically (with hover-underline),
matching the existing `.stat-card__meta a` pattern, so future
stat-card links get the right styling without remembering to add a
utility class.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:47:33 +01:00
..