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:
@@ -4,6 +4,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Doctate — Fall {{ case_id_short }}</title>
|
||||
<style>
|
||||
body {
|
||||
@@ -348,6 +349,23 @@
|
||||
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>
|
||||
<script>
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Doctate — Aufnahmen Fall {{ case_id_short }}</title>
|
||||
<style>
|
||||
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
|
||||
shrink. `:has()` reacts to descendant state from the parent. */
|
||||
.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>
|
||||
<script>
|
||||
try {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Doctate — Login</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; max-width: 360px; margin: 4em auto; padding: 0 1em; }
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<html lang="de" style="--preview-lines: {{ preview_lines }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Doctate — Fälle</title>
|
||||
<style>
|
||||
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; }
|
||||
.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; }
|
||||
|
||||
@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>
|
||||
<script>
|
||||
try {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user