plan: responsive WebUI on phone viewports

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.
This commit is contained in:
2026-05-30 15:40:23 +02:00
parent 4dc15c5450
commit 67d26eaf4b
+420
View File
@@ -0,0 +1,420 @@
# 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 `implement`
> skill 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
`@media` breakpoint.
- Modify: `server/templates/login.html:4` — viewport meta only.
- Modify: `server/templates/my_cases.html:6,78` — viewport meta +
`@media` block.
- Modify: `server/templates/case_recordings.html:5,113` — viewport meta
+ `@media` block.
- Modify: `server/templates/case_page.html:6,350` — viewport meta +
`@media` block.
---
## 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:
```rust
//! 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:
```html
<meta charset="utf-8">
```
with:
```html
<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:
```html
<meta charset="utf-8">
```
with:
```html
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
```
- [ ] **Step 2: Append the `@media` block before `</style>`**
In `server/templates/my_cases.html`, replace the final rule + closing
`</style>`:
```html
html.admin-view-off .admin-only { display: none !important; }
</style>
```
with:
```html
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:
```html
<meta charset="utf-8">
```
with:
```html
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
```
- [ ] **Step 2: Append the `@media` block before `</style>`**
In `server/templates/case_recordings.html`, replace the final rule +
closing `</style>`:
```html
.recording-head:has(.rec-delete-form.is-confirming) .player { visibility: hidden; }
</style>
```
with:
```html
.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:
```html
<meta charset="utf-8" />
```
with:
```html
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
```
- [ ] **Step 2: Append the `@media` block before `</style>`**
In `server/templates/case_page.html`, replace the final rule + closing
`</style>`:
```html
html.admin-view-off .admin-only {
display: none !important;
}
</style>
```
with:
```html
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).