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">
|
<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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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