Skip to content

Commit 0783f9d

Browse files
authored
Merge pull request #2 from ArietidsZ/feature/setup-studio-ux-redesign
feat: redesign setup studio workflow
2 parents 9cd020d + e658e44 commit 0783f9d

14 files changed

Lines changed: 2652 additions & 733 deletions

docs/superpowers/specs/2026-03-17-setup-studio-ux-design.md

Lines changed: 402 additions & 0 deletions
Large diffs are not rendered by default.

tests/adapters/test_api_app.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None:
102102
rss = client.get("/rss?limit=5")
103103
runtime_response = client.get("/runtime")
104104
bootstrap = client.post("/setup/bootstrap")
105+
runtime_payload = runtime_response.json()
106+
bootstrap_payload = bootstrap.json()
105107

106108
assert ingest.status_code == 200
107109
assert ingest.json() == {
@@ -127,16 +129,26 @@ def test_routes_delegate_to_runtime_use_cases_and_keep_http_shapes() -> None:
127129
assert rss.status_code == 200
128130
assert rss.text == "<rss>feed</rss>"
129131
assert runtime_response.status_code == 200
130-
assert runtime_response.json() == {
132+
assert runtime_payload == {
131133
"ollama_version": "0.6.0",
132134
"local_models": {"qwen": {"size": 1}},
133135
"reachable": True,
134136
"database_path": ".data/runtime.db",
135137
"storage_dir": ".data",
136138
"models": ["qwen", "qwen:min"],
139+
"setup_view": {
140+
"state": "blocked",
141+
"missing_models": ["qwen:min"],
142+
"next_action": "Bootstrap required models",
143+
},
144+
}
145+
assert runtime_payload["setup_view"] == {
146+
"state": "blocked",
147+
"missing_models": ["qwen:min"],
148+
"next_action": "Bootstrap required models",
137149
}
138150
assert bootstrap.status_code == 200
139-
assert bootstrap.json() == {
151+
assert bootstrap_payload == {
140152
"models_prepared": ["qwen"],
141153
"runtime": {
142154
"ollama_version": "0.6.0",

tests/test_api_setup.py

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ def _build_runtime(config: Config) -> AppRuntime:
4141
)
4242

4343

44+
def _is_javascript_content_type(content_type: str) -> bool:
45+
return content_type.startswith(("text/javascript", "application/javascript"))
46+
47+
4448
def test_gui_and_setup_routes() -> None:
4549
config = Config()
4650
app = create_app(_build_runtime(config))
@@ -57,13 +61,15 @@ def test_gui_and_setup_routes() -> None:
5761

5862
css = client.get("/static/setup.css")
5963
assert css.status_code == 200
60-
assert "--accent" in css.text
64+
assert ".shell" in css.text
6165

6266
js = client.get("/static/setup.js")
6367
assert js.status_code == 200
64-
assert "runDiagnostics" in js.text
65-
assert "API_KEY=${fields.apiKey.value.trim()}" not in js.text
66-
assert "const apiKey = fields.apiKey.value.trim();" in js.text
68+
assert _is_javascript_content_type(js.headers["content-type"])
69+
70+
setup_state = client.get("/static/setup_state.js")
71+
assert setup_state.status_code == 200
72+
assert _is_javascript_content_type(setup_state.headers["content-type"])
6773

6874
setup = client.get("/setup/config")
6975
assert setup.status_code == 200
@@ -82,6 +88,17 @@ def test_gui_and_setup_routes() -> None:
8288
diagnostics = client.get("/setup/diagnostics")
8389
assert diagnostics.status_code == 200
8490
diag_payload = diagnostics.json()
91+
setup_view = diag_payload["setup_view"]
92+
assert set(setup_view) >= {"state", "checks", "blockers", "next_action"}
93+
assert setup_view["state"] in {"ready", "blocked"}
94+
assert isinstance(setup_view["checks"], list)
95+
assert {check["id"] for check in setup_view["checks"]} >= {
96+
"python",
97+
"ffmpeg",
98+
"ffprobe",
99+
"yt_dlp",
100+
"ollama",
101+
}
85102
assert "platform" in diag_payload
86103
assert "dependencies" in diag_payload
87104
assert "ready" in diag_payload
@@ -102,18 +119,7 @@ def test_runtime_requires_api_key_when_enabled() -> None:
102119

103120
authorized = client.get("/runtime", headers={"X-API-Key": "secret"})
104121
assert authorized.status_code == 200
105-
assert authorized.json() == {
106-
"ollama_version": "0.6.0",
107-
"local_models": {"qwen3.5:2b-q4_K_M": {}},
108-
"reachable": True,
109-
"database_path": ".data/vra.db",
110-
"storage_dir": ".data",
111-
"models": [
112-
"qwen3.5:4b-q4_K_M",
113-
"qwen3.5:2b-q4_K_M",
114-
"qwen3.5:0.8b-q8_0",
115-
],
116-
}
122+
assert authorized.json()["setup_view"]["state"] == "blocked"
117123

118124

119125
def test_setup_config_omits_api_key_and_uses_current_bind_in_commands() -> None:
Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,269 @@
1+
from __future__ import annotations
2+
3+
import re
4+
from dataclasses import dataclass
5+
from typing import Any, cast
6+
7+
from fastapi.testclient import TestClient
8+
9+
from adapter_api import create_app
10+
from core_config import Config
11+
from video_rss_aggregator.bootstrap import AppRuntime, AppUseCases
12+
13+
14+
@dataclass
15+
class _AsyncValue:
16+
value: Any
17+
18+
async def execute(self, *_args, **_kwargs) -> Any:
19+
return self.value
20+
21+
22+
def _build_runtime(config: Config) -> AppRuntime:
23+
runtime_status = {
24+
"ollama_version": "0.6.0",
25+
"local_models": {"qwen3.5:2b-q4_K_M": {}},
26+
"reachable": True,
27+
"database_path": config.database_path,
28+
"storage_dir": config.storage_dir,
29+
"models": list(config.model_priority),
30+
}
31+
return AppRuntime(
32+
config=config,
33+
use_cases=AppUseCases(
34+
get_runtime_status=_AsyncValue(runtime_status),
35+
bootstrap_runtime=_AsyncValue({"models": ["qwen3.5:2b-q4_K_M"]}),
36+
ingest_feed=cast(Any, _AsyncValue(None)),
37+
process_source=cast(Any, _AsyncValue(None)),
38+
render_rss_feed=_AsyncValue("<rss></rss>"),
39+
),
40+
close=lambda: None,
41+
)
42+
43+
44+
client = TestClient(create_app(_build_runtime(Config())))
45+
46+
47+
def _is_javascript_content_type(content_type: str) -> bool:
48+
return content_type.startswith(("text/javascript", "application/javascript"))
49+
50+
51+
def class_tokens_for_id(html: str, element_id: str) -> set[str]:
52+
tag_match = re.search(rf'<[^>]*\bid="{re.escape(element_id)}"[^>]*>', html)
53+
assert tag_match is not None
54+
55+
class_match = re.search(r'\bclass="([^"]+)"', tag_match.group(0))
56+
assert class_match is not None
57+
58+
return set(class_match.group(1).split())
59+
60+
61+
def test_setup_css_supports_shell_and_disclosure_layout() -> None:
62+
css = client.get("/static/setup.css")
63+
64+
assert css.status_code == 200
65+
assert "--paper-base" in css.text
66+
assert ".shell" in css.text
67+
assert ".progress-rail" in css.text
68+
assert ".detail-drawer" in css.text
69+
assert ".summary-card" in css.text
70+
assert "@media (max-width: 860px)" in css.text
71+
assert '#setup-progress[data-mobile-open="false"]' in css.text
72+
assert '#mobile-step-toggle[aria-expanded="true"]' in css.text
73+
assert "#6366f1" not in css.text
74+
75+
76+
def test_setup_home_renders_shell_and_collapsed_detail_drawers() -> None:
77+
home = client.get("/")
78+
workbench_classes = class_tokens_for_id(home.text, "setup-workbench")
79+
progress_classes = class_tokens_for_id(home.text, "setup-progress")
80+
prerequisites_panel_classes = class_tokens_for_id(
81+
home.text, "step-panel-prerequisites"
82+
)
83+
configuration_panel_classes = class_tokens_for_id(
84+
home.text, "step-panel-configuration"
85+
)
86+
runtime_panel_classes = class_tokens_for_id(home.text, "step-panel-runtime")
87+
process_panel_classes = class_tokens_for_id(home.text, "step-panel-process")
88+
89+
assert home.status_code == 200
90+
assert 'id="setup-workbench"' in home.text
91+
assert 'id="setup-progress"' in home.text
92+
assert 'id="readiness-summary"' in home.text
93+
assert 'id="blocker-summary"' in home.text
94+
assert 'id="common-fixes"' in home.text
95+
assert 'data-step-id="prerequisites"' in home.text
96+
assert 'data-step-id="configuration"' in home.text
97+
assert 'data-step-id="runtime"' in home.text
98+
assert 'data-step-id="process"' in home.text
99+
assert "shell" in workbench_classes
100+
assert "progress-rail" in progress_classes
101+
assert {"step-panel", "card"} <= prerequisites_panel_classes
102+
assert {"step-panel", "card", "wide"} <= configuration_panel_classes
103+
assert {"step-panel", "card"} <= runtime_panel_classes
104+
assert {"step-panel", "card"} <= process_panel_classes
105+
assert 'id="mobile-step-toggle"' in home.text
106+
assert 'aria-controls="setup-progress"' in home.text
107+
assert 'aria-expanded="false"' in home.text
108+
assert 'id="run-diagnostics"' in home.text
109+
assert 'id="copy-env"' in home.text
110+
assert 'id="runtime-check"' in home.text
111+
assert 'id="bootstrap-models"' in home.text
112+
assert 'id="process-run"' in home.text
113+
assert 'id="runtime-summary"' in home.text
114+
assert 'id="process-summary"' in home.text
115+
assert 'id="advanced-config"' in home.text
116+
assert 'id="runtime-details"' in home.text
117+
assert 'id="process-details"' in home.text
118+
assert re.search(
119+
r'<details[^>]*id="advanced-config"(?![^>]*\bopen\b)[^>]*>', home.text
120+
)
121+
assert re.search(
122+
r'<details[^>]*id="runtime-details"(?![^>]*\bopen\b)[^>]*>', home.text
123+
)
124+
assert re.search(
125+
r'<details[^>]*id="process-details"(?![^>]*\bopen\b)[^>]*>', home.text
126+
)
127+
128+
129+
def test_setup_assets_expose_module_and_top_level_contracts() -> None:
130+
home = client.get("/")
131+
entry_js = client.get("/static/setup.js")
132+
api_js = client.get("/static/setup_api.js")
133+
state_js = client.get("/static/setup_state.js")
134+
view_models_js = client.get("/static/setup_view_models.js")
135+
views_js = client.get("/static/setup_views.js")
136+
137+
assert 'src="static/setup.js" type="module"' in home.text
138+
139+
for response in (entry_js, api_js, state_js, view_models_js, views_js):
140+
assert response.status_code == 200
141+
assert _is_javascript_content_type(response.headers["content-type"])
142+
143+
assert 'import { createSetupApi } from "./setup_api.js";' in entry_js.text
144+
assert 'import { createSetupState } from "./setup_state.js";' in entry_js.text
145+
assert (
146+
'import { buildShellSummaryView } from "./setup_view_models.js";'
147+
in entry_js.text
148+
)
149+
assert (
150+
'import { buildProcessSummaryView } from "./setup_view_models.js";'
151+
in entry_js.text
152+
)
153+
assert (
154+
'import { buildProcessFailureView } from "./setup_view_models.js";'
155+
in entry_js.text
156+
)
157+
assert 'import { createSetupViews } from "./setup_views.js";' in entry_js.text
158+
assert "function boot()" in entry_js.text
159+
assert "boot();" in entry_js.text
160+
assert "handleDiagnosticsRun" in entry_js.text
161+
assert "handleRuntimeCheck" in entry_js.text
162+
assert "handleBootstrapModels" in entry_js.text
163+
assert "handleProcessRun" in entry_js.text
164+
assert "runDiagnostics();" not in entry_js.text
165+
166+
assert "export function createSetupApi" in api_js.text
167+
assert "runDiagnostics" in api_js.text
168+
assert "checkRuntime" in api_js.text
169+
assert "bootstrapModels" in api_js.text
170+
assert "runProcess" in api_js.text
171+
172+
assert "export function createSetupState" in state_js.text
173+
assert "beginDiagnosticsCheck" in state_js.text
174+
assert "markConfigurationComplete" in state_js.text
175+
assert "beginRuntimeCheck" in state_js.text
176+
assert "completeRuntimeCheck" in state_js.text
177+
assert "beginProcess" in state_js.text
178+
assert "markProcessSuccess" in state_js.text
179+
assert "markProcessFailure" in state_js.text
180+
assert (
181+
'const STEP_ORDER = ["prerequisites", "configuration", "runtime", "process"]'
182+
in state_js.text
183+
)
184+
185+
assert "export function buildShellSummaryView" in view_models_js.text
186+
assert "export function buildStaleSummaryLabel" in view_models_js.text
187+
assert "export function buildProcessSummaryView" in view_models_js.text
188+
assert "export function buildProcessFailureView" in view_models_js.text
189+
190+
assert "export function createSetupViews" in views_js.text
191+
assert "renderShellState" in views_js.text
192+
assert "renderDiagnosticsSummary" in views_js.text
193+
assert "renderRuntimeSummary" in views_js.text
194+
assert "renderRuntimeDetails" in views_js.text
195+
assert "renderProcessSummary" in views_js.text
196+
assert "renderProcessDetails" in views_js.text
197+
assert "bindMobileStepToggle" in views_js.text
198+
assert 'document.getElementById("mobile-step-toggle")' in views_js.text
199+
assert (
200+
'progressRail.dataset.mobileOpen = expanded ? "true" : "false";'
201+
in views_js.text
202+
)
203+
assert (
204+
'mobileStepToggle.setAttribute("aria-expanded", String(expanded));'
205+
in views_js.text
206+
)
207+
assert "views.bindMobileStepToggle();" in entry_js.text
208+
209+
210+
def test_setup_js_prefers_backend_setup_views_for_state_transitions() -> None:
211+
entry_js = client.get("/static/setup.js")
212+
213+
assert entry_js.status_code == 200
214+
assert "function toDiagnosticsView(report)" in entry_js.text
215+
assert (
216+
'return report.setup_view || { state: report.ready ? "ready" : "blocked" };'
217+
in entry_js.text
218+
)
219+
assert "function toRuntimeView(runtime)" in entry_js.text
220+
assert (
221+
'return runtime.setup_view || { state: runtime.reachable ? "ready" : "blocked" };'
222+
in entry_js.text
223+
)
224+
assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text
225+
assert "state.applyDiagnosticsResult(diagnosticsView);" in entry_js.text
226+
assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text
227+
assert "state.completeRuntimeCheck(runtimeView);" in entry_js.text
228+
229+
230+
def test_setup_js_renders_shaped_setup_view_summaries_and_common_fixes() -> None:
231+
entry_js = client.get("/static/setup.js")
232+
233+
assert entry_js.status_code == 200
234+
assert "const diagnosticsView = toDiagnosticsView(report);" in entry_js.text
235+
assert "views.renderDiagnosticsSummary(diagnosticsView);" in entry_js.text
236+
assert "views.renderCommonFixes(diagnosticsView);" in entry_js.text
237+
assert 'if (diagnosticsView.state === "ready") {' in entry_js.text
238+
assert "if (report.ready) {" not in entry_js.text
239+
assert "const runtimeView = toRuntimeView(runtime);" in entry_js.text
240+
assert "renderRuntimeState(runtimeView, runtime);" in entry_js.text
241+
assert (
242+
"renderRuntimeState(runtimeView, { ...runtime, bootstrap: report });"
243+
in entry_js.text
244+
)
245+
246+
247+
def test_setup_views_expose_common_fixes_and_runtime_readiness_copy() -> None:
248+
views_js = client.get("/static/setup_views.js")
249+
250+
assert views_js.status_code == 200
251+
assert "function renderCommonFixes(view)" in views_js.text
252+
assert 'const container = document.getElementById("common-fixes");' in views_js.text
253+
assert "const fixes = Array.isArray(view?.checks)" in views_js.text
254+
assert "view.checks.filter((check) => check.fix)" in views_js.text
255+
assert "function renderRuntimeSummary(runtimeView, runtime)" in views_js.text
256+
assert (
257+
'["Required models", `${requiredModels.length} configured`],' in views_js.text
258+
)
259+
assert '["Available locally", `${localModels.length} ready`],' in views_js.text
260+
assert "runtimeView?.next_action ||" in views_js.text
261+
assert "renderCommonFixes," in views_js.text
262+
263+
264+
def test_setup_process_summary_view_reads_nested_model_used() -> None:
265+
view_models_js = client.get("/static/setup_view_models.js")
266+
267+
assert view_models_js.status_code == 200
268+
assert "function collectProcessModel(result)" in view_models_js.text
269+
assert "result.summary?.model_used" in view_models_js.text

0 commit comments

Comments
 (0)