Files
doctate/docs/plans/2026-05-30-responsive-webui.md
T
Brummel 67d26eaf4b 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.
2026-05-30 15:40:23 +02:00

12 KiB

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:

//! 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:

<meta charset="utf-8">

with:

<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:

<meta charset="utf-8">

with:

<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.admin-view-off .admin-only { display: none !important; }
</style>

with:

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:

<meta charset="utf-8">

with:

<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>:

.recording-head:has(.rec-delete-form.is-confirming) .player { visibility: hidden; }
</style>

with:

.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:

        <meta charset="utf-8" />

with:

        <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.admin-view-off .admin-only {
                display: none !important;
            }
        </style>

with:

            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).