Plan for issue #10. CSS-only: viewport meta tag on every page plus a @media (max-width: 640px) breakpoint on the three desktop-width pages. Desktop (>=900px) render stays byte-identical.
12 KiB
Responsive WebUI on Phone Viewports — Implementation Plan
Parent spec: Gitea issue #10 ("Make the server WebUI usable on phone viewports") — verified root causes + acceptance criteria. No separate spec file; the issue body is the spec.
For agentic workers: REQUIRED SUB-SKILL: use the
implementskill to run this plan. Steps use- [ ]checkboxes for tracking.
Goal: Make the server-rendered pages readable and operable on a ~390px phone viewport without changing the desktop (>=900px) layout.
Architecture: CSS-only. Each of the four page templates owns its
own <head>/<style> (askama uses {% import %}/{% include %}, no
shared base layout). Fix = (1) add a width=device-width viewport meta
tag to every page, (2) append a single @media (max-width: 640px)
block to the three desktop-width pages that overrides only the
problematic rules. A max-width query never matches >=900px, so the
desktop render is byte-identical. Templates are compile-time embedded
by askama, so a cargo build/cargo test of the server crate is
required for edits to take effect.
Tech Stack: askama 0.12 templates under server/templates/, axum
routes, existing server/tests/common HTTP integration-test harness.
Files this plan creates or modifies
- Create:
server/tests/responsive_test.rs— regression pins: every page ships the viewport meta; the three desktop-width pages ship an@mediabreakpoint. - Modify:
server/templates/login.html:4— viewport meta only. - Modify:
server/templates/my_cases.html:6,78— viewport meta +@mediablock. - Modify:
server/templates/case_recordings.html:5,113— viewport meta@mediablock.
- Modify:
server/templates/case_page.html:6,350— viewport meta +@mediablock.
Notes on scope boundary
The automated tests below pin the two textual acceptance criteria
(viewport meta present; @media breakpoint present). The remaining
criteria — readable/operable at 390px, vertical reflow of rows/bars,
~44px touch targets — are visual and cannot be unit-tested without a
headless browser. They are covered by the CSS in Tasks 3-5 and must be
confirmed by eye in a narrow viewport (a fieldtest / manual pass at
~390px). This boundary is deliberate, not a coverage gap to "fix" by
adding a browser-automation dependency.
Task 1: Regression test — RED
Files:
-
Create:
server/tests/responsive_test.rs -
Step 1: Write the failing test
Create server/tests/responsive_test.rs with exactly:
//! Responsive WebUI regression pins for issue #10.
//!
//! These guard the two *textual* acceptance criteria that survive
//! without a headless browser: every page ships a `width=device-width`
//! viewport meta tag, and the three desktop-width pages (cases list,
//! case page, recordings) carry at least one `@media` breakpoint so the
//! layout can reflow on narrow viewports. The visual criteria (operable
//! at 390px, 44px touch targets) are verified by eye; these tests stop a
//! future template rewrite from silently dropping the viewport tag or
//! the breakpoints.
mod common;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::util::ServiceExt;
use common::{TestConfig, body_string, get_with_cookie, paths, seed_case, test_user};
const VIEWPORT: &str = "width=device-width";
#[tokio::test]
async fn login_page_has_viewport_meta() {
let cfg = TestConfig::new()
.with_label("resp-login")
.with_user(test_user("dr_a"))
.build();
let app = doctate_server::create_router(cfg);
let resp = app
.oneshot(
Request::builder()
.uri(paths::LOGIN)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains(VIEWPORT), "login page missing viewport meta");
}
#[tokio::test]
async fn cases_list_has_viewport_and_media_query() {
let cfg = TestConfig::new()
.with_label("resp-cases")
.with_user(test_user("dr_a"))
.build();
let app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await;
let resp = app
.oneshot(get_with_cookie(paths::CASES, &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains(VIEWPORT), "cases list missing viewport meta");
assert!(
body.contains("@media"),
"cases list missing @media breakpoint"
);
}
#[tokio::test]
async fn case_page_has_viewport_and_media_query() {
let cfg = TestConfig::new()
.with_label("resp-cp")
.with_user(test_user("dr_a"))
.with_llm("http://unused")
.build();
let case_id = "22222222-2222-2222-2222-222222222222";
seed_case(&cfg.data_path, "dr_a", case_id);
let app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await;
let resp = app
.oneshot(get_with_cookie(paths::case_detail(case_id), &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains(VIEWPORT), "case page missing viewport meta");
assert!(
body.contains("@media"),
"case page missing @media breakpoint"
);
}
#[tokio::test]
async fn case_recordings_has_viewport_and_media_query() {
let cfg = TestConfig::new()
.with_label("resp-rec")
.with_user(test_user("dr_a"))
.build();
let case_id = "33333333-3333-3333-3333-333333333333";
seed_case(&cfg.data_path, "dr_a", case_id);
let app = doctate_server::create_router(cfg);
let cookie = common::login(&app, "dr_a", "s").await;
let resp = app
.oneshot(get_with_cookie(paths::case_recordings(case_id), &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(
body.contains(VIEWPORT),
"recordings page missing viewport meta"
);
assert!(
body.contains("@media"),
"recordings page missing @media breakpoint"
);
}
- Step 2: Run the test to verify it fails
Run: cargo test -p doctate-server --test responsive_test -j 4
Expected: FAIL — login page missing viewport meta (and the three
@media assertions) panic, because no template carries a viewport tag
or @media yet.
Task 2: login.html — viewport meta
Files:
-
Modify:
server/templates/login.html:4 -
Step 1: Add the viewport meta tag
In server/templates/login.html, replace the charset line:
<meta charset="utf-8">
with:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
(login.html needs no @media: its body is already
max-width: 360px, the form is a column, and inputs are width: 100%.)
- Step 2: Verify its test passes
Run: cargo test -p doctate-server --test responsive_test login_page_has_viewport_meta -j 4
Expected: PASS
Task 3: my_cases.html — viewport meta + reflow breakpoint
Files:
-
Modify:
server/templates/my_cases.html:6,78 -
Step 1: Add the viewport meta tag
In server/templates/my_cases.html, replace the charset line:
<meta charset="utf-8">
with:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- Step 2: Append the
@mediablock before</style>
In server/templates/my_cases.html, replace the final rule + closing
</style>:
html.admin-view-off .admin-only { display: none !important; }
</style>
with:
html.admin-view-off .admin-only { display: none !important; }
@media (max-width: 640px) {
body { margin: 1em auto; }
header { flex-wrap: wrap; gap: 0.6em; }
.case-row,
.case-list.has-bulk .case-row,
html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr; }
.case-row .actions { flex-wrap: wrap; }
.case-row .actions button,
.required-bar button,
.bulk-bar button { min-height: 44px; }
.case-row .actions .delete-btn,
.case-row .actions .reopen-btn { min-width: 44px; min-height: 44px; justify-content: center; }
}
</style>
- Step 3: Verify its test passes
Run: cargo test -p doctate-server --test responsive_test cases_list_has_viewport_and_media_query -j 4
Expected: PASS
Task 4: case_recordings.html — viewport meta + player/touch breakpoint
Files:
-
Modify:
server/templates/case_recordings.html:5,113 -
Step 1: Add the viewport meta tag
In server/templates/case_recordings.html, replace the charset line:
<meta charset="utf-8">
with:
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
- Step 2: Append the
@mediablock before</style>
In server/templates/case_recordings.html, replace the final rule +
closing </style>:
.recording-head:has(.rec-delete-form.is-confirming) .player { visibility: hidden; }
</style>
with:
.recording-head:has(.rec-delete-form.is-confirming) .player { visibility: hidden; }
@media (max-width: 640px) {
body { margin: 1em auto; }
header { flex-wrap: wrap; gap: 0.6em; }
.player { width: 100%; justify-content: flex-start; }
.player .seek { max-width: none; }
.player .play { width: 44px; height: 44px; }
.rec-delete-form .delete-btn { min-width: 44px; min-height: 44px; justify-content: center; }
}
</style>
- Step 3: Verify its test passes
Run: cargo test -p doctate-server --test responsive_test case_recordings_has_viewport_and_media_query -j 4
Expected: PASS
Task 5: case_page.html — viewport meta + reflow breakpoint
Files:
- Modify:
server/templates/case_page.html:6,350
Note: this template uses 8-space-indented, multi-line CSS and a
self-closing <meta ... /> style — match it.
- Step 1: Add the viewport meta tag
In server/templates/case_page.html, replace the charset line:
<meta charset="utf-8" />
with:
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
- Step 2: Append the
@mediablock before</style>
In server/templates/case_page.html, replace the final rule + closing
</style>:
html.admin-view-off .admin-only {
display: none !important;
}
</style>
with:
html.admin-view-off .admin-only {
display: none !important;
}
@media (max-width: 640px) {
body {
margin: 1em auto;
}
.oneliner-input {
min-width: 8em;
}
.doc-content {
padding: 1em 1em;
}
.actions button {
min-height: 44px;
}
.failure-banner dl.failure-details {
grid-template-columns: 1fr;
}
}
</style>
- Step 3: Verify its test passes
Run: cargo test -p doctate-server --test responsive_test case_page_has_viewport_and_media_query -j 4
Expected: PASS
Task 6: Full verification
Files: (none — verification only)
- Step 1: Run the full responsive suite
Run: cargo test -p doctate-server --test responsive_test -j 4
Expected: PASS — all 4 tests green.
- Step 2: Run the broader web/template tests for regressions
Run: cargo test -p doctate-server --test web_test --test login_test --test case_page_test -j 4
Expected: PASS — viewport/@media additions are purely additive; no
existing assertion changes.
- Step 3: cargo fmt
Run: cargo fmt -p doctate-server
Expected: no diff in the new test file (it is already rustfmt-clean).