feat(web): make server WebUI usable on phone viewports

The server-rendered pages worked on desktop but were near-unusable on a
phone: no viewport meta tag (mobile browsers laid out in a ~980px canvas
and zoomed out, rendering everything tiny) and a fixed `max-width: 900px`
desktop body with zero responsive breakpoints.

Fix is CSS-only and purely additive:
- Add `<meta name="viewport" content="width=device-width, initial-scale=1">`
  to all four page templates (each owns its own <head> — askama uses
  import/include here, no shared base layout, so the tag lands in 4 places).
- Append a single `@media (max-width: 640px)` block to the three
  desktop-width pages (my_cases, case_recordings, case_page): case rows
  and action bars reflow to a single column, the audio player goes
  full-width, and primary touch targets (delete/reopen/play) get a 44px
  minimum. login.html already has a 360px column layout, so it needs the
  viewport tag only.

Desktop layout is preserved by construction: a `max-width: 640px` query
never matches at >=900px, so the desktop render is byte-identical. The
diff adds lines only — no existing rule is touched.

Breakpoint chosen at 640px (well below the 900px body width) so all
portrait phones, including the ~390px target, fall under it while
tablets keep the desktop layout.

New integration test server/tests/responsive_test.rs pins the two
textual acceptance criteria that survive without a headless browser:
every page ships the viewport meta, and the three desktop-width pages
ship an @media breakpoint. The visual criteria (operable at 390px, 44px
touch targets) are CSS-driven and verified by eye — deliberately not
covered by a browser-automation dependency.

Verified: cargo test -p doctate-server --test responsive_test (4/4
green); web/login/case_page regression suites green; diff is additive.

closes #10
This commit is contained in:
2026-05-30 15:44:53 +02:00
parent 67d26eaf4b
commit a37a1ae363
5 changed files with 160 additions and 0 deletions
+18
View File
@@ -4,6 +4,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Doctate — Fall {{ case_id_short }}</title> <title>Doctate — Fall {{ case_id_short }}</title>
<style> <style>
body { body {
@@ -348,6 +349,23 @@
html.admin-view-off .admin-only { html.admin-view-off .admin-only {
display: none !important; 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> </style>
<script> <script>
try { try {
+10
View File
@@ -3,6 +3,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Doctate — Aufnahmen Fall {{ case_id_short }}</title> <title>Doctate — Aufnahmen Fall {{ case_id_short }}</title>
<style> <style>
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; } body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
@@ -111,6 +112,15 @@ html.admin-view-off .admin-only { display: none !important; }
layout slot — and its height — reserved, so the row doesn't layout slot — and its height — reserved, so the row doesn't
shrink. `:has()` reacts to descendant state from the parent. */ shrink. `:has()` reacts to descendant state from the parent. */
.recording-head:has(.rec-delete-form.is-confirming) .player { visibility: hidden; } .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> </style>
<script> <script>
try { try {
+1
View File
@@ -2,6 +2,7 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Doctate — Login</title> <title>Doctate — Login</title>
<style> <style>
body { font-family: sans-serif; max-width: 360px; margin: 4em auto; padding: 0 1em; } body { font-family: sans-serif; max-width: 360px; margin: 4em auto; padding: 0 1em; }
+15
View File
@@ -4,6 +4,7 @@
<html lang="de" style="--preview-lines: {{ preview_lines }}"> <html lang="de" style="--preview-lines: {{ preview_lines }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Doctate — Fälle</title> <title>Doctate — Fälle</title>
<style> <style>
body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; } body { font-family: sans-serif; max-width: 900px; margin: 2em auto; padding: 0 1em; }
@@ -76,6 +77,20 @@ html.admin-view-off .case-list.has-bulk .case-row { grid-template-columns: 1fr a
.header-right { display: flex; align-items: center; gap: 0.8em; } .header-right { display: flex; align-items: center; gap: 0.8em; }
.admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; } .admin-toggle { font-size: 0.85em; color: #666; display: inline-flex; align-items: center; gap: 0.3em; cursor: pointer; user-select: none; }
html.admin-view-off .admin-only { display: none !important; } 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> </style>
<script> <script>
try { try {
+116
View File
@@ -0,0 +1,116 @@
//! 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"
);
}