From 1b7307f506e1fe29289a6010a6691d66fc3546a3 Mon Sep 17 00:00:00 2001
From: Arietids
Date: Tue, 17 Mar 2026 15:47:26 +0800
Subject: [PATCH 1/5] docs: define setup studio UX redesign
---
.../2026-03-17-setup-studio-ux-design.md | 402 ++++++++++++++++++
1 file changed, 402 insertions(+)
create mode 100644 docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md
diff --git a/docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md b/docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md
new file mode 100644
index 0000000..af9bb6a
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md
@@ -0,0 +1,402 @@
+# Setup Studio UX Redesign
+
+Date: 2026-03-17
+Status: Approved
+
+## Goal
+
+Redesign the setup studio so first-time Windows users can reach a correct,
+working local setup with fewer mistakes and less confusion.
+
+The primary optimization target is not speed. It is error prevention:
+
+- make the next safe action obvious
+- surface blockers before deeper configuration work
+- reduce overwhelming walls of fields and raw output
+- preserve access to advanced controls without making them the default path
+
+## Context
+
+The current setup studio already exposes the right backend capabilities, but the
+frontend shape makes them harder to use safely:
+
+- the page presents prerequisites, full configuration, runtime checks, and a
+ first processing run with roughly equal weight on one screen
+- diagnostics auto-run on page load, which creates output before the user has
+ context for what they are seeing
+- configuration is shown as a large all-fields form instead of a guided,
+ beginner-safe workflow
+- raw JSON and log-like output dominate the feedback model
+- the current dark control-panel styling feels more like an expert dashboard
+ than a first-run setup companion
+
+The backend seams already align well with a guided setup flow:
+
+- `GET /setup/config`
+- `GET /setup/diagnostics`
+- `POST /setup/bootstrap`
+- `GET /runtime`
+- `POST /process`
+
+The redesign should reuse these surfaces instead of requiring a backend rewrite.
+
+## Chosen Approach
+
+Adopt a guided workbench layout with persistent progress, step-level guardrails,
+and progressive disclosure.
+
+This approach was chosen over:
+
+1. a strict wizard, which would reduce mistakes well but add too much friction
+ for repeated use
+2. a denser mission-control dashboard, which would preserve speed but keep too
+ much cognitive load for first-time users
+
+The guided workbench keeps the user oriented with a visible checklist while
+focusing the main panel on one step at a time.
+
+## UX Principles
+
+- Beginner-first by default, expert-capable on demand
+- Explain what matters before exposing every tuning knob
+- Make status human-readable first, machine-readable second
+- Prevent mistakes earlier instead of describing them after failure
+- Keep progress visible and reversible
+- Use an instructional tone instead of a dashboard tone
+
+## Information Architecture
+
+### Overall Layout
+
+Desktop layout uses two coordinated regions:
+
+1. a left-side progress rail
+2. a right-side active step panel
+
+The left rail contains:
+
+- overall readiness summary
+- current blocker summary
+- four-step checklist with state indicators
+- quick help and common-fix links
+
+The right panel contains the currently active step only:
+
+- step title and short explanation
+- why the step matters
+- required checks or inputs
+- primary action area
+- result summary and next action
+- advanced details behind collapsible sections
+
+Mobile layout collapses this into a current-step-first view with progress and
+the full checklist available behind an expander or sheet.
+
+Mobile uses responsive reflow, not a different product flow. The checklist,
+blocker summaries, advanced details, and actions remain the same concepts with a
+different presentation.
+
+### Step Order
+
+1. Install prerequisites
+2. Build configuration
+3. Verify runtime
+4. Run first processing test
+
+This order matches the real dependency chain and prevents users from editing
+deep runtime knobs before the environment is viable.
+
+### Progress Persistence
+
+The workbench does not introduce a durable server-side wizard session.
+
+- progress is a client-side view model
+- configuration fields rehydrate from `GET /setup/config` on load
+- prerequisite, runtime, and processing status are derived from the latest check
+ results collected in the current browser session
+- after reload, steps return to an unverified state unless they can be derived
+ directly from current config payloads
+
+This keeps the UI honest about what has been verified while avoiding new backend
+state-management scope.
+
+## Step Design
+
+### 1. Install Prerequisites
+
+Primary source: `GET /setup/diagnostics`
+
+Purpose:
+
+- confirm Python, FFmpeg, FFprobe, yt-dlp, and Ollama availability
+- show exact missing items before any deeper setup work
+
+Default presentation:
+
+- dependency cards with clear pass/fail state
+- short explanation of why each dependency matters
+- specific recovery guidance for failed checks
+- explicit `Run checks` or `Re-check` action
+
+Not shown by default:
+
+- raw diagnostic payloads
+- large unstructured output blocks
+
+### 2. Build Configuration
+
+Primary source: `GET /setup/config`
+
+Purpose:
+
+- let the user produce a correct starter configuration without facing the full
+ tuning surface immediately
+
+Default presentation:
+
+- basic mode for the small set of fields that matter first
+- advanced mode for VRAM budgeting, thresholds, retention, and similar tuning
+- generated `.env` preview as an output artifact
+- copy actions for `.env` and quick commands
+
+Basic mode should prioritize:
+
+- bind address
+- storage directory
+- Ollama URL
+- model priority
+
+RSS title, link, and description should live in advanced mode with their current
+defaults preserved. They are useful for publishing setup, but not necessary for
+first-run validation.
+
+Advanced mode should contain:
+
+- budget ratio
+- reserve amount
+- frame extraction thresholds
+- transcript and summary retention
+- token and output tuning
+
+The full `.env` block remains available, but it should no longer be the primary
+editing model.
+
+### 3. Verify Runtime
+
+Primary sources:
+
+- `GET /runtime`
+- `POST /setup/bootstrap`
+
+Purpose:
+
+- prove the configured runtime is reachable and ready to summarize
+
+Default presentation:
+
+- connection status in plain language
+- whether Ollama is reachable
+- visible local-model readiness
+- missing-model explanation before bootstrap
+- bootstrap progress and completion state
+
+Advanced runtime payload details can remain accessible through a disclosure, but
+the first screen should answer a simple question: can this system actually run?
+
+### 4. Run First Processing Test
+
+Primary source: `POST /process`
+
+Purpose:
+
+- give the user a confidence-building proof that the full pipeline works end to
+ end
+
+Default presentation:
+
+- sample source prefilled with the current public default already used by the UI:
+ `https://www.youtube.com/watch?v=dQw4w9WgXcQ`
+- optional title override
+- expectation setting that processing may take time
+- a success summary emphasizing outcome, not raw response shape
+
+The sample source remains editable. If the default source is unavailable or the
+request fails, the UI keeps the step in a failed state, preserves the entered
+value, and suggests retrying with a different URL or local file path.
+
+The first success view should highlight:
+
+- processed title
+- transcript size
+- frame count
+- model used
+- generated summary highlights
+
+Raw response details should remain available behind a details section.
+
+## Guardrail Model
+
+Each step uses a small set of explicit states:
+
+- `unverified`
+- `blocked`
+- `ready`
+- `running`
+- `failed`
+- `complete`
+
+Rules:
+
+- diagnostics do not auto-run on initial page load
+- page load initializes prerequisite, runtime, and processing steps as
+ `unverified` until the user runs them
+- steps that depend on unmet prerequisites stay visibly blocked
+- blocked steps explain both the problem and the exact next fix
+- failed steps preserve the most recent meaningful result, show recovery
+ actions, and allow retry without clearing user-entered inputs
+- completing one step highlights the recommended next step
+- completed steps remain editable without losing overall orientation
+- advanced controls are available but collapsed by default
+
+This model changes the interface from an output-heavy control panel into a
+guided state machine.
+
+## Frontend Units and Boundaries
+
+The implementation plan should decompose the setup studio into a small set of
+clear frontend units even if the first pass remains in the existing template,
+CSS, and JS files.
+
+Suggested units:
+
+- `setup shell`: template structure for the progress rail, active step panel,
+ helper sections, and mobile variants
+- `step state controller`: client-side state machine that owns transitions,
+ gating rules, and current-step selection
+- `setup API client`: thin wrappers around the existing setup/runtime/process
+ endpoints
+- `view-model mappers`: functions that convert raw diagnostics, runtime, and
+ process payloads into human-readable summaries and blocker/fix models
+- `step renderers`: focused DOM update logic for each step's summary, actions,
+ and detail sections
+- `adapter regression tests`: tests that verify structure, state-driven
+ behavior, and critical content without depending on visual styling details
+
+Boundaries:
+
+- API client functions should not decide UX copy or state transitions
+- view-model mappers should not manipulate the DOM directly
+- the state controller should consume mapped results and decide whether a step
+ is unverified, blocked, ready, failed, running, or complete
+- DOM renderers should be replaceable without changing endpoint integration
+
+This gives the planner independently understandable and testable units without
+forcing an unnecessary framework migration.
+
+## Content and Messaging Strategy
+
+Every step should answer the same questions in the same order:
+
+1. what are we checking or configuring?
+2. why does it matter?
+3. what should the user do now?
+4. what happened?
+5. what comes next?
+
+Messaging style should be:
+
+- direct
+- plain-language
+- specific about recovery steps
+- calm rather than alarmist
+- instructional rather than overly technical
+
+Raw JSON, full diagnostic objects, and other machine-shaped data should move to
+secondary detail views.
+
+## Visual Direction
+
+Use the approved `Workshop Manual` direction.
+
+Characteristics:
+
+- warm neutral palette rather than dark glassmorphism
+- supportive, trust-building visual tone
+- stronger reading comfort for first-run guidance
+- status colors used sparingly and intentionally
+- interface that feels instructional and grounded instead of like a mission
+ control board
+
+This direction should replace the current heavy dark background and purple-led
+accent language in `video_rss_aggregator/static/setup.css`.
+
+## Data and Interaction Mapping
+
+The redesign should preserve the current backend contract and remap the frontend
+around it.
+
+- load configuration defaults from `GET /setup/config`
+- run dependency checks only when the user starts or repeats them via
+ `GET /setup/diagnostics`
+- check runtime readiness via `GET /runtime`
+- bootstrap models via `POST /setup/bootstrap`
+- run the first validation job via `POST /process`
+
+Allowed backend changes are intentionally narrow:
+
+- no new workflow endpoints for the redesign's first implementation pass
+- optional response-shaping additions to existing setup/runtime endpoints if the
+ frontend needs clearer view-model fields
+- no changes to application-use-case responsibilities beyond exposing clearer
+ adapter-facing payloads
+
+If response shaping is needed, it should happen at the FastAPI adapter boundary
+or in lightweight view-model helpers, not by expanding core domain or workflow
+scope.
+
+## Error Handling
+
+- Show blocker summaries inline at the step level
+- Pair failures with actionable fixes, not just error text
+- Keep raw error details available in expandable sections
+- Avoid presenting partial or failed operations as generic success states
+- Preserve user-entered configuration while retrying checks or actions
+- Keep the last successful summary visible when a later retry fails, clearly
+ labeled as stale if needed
+
+## Testing Strategy
+
+Update the setup studio regression net to validate the new guided experience.
+
+Key coverage areas:
+
+- setup page structure reflects the step-based workbench
+- beginner-first content appears before advanced controls
+- diagnostics are not auto-triggered on page load
+- `.env` generation still reflects edited configuration values
+- runtime and processing results render human-readable summaries
+- advanced details remain available for debugging without dominating the default
+ view
+
+Tests should continue to protect the existing setup/runtime/process API
+contracts, while adapter-facing UI tests evolve to reflect the new structure.
+
+## Non-Goals
+
+- redesigning the underlying runtime architecture
+- adding new core setup endpoints unless view-model gaps make them necessary
+- building a multi-page onboarding flow disconnected from the current server UI
+- optimizing primarily for expert repeat use at the expense of first-run safety
+
+## Expected Benefits
+
+- fewer first-run setup mistakes
+- clearer progression from install to successful processing
+- less intimidation from the configuration surface
+- improved trust in runtime readiness and failure recovery
+- a more distinctive and intentional setup experience
+
+## Next Step
+
+Create an implementation plan for the setup studio redesign, staged around
+template structure, interaction model, visual system, and regression updates.
From e334038eef9a63e093e0c1b07a15606bf722ce8a Mon Sep 17 00:00:00 2001
From: Arietids
Date: Tue, 17 Mar 2026 18:40:55 +0800
Subject: [PATCH 2/5] feat: redesign setup studio workflow
Guide first-time setup through prerequisites, configuration, runtime validation, and a first processing run so blockers and recovery steps are clear.
---
tests/adapters/test_api_app.py | 16 +-
tests/test_api_setup.py | 34 +-
tests/test_setup_studio_contract.py | 255 ++++++
tests/test_setup_view_models.py | 166 ++++
video_rss_aggregator/api.py | 14 +-
video_rss_aggregator/setup_view_models.py | 142 +++
video_rss_aggregator/static/setup.css | 837 ++++++++++--------
video_rss_aggregator/static/setup.js | 404 ++++-----
video_rss_aggregator/static/setup_api.js | 55 ++
video_rss_aggregator/static/setup_state.js | 216 +++++
.../static/setup_view_models.js | 164 ++++
video_rss_aggregator/static/setup_views.js | 405 +++++++++
video_rss_aggregator/templates/setup.html | 230 +++--
13 files changed, 2206 insertions(+), 732 deletions(-)
create mode 100644 tests/test_setup_studio_contract.py
create mode 100644 tests/test_setup_view_models.py
create mode 100644 video_rss_aggregator/setup_view_models.py
create mode 100644 video_rss_aggregator/static/setup_api.js
create mode 100644 video_rss_aggregator/static/setup_state.js
create mode 100644 video_rss_aggregator/static/setup_view_models.js
create mode 100644 video_rss_aggregator/static/setup_views.js
diff --git a/tests/adapters/test_api_app.py b/tests/adapters/test_api_app.py
index c6cbc25..70e2df7 100644
--- a/tests/adapters/test_api_app.py
+++ b/tests/adapters/test_api_app.py
@@ -102,6 +102,8 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None:
rss = client.get("/rss?limit=5")
runtime_response = client.get("/runtime")
bootstrap = client.post("/setup/bootstrap")
+ runtime_payload = runtime_response.json()
+ bootstrap_payload = bootstrap.json()
assert ingest.status_code == 200
assert ingest.json() == {
@@ -127,16 +129,26 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None:
assert rss.status_code == 200
assert rss.text == "feed"
assert runtime_response.status_code == 200
- assert runtime_response.json() == {
+ assert runtime_payload == {
"ollama_version": "0.6.0",
"local_models": {"qwen": {"size": 1}},
"reachable": True,
"database_path": ".data/runtime.db",
"storage_dir": ".data",
"models": ["qwen", "qwen:min"],
+ "setup_view": {
+ "state": "blocked",
+ "missing_models": ["qwen:min"],
+ "next_action": "Bootstrap required models",
+ },
+ }
+ assert runtime_payload["setup_view"] == {
+ "state": "blocked",
+ "missing_models": ["qwen:min"],
+ "next_action": "Bootstrap required models",
}
assert bootstrap.status_code == 200
- assert bootstrap.json() == {
+ assert bootstrap_payload == {
"models_prepared": ["qwen"],
"runtime": {
"ollama_version": "0.6.0",
diff --git a/tests/test_api_setup.py b/tests/test_api_setup.py
index 70ca047..0d4d2fd 100644
--- a/tests/test_api_setup.py
+++ b/tests/test_api_setup.py
@@ -57,13 +57,15 @@ def test_gui_and_setup_routes() -> None:
css = client.get("/static/setup.css")
assert css.status_code == 200
- assert "--accent" in css.text
+ assert ".shell" in css.text
js = client.get("/static/setup.js")
assert js.status_code == 200
- assert "runDiagnostics" in js.text
- assert "API_KEY=${fields.apiKey.value.trim()}" not in js.text
- assert "const apiKey = fields.apiKey.value.trim();" in js.text
+ assert js.headers["content-type"].startswith("text/javascript")
+
+ setup_state = client.get("/static/setup_state.js")
+ assert setup_state.status_code == 200
+ assert setup_state.headers["content-type"].startswith("text/javascript")
setup = client.get("/setup/config")
assert setup.status_code == 200
@@ -82,6 +84,17 @@ def test_gui_and_setup_routes() -> None:
diagnostics = client.get("/setup/diagnostics")
assert diagnostics.status_code == 200
diag_payload = diagnostics.json()
+ setup_view = diag_payload["setup_view"]
+ assert set(setup_view) >= {"state", "checks", "blockers", "next_action"}
+ assert setup_view["state"] in {"ready", "blocked"}
+ assert isinstance(setup_view["checks"], list)
+ assert {check["id"] for check in setup_view["checks"]} >= {
+ "python",
+ "ffmpeg",
+ "ffprobe",
+ "yt_dlp",
+ "ollama",
+ }
assert "platform" in diag_payload
assert "dependencies" in diag_payload
assert "ready" in diag_payload
@@ -102,18 +115,7 @@ def test_runtime_requires_api_key_when_enabled() -> None:
authorized = client.get("/runtime", headers={"X-API-Key": "secret"})
assert authorized.status_code == 200
- assert authorized.json() == {
- "ollama_version": "0.6.0",
- "local_models": {"qwen3.5:2b-q4_K_M": {}},
- "reachable": True,
- "database_path": ".data/vra.db",
- "storage_dir": ".data",
- "models": [
- "qwen3.5:4b-q4_K_M",
- "qwen3.5:2b-q4_K_M",
- "qwen3.5:0.8b-q8_0",
- ],
- }
+ assert authorized.json()["setup_view"]["state"] == "blocked"
def test_setup_config_omits_api_key_and_uses_current_bind_in_commands() -> None:
diff --git a/tests/test_setup_studio_contract.py b/tests/test_setup_studio_contract.py
new file mode 100644
index 0000000..6b40f16
--- /dev/null
+++ b/tests/test_setup_studio_contract.py
@@ -0,0 +1,255 @@
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any, cast
+
+from fastapi.testclient import TestClient
+
+from adapter_api import create_app
+from core_config import Config
+from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases
+
+
+@dataclass
+class _AsyncValue:
+ value: Any
+
+ async def execute(self, *_args, **_kwargs) -> Any:
+ return self.value
+
+
+def _build_runtime(config: Config) -> AppRuntime:
+ runtime_status = {
+ "ollama_version": "0.6.0",
+ "local_models": {"qwen3.5:2b-q4_K_M": {}},
+ "reachable": True,
+ "database_path": config.database_path,
+ "storage_dir": config.storage_dir,
+ "models": list(config.model_priority),
+ }
+ return AppRuntime(
+ config=config,
+ use_cases=AppUseCases(
+ get_runtime_status=_AsyncValue(runtime_status),
+ bootstrap_runtime=_AsyncValue({"models": ["qwen3.5:2b-q4_K_M"]}),
+ ingest_feed=cast(Any, _AsyncValue(None)),
+ process_source=cast(Any, _AsyncValue(None)),
+ render_rss_feed=_AsyncValue(""),
+ ),
+ close=lambda: None,
+ )
+
+
+client = TestClient(create_app(_build_runtime(Config())))
+
+
+def class_tokens_for_id(html: str, element_id: str) -> set[str]:
+ tag_match = re.search(rf'<[^>]*\bid="{re.escape(element_id)}"[^>]*>', html)
+ assert tag_match is not None
+
+ class_match = re.search(r'\bclass="([^"]+)"', tag_match.group(0))
+ assert class_match is not None
+
+ return set(class_match.group(1).split())
+
+
+def test_setup_css_supports_shell_and_disclosure_layout() -> None:
+ css = client.get("/static/setup.css")
+
+ assert css.status_code == 200
+ assert "--paper-base" in css.text
+ assert ".shell" in css.text
+ assert ".progress-rail" in css.text
+ assert ".detail-drawer" in css.text
+ assert ".summary-card" in css.text
+ assert "@media (max-width: 860px)" in css.text
+ assert '#setup-progress[data-mobile-open="false"]' in css.text
+ assert '#mobile-step-toggle[aria-expanded="true"]' in css.text
+ assert "#6366f1" not in css.text
+
+
+def test_setup_home_renders_shell_and_collapsed_detail_drawers() -> None:
+ home = client.get("/")
+ workbench_classes = class_tokens_for_id(home.text, "setup-workbench")
+ progress_classes = class_tokens_for_id(home.text, "setup-progress")
+ prerequisites_panel_classes = class_tokens_for_id(
+ home.text, "step-panel-prerequisites"
+ )
+ configuration_panel_classes = class_tokens_for_id(
+ home.text, "step-panel-configuration"
+ )
+ runtime_panel_classes = class_tokens_for_id(home.text, "step-panel-runtime")
+ process_panel_classes = class_tokens_for_id(home.text, "step-panel-process")
+
+ assert home.status_code == 200
+ assert 'id="setup-workbench"' in home.text
+ assert 'id="setup-progress"' in home.text
+ assert 'id="readiness-summary"' in home.text
+ assert 'id="blocker-summary"' in home.text
+ assert 'id="common-fixes"' in home.text
+ assert 'data-step-id="prerequisites"' in home.text
+ assert 'data-step-id="configuration"' in home.text
+ assert 'data-step-id="runtime"' in home.text
+ assert 'data-step-id="process"' in home.text
+ assert "shell" in workbench_classes
+ assert "progress-rail" in progress_classes
+ assert {"step-panel", "card"} <= prerequisites_panel_classes
+ assert {"step-panel", "card", "wide"} <= configuration_panel_classes
+ assert {"step-panel", "card"} <= runtime_panel_classes
+ assert {"step-panel", "card"} <= process_panel_classes
+ assert 'id="mobile-step-toggle"' in home.text
+ assert 'aria-controls="setup-progress"' in home.text
+ assert 'aria-expanded="false"' in home.text
+ assert 'id="run-diagnostics"' in home.text
+ assert 'id="copy-env"' in home.text
+ assert 'id="runtime-check"' in home.text
+ assert 'id="bootstrap-models"' in home.text
+ assert 'id="process-run"' in home.text
+ assert 'id="runtime-summary"' in home.text
+ assert 'id="process-summary"' in home.text
+ assert 'id="advanced-config"' in home.text
+ assert 'id="runtime-details"' in home.text
+ assert 'id="process-details"' in home.text
+ assert re.search(
+ r']*id="advanced-config"(?![^>]*\bopen\b)[^>]*>', home.text
+ )
+ assert re.search(
+ r']*id="runtime-details"(?![^>]*\bopen\b)[^>]*>', home.text
+ )
+ assert re.search(
+ r']*id="process-details"(?![^>]*\bopen\b)[^>]*>', home.text
+ )
+
+
+def test_setup_assets_expose_module_and_top_level_contracts() -> None:
+ home = client.get("/")
+ entry_js = client.get("/static/setup.js")
+ api_js = client.get("/static/setup_api.js")
+ state_js = client.get("/static/setup_state.js")
+ view_models_js = client.get("/static/setup_view_models.js")
+ views_js = client.get("/static/setup_views.js")
+
+ assert 'src="static/setup.js" type="module"' in home.text
+
+ for response in (entry_js, api_js, state_js, view_models_js, views_js):
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/javascript")
+
+ assert 'import { createSetupApi } from "./setup_api.js";' in entry_js.text
+ assert 'import { createSetupState } from "./setup_state.js";' in entry_js.text
+ assert (
+ 'import { buildShellSummaryView } from "./setup_view_models.js";'
+ in entry_js.text
+ )
+ assert (
+ 'import { buildProcessSummaryView } from "./setup_view_models.js";'
+ in entry_js.text
+ )
+ assert (
+ 'import { buildProcessFailureView } from "./setup_view_models.js";'
+ in entry_js.text
+ )
+ assert 'import { createSetupViews } from "./setup_views.js";' in entry_js.text
+ assert "function boot()" in entry_js.text
+ assert "boot();" in entry_js.text
+ assert "handleDiagnosticsRun" in entry_js.text
+ assert "handleRuntimeCheck" in entry_js.text
+ assert "handleBootstrapModels" in entry_js.text
+ assert "handleProcessRun" in entry_js.text
+ assert "runDiagnostics();" not in entry_js.text
+
+ assert "export function createSetupApi" in api_js.text
+ assert "runDiagnostics" in api_js.text
+ assert "checkRuntime" in api_js.text
+ assert "bootstrapModels" in api_js.text
+ assert "runProcess" in api_js.text
+
+ assert "export function createSetupState" in state_js.text
+ assert "beginDiagnosticsCheck" in state_js.text
+ assert "markConfigurationComplete" in state_js.text
+ assert "beginRuntimeCheck" in state_js.text
+ assert "completeRuntimeCheck" in state_js.text
+ assert "beginProcess" in state_js.text
+ assert "markProcessSuccess" in state_js.text
+ assert "markProcessFailure" in state_js.text
+ assert (
+ 'const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"]'
+ in state_js.text
+ )
+
+ assert "export function buildShellSummaryView" in view_models_js.text
+ assert "export function buildStaleSummaryLabel" in view_models_js.text
+ assert "export function buildProcessSummaryView" in view_models_js.text
+ assert "export function buildProcessFailureView" in view_models_js.text
+
+ assert "export function createSetupViews" in views_js.text
+ assert "renderShellState" in views_js.text
+ assert "renderDiagnosticsSummary" in views_js.text
+ assert "renderRuntimeSummary" in views_js.text
+ assert "renderRuntimeDetails" in views_js.text
+ assert "renderProcessSummary" in views_js.text
+ assert "renderProcessDetails" in views_js.text
+ assert "bindMobileStepToggle" in views_js.text
+ assert 'document.getElementById("mobile-step-toggle")' in views_js.text
+ assert (
+ 'progressRail.dataset.mobileOpen = expanded ? "true" : "false";'
+ in views_js.text
+ )
+ assert (
+ 'mobileStepToggle.setAttribute("aria-expanded", String(expanded));'
+ in views_js.text
+ )
+ assert "views.bindMobileStepToggle();" in entry_js.text
+
+
+def test_setup_js_prefers_backend_setup_views_for_state_transitions() -> None:
+ entry_js = client.get("/static/setup.js")
+
+ assert entry_js.status_code == 200
+ assert "function toDiagnosticsView(report)" in entry_js.text
+ assert (
+ 'return report.setup_view || { state: report.ready ? "ready" : "blocked" };'
+ in entry_js.text
+ )
+ assert "function toRuntimeView(runtime)" in entry_js.text
+ assert (
+ 'return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" };'
+ in entry_js.text
+ )
+ assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text
+ assert "state.applyDiagnosticsResult(diagnosticsView);" in entry_js.text
+ assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text
+ assert "state.completeRuntimeCheck(runtimeView);" in entry_js.text
+
+
+def test_setup_js_renders_shaped_setup_view_summaries_and_common_fixes() -> None:
+ entry_js = client.get("/static/setup.js")
+
+ assert entry_js.status_code == 200
+ assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text
+ assert "views.renderDiagnosticsSummary(diagnosticsView);" in entry_js.text
+ assert "views.renderCommonFixes(diagnosticsView);" in entry_js.text
+ assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text
+ assert "renderRuntimeState(runtimeView, runtime);" in entry_js.text
+ assert (
+ "renderRuntimeState(runtimeView, { ...runtime, bootstrap: report });"
+ in entry_js.text
+ )
+
+
+def test_setup_views_expose_common_fixes_and_runtime_readiness_copy() -> None:
+ views_js = client.get("/static/setup_views.js")
+
+ assert views_js.status_code == 200
+ assert "function renderCommonFixes(view)" in views_js.text
+ assert 'const container = document.getElementById("common-fixes");' in views_js.text
+ assert "const fixes = Array.isArray(view?.checks)" in views_js.text
+ assert "view.checks.filter((check) => check.fix)" in views_js.text
+ assert "function renderRuntimeSummary(runtimeView, runtime)" in views_js.text
+ assert (
+ '["Required models", `${requiredModels.length} configured`],' in views_js.text
+ )
+ assert '["Available locally", `${localModels.length} ready`],' in views_js.text
+ assert "runtimeView?.next_action ||" in views_js.text
+ assert "renderCommonFixes," in views_js.text
diff --git a/tests/test_setup_view_models.py b/tests/test_setup_view_models.py
new file mode 100644
index 0000000..14ddb87
--- /dev/null
+++ b/tests/test_setup_view_models.py
@@ -0,0 +1,166 @@
+from video_rss_aggregator.setup_view_models import (
+ build_diagnostics_view,
+ build_runtime_view,
+)
+
+
+def test_build_diagnostics_view_marks_missing_dependencies_blocked() -> None:
+ report = {
+ "ready": False,
+ "platform": {
+ "python_version": "3.11.9",
+ "python_executable": "/usr/bin/python3",
+ },
+ "dependencies": {
+ "ffmpeg": {"available": False, "command": None},
+ "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"},
+ "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"},
+ "ollama": {"reachable": False, "error": "connection refused"},
+ },
+ }
+
+ view = build_diagnostics_view(report)
+
+ assert view["state"] == "blocked"
+ assert view["blockers"] == [
+ "Install FFmpeg and add it to PATH",
+ "Start Ollama so the local API is reachable",
+ ]
+ assert view["checks"][0] == {
+ "id": "python",
+ "label": "Python 3.11+",
+ "state": "complete",
+ "detail": "3.11.9 via /usr/bin/python3",
+ "fix": None,
+ }
+ assert view["checks"][1]["id"] == "ffmpeg"
+ assert view["checks"][1]["state"] == "blocked"
+ assert view["next_action"] == "Resolve the blocked checks and run diagnostics again"
+
+
+def test_build_diagnostics_view_marks_ready_path_complete() -> None:
+ report = {
+ "ready": True,
+ "platform": {
+ "python_version": "3.11.9",
+ "python_executable": "/usr/bin/python3",
+ },
+ "dependencies": {
+ "ffmpeg": {"available": True, "command": "/usr/bin/ffmpeg"},
+ "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"},
+ "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"},
+ "ollama": {"reachable": True, "error": None},
+ },
+ }
+
+ view = build_diagnostics_view(report)
+
+ assert view["state"] == "ready"
+ assert view["blockers"] == []
+ assert view["next_action"] == "Continue to configuration"
+
+
+def test_build_diagnostics_view_uses_ollama_version_for_reachable_detail() -> None:
+ report = {
+ "ready": True,
+ "platform": {
+ "python_version": "3.11.9",
+ "python_executable": "/usr/bin/python3",
+ },
+ "dependencies": {
+ "ffmpeg": {"available": True, "command": "/usr/bin/ffmpeg"},
+ "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"},
+ "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"},
+ "ollama": {"reachable": True, "error": None, "version": "0.6.0"},
+ },
+ }
+
+ view = build_diagnostics_view(report)
+
+ assert view["checks"][4]["id"] == "ollama"
+ assert view["checks"][4]["detail"] == "0.6.0"
+
+
+def test_build_diagnostics_view_blocks_python_below_311() -> None:
+ report = {
+ "ready": False,
+ "platform": {
+ "python_version": "3.10.14",
+ "python_executable": "/usr/bin/python3",
+ },
+ "dependencies": {
+ "ffmpeg": {"available": True, "command": "/usr/bin/ffmpeg"},
+ "ffprobe": {"available": True, "command": "/usr/bin/ffprobe"},
+ "yt_dlp": {"available": True, "command": "/usr/bin/yt-dlp"},
+ "ollama": {"reachable": True, "error": None, "version": "0.6.0"},
+ },
+ }
+
+ view = build_diagnostics_view(report)
+
+ assert view["state"] == "blocked"
+ assert view["checks"][0]["state"] == "blocked"
+ assert view["checks"][0]["fix"] == "Install Python 3.11+"
+ assert "Install Python 3.11+" in view["blockers"]
+
+
+def test_build_runtime_view_marks_missing_models_blocked() -> None:
+ runtime = {
+ "reachable": True,
+ "local_models": {"qwen3.5:2b-q4_K_M": {}},
+ "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"],
+ }
+
+ view = build_runtime_view(runtime)
+
+ assert view["state"] == "blocked"
+ assert view["missing_models"] == ["qwen3.5:4b-q4_K_M"]
+ assert view["next_action"] == "Bootstrap required models"
+
+
+def test_build_runtime_view_marks_unreachable_runtime_for_check() -> None:
+ runtime = {
+ "reachable": False,
+ "local_models": {
+ "qwen3.5:4b-q4_K_M": {},
+ "qwen3.5:2b-q4_K_M": {},
+ },
+ "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"],
+ }
+
+ view = build_runtime_view(runtime)
+
+ assert view["state"] == "blocked"
+ assert view["missing_models"] == []
+ assert view["next_action"] == "Check runtime"
+
+
+def test_build_runtime_view_accepts_list_shaped_local_models() -> None:
+ runtime = {
+ "reachable": True,
+ "local_models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"],
+ "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"],
+ }
+
+ view = build_runtime_view(runtime)
+
+ assert view["state"] == "ready"
+ assert view["missing_models"] == []
+ assert view["next_action"] == "Run the first processing test"
+
+
+def test_build_runtime_view_marks_ready_path_complete() -> None:
+ runtime = {
+ "reachable": True,
+ "local_models": {
+ "qwen3.5:4b-q4_K_M": {},
+ "qwen3.5:2b-q4_K_M": {},
+ },
+ "models": ["qwen3.5:4b-q4_K_M", "qwen3.5:2b-q4_K_M"],
+ }
+
+ view = build_runtime_view(runtime)
+
+ assert view["state"] == "ready"
+ assert view["missing_models"] == []
+ assert view["next_action"] == "Run the first processing test"
diff --git a/video_rss_aggregator/api.py b/video_rss_aggregator/api.py
index f1fbd8d..9041366 100644
--- a/video_rss_aggregator/api.py
+++ b/video_rss_aggregator/api.py
@@ -19,6 +19,10 @@
from service_media import runtime_dependency_report
from video_rss_aggregator.bootstrap import AppRuntime, build_runtime
from video_rss_aggregator.domain.outcomes import Failure
+from video_rss_aggregator.setup_view_models import (
+ build_diagnostics_view,
+ build_runtime_view,
+)
class IngestRequest(BaseModel):
@@ -131,7 +135,7 @@ async def setup_diagnostics(request: Request) -> dict[str, object]:
ytdlp_ok = bool(ytdlp["available"])
ollama_ok = bool(ollama["reachable"])
- return {
+ diagnostics_payload = {
"platform": {
"system": platform.system(),
"release": platform.release(),
@@ -146,6 +150,8 @@ async def setup_diagnostics(request: Request) -> dict[str, object]:
},
"ready": ffmpeg_ok and ffprobe_ok and ytdlp_ok and ollama_ok,
}
+ diagnostics_payload["setup_view"] = build_diagnostics_view(diagnostics_payload)
+ return diagnostics_payload
@app.post("/setup/bootstrap")
async def setup_bootstrap(
@@ -220,6 +226,10 @@ async def rss_feed(
async def runtime_status(
request: Request, _=Depends(_check_auth)
) -> dict[str, object]:
- return await _runtime(request).use_cases.get_runtime_status.execute()
+ runtime_payload = await _runtime(request).use_cases.get_runtime_status.execute()
+ return {
+ **runtime_payload,
+ "setup_view": build_runtime_view(runtime_payload),
+ }
return app
diff --git a/video_rss_aggregator/setup_view_models.py b/video_rss_aggregator/setup_view_models.py
new file mode 100644
index 0000000..c5154f6
--- /dev/null
+++ b/video_rss_aggregator/setup_view_models.py
@@ -0,0 +1,142 @@
+from __future__ import annotations
+
+
+def build_diagnostics_view(report: dict[str, object]) -> dict[str, object]:
+ platform = _as_dict(report.get("platform"))
+ dependencies = _as_dict(report.get("dependencies"))
+
+ python_version = str(platform.get("python_version") or "unknown")
+ python_executable = str(platform.get("python_executable") or "unknown")
+ python_ready = _is_supported_python(python_version)
+
+ checks = [
+ {
+ "id": "python",
+ "label": "Python 3.11+",
+ "state": "complete" if python_ready else "blocked",
+ "detail": f"{python_version} via {python_executable}",
+ "fix": None if python_ready else "Install Python 3.11+",
+ },
+ _dependency_check(
+ "ffmpeg",
+ "FFmpeg",
+ dependencies.get("ffmpeg"),
+ fix="Install FFmpeg and add it to PATH",
+ ),
+ _dependency_check(
+ "ffprobe",
+ "FFprobe",
+ dependencies.get("ffprobe"),
+ fix="Install FFprobe and add it to PATH",
+ ),
+ _dependency_check(
+ "yt_dlp",
+ "yt-dlp",
+ dependencies.get("yt_dlp"),
+ fix="Install yt-dlp and add it to PATH",
+ ),
+ _ollama_check(dependencies.get("ollama")),
+ ]
+
+ blockers = [
+ check["fix"] for check in checks if check["state"] == "blocked" and check["fix"]
+ ]
+ state = "ready" if not blockers and bool(report.get("ready")) else "blocked"
+
+ return {
+ "state": state,
+ "blockers": blockers,
+ "checks": checks,
+ "next_action": "Continue to configuration"
+ if state == "ready"
+ else "Resolve the blocked checks and run diagnostics again",
+ }
+
+
+def build_runtime_view(runtime: dict[str, object]) -> dict[str, object]:
+ local_models = _local_model_names(runtime.get("local_models"))
+ models = [str(model) for model in _as_list(runtime.get("models"))]
+ missing_models = [model for model in models if model not in local_models]
+
+ reachable = bool(runtime.get("reachable"))
+ state = "ready" if reachable and not missing_models else "blocked"
+ if not reachable:
+ next_action = "Check runtime"
+ elif missing_models:
+ next_action = "Bootstrap required models"
+ else:
+ next_action = "Run the first processing test"
+
+ return {
+ "state": state,
+ "missing_models": missing_models,
+ "next_action": next_action,
+ }
+
+
+def _dependency_check(
+ check_id: str,
+ label: str,
+ dependency: object,
+ *,
+ fix: str,
+) -> dict[str, object]:
+ payload = _as_dict(dependency)
+ command = payload.get("command")
+ available = bool(payload.get("available"))
+
+ return {
+ "id": check_id,
+ "label": label,
+ "state": "complete" if available else "blocked",
+ "detail": str(command) if command else "Not detected",
+ "fix": None if available else fix,
+ }
+
+
+def _ollama_check(dependency: object) -> dict[str, object]:
+ payload = _as_dict(dependency)
+ reachable = bool(payload.get("reachable"))
+ error = payload.get("error")
+ version = payload.get("version")
+
+ return {
+ "id": "ollama",
+ "label": "Ollama API",
+ "state": "complete" if reachable else "blocked",
+ "detail": str(version)
+ if reachable and version
+ else ("Reachable" if reachable else str(error or "Unavailable")),
+ "fix": None if reachable else "Start Ollama so the local API is reachable",
+ }
+
+
+def _as_dict(value: object) -> dict[str, object]:
+ if isinstance(value, dict):
+ return value
+ return {}
+
+
+def _as_list(value: object) -> list[object]:
+ if isinstance(value, list):
+ return value
+ return []
+
+
+def _is_supported_python(version: str) -> bool:
+ parts = version.split(".")
+ if len(parts) < 2:
+ return False
+ if not parts[0].isdigit() or not parts[1].isdigit():
+ return False
+ major = int(parts[0])
+ minor = int(parts[1])
+ return major > 3 or (major == 3 and minor >= 11)
+
+
+def _local_model_names(value: object) -> set[str]:
+ if isinstance(value, dict):
+ return {str(model) for model in value}
+ if isinstance(value, list):
+ return {str(model) for model in value}
+ return set()
diff --git a/video_rss_aggregator/static/setup.css b/video_rss_aggregator/static/setup.css
index 6542293..239ece8 100644
--- a/video_rss_aggregator/static/setup.css
+++ b/video_rss_aggregator/static/setup.css
@@ -1,39 +1,39 @@
-/* ─── Design Tokens ─────────────────────────────────────────── */
:root {
- --bg-base: #0a0a0f;
- --bg-elevated: #121218;
- --bg-surface: rgba(255, 255, 255, 0.04);
- --panel: rgba(255, 255, 255, 0.05);
- --panel-hover: rgba(255, 255, 255, 0.07);
- --panel-border: rgba(255, 255, 255, 0.08);
- --panel-border-hover: rgba(255, 255, 255, 0.14);
- --text-primary: rgba(255, 255, 255, 0.92);
- --text-secondary: rgba(255, 255, 255, 0.55);
- --text-tertiary: rgba(255, 255, 255, 0.35);
- --accent: #6366f1;
- --accent-soft: rgba(99, 102, 241, 0.15);
- --accent-glow: rgba(99, 102, 241, 0.25);
- --success: #34d399;
- --success-soft: rgba(52, 211, 153, 0.12);
- --warning: #fbbf24;
- --warning-soft: rgba(251, 191, 36, 0.12);
- --danger: #f87171;
- --danger-soft: rgba(248, 113, 113, 0.12);
+ --paper-base: #f5f1e8;
+ --paper-panel: #fffaf2;
+ --surface-raised: rgba(255, 250, 242, 0.72);
+ --surface-raised-strong: rgba(255, 250, 242, 0.92);
+ --surface-olive-wash: rgba(107, 122, 64, 0.05);
+ --surface-code: rgba(47, 42, 36, 0.06);
+ --surface-input: #fffdf8;
+ --surface-input-focus: #ffffff;
+ --surface-output: #f2ebdf;
+ --paper-shadow: rgba(47, 42, 36, 0.08);
+ --paper-shadow-strong: rgba(47, 42, 36, 0.1);
+ --ink-strong: #2f2a24;
+ --ink-soft: #665d52;
+ --ink-muted: #8a7f72;
+ --accent-olive: #6b7a40;
+ --accent-olive-soft: rgba(107, 122, 64, 0.14);
+ --accent-amber: #b86a2b;
+ --accent-amber-soft: rgba(184, 106, 43, 0.16);
+ --danger-rust: #b44c37;
+ --danger-rust-soft: rgba(180, 76, 55, 0.14);
+ --line-soft: rgba(47, 42, 36, 0.12);
+ --line-strong: rgba(47, 42, 36, 0.2);
+ --focus-ring: 0 0 0 3px rgba(107, 122, 64, 0.16);
--radius-sm: 10px;
- --radius-md: 16px;
- --radius-lg: 22px;
- --spring: cubic-bezier(0.22, 1, 0.36, 1);
+ --radius-md: 18px;
+ --radius-lg: 28px;
+ --duration-fast: 160ms;
+ --duration-normal: 280ms;
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
- --duration-fast: 180ms;
- --duration-normal: 350ms;
- --duration-slow: 600ms;
}
-/* ─── Reset & Base ──────────────────────────────────────────── */
-*, *::before, *::after {
+*,
+*::before,
+*::after {
box-sizing: border-box;
- margin: 0;
- padding: 0;
}
html {
@@ -43,193 +43,321 @@ html {
}
body {
- font-family: "Avenir Next", "Segoe UI", "Trebuchet MS", sans-serif;
- color: var(--text-primary);
- background: var(--bg-base);
+ margin: 0;
min-height: 100vh;
- line-height: 1.5;
- overflow-x: hidden;
+ font-family: "Avenir Next", "Segoe UI", sans-serif;
+ line-height: 1.55;
+ color: var(--ink-strong);
+ background:
+ radial-gradient(circle at top left, rgba(184, 106, 43, 0.12), transparent 28%),
+ linear-gradient(180deg, #f8f4ec 0%, var(--paper-base) 42%, #efe6d8 100%);
}
-/* Ambient gradient orbs */
-body::before,
-body::after {
+body::before {
content: "";
position: fixed;
- border-radius: 50%;
+ inset: 0;
pointer-events: none;
- filter: blur(120px);
- opacity: 0.4;
-}
-
-body::before {
- width: 600px;
- height: 600px;
- top: -200px;
- left: -100px;
- background: radial-gradient(circle, rgba(99, 102, 241, 0.3), transparent 70%);
-}
-
-body::after {
- width: 500px;
- height: 500px;
- bottom: -150px;
- right: -100px;
- background: radial-gradient(circle, rgba(52, 211, 153, 0.2), transparent 70%);
+ background-image: linear-gradient(rgba(47, 42, 36, 0.025) 1px, transparent 1px),
+ linear-gradient(90deg, rgba(47, 42, 36, 0.025) 1px, transparent 1px);
+ background-size: 22px 22px;
+ mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.45), transparent 88%);
}
-/* ─── Shell (Page Container) ────────────────────────────────── */
.shell {
- width: min(960px, 90vw);
+ width: min(1180px, calc(100vw - 40px));
margin: 0 auto;
- padding: 48px 0 80px;
+ padding: 40px 0 72px;
position: relative;
- z-index: 1;
}
-/* ─── Hero ──────────────────────────────────────────────────── */
+.hero,
+.progress-rail,
+.step-panel,
+.detail-drawer,
+.toast {
+ background: var(--paper-panel);
+ border: 1px solid var(--line-soft);
+ box-shadow: 0 18px 42px var(--paper-shadow);
+}
+
.hero {
- margin-bottom: 40px;
- padding: 40px 36px;
+ margin-bottom: 28px;
+ padding: 34px 36px;
border-radius: var(--radius-lg);
- background: var(--panel);
- border: 1px solid var(--panel-border);
- backdrop-filter: blur(40px) saturate(1.2);
- -webkit-backdrop-filter: blur(40px) saturate(1.2);
- opacity: 0;
- transform: translateY(20px);
- animation: fadeSlideUp var(--duration-slow) var(--spring) forwards;
+ position: relative;
+ overflow: hidden;
+}
+
+.hero::after {
+ content: "";
+ position: absolute;
+ inset: auto 0 0 0;
+ height: 5px;
+ background: linear-gradient(90deg, var(--accent-olive), var(--accent-amber));
}
.eyebrow {
display: inline-flex;
- align-items: center;
- gap: 6px;
- font-size: 0.7rem;
- font-weight: 600;
- letter-spacing: 0.1em;
- text-transform: uppercase;
- color: var(--accent);
- background: var(--accent-soft);
- padding: 5px 12px;
+ margin: 0 0 14px;
+ padding: 6px 12px;
border-radius: 999px;
- margin-bottom: 16px;
+ background: var(--accent-olive-soft);
+ color: var(--accent-olive);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.11em;
+ text-transform: uppercase;
+}
+
+h1,
+h2,
+h3,
+strong {
+ font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", serif;
}
h1 {
- font-size: clamp(1.6rem, 3vw, 2.2rem);
- font-weight: 600;
- line-height: 1.15;
- letter-spacing: -0.025em;
- color: var(--text-primary);
- margin-bottom: 10px;
+ margin: 0 0 10px;
+ font-size: clamp(2rem, 4vw, 3.2rem);
+ line-height: 1.05;
+}
+
+h2,
+h3,
+p,
+ol,
+pre {
+ margin: 0;
+}
+
+h2 {
+ font-size: 1.3rem;
+ line-height: 1.2;
+}
+
+h3 {
+ font-size: 1rem;
+ margin-bottom: 8px;
+}
+
+.subhead,
+.muted,
+ol,
+.status,
+.footer {
+ color: var(--ink-soft);
}
.subhead {
- color: var(--text-secondary);
- max-width: 640px;
- line-height: 1.65;
- font-size: 0.94rem;
+ max-width: 720px;
+ font-size: 1rem;
}
-/* ─── Grid ──────────────────────────────────────────────────── */
.grid {
display: grid;
- gap: 20px;
- grid-template-columns: repeat(12, minmax(0, 1fr));
+ gap: 24px;
}
-/* ─── Cards ─────────────────────────────────────────────────── */
-.card {
- grid-column: span 6;
- background: var(--panel);
- border: 1px solid var(--panel-border);
- border-radius: var(--radius-lg);
- padding: 28px;
- backdrop-filter: blur(30px) saturate(1.15);
- -webkit-backdrop-filter: blur(30px) saturate(1.15);
- transition:
- background var(--duration-normal) var(--spring),
- border-color var(--duration-normal) var(--spring),
- transform var(--duration-normal) var(--spring),
- box-shadow var(--duration-normal) var(--spring);
+.workbench {
+ display: grid;
+ grid-template-columns: 300px minmax(0, 1fr);
+ gap: 24px;
+ align-items: start;
+}
- /* Animation — cards start hidden, revealed by IntersectionObserver in JS */
- opacity: 0;
- transform: translateY(24px) scale(0.98);
+.progress-rail {
+ border-radius: var(--radius-lg);
+ padding: 24px;
+ position: sticky;
+ top: 24px;
}
-.card.revealed {
- opacity: 1;
- transform: translateY(0) scale(1);
- transition:
- opacity var(--duration-slow) var(--spring),
- transform var(--duration-slow) var(--spring),
- background var(--duration-normal) var(--spring),
- border-color var(--duration-normal) var(--spring),
- box-shadow var(--duration-normal) var(--spring);
+.step-list {
+ list-style: none;
+ padding: 0;
+ margin: 24px 0;
+ display: grid;
+ gap: 12px;
}
-.card:hover {
- background: var(--panel-hover);
- border-color: var(--panel-border-hover);
- transform: translateY(-2px) scale(1);
- box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25);
+.step-item {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ gap: 12px;
+ align-items: start;
+ padding: 14px;
+ border-radius: var(--radius-md);
+ border: 1px solid transparent;
+ background: var(--surface-olive-wash);
}
-.card.wide {
- grid-column: span 12;
+.step-item .muted {
+ margin-top: 4px;
+ font-size: 0.9rem;
}
-.card h2 {
- font-size: 0.95rem;
- font-weight: 600;
- letter-spacing: -0.01em;
- margin-bottom: 6px;
- display: flex;
+.step-number {
+ display: inline-flex;
align-items: center;
- gap: 8px;
+ justify-content: center;
+ width: 32px;
+ height: 32px;
+ border-radius: 999px;
+ flex-shrink: 0;
+ background: var(--accent-olive-soft);
+ color: var(--accent-olive);
+ font-size: 0.88rem;
+ font-weight: 700;
}
-.card h2 .step-number {
- display: inline-flex;
+.step-item[data-state="active"] {
+ border-color: rgba(107, 122, 64, 0.28);
+ background: rgba(107, 122, 64, 0.12);
+}
+
+.step-item[data-state="complete"] {
+ border-color: rgba(107, 122, 64, 0.24);
+}
+
+.step-item[data-state="blocked"] {
+ border-color: rgba(180, 76, 55, 0.24);
+ background: var(--danger-rust-soft);
+}
+
+#common-fixes,
+#active-step-copy,
+#readiness-summary,
+#blocker-summary,
+#advanced-config {
+ padding: 18px;
+ border-radius: var(--radius-md);
+ background: var(--surface-raised);
+ border: 1px dashed var(--line-soft);
+}
+
+#blocker-summary {
+ margin-top: 14px;
+}
+
+.step-stage {
+ display: grid;
+ gap: 18px;
+ min-width: 0;
+}
+
+.mobile-step-toggle,
+#mobile-step-toggle {
+ display: none;
align-items: center;
justify-content: center;
- width: 24px;
- height: 24px;
- border-radius: 8px;
- background: var(--accent-soft);
- color: var(--accent);
+ width: fit-content;
+ padding: 10px 14px;
+ border-radius: 999px;
+ border: 1px solid var(--line-strong);
+ background: var(--surface-raised-strong);
+ color: var(--ink-strong);
+}
+
+.card,
+.step-panel,
+.detail-drawer {
+ border-radius: var(--radius-lg);
+ padding: 24px;
+}
+
+.card {
+ opacity: 0;
+ transform: translateY(18px);
+ transition:
+ opacity var(--duration-normal) var(--ease-out),
+ transform var(--duration-normal) var(--ease-out),
+ box-shadow var(--duration-fast) ease;
+}
+
+.step-panel {
+ display: grid;
+ gap: 16px;
+}
+
+.card.wide {
+ grid-column: auto;
+}
+
+.summary-card {
+ display: grid;
+ gap: 12px;
+ padding: 18px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line-soft);
+ background: var(--surface-raised);
+}
+
+.summary-card[data-state="unverified"] {
+ border-style: dashed;
+}
+
+.summary-card[data-state="complete"] {
+ border-color: rgba(107, 122, 64, 0.28);
+ background: rgba(107, 122, 64, 0.08);
+}
+
+.summary-card[data-state="blocked"],
+.summary-card[data-state="failed"] {
+ border-color: rgba(180, 76, 55, 0.26);
+ background: rgba(180, 76, 55, 0.08);
+}
+
+.summary-card[data-state="stale"] {
+ border-color: rgba(184, 106, 43, 0.3);
+ background: rgba(184, 106, 43, 0.08);
+}
+
+.summary-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px 14px;
+ margin: 0;
+}
+
+.summary-grid div {
+ min-width: 0;
+}
+
+.summary-grid dt {
+ margin: 0 0 4px;
font-size: 0.72rem;
- font-weight: 600;
- flex-shrink: 0;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-muted);
}
-.muted {
- color: var(--text-secondary);
- line-height: 1.6;
- font-size: 0.86rem;
- margin-bottom: 16px;
+.summary-grid dd {
+ margin: 0;
+ color: var(--ink-strong);
+ word-break: break-word;
}
-/* ─── Lists ─────────────────────────────────────────────────── */
-ol {
- padding-left: 20px;
- line-height: 1.7;
- color: var(--text-secondary);
- font-size: 0.88rem;
+.stale-chip {
+ display: inline-flex;
+ width: fit-content;
+ padding: 5px 10px;
+ border-radius: 999px;
+ background: rgba(184, 106, 43, 0.14);
+ color: var(--accent-amber);
+ font-size: 0.78rem;
+ font-weight: 700;
}
-ol li {
- padding-left: 4px;
- margin-bottom: 4px;
+ol {
+ padding-left: 20px;
}
-ol li::marker {
- color: var(--text-tertiary);
+ol li + li {
+ margin-top: 6px;
}
-/* ─── Code & Mono ───────────────────────────────────────────── */
code,
pre,
input,
@@ -238,19 +366,20 @@ textarea {
}
code {
- background: var(--bg-surface);
padding: 2px 6px;
- border-radius: 5px;
- font-size: 0.82em;
- color: var(--text-primary);
+ border-radius: 6px;
+ background: var(--surface-code);
+ color: var(--ink-strong);
}
-/* ─── Form Fields ───────────────────────────────────────────── */
-.field-grid {
+.field-grid,
+.toggle-grid {
display: grid;
- gap: 12px;
+ gap: 14px;
+}
+
+.field-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-bottom: 16px;
}
.field-grid.single-column {
@@ -258,8 +387,7 @@ code {
}
.field {
- display: flex;
- flex-direction: column;
+ display: grid;
gap: 6px;
}
@@ -268,24 +396,22 @@ code {
}
.field label {
- color: var(--text-tertiary);
font-size: 0.74rem;
- font-weight: 500;
- letter-spacing: 0.03em;
+ font-weight: 700;
+ letter-spacing: 0.08em;
text-transform: uppercase;
+ color: var(--ink-muted);
}
input[type="text"],
input[type="number"],
textarea {
width: 100%;
- border: 1px solid var(--panel-border);
+ padding: 11px 12px;
+ border: 1px solid var(--line-soft);
border-radius: var(--radius-sm);
- padding: 10px 12px;
- background: rgba(255, 255, 255, 0.03);
- color: var(--text-primary);
- font-size: 0.86rem;
- outline: none;
+ background: var(--surface-input);
+ color: var(--ink-strong);
transition:
border-color var(--duration-fast) ease,
box-shadow var(--duration-fast) ease,
@@ -295,347 +421,272 @@ textarea {
input[type="text"]:hover,
input[type="number"]:hover,
textarea:hover {
- border-color: rgba(255, 255, 255, 0.14);
- background: rgba(255, 255, 255, 0.045);
+ border-color: var(--line-strong);
}
input[type="text"]:focus,
input[type="number"]:focus,
textarea:focus {
- border-color: var(--accent);
- box-shadow: 0 0 0 3px var(--accent-glow);
- background: rgba(255, 255, 255, 0.05);
+ outline: none;
+ border-color: var(--accent-olive);
+ box-shadow: var(--focus-ring);
+ background: var(--surface-input-focus);
}
textarea {
+ min-height: 58px;
resize: vertical;
- min-height: 52px;
}
-/* ─── Toggles ───────────────────────────────────────────────── */
.toggle-grid {
- display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 10px;
- margin-bottom: 16px;
}
.inline {
display: flex;
+ gap: 10px;
align-items: center;
- gap: 8px;
- color: var(--text-secondary);
- font-size: 0.86rem;
- cursor: pointer;
- transition: color var(--duration-fast) ease;
-}
-
-.inline:hover {
- color: var(--text-primary);
+ padding: 12px 14px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line-soft);
+ background: var(--surface-olive-wash);
+ color: var(--ink-strong);
}
input[type="checkbox"] {
- appearance: none;
- -webkit-appearance: none;
width: 18px;
height: 18px;
- border: 1.5px solid var(--panel-border-hover);
- border-radius: 5px;
- background: rgba(255, 255, 255, 0.04);
- cursor: pointer;
- position: relative;
- flex-shrink: 0;
- transition:
- background var(--duration-fast) ease,
- border-color var(--duration-fast) ease;
-}
-
-input[type="checkbox"]:checked {
- background: var(--accent);
- border-color: var(--accent);
-}
-
-input[type="checkbox"]:checked::after {
- content: "";
- position: absolute;
- top: 3px;
- left: 5.5px;
- width: 5px;
- height: 8px;
- border: 2px solid white;
- border-top: none;
- border-left: none;
- transform: rotate(40deg);
+ margin: 0;
+ accent-color: var(--accent-olive);
}
-/* ─── Buttons ───────────────────────────────────────────────── */
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
- margin-bottom: 14px;
}
.top-space {
- margin-top: 16px;
+ margin-top: 4px;
}
button {
- border: none;
- border-radius: var(--radius-sm);
- padding: 9px 18px;
- font-family: "Inter", -apple-system, sans-serif;
- font-size: 0.82rem;
- font-weight: 600;
+ appearance: none;
+ border: 1px solid transparent;
+ border-radius: 999px;
+ padding: 10px 16px;
+ font: inherit;
+ font-weight: 700;
cursor: pointer;
- color: white;
- background: var(--accent);
- position: relative;
- overflow: hidden;
+ color: var(--paper-panel);
+ background: var(--accent-olive);
transition:
- transform var(--duration-fast) var(--spring),
- filter var(--duration-fast) ease,
- box-shadow var(--duration-fast) ease;
-}
-
-button::after {
- content: "";
- position: absolute;
- inset: 0;
- background: rgba(255, 255, 255, 0);
- transition: background var(--duration-fast) ease;
+ transform var(--duration-fast) var(--ease-out),
+ box-shadow var(--duration-fast) ease,
+ background var(--duration-fast) ease,
+ border-color var(--duration-fast) ease;
}
button:hover {
transform: translateY(-1px);
- box-shadow: 0 4px 16px rgba(99, 102, 241, 0.3);
+ box-shadow: 0 10px 24px rgba(107, 122, 64, 0.18);
}
-button:hover::after {
- background: rgba(255, 255, 255, 0.08);
+button.pressing {
+ transform: scale(0.98);
+ box-shadow: 0 6px 14px rgba(107, 122, 64, 0.16);
}
button:active {
- transform: scale(0.97);
- box-shadow: none;
+ transform: translateY(0);
}
button.warm {
- background: linear-gradient(135deg, #f59e0b, #f97316);
- color: #1a0800;
+ background: var(--accent-amber);
}
button.warm:hover {
- box-shadow: 0 4px 16px rgba(245, 158, 11, 0.3);
+ box-shadow: 0 10px 24px rgba(184, 106, 43, 0.18);
}
button.ghost {
- background: rgba(255, 255, 255, 0.06);
- color: var(--text-primary);
- border: 1px solid var(--panel-border);
+ color: var(--ink-strong);
+ background: var(--surface-raised);
+ border-color: var(--line-strong);
}
button.ghost:hover {
- background: rgba(255, 255, 255, 0.1);
- border-color: var(--panel-border-hover);
- box-shadow: none;
+ box-shadow: 0 10px 20px var(--paper-shadow-strong);
}
-/* Button press spring animation */
-button.pressing {
- animation: buttonPress var(--duration-fast) var(--spring);
-}
-
-/* ─── Pre / Output ──────────────────────────────────────────── */
pre {
- margin: 0;
- border: 1px solid var(--panel-border);
- border-radius: var(--radius-md);
- background: rgba(0, 0, 0, 0.35);
- padding: 16px;
- min-height: 100px;
- max-height: 260px;
+ min-height: 110px;
+ max-width: 100%;
overflow: auto;
- line-height: 1.55;
- font-size: 0.78rem;
- color: var(--text-secondary);
- transition: border-color var(--duration-normal) ease;
-}
-
-pre:hover {
- border-color: var(--panel-border-hover);
-}
-
-/* Loading shimmer on pre */
-pre.loading {
- position: relative;
- overflow: hidden;
+ padding: 16px;
+ border-radius: var(--radius-md);
+ border: 1px solid var(--line-soft);
+ background: var(--surface-output);
+ color: var(--ink-strong);
+ line-height: 1.6;
+ white-space: pre-wrap;
}
-pre.loading::after {
- content: "";
- position: absolute;
- inset: 0;
- background: linear-gradient(
- 90deg,
- transparent 0%,
- rgba(255, 255, 255, 0.03) 40%,
- rgba(255, 255, 255, 0.06) 50%,
- rgba(255, 255, 255, 0.03) 60%,
- transparent 100%
- );
- animation: shimmer 1.8s ease-in-out infinite;
+.detail-drawer {
+ display: grid;
+ gap: 12px;
}
-/* Custom scrollbar */
-pre::-webkit-scrollbar {
- width: 6px;
- height: 6px;
+.detail-drawer summary {
+ cursor: pointer;
+ font-weight: 700;
+ color: var(--ink-strong);
}
-pre::-webkit-scrollbar-track {
- background: transparent;
+.detail-drawer[open] summary {
+ color: var(--accent-olive);
}
-pre::-webkit-scrollbar-thumb {
- background: rgba(255, 255, 255, 0.1);
- border-radius: 3px;
+.card.revealed {
+ opacity: 1;
+ transform: translateY(0);
}
-pre::-webkit-scrollbar-thumb:hover {
- background: rgba(255, 255, 255, 0.18);
+pre.loading {
+ background-image: linear-gradient(
+ 90deg,
+ var(--surface-output) 0%,
+ #f7f1e7 45%,
+ var(--surface-output) 100%
+ );
+ background-size: 220% 100%;
+ animation: setup-loading 1.4s ease-in-out infinite;
}
-/* ─── Status ────────────────────────────────────────────────── */
.status {
- margin-top: 10px;
- font-size: 0.78rem;
- color: var(--text-tertiary);
min-height: 1.2em;
- transition: opacity var(--duration-fast) ease;
+ font-size: 0.88rem;
}
-.status.error {
- color: var(--danger);
+.status.fade-in {
+ animation: status-fade-in var(--duration-normal) var(--ease-out);
}
-.status.fade-in {
- animation: fadeIn var(--duration-normal) var(--spring);
+.status.error {
+ color: var(--danger-rust);
}
-/* ─── Toast ─────────────────────────────────────────────────── */
.toast {
position: fixed;
- bottom: 32px;
- left: 50%;
- transform: translateX(-50%) translateY(20px);
- background: var(--bg-elevated);
- border: 1px solid var(--panel-border-hover);
- color: var(--text-primary);
- padding: 10px 20px;
- border-radius: var(--radius-sm);
- font-size: 0.82rem;
- font-weight: 500;
- backdrop-filter: blur(20px);
- -webkit-backdrop-filter: blur(20px);
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
- z-index: 1000;
+ right: 24px;
+ bottom: 24px;
+ max-width: min(340px, calc(100vw - 32px));
+ padding: 14px 16px;
+ border-radius: var(--radius-md);
+ color: var(--ink-strong);
opacity: 0;
+ transform: translateY(12px);
pointer-events: none;
transition:
- opacity var(--duration-normal) var(--spring),
- transform var(--duration-normal) var(--spring);
+ opacity var(--duration-normal) ease,
+ transform var(--duration-normal) var(--ease-out);
}
.toast.visible {
opacity: 1;
- transform: translateX(-50%) translateY(0);
- pointer-events: auto;
+ transform: translateY(0);
}
-/* ─── Footer ────────────────────────────────────────────────── */
.footer {
- margin-top: 48px;
- padding-top: 24px;
- border-top: 1px solid var(--panel-border);
+ margin-top: 30px;
text-align: center;
- color: var(--text-tertiary);
- font-size: 0.74rem;
- opacity: 0;
- animation: fadeSlideUp var(--duration-slow) var(--spring) 0.8s forwards;
+ font-size: 0.82rem;
}
-/* ─── Responsive ────────────────────────────────────────────── */
@media (max-width: 860px) {
.shell {
- padding: 32px 0 60px;
+ width: min(100vw - 24px, 920px);
+ padding: 24px 0 56px;
+ }
+
+ .workbench {
+ grid-template-columns: 1fr;
}
- .hero {
- padding: 28px 24px;
+ .progress-rail {
+ position: static;
+ order: 2;
}
- .card,
- .card.wide,
- .field-span {
- grid-column: span 12;
+ #setup-progress[data-mobile-open="false"] {
+ display: none;
}
- .card {
- padding: 22px;
+ #setup-progress[data-mobile-open="true"] {
+ display: grid;
+ }
+
+ .mobile-step-toggle,
+ #mobile-step-toggle {
+ display: inline-flex;
+ }
+
+ #mobile-step-toggle[aria-expanded="true"] {
+ background: var(--accent-olive);
+ border-color: var(--accent-olive);
+ color: var(--paper-panel);
}
.field-grid,
- .toggle-grid {
+ .toggle-grid,
+ .summary-grid {
grid-template-columns: 1fr;
}
-}
-@media (max-width: 480px) {
- .shell {
- width: 94vw;
- padding: 24px 0 48px;
+ .field-span {
+ grid-column: auto;
}
+}
- .hero {
- padding: 24px 18px;
- border-radius: var(--radius-md);
+@media (min-width: 861px) {
+ #setup-progress {
+ display: block;
}
+}
- .card {
- border-radius: var(--radius-md);
+@media (max-width: 560px) {
+ .hero,
+ .card,
+ .step-panel,
+ .detail-drawer,
+ .progress-rail {
padding: 18px;
+ border-radius: 20px;
}
h1 {
- font-size: 1.4rem;
+ font-size: 1.8rem;
+ }
+}
+
+@keyframes setup-loading {
+ 0% {
+ background-position: 100% 0;
+ }
+
+ 100% {
+ background-position: -100% 0;
}
}
-/* ─── Keyframes ─────────────────────────────────────────────── */
-@keyframes fadeSlideUp {
+@keyframes status-fade-in {
from {
opacity: 0;
- transform: translateY(20px);
+ transform: translateY(2px);
}
+
to {
opacity: 1;
transform: translateY(0);
}
}
-
-@keyframes fadeIn {
- from { opacity: 0; }
- to { opacity: 1; }
-}
-
-@keyframes buttonPress {
- 0% { transform: scale(1); }
- 40% { transform: scale(0.97); }
- 100% { transform: scale(1); }
-}
-
-@keyframes shimmer {
- 0% { transform: translateX(-100%); }
- 100% { transform: translateX(100%); }
-}
diff --git a/video_rss_aggregator/static/setup.js b/video_rss_aggregator/static/setup.js
index 08d7483..4b32a2d 100644
--- a/video_rss_aggregator/static/setup.js
+++ b/video_rss_aggregator/static/setup.js
@@ -1,6 +1,9 @@
-/* ─── Micro-interaction layer ───────────────────────────────── */
-/* Preserves all existing business logic; adds reveal, press */
-/* feedback, toast, shimmer, and smooth-scroll behaviours. */
+import { createSetupApi } from "./setup_api.js";
+import { createSetupState } from "./setup_state.js";
+import { buildShellSummaryView } from "./setup_view_models.js";
+import { buildProcessSummaryView } from "./setup_view_models.js";
+import { buildProcessFailureView } from "./setup_view_models.js";
+import { createSetupViews } from "./setup_views.js";
const defaults = JSON.parse(document.getElementById("setup-defaults").textContent);
@@ -47,300 +50,227 @@ const status = {
process: document.getElementById("process-status"),
};
-/* ─── Toast ─────────────────────────────────────────────────── */
-const toastEl = document.getElementById("toast");
-let toastTimer = null;
-
-function showToast(message, duration = 2200) {
- toastEl.textContent = message;
- toastEl.classList.add("visible");
- clearTimeout(toastTimer);
- toastTimer = setTimeout(() => {
- toastEl.classList.remove("visible");
- }, duration);
-}
+const state = createSetupState({ defaults, fields });
+const views = createSetupViews({
+ document,
+ outputs,
+ status,
+ toastEl: document.getElementById("toast"),
+});
+const api = createSetupApi({
+ fetchImpl: (...args) => fetch(...args),
+ getAuthHeaders: state.authHeaders,
+});
-/* ─── Status helpers ────────────────────────────────────────── */
-function setStatus(target, message, isError = false) {
- target.style.opacity = "0";
- requestAnimationFrame(() => {
- target.textContent = message;
- target.classList.toggle("error", isError);
- target.style.opacity = "";
- target.classList.remove("fade-in");
- void target.offsetWidth; /* force reflow for re-trigger */
- target.classList.add("fade-in");
- });
-}
+function renderShell() {
+ const shellState = state.getShellState();
+ const shellSummaryView = buildShellSummaryView(shellState);
-function asBool(value) {
- return value ? "true" : "false";
+ views.renderShellState(shellState, shellSummaryView);
}
-/* ─── Shimmer helpers ───────────────────────────────────────── */
-function startLoading(preEl) {
- preEl.classList.add("loading");
+function toDiagnosticsView(report) {
+ return report.setup_view || { state: report.ready ? "ready" : "blocked" };
}
-function stopLoading(preEl) {
- preEl.classList.remove("loading");
+function toRuntimeView(runtime) {
+ return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" };
}
-/* ─── Smooth scroll to element ──────────────────────────────── */
-function scrollIntoViewSmooth(el) {
- el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+function refreshEnvBlock() {
+ views.renderEnvBlock(state.buildEnvLines());
}
-/* ─── Env block renderer ────────────────────────────────────── */
-function renderEnvBlock() {
- const apiKey = fields.apiKey.value.trim();
- const lines = [
- "# Video RSS Aggregator generated configuration",
- `BIND_ADDRESS=${fields.bind.value.trim()}`,
- `VRA_STORAGE_DIR=${fields.storage.value.trim()}`,
- `VRA_DATABASE_PATH=${fields.db.value.trim()}`,
- `VRA_OLLAMA_BASE_URL=${fields.ollama.value.trim()}`,
- `VRA_MODEL_PRIMARY=${fields.modelPrimary.value.trim()}`,
- `VRA_MODEL_FALLBACK=${fields.modelFallback.value.trim()}`,
- `VRA_MODEL_MIN=${fields.modelMin.value.trim()}`,
- `VRA_AUTO_PULL_MODELS=${asBool(fields.autoPullModels.checked)}`,
- `VRA_VRAM_BUDGET_MB=${fields.budget.value.trim()}`,
- `VRA_MODEL_SIZE_BUDGET_RATIO=${fields.budgetRatio.value.trim()}`,
- `VRA_MODEL_SELECTION_RESERVE_MB=${fields.reserve.value.trim()}`,
- `VRA_CONTEXT_TOKENS=${fields.contextTokens.value.trim()}`,
- `VRA_MAX_OUTPUT_TOKENS=${fields.outputTokens.value.trim()}`,
- `VRA_MAX_FRAMES=${fields.maxFrames.value.trim()}`,
- `VRA_FRAME_SCENE_DETECTION=${asBool(fields.sceneDetection.checked)}`,
- `VRA_FRAME_SCENE_THRESHOLD=${fields.sceneThreshold.value.trim()}`,
- `VRA_FRAME_SCENE_MIN_FRAMES=${fields.sceneMin.value.trim()}`,
- `VRA_MAX_TRANSCRIPT_CHARS=${fields.maxTranscript.value.trim()}`,
- `VRA_TRANSCRIPT_RETENTION_PER_VIDEO=${fields.transcriptRetention.value.trim()}`,
- `VRA_SUMMARY_RETENTION_PER_VIDEO=${fields.summaryRetention.value.trim()}`,
- `VRA_RSS_TITLE=${fields.rssTitle.value.trim()}`,
- `VRA_RSS_LINK=${fields.rssLink.value.trim()}`,
- `VRA_RSS_DESCRIPTION=${fields.rssDescription.value.trim()}`,
- ];
- if (apiKey) {
- lines.splice(2, 0, `API_KEY=${apiKey}`);
- }
- outputs.env.textContent = lines.join("\n");
-}
+function renderRuntimeState(runtimeView = null, runtime = null) {
+ const currentRuntimeView = runtimeView || { state: "unverified" };
+ const currentRuntime = runtime || currentRuntimeView;
-async function copyEnvBlock() {
- try {
- await navigator.clipboard.writeText(outputs.env.textContent);
- showToast("Copied to clipboard");
- } catch (error) {
- setStatus(status.env, `Copy failed: ${error}`, true);
- }
+ views.renderRuntimeSummary(currentRuntimeView, currentRuntime);
+ views.renderRuntimeDetails(currentRuntime);
+ views.renderCommonFixes(currentRuntimeView);
}
-/* ─── API helpers ───────────────────────────────────────────── */
-function authHeaders() {
- const token = fields.apiKey.value.trim();
- if (!token) {
- return {};
- }
- return { "X-API-Key": token };
+function renderProcessState(summaryView = null, detailView = null) {
+ views.renderProcessSummary(summaryView);
+ views.renderProcessDetails(detailView || summaryView || "");
}
-async function apiFetch(path, options = {}) {
- const headers = {
- ...(options.headers || {}),
- ...authHeaders(),
- };
-
- const response = await fetch(path, {
- ...options,
- headers,
- });
-
- const bodyText = await response.text();
- let data;
- try {
- data = JSON.parse(bodyText);
- } catch {
- data = bodyText;
- }
+function syncConfigurationProgress() {
+ refreshEnvBlock();
+ const configurationReady = state.markConfigurationComplete();
- if (!response.ok) {
- throw new Error(typeof data === "string" ? data : JSON.stringify(data, null, 2));
- }
- return data;
+ renderShell();
+ return configurationReady;
}
-/* ─── API operations ────────────────────────────────────────── */
-async function loadSetupConfig() {
+async function copyEnvBlock() {
try {
- const configData = await apiFetch("setup/config");
- outputs.runtime.textContent = JSON.stringify(configData, null, 2);
- setStatus(status.runtime, "Loaded setup defaults.");
+ syncConfigurationProgress();
+ await navigator.clipboard.writeText(outputs.env.textContent);
+ views.showToast("Copied to clipboard");
} catch (error) {
- setStatus(status.runtime, `Unable to load setup config: ${error}`, true);
+ views.setStatus(status.env, `Copy failed: ${error}`, true);
}
}
-async function runDiagnostics() {
- setStatus(status.diagnostics, "Running diagnostics...");
- startLoading(outputs.diagnostics);
+async function handleDiagnostics() {
+ const requestDiagnostics = api.runDiagnostics;
+
+ state.beginDiagnosticsCheck();
+ renderShell();
+ views.setStatus(status.diagnostics, "Running diagnostics...");
+ views.startLoading(outputs.diagnostics);
try {
- const report = await apiFetch("setup/diagnostics");
+ const report = await requestDiagnostics();
+ const diagnosticsView = toDiagnosticsView(report);
+
+ state.applyDiagnosticsResult(diagnosticsView);
+ syncConfigurationProgress();
outputs.diagnostics.textContent = JSON.stringify(report, null, 2);
+ views.renderDiagnosticsSummary(diagnosticsView);
+ views.renderCommonFixes(diagnosticsView);
if (report.ready) {
- setStatus(status.diagnostics, "All required dependencies are available.");
+ views.setStatus(status.diagnostics, "All required dependencies are available.");
} else {
- setStatus(status.diagnostics, "Some dependencies are missing or unreachable.", true);
+ views.setStatus(status.diagnostics, "Some dependencies are missing or unreachable.", true);
}
} catch (error) {
- setStatus(status.diagnostics, `Diagnostics failed: ${error}`, true);
+ state.applyDiagnosticsResult({ state: "blocked" });
+ views.setStatus(status.diagnostics, `Diagnostics failed: ${error}`, true);
} finally {
- stopLoading(outputs.diagnostics);
- scrollIntoViewSmooth(outputs.diagnostics);
+ renderShell();
+ views.stopLoading(outputs.diagnostics);
+ views.scrollIntoViewSmooth(outputs.diagnostics);
}
}
-async function checkRuntime() {
- setStatus(status.runtime, "Checking runtime...");
- startLoading(outputs.runtime);
+const handleDiagnosticsRun = handleDiagnostics;
+
+async function handleRuntimeCheck() {
+ if (!state.beginRuntimeCheck()) {
+ renderShell();
+ views.setStatus(
+ status.runtime,
+ "Complete prerequisites and refresh the configuration before running runtime checks.",
+ true,
+ );
+ return;
+ }
+
+ renderShell();
+ views.setStatus(status.runtime, "Checking runtime...");
+ views.startLoading(outputs.runtime);
try {
- const runtime = await apiFetch("runtime");
- outputs.runtime.textContent = JSON.stringify(runtime, null, 2);
- setStatus(status.runtime, "Runtime check completed.");
+ const runtime = await api.checkRuntime();
+ const runtimeView = toRuntimeView(runtime);
+
+ state.completeRuntimeCheck(runtimeView);
+ renderRuntimeState(runtimeView, runtime);
+ views.setStatus(status.runtime, "Runtime check completed.");
} catch (error) {
- setStatus(status.runtime, `Runtime check failed: ${error}`, true);
+ state.completeRuntimeCheck({ state: "blocked" });
+ renderRuntimeState({ state: "blocked" }, { reachable: false, error: String(error) });
+ views.setStatus(status.runtime, `Runtime check failed: ${error}`, true);
} finally {
- stopLoading(outputs.runtime);
- scrollIntoViewSmooth(outputs.runtime);
+ renderShell();
+ views.stopLoading(outputs.runtime);
+ views.scrollIntoViewSmooth(outputs.runtime);
}
}
-async function bootstrapModels() {
- setStatus(status.runtime, "Bootstrapping models...");
- startLoading(outputs.runtime);
+async function handleBootstrapModels() {
+ if (!state.beginRuntimeCheck()) {
+ renderShell();
+ views.setStatus(
+ status.runtime,
+ "Complete prerequisites and refresh the configuration before bootstrapping models.",
+ true,
+ );
+ return;
+ }
+
+ renderShell();
+ views.setStatus(status.runtime, "Bootstrapping models...");
+ views.startLoading(outputs.runtime);
try {
- const report = await apiFetch("setup/bootstrap", { method: "POST" });
- outputs.runtime.textContent = JSON.stringify(report, null, 2);
- setStatus(status.runtime, "Models are ready.");
+ const report = await api.bootstrapModels();
+ const runtime = await api.checkRuntime();
+ const runtimeView = toRuntimeView(runtime);
+
+ state.completeRuntimeCheck(runtimeView);
+ renderRuntimeState(runtimeView, { ...runtime, bootstrap: report });
+ views.setStatus(status.runtime, "Models are ready.");
} catch (error) {
- setStatus(status.runtime, `Bootstrap failed: ${error}`, true);
+ state.completeRuntimeCheck({ state: "blocked" });
+ renderRuntimeState({ state: "blocked" }, { reachable: false, error: String(error) });
+ views.setStatus(status.runtime, `Bootstrap failed: ${error}`, true);
} finally {
- stopLoading(outputs.runtime);
- scrollIntoViewSmooth(outputs.runtime);
+ renderShell();
+ views.stopLoading(outputs.runtime);
+ views.scrollIntoViewSmooth(outputs.runtime);
}
}
-async function runProcess() {
+async function handleProcessRun() {
const sourceUrl = fields.processSource.value.trim();
if (!sourceUrl) {
- setStatus(status.process, "Please provide a source URL or local path.", true);
+ views.setStatus(status.process, "Please provide a source URL or local path.", true);
+ return;
+ }
+
+ if (!state.beginProcess()) {
+ renderShell();
+ views.setStatus(status.process, "Complete the runtime check before processing a source.", true);
return;
}
- setStatus(status.process, "Processing source... this may take a while.");
- startLoading(outputs.process);
+ renderShell();
+ views.setStatus(status.process, "Processing source... this may take a while.");
+ views.startLoading(outputs.process);
try {
- const payload = {
+ const result = await api.runProcess({
source_url: sourceUrl,
title: fields.processTitle.value.trim() || null,
- };
- const result = await apiFetch("process", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
});
- outputs.process.textContent = JSON.stringify(result, null, 2);
- setStatus(status.process, "Processing completed.");
+ const summaryView = buildProcessSummaryView(result);
+ state.markProcessSuccess(result);
+ renderProcessState(summaryView, summaryView);
+ views.setStatus(status.process, "Processing completed.");
} catch (error) {
- setStatus(status.process, `Process request failed: ${error}`, true);
+ state.markProcessFailure(error);
+ const priorSummary = state.steps.process.summary
+ ? buildProcessSummaryView(state.steps.process.summary, true)
+ : null;
+ const failureView = buildProcessFailureView(error, priorSummary);
+ const summaryView = priorSummary || failureView;
+ renderProcessState(summaryView, failureView);
+ views.setStatus(status.process, `Process request failed: ${error}`, true);
} finally {
- stopLoading(outputs.process);
- scrollIntoViewSmooth(outputs.process);
+ renderShell();
+ views.stopLoading(outputs.process);
+ views.scrollIntoViewSmooth(outputs.process);
}
}
-/* ─── Hydrate defaults ──────────────────────────────────────── */
-function hydrateDefaults() {
- fields.bind.value = defaults.bind_address || "127.0.0.1:8080";
- fields.apiKey.value = defaults.api_key || "";
- fields.storage.value = defaults.storage_dir || ".data";
- fields.db.value = defaults.database_path || ".data/vra.db";
- fields.ollama.value = defaults.ollama_base_url || "http://127.0.0.1:11434";
- fields.modelPrimary.value = defaults.model_primary || "qwen3.5:4b-q4_K_M";
- fields.modelFallback.value = defaults.model_fallback || "qwen3.5:2b-q4_K_M";
- fields.modelMin.value = defaults.model_min || "qwen3.5:0.8b-q8_0";
- fields.autoPullModels.checked = Boolean(defaults.auto_pull_models);
- fields.budget.value = defaults.vram_budget_mb ?? 8192;
- fields.budgetRatio.value = defaults.model_size_budget_ratio ?? 0.75;
- fields.reserve.value = defaults.model_selection_reserve_mb ?? 768;
- fields.contextTokens.value = defaults.context_tokens ?? 3072;
- fields.outputTokens.value = defaults.max_output_tokens ?? 768;
- fields.maxFrames.value = defaults.max_frames ?? 5;
- fields.sceneDetection.checked = Boolean(defaults.frame_scene_detection);
- fields.sceneThreshold.value = defaults.frame_scene_threshold ?? 0.28;
- fields.sceneMin.value = defaults.frame_scene_min_frames ?? 2;
- fields.maxTranscript.value = defaults.max_transcript_chars ?? 16000;
- fields.transcriptRetention.value = defaults.transcript_retention_per_video ?? 3;
- fields.summaryRetention.value = defaults.summary_retention_per_video ?? 5;
- fields.rssTitle.value = defaults.rss_title || "Video RSS Aggregator";
- fields.rssLink.value = defaults.rss_link || "http://127.0.0.1:8080/rss";
- fields.rssDescription.value = defaults.rss_description || "Video summaries";
- fields.processSource.value = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
- renderEnvBlock();
+function bindEvents() {
+ document.getElementById("copy-env").addEventListener("click", copyEnvBlock);
+ document.getElementById("run-diagnostics").addEventListener("click", handleDiagnosticsRun);
+ document.getElementById("runtime-check").addEventListener("click", handleRuntimeCheck);
+ document.getElementById("bootstrap-models").addEventListener("click", handleBootstrapModels);
+ document.getElementById("process-run").addEventListener("click", handleProcessRun);
+ views.bindEnvInputs(fields, syncConfigurationProgress);
}
-/* ─── Button press feedback ─────────────────────────────────── */
-function addPressEffect(button) {
- button.addEventListener("mousedown", () => {
- button.classList.remove("pressing");
- void button.offsetWidth;
- button.classList.add("pressing");
- });
-
- button.addEventListener("animationend", () => {
- button.classList.remove("pressing");
- });
+function boot() {
+ state.hydrateDefaults();
+ renderShell();
+ syncConfigurationProgress();
+ renderRuntimeState();
+ renderProcessState();
+ bindEvents();
+ views.bindMobileStepToggle();
+ views.bindPressEffects();
+ views.bindRevealCards();
}
-document.querySelectorAll("button").forEach(addPressEffect);
-
-/* ─── Event bindings ────────────────────────────────────────── */
-document.getElementById("copy-env").addEventListener("click", copyEnvBlock);
-document.getElementById("run-diagnostics").addEventListener("click", runDiagnostics);
-document.getElementById("runtime-check").addEventListener("click", checkRuntime);
-document.getElementById("bootstrap-models").addEventListener("click", bootstrapModels);
-document.getElementById("process-run").addEventListener("click", runProcess);
-
-Object.values(fields).forEach((node) => {
- if (!node || node === fields.processSource || node === fields.processTitle) {
- return;
- }
- node.addEventListener("input", renderEnvBlock);
- node.addEventListener("change", renderEnvBlock);
-});
-
-/* ─── Intersection Observer — staggered card reveal ─────────── */
-const revealObserver = new IntersectionObserver(
- (entries) => {
- entries.forEach((entry) => {
- if (entry.isIntersecting) {
- const card = entry.target;
- const delay = getComputedStyle(card).getPropertyValue("--reveal-delay").trim();
- const ms = parseInt(delay, 10) || 0;
- setTimeout(() => {
- card.classList.add("revealed");
- }, ms);
- revealObserver.unobserve(card);
- }
- });
- },
- { threshold: 0.08 }
-);
-
-document.querySelectorAll(".card").forEach((card) => {
- revealObserver.observe(card);
-});
-
-/* ─── Boot ──────────────────────────────────────────────────── */
-hydrateDefaults();
-runDiagnostics();
-loadSetupConfig();
+boot();
diff --git a/video_rss_aggregator/static/setup_api.js b/video_rss_aggregator/static/setup_api.js
new file mode 100644
index 0000000..12cd455
--- /dev/null
+++ b/video_rss_aggregator/static/setup_api.js
@@ -0,0 +1,55 @@
+export function createSetupApi({ fetchImpl, getAuthHeaders }) {
+ async function apiFetch(path, options = {}) {
+ const headers = {
+ ...(options.headers || {}),
+ ...getAuthHeaders(),
+ };
+
+ const response = await fetchImpl(path, {
+ ...options,
+ headers,
+ });
+
+ const bodyText = await response.text();
+ let data;
+ try {
+ data = JSON.parse(bodyText);
+ } catch {
+ data = bodyText;
+ }
+
+ if (!response.ok) {
+ throw new Error(typeof data === "string" ? data : JSON.stringify(data, null, 2));
+ }
+
+ return data;
+ }
+
+ function runDiagnostics() {
+ return apiFetch("setup/diagnostics");
+ }
+
+ function checkRuntime() {
+ return apiFetch("runtime");
+ }
+
+ function bootstrapModels() {
+ return apiFetch("setup/bootstrap", { method: "POST" });
+ }
+
+ function runProcess(payload) {
+ return apiFetch("process", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ }
+
+ return {
+ apiFetch,
+ bootstrapModels,
+ checkRuntime,
+ runDiagnostics,
+ runProcess,
+ };
+}
diff --git a/video_rss_aggregator/static/setup_state.js b/video_rss_aggregator/static/setup_state.js
new file mode 100644
index 0000000..d877371
--- /dev/null
+++ b/video_rss_aggregator/static/setup_state.js
@@ -0,0 +1,216 @@
+function asBool(value) {
+ return value ? "true" : "false";
+}
+
+const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"];
+
+export function createSetupState({ defaults = {}, fields = {} } = {}) {
+ const steps = {
+ prerequisites: { state: "unverified" },
+ configuration: { state: "ready" },
+ runtime: { state: "unverified" },
+ process: { state: "unverified", stale: false, summary: null, error: null },
+ };
+ const shellState = { steps };
+
+ function getStepStatus(stepId) {
+ if (stepId === "configuration" && steps.prerequisites.state !== "complete") {
+ return "blocked";
+ }
+
+ if (stepId === "runtime" && steps.configuration.state !== "complete") {
+ return "blocked";
+ }
+
+ if (stepId === "process" && steps.runtime.state !== "complete") {
+ return "blocked";
+ }
+
+ return steps[stepId].state;
+ }
+
+ function getActiveStep() {
+ const runningStep = STEP_ORDER.find((stepId) => steps[stepId].state === "running");
+ if (runningStep) {
+ return runningStep;
+ }
+
+ const failedStep = STEP_ORDER.find((stepId) => steps[stepId].state === "failed");
+ if (failedStep) {
+ return failedStep;
+ }
+
+ return getRecommendedNextStep() || "process";
+ }
+
+ function getShellState() {
+ return {
+ activeStep: getActiveStep(),
+ steps: STEP_ORDER.map((stepId) => ({
+ id: stepId,
+ status: getStepStatus(stepId),
+ })),
+ };
+ }
+
+ function buildEnvLines() {
+ const apiKey = fields.apiKey.value.trim();
+ const lines = [
+ "# Video RSS Aggregator generated configuration",
+ `BIND_ADDRESS=${fields.bind.value.trim()}`,
+ `VRA_STORAGE_DIR=${fields.storage.value.trim()}`,
+ `VRA_DATABASE_PATH=${fields.db.value.trim()}`,
+ `VRA_OLLAMA_BASE_URL=${fields.ollama.value.trim()}`,
+ `VRA_MODEL_PRIMARY=${fields.modelPrimary.value.trim()}`,
+ `VRA_MODEL_FALLBACK=${fields.modelFallback.value.trim()}`,
+ `VRA_MODEL_MIN=${fields.modelMin.value.trim()}`,
+ `VRA_AUTO_PULL_MODELS=${asBool(fields.autoPullModels.checked)}`,
+ `VRA_VRAM_BUDGET_MB=${fields.budget.value.trim()}`,
+ `VRA_MODEL_SIZE_BUDGET_RATIO=${fields.budgetRatio.value.trim()}`,
+ `VRA_MODEL_SELECTION_RESERVE_MB=${fields.reserve.value.trim()}`,
+ `VRA_CONTEXT_TOKENS=${fields.contextTokens.value.trim()}`,
+ `VRA_MAX_OUTPUT_TOKENS=${fields.outputTokens.value.trim()}`,
+ `VRA_MAX_FRAMES=${fields.maxFrames.value.trim()}`,
+ `VRA_FRAME_SCENE_DETECTION=${asBool(fields.sceneDetection.checked)}`,
+ `VRA_FRAME_SCENE_THRESHOLD=${fields.sceneThreshold.value.trim()}`,
+ `VRA_FRAME_SCENE_MIN_FRAMES=${fields.sceneMin.value.trim()}`,
+ `VRA_MAX_TRANSCRIPT_CHARS=${fields.maxTranscript.value.trim()}`,
+ `VRA_TRANSCRIPT_RETENTION_PER_VIDEO=${fields.transcriptRetention.value.trim()}`,
+ `VRA_SUMMARY_RETENTION_PER_VIDEO=${fields.summaryRetention.value.trim()}`,
+ `VRA_RSS_TITLE=${fields.rssTitle.value.trim()}`,
+ `VRA_RSS_LINK=${fields.rssLink.value.trim()}`,
+ `VRA_RSS_DESCRIPTION=${fields.rssDescription.value.trim()}`,
+ ];
+
+ if (apiKey) {
+ lines.splice(2, 0, `API_KEY=${apiKey}`);
+ }
+
+ return lines;
+ }
+
+ function authHeaders() {
+ const token = fields.apiKey.value.trim();
+ return token ? { "X-API-Key": token } : {};
+ }
+
+ function beginDiagnosticsCheck() {
+ steps.prerequisites.state = "running";
+ }
+
+ function applyDiagnosticsResult(view) {
+ const diagnosticsReady = view.state === "ready";
+
+ steps.prerequisites.state = diagnosticsReady ? "complete" : "blocked";
+ steps.configuration.state = "ready";
+ steps.runtime.state = "unverified";
+ steps.process = { ...steps.process, state: "unverified", error: null };
+ }
+
+ function markConfigurationComplete() {
+ if (steps.prerequisites.state !== "complete") {
+ return false;
+ }
+
+ steps.configuration.state = "complete";
+ return true;
+ }
+
+ function beginRuntimeCheck() {
+ if (steps.prerequisites.state !== "complete") {
+ return false;
+ }
+
+ if (steps.configuration.state !== "complete") {
+ return false;
+ }
+
+ steps.runtime.state = "running";
+ return true;
+ }
+
+ function completeRuntimeCheck(view) {
+ const runtimeReady = view.state === "ready";
+
+ steps.runtime.state = runtimeReady ? "complete" : "blocked";
+ steps.process = {
+ ...steps.process,
+ state: "unverified",
+ error: runtimeReady ? null : steps.process.error,
+ };
+ }
+
+ function beginProcess() {
+ if (getStepStatus("runtime") !== "complete") {
+ return false;
+ }
+
+ steps.process.state = "running";
+ return true;
+ }
+
+ function markProcessSuccess(summary) {
+ steps.process = { state: "complete", stale: false, summary, error: null };
+ }
+
+ function markProcessFailure(error) {
+ steps.process = {
+ ...steps.process,
+ state: "failed",
+ stale: Boolean(steps.process.summary),
+ error,
+ };
+ }
+
+ function getRecommendedNextStep() {
+ return STEP_ORDER.find((stepId) =>
+ ["ready", "unverified", "blocked"].includes(getStepStatus(stepId)),
+ );
+ }
+
+ function hydrateDefaults() {
+ fields.bind.value = defaults.bind_address || "127.0.0.1:8080";
+ fields.apiKey.value = defaults.api_key || "";
+ fields.storage.value = defaults.storage_dir || ".data";
+ fields.db.value = defaults.database_path || ".data/vra.db";
+ fields.ollama.value = defaults.ollama_base_url || "http://127.0.0.1:11434";
+ fields.modelPrimary.value = defaults.model_primary || "qwen3.5:4b-q4_K_M";
+ fields.modelFallback.value = defaults.model_fallback || "qwen3.5:2b-q4_K_M";
+ fields.modelMin.value = defaults.model_min || "qwen3.5:0.8b-q8_0";
+ fields.autoPullModels.checked = Boolean(defaults.auto_pull_models);
+ fields.budget.value = defaults.vram_budget_mb ?? 8192;
+ fields.budgetRatio.value = defaults.model_size_budget_ratio ?? 0.75;
+ fields.reserve.value = defaults.model_selection_reserve_mb ?? 768;
+ fields.contextTokens.value = defaults.context_tokens ?? 3072;
+ fields.outputTokens.value = defaults.max_output_tokens ?? 768;
+ fields.maxFrames.value = defaults.max_frames ?? 5;
+ fields.sceneDetection.checked = Boolean(defaults.frame_scene_detection);
+ fields.sceneThreshold.value = defaults.frame_scene_threshold ?? 0.28;
+ fields.sceneMin.value = defaults.frame_scene_min_frames ?? 2;
+ fields.maxTranscript.value = defaults.max_transcript_chars ?? 16000;
+ fields.transcriptRetention.value = defaults.transcript_retention_per_video ?? 3;
+ fields.summaryRetention.value = defaults.summary_retention_per_video ?? 5;
+ fields.rssTitle.value = defaults.rss_title || "Video RSS Aggregator";
+ fields.rssLink.value = defaults.rss_link || "http://127.0.0.1:8080/rss";
+ fields.rssDescription.value = defaults.rss_description || "Video summaries";
+ fields.processSource.value = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
+ }
+
+ return {
+ authHeaders,
+ beginDiagnosticsCheck,
+ beginProcess,
+ beginRuntimeCheck,
+ buildEnvLines,
+ applyDiagnosticsResult,
+ completeRuntimeCheck,
+ getShellState,
+ getRecommendedNextStep,
+ hydrateDefaults,
+ markConfigurationComplete,
+ markProcessFailure,
+ markProcessSuccess,
+ shellState,
+ steps,
+ };
+}
diff --git a/video_rss_aggregator/static/setup_view_models.js b/video_rss_aggregator/static/setup_view_models.js
new file mode 100644
index 0000000..9ec1935
--- /dev/null
+++ b/video_rss_aggregator/static/setup_view_models.js
@@ -0,0 +1,164 @@
+function toTitleCase(value) {
+ return String(value || "")
+ .split(/[-_\s]+/)
+ .filter(Boolean)
+ .map((token) => token.charAt(0).toUpperCase() + token.slice(1))
+ .join(" ");
+}
+
+function formatCount(value, noun) {
+ if (value === null || value === undefined || value === "") {
+ return `${noun} not reported`;
+ }
+
+ const count = Number(value);
+ if (Number.isNaN(count)) {
+ return `${value} ${noun}`;
+ }
+
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
+}
+
+function collectProcessFrameCount(result) {
+ if (typeof result.frame_count === "number") {
+ return result.frame_count;
+ }
+
+ if (typeof result.frames_processed === "number") {
+ return result.frames_processed;
+ }
+
+ if (typeof result.selected_frame_count === "number") {
+ return result.selected_frame_count;
+ }
+
+ if (Array.isArray(result.frames)) {
+ return result.frames.length;
+ }
+
+ return null;
+}
+
+function collectTranscriptChars(result) {
+ if (typeof result.transcript_chars === "number") {
+ return result.transcript_chars;
+ }
+
+ if (typeof result.transcript_length === "number") {
+ return result.transcript_length;
+ }
+
+ if (typeof result.transcript === "string") {
+ return result.transcript.length;
+ }
+
+ return null;
+}
+
+function collectProcessModel(result) {
+ return (
+ result.model ||
+ result.model_name ||
+ result.summary_model ||
+ result.selected_model ||
+ "Model not reported"
+ );
+}
+
+function describeScalar(value) {
+ if (value === null || value === undefined || value === "") {
+ return "Not reported";
+ }
+
+ if (typeof value === "boolean") {
+ return value ? "Yes" : "No";
+ }
+
+ return String(value);
+}
+
+function buildDetailRows(payload) {
+ return Object.entries(payload || {})
+ .filter(([, value]) => value !== null && value !== undefined)
+ .map(([key, value]) => {
+ if (typeof value === "object") {
+ return `${toTitleCase(key)}: ${JSON.stringify(value, null, 2)}`;
+ }
+
+ return `${toTitleCase(key)}: ${value}`;
+ });
+}
+
+function stringifyPayload(payload) {
+ return JSON.stringify(payload, null, 2);
+}
+
+export function buildShellSummaryView(shellState) {
+ const activeStep = shellState.steps.find((step) => step.id === shellState.activeStep);
+ const activeStepLabel = activeStep ? toTitleCase(activeStep.id) : "Prerequisites";
+ const blockedSteps = shellState.steps.filter((step) => step.status === "blocked");
+
+ return {
+ activeStepId: shellState.activeStep,
+ activeStepLabel,
+ readinessTitle: "Readiness",
+ readinessBody: `Start with ${activeStepLabel.toLowerCase()}.`,
+ blockerTitle: "Current blockers",
+ blockerBody:
+ blockedSteps.length > 0
+ ? `${blockedSteps.length} step blockers need attention.`
+ : "Diagnostics and runtime checks will surface anything still missing.",
+ activeStepEyebrow: "Active step",
+ activeStepTitle: `Focus: ${activeStepLabel}`,
+ activeStepBody: "Move through the workbench one step at a time.",
+ };
+}
+
+export function buildStaleSummaryLabel() {
+ return "Showing the last successful result while the latest rerun failed.";
+}
+
+export function buildProcessSummaryView(result, stale = false) {
+ if (!result || typeof result !== "object") {
+ const summaryLines = [String(result)];
+
+ return {
+ heading: stale ? "Previous successful run" : "Process result",
+ title: "No successful process result yet",
+ model: "Model not reported",
+ frameCount: "Frames not reported",
+ transcriptChars: "Transcript not reported",
+ source: "Not reported",
+ feedUrl: "Not reported",
+ staleLabel: stale ? buildStaleSummaryLabel() : "",
+ detailLines: summaryLines,
+ raw: stringifyPayload(result),
+ };
+ }
+
+ const summaryLines = buildDetailRows(result);
+
+ return {
+ heading: stale ? "Previous successful run" : "Latest successful run",
+ title: describeScalar(result.title || result.source_title || result.video_title || "Untitled source"),
+ model: describeScalar(collectProcessModel(result)),
+ frameCount: formatCount(collectProcessFrameCount(result), "frame"),
+ transcriptChars: formatCount(collectTranscriptChars(result), "transcript char"),
+ source: describeScalar(result.source_url || result.url || result.source_path),
+ feedUrl: describeScalar(result.feed_url || result.rss_url),
+ staleLabel: stale ? buildStaleSummaryLabel() : "",
+ detailLines: summaryLines,
+ raw: stringifyPayload(result),
+ };
+}
+
+export function buildProcessFailureView(error, priorSummary = null) {
+ const message = error instanceof Error ? error.message : String(error);
+
+ return {
+ heading: "Latest run failed",
+ message,
+ staleLabel: priorSummary ? buildStaleSummaryLabel() : "",
+ detailLines: [`Error: ${message}`],
+ };
+}
diff --git a/video_rss_aggregator/static/setup_views.js b/video_rss_aggregator/static/setup_views.js
new file mode 100644
index 0000000..fa5d460
--- /dev/null
+++ b/video_rss_aggregator/static/setup_views.js
@@ -0,0 +1,405 @@
+export function createSetupViews({ document, outputs, status, toastEl }) {
+ let toastTimer = null;
+
+ function appendTextNode(parent, tagName, text, className = "") {
+ const node = document.createElement(tagName);
+ if (className) {
+ node.className = className;
+ }
+ node.textContent = text;
+ parent.appendChild(node);
+ return node;
+ }
+
+ function buildSummaryGrid(rows) {
+ const list = document.createElement("dl");
+ list.className = "summary-grid";
+
+ rows.forEach(([label, value]) => {
+ const wrapper = document.createElement("div");
+ const term = document.createElement("dt");
+ const detail = document.createElement("dd");
+
+ term.textContent = label;
+ detail.textContent = value;
+ wrapper.append(term, detail);
+ list.appendChild(wrapper);
+ });
+
+ return list;
+ }
+
+ function showToast(message, duration = 2200) {
+ toastEl.textContent = message;
+ toastEl.classList.add("visible");
+ clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => {
+ toastEl.classList.remove("visible");
+ }, duration);
+ }
+
+ function setStatus(target, message, isError = false) {
+ target.style.opacity = "0";
+ requestAnimationFrame(() => {
+ target.textContent = message;
+ target.classList.toggle("error", isError);
+ target.style.opacity = "";
+ target.classList.remove("fade-in");
+ void target.offsetWidth;
+ target.classList.add("fade-in");
+ });
+ }
+
+ function startLoading(preEl) {
+ preEl.classList.add("loading");
+ }
+
+ function stopLoading(preEl) {
+ preEl.classList.remove("loading");
+ }
+
+ function scrollIntoViewSmooth(el) {
+ el.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+
+ function renderEnvBlock(lines) {
+ outputs.env.textContent = lines.join("\n");
+ }
+
+ function renderProgress(shellState) {
+ document.querySelectorAll(".step-item").forEach((stepEl) => {
+ const stepId = stepEl.dataset.stepId;
+ const step = shellState.steps.find((item) => item.id === stepId);
+ if (!step) {
+ return;
+ }
+
+ const panel = document.getElementById(`step-panel-${stepId}`);
+ const state = step.id === shellState.activeStep ? "active" : step.status;
+
+ stepEl.setAttribute("data-state", state);
+ if (panel) {
+ panel.setAttribute("data-state", state);
+ }
+ });
+ }
+
+ function renderActiveStep(shellSummaryView) {
+ const activeStepCopy = document.getElementById("active-step-copy");
+ const eyebrow = document.createElement("p");
+ eyebrow.className = "eyebrow";
+ eyebrow.textContent = shellSummaryView.activeStepEyebrow;
+ const heading = document.createElement("h2");
+ heading.textContent = shellSummaryView.activeStepTitle;
+ const body = document.createElement("p");
+ body.className = "muted";
+ body.textContent = shellSummaryView.activeStepBody;
+ activeStepCopy.replaceChildren(eyebrow, heading, body);
+ }
+
+ function renderShellState(shellState, shellSummaryView) {
+ renderProgress(shellState);
+
+ const readinessSummary = document.getElementById("readiness-summary");
+ const blockerSummary = document.getElementById("blocker-summary");
+
+ const readinessHeading = document.createElement("h2");
+ readinessHeading.textContent = shellSummaryView.readinessTitle;
+ const readinessBody = document.createElement("p");
+ readinessBody.className = "muted";
+ readinessBody.textContent = shellSummaryView.readinessBody;
+ readinessSummary.replaceChildren(readinessHeading, readinessBody);
+
+ const blockerHeading = document.createElement("h2");
+ blockerHeading.textContent = shellSummaryView.blockerTitle;
+ const blockerBody = document.createElement("p");
+ blockerBody.className = "muted";
+ blockerBody.textContent = shellSummaryView.blockerBody;
+ blockerSummary.replaceChildren(blockerHeading, blockerBody);
+ renderActiveStep(shellSummaryView);
+ }
+
+ function renderDiagnosticsSummary(report) {
+ const blockers = Array.isArray(report?.blockers) ? report.blockers : [];
+ const warningChecks = Array.isArray(report?.checks)
+ ? report.checks.filter((check) => check.state !== "complete")
+ : [];
+ const lines = [
+ report.ready
+ || report?.state === "ready"
+ ? "Prerequisites look ready for configuration and runtime checks."
+ : "Diagnostics found blockers you should address before continuing.",
+ ];
+
+ if (blockers.length > 0) {
+ lines.push(`Fixes: ${blockers.join(" | ")}`);
+ } else if (Array.isArray(report?.missing) && report.missing.length > 0) {
+ lines.push(`Missing: ${report.missing.join(", ")}`);
+ }
+
+ if (warningChecks.length > 0) {
+ lines.push(`Checks: ${warningChecks.map((check) => `${check.label}: ${check.detail}`).join(" | ")}`);
+ } else if (Array.isArray(report?.warnings) && report.warnings.length > 0) {
+ lines.push(`Warnings: ${report.warnings.join(" | ")}`);
+ }
+
+ outputs.diagnostics.textContent = lines.join("\n");
+ }
+
+ function renderCommonFixes(view) {
+ const container = document.getElementById("common-fixes");
+ const heading = document.createElement("h2");
+ heading.textContent = "Common fixes";
+
+ const fixes = Array.isArray(view?.checks)
+ ? view.checks.filter((check) => check.fix)
+ : Array.isArray(view?.missing_models)
+ ? view.missing_models.map((model) => ({
+ label: model,
+ detail: "Required locally before processing can start.",
+ fix: `Pull or bootstrap ${model}`,
+ }))
+ : [];
+
+ if (fixes.length === 0) {
+ const body = document.createElement("p");
+ body.className = "muted";
+ body.textContent = view?.next_action || "Quick links and remediation steps will appear here.";
+ container.replaceChildren(heading, body);
+ return;
+ }
+
+ const list = document.createElement("ol");
+ fixes.forEach((item) => {
+ const row = document.createElement("li");
+ const title = document.createElement("strong");
+ title.textContent = item.label || "Fix";
+ const detail = document.createElement("p");
+ detail.className = "muted";
+ detail.textContent = item.detail || item.fix;
+ const fix = document.createElement("p");
+ fix.textContent = item.fix;
+ row.append(title, detail, fix);
+ list.appendChild(row);
+ });
+
+ container.replaceChildren(heading, list);
+ }
+
+ function renderRuntimeSummary(runtimeView, runtime) {
+ const summary = document.getElementById("runtime-summary");
+ const isUnverified = !runtimeView || runtimeView.state === "unverified";
+
+ if (isUnverified) {
+ summary.setAttribute("data-state", "unverified");
+ const eyebrow = appendTextNode(summary, "p", "Runtime summary", "eyebrow");
+ const heading = appendTextNode(summary, "h3", "Runtime not checked yet");
+ const body = appendTextNode(
+ summary,
+ "p",
+ "Run a runtime check or bootstrap models to confirm connectivity and local model availability.",
+ "muted",
+ );
+ summary.replaceChildren(eyebrow, heading, body);
+ return;
+ }
+
+ const requiredModels = Array.isArray(runtime?.models) ? runtime.models : [];
+ const localModels = Array.isArray(runtime?.local_models)
+ ? runtime.local_models
+ : Object.keys(runtime?.local_models || {});
+ const databasePath = runtime?.database_path || "Database path not reported";
+ const storageDir = runtime?.storage_dir || "Storage directory not reported";
+ const eyebrow = document.createElement("p");
+ eyebrow.className = "eyebrow";
+ eyebrow.textContent = "Runtime summary";
+ const heading = document.createElement("h3");
+ heading.textContent = runtimeView?.state === "ready" ? "Runtime ready for processing" : "Runtime needs local model setup";
+ const body = document.createElement("p");
+ body.className = "muted";
+ body.textContent = runtimeView?.next_action || "Check runtime connectivity and model readiness.";
+ const grid = buildSummaryGrid([
+ ["Required models", `${requiredModels.length} configured`],
+ ["Available locally", `${localModels.length} ready`],
+ ["Database", databasePath],
+ ["Storage", storageDir],
+ ]);
+
+ summary.setAttribute("data-state", runtimeView?.state === "ready" ? "complete" : "blocked");
+ summary.replaceChildren(eyebrow, heading, body, grid);
+ }
+
+ function renderRuntimeDetails(runtime) {
+ outputs.runtime.textContent = JSON.stringify(runtime, null, 2);
+ }
+
+ function renderProcessSummary(summaryView) {
+ const summary = document.getElementById("process-summary");
+ const isStale = Boolean(summaryView?.staleLabel);
+ const isFailure = Boolean(summaryView?.message);
+
+ summary.setAttribute(
+ "data-state",
+ isFailure ? "failed" : isStale ? "stale" : summaryView?.title ? "complete" : "unverified",
+ );
+
+ if (!summaryView) {
+ const eyebrow = document.createElement("p");
+ eyebrow.className = "eyebrow";
+ eyebrow.textContent = "Processing summary";
+ const heading = document.createElement("h3");
+ heading.textContent = "No processing run yet";
+ const body = document.createElement("p");
+ body.className = "muted";
+ body.textContent = "Run one source to see a human-readable result here.";
+ summary.replaceChildren(eyebrow, heading, body);
+ return;
+ }
+
+ const children = [];
+ const eyebrow = document.createElement("p");
+ eyebrow.className = "eyebrow";
+ eyebrow.textContent = summaryView.heading || "Processing summary";
+ children.push(eyebrow);
+
+ const heading = document.createElement("h3");
+ heading.textContent = summaryView.title || "Process run";
+ children.push(heading);
+
+ if (summaryView.staleLabel) {
+ const staleChip = document.createElement("p");
+ staleChip.className = "stale-chip";
+ staleChip.textContent = summaryView.staleLabel;
+ children.push(staleChip);
+ }
+
+ if (summaryView.message) {
+ const message = document.createElement("p");
+ message.className = "muted";
+ message.textContent = summaryView.message;
+ children.push(message);
+ }
+
+ children.push(
+ buildSummaryGrid([
+ ["Model", summaryView.model || "Model not reported"],
+ ["Frames", summaryView.frameCount || "Frames not reported"],
+ ["Transcript", summaryView.transcriptChars || "Transcript not reported"],
+ ["Source", summaryView.source || "Not reported"],
+ ["Feed", summaryView.feedUrl || "Not reported"],
+ ]),
+ );
+
+ summary.replaceChildren(...children);
+ }
+
+ function renderProcessDetails(detailView) {
+ if (typeof detailView === "string") {
+ outputs.process.textContent = detailView;
+ return;
+ }
+
+ if (Array.isArray(detailView?.detailLines)) {
+ outputs.process.textContent = detailView.detailLines.join("\n");
+ return;
+ }
+
+ if (detailView?.raw) {
+ outputs.process.textContent = detailView.raw;
+ return;
+ }
+
+ outputs.process.textContent = String(detailView || "");
+ }
+
+ function addPressEffect(button) {
+ button.addEventListener("mousedown", () => {
+ button.classList.remove("pressing");
+ void button.offsetWidth;
+ button.classList.add("pressing");
+ });
+
+ button.addEventListener("animationend", () => {
+ button.classList.remove("pressing");
+ });
+ }
+
+ function bindPressEffects() {
+ document.querySelectorAll("button").forEach(addPressEffect);
+ }
+
+ function bindEnvInputs(fields, onUpdate) {
+ Object.values(fields).forEach((node) => {
+ if (!node || node === fields.processSource || node === fields.processTitle) {
+ return;
+ }
+ node.addEventListener("input", onUpdate);
+ node.addEventListener("change", onUpdate);
+ });
+ }
+
+ function bindRevealCards() {
+ const revealObserver = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ const card = entry.target;
+ const delay = getComputedStyle(card).getPropertyValue("--reveal-delay").trim();
+ const ms = parseInt(delay, 10) || 0;
+ setTimeout(() => {
+ card.classList.add("revealed");
+ }, ms);
+ revealObserver.unobserve(card);
+ }
+ });
+ },
+ { threshold: 0.08 }
+ );
+
+ document.querySelectorAll(".card").forEach((card) => {
+ revealObserver.observe(card);
+ });
+ }
+
+ function bindMobileStepToggle() {
+ const mobileStepToggle = document.getElementById("mobile-step-toggle");
+ const progressRail = document.getElementById("setup-progress");
+
+ if (!mobileStepToggle || !progressRail) {
+ return;
+ }
+
+ function setExpanded(expanded) {
+ progressRail.dataset.mobileOpen = expanded ? "true" : "false";
+ mobileStepToggle.setAttribute("aria-expanded", String(expanded));
+ mobileStepToggle.textContent = expanded ? "Hide setup steps" : "Open setup steps";
+ }
+
+ setExpanded(progressRail.dataset.mobileOpen === "true");
+ mobileStepToggle.addEventListener("click", () => {
+ setExpanded(progressRail.dataset.mobileOpen !== "true");
+ });
+ }
+
+ return {
+ bindEnvInputs,
+ bindMobileStepToggle,
+ bindPressEffects,
+ bindRevealCards,
+ renderActiveStep,
+ renderDiagnosticsSummary,
+ renderEnvBlock,
+ renderCommonFixes,
+ renderProcessDetails,
+ renderProcessSummary,
+ renderProgress,
+ renderRuntimeDetails,
+ renderRuntimeSummary,
+ renderShellState,
+ scrollIntoViewSmooth,
+ setStatus,
+ showToast,
+ startLoading,
+ stopLoading,
+ };
+}
diff --git a/video_rss_aggregator/templates/setup.html b/video_rss_aggregator/templates/setup.html
index 9126eaf..a6f7141 100644
--- a/video_rss_aggregator/templates/setup.html
+++ b/video_rss_aggregator/templates/setup.html
@@ -9,7 +9,7 @@
-
+
Setup Studio
Video RSS Aggregator Installation + Configuration
@@ -19,92 +19,158 @@ Video RSS Aggregator Installation + Configuration
-
-
- 1 Install Prerequisites
- Follow this sequence before starting API operations.
-
- - Install Python 3.11+ and make sure
python is on PATH.
- - Install Ollama for Windows and open it once so the API is online.
- - Install FFmpeg/FFprobe and confirm both commands resolve in terminal.
- - In this repo run
pip install -e ., then python -m vra bootstrap.
- - Run
python -m vra serve --bind <bind address> to keep this GUI active.
-
-
-
-
-
-
-
-
-
- 2 Configuration Builder
- Edit the full runtime surface and copy the generated .env block.
-
-