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:
@@ -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).
|
||||
Reference in New Issue
Block a user