Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/design/DESIGN_REVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,28 @@ The first screen of each key page, before and after, at one scale:

The whole gallery is reproduced by `pnpm run shots` at any commit; the before set is the tree of `v1.0.0`. Two measures of the change on a phone: the person page went from 8,111 px to 4,113 px of scrolling, the session page from 10,563 px to 9,702 px; the settings page grew by a card (the account) and a row (the sections). The accessibility sweep of the gallery stays at zero findings.

## Second pass, 2026-10-05

Every screen again, at full size this time, on a phone and on a desktop (and, from this pass on, on a tablet: the gallery photographs three devices). What the first pass had left:

| Screen | Found | Changed |
| --- | --- | --- |
| Home | The due strip, a second-level heading, sat above the page's title. | The title and "Add a person" first; then what is due and the coming appointments; then the persons. |
| Mark | The reports kept a second primary button on the page; the chart's metric control overflowed the phone's width ("Colour contrast with the skin" cut off); the facts grid broke "since" into a narrow column on a desktop. | Reports fold under their title, as on the person page; the metric control wraps and the label is "Colour contrast"; the facts' columns are wider. |
| Visit | The quality note had landed after the controls and the privacy note, far from the photograph; three primary buttons (take a photo, measure, save); one long column on a desktop. | The note is the photograph's caption; the primary action follows the state (take a photo until there is one, measure until there is a measurement, save when something changed); from 900 px the photographs sit on the left, sticky, and the measurements and notes on the right. |
| Session | Eighteen regions in one column on a desktop. | Two columns from 900 px; the next region spans both. |
| Session zone | Photo, marks and panels in one column on a desktop. | From 900 px the photograph sits on the left, sticky, the marks and their panels on the right. |
| Settings | Fourteen cards in one column, 13,900 px on a phone; the administrator's groups the longest. | The occasional and administrative groups fold under their titles with a hint (second factor, reference card, your data, accounts, email delivery, webhooks, backups, instance); the list of sections opens the one it points to; account, appearance and reminders stay open. |
| Sign in | The wordmark larger than in the shell. | The shell's size. |

New rules from this pass: the primary action is a function of the page's state, not a fixed button; a control with several options wraps rather than overflows; a group of settings used once a season folds like a tool.

The five screens that changed most, after the pass, on the three devices the gallery photographs:

![Second pass, three devices](review/board-second-pass.png)

On a phone the settings page went from 13,873 px to 6,846 px of scrolling. The accessibility sweep stays at zero findings on all three devices.

## Deferred, for a decision

The brief proposed a bottom navigation bar on phones (Map, Due, Add, Visit). The interface has a top bar (Persons, Settings) and the person page as the hub, and this pass keeps it: the hub carries the one primary action within the thumb's reach, and a bottom bar would duplicate what the person page now does. The question for the Product Owner, with a default:
Expand Down
Binary file added docs/design/review/board-second-pass.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/e2e/gallery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ test("every screen, for a look at the design", async ({ page }, info) => {
await measuredVisit(page, false);
await page.getByRole("link", { name: /Back to Chest mark/ }).click();
await expect(page.getByRole("slider", { name: /Longest diameter at 2 visits/ })).toBeVisible();
await openTool(page, "mark-reports", "Reports");
await page.getByRole("button", { name: "Make the record of this mark", exact: true }).click();
await expect(page.getByRole("link", { name: /Download the PDF/ })).toBeVisible({ timeout: 60_000 });
await snap("08-mark");
Expand Down Expand Up @@ -150,6 +151,7 @@ test("every screen, for a look at the design", async ({ page }, info) => {
await page.getByRole("link", { name: "Settings", exact: true }).click();
await expect(page.getByRole("heading", { name: "Settings", exact: true })).toBeVisible();
await snap("17-settings");
await openTool(page, "second-factor", "Second factor");
await page.getByRole("button", { name: "Set up a second factor", exact: true }).click();
const dialog = page.getByRole("dialog");
await expect(dialog).toBeVisible();
Expand All @@ -164,6 +166,7 @@ test("every screen, for a look at the design", async ({ page }, info) => {
await expect(page.getByRole("region", { name: "Recovery codes" })).toBeVisible();
await snap("20-settings-recovery-codes");
await page.getByRole("button", { name: "I have saved them", exact: true }).click();
await openTool(page, "instance", "Instance (administrator)");
await page.getByRole("link", { name: /Evaluation set/ }).click();
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await snap("21-evaluation");
Expand Down
4 changes: 4 additions & 0 deletions frontend/e2e/journey.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ test("from a fresh instance to a measured mark, accessibly", async ({ page }) =>
await expect(chart).toBeVisible();

// The mark's record as a PDF, rendered by the background worker.
await openTool(page, "mark-reports", "Reports");
await page.getByRole("button", { name: "Make the record of this mark", exact: true }).click();
const pdf = page.getByRole("link", { name: /Download the PDF/ });
await expect(pdf).toBeVisible({ timeout: 60_000 });
Expand Down Expand Up @@ -129,6 +130,7 @@ test("a full-body session: regions photographed or skipped, a mark pointed at, a
test("the second factor: set up in the settings, then a sign-in that needs the code", async ({ page }) => {
await signIn(page);
await page.getByRole("link", { name: "Settings", exact: true }).click();
await openTool(page, "second-factor", "Second factor");
await page.getByRole("button", { name: "Set up a second factor", exact: true }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel("Password").fill(PASSWORD);
Expand Down Expand Up @@ -179,9 +181,11 @@ test("the second factor: set up in the settings, then a sign-in that needs the c

// Leave the account as the other tests expect it.
await page.getByRole("link", { name: "Settings", exact: true }).click();
await openTool(page, "second-factor", "Second factor");
await expect(page.getByText("The second factor is on. 9 recovery codes left.")).toBeVisible();
await page.getByRole("button", { name: "Turn it off", exact: true }).click();
await dialog.getByLabel("Password").fill(PASSWORD);
await dialog.getByRole("button", { name: "Confirm", exact: true }).click();
await openTool(page, "second-factor", "Second factor");
await expect(page.getByRole("button", { name: "Set up a second factor", exact: true })).toBeVisible();
});
10 changes: 10 additions & 0 deletions frontend/playwright.gallery.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ import { defineConfig, devices } from "@playwright/test";
const stamp = Date.now();
const SERVERS = [
{ name: "phone", port: 8791, device: devices["Pixel 7"] },
{
name: "tablet",
port: 8793,
device: {
...devices["Desktop Chrome"],
viewport: { width: 820, height: 1180 },
isMobile: true,
hasTouch: true,
},
},
{
name: "desktop",
port: 8792,
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/design-system/components/Disclosure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export function Disclosure({ id, title, hint, defaultOpen = false, children }: P
useEffect(() => {
if (defaultOpen) setOpen(true); // something became live: show it; never fold on the person's behalf
}, [defaultOpen]);
useEffect(() => {
// A link to the tool (#id) opens it, on arrival and when the hash changes.
const sync = () => {
if (window.location.hash === `#${id}`) setOpen(true);
};
sync();
window.addEventListener("hashchange", sync);
return () => window.removeEventListener("hashchange", sync);
}, [id]);
return (
<details
id={id}
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/design-system/components/Segmented.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
.group button {
min-height: var(--touch-target);
padding: 0 var(--space-4);
white-space: nowrap;
border: 0;
background: transparent;
color: var(--color-ink-2);
Expand All @@ -20,15 +21,18 @@
.wide {
display: flex;
flex: 1 1 18rem;
flex-wrap: wrap;
width: 100%;
border-radius: var(--radius-card);
}

.wide button {
flex: 1 1 auto;
min-width: max-content; /* a label never breaks or clips: the option moves to the next row instead */
padding: var(--space-1) var(--space-2);
font-size: var(--text-sm);
line-height: var(--leading-heading);
white-space: nowrap;
}

.group button:focus-visible {
Expand Down
22 changes: 9 additions & 13 deletions frontend/src/features/admin/AdminSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useEffect, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { Button } from "@/design-system/components/Button";
import { Card } from "@/design-system/components/Card";
import { Notice } from "@/design-system/components/Notice";
import { TextField } from "@/design-system/components/TextField";
import {
Expand All @@ -15,10 +14,11 @@ import {
import { supportedLanguages, type Language } from "@/lib/i18n";
import { BackupSettings } from "./BackupSettings";
import { WebhookSettings } from "./WebhookSettings";
import { Group } from "@/features/settings/Group";
import styles from "@/features/settings/settings.module.css";

/** For administrators: how reminder emails leave the server, and the language of new accounts. */
export function AdminSettings() {
export function AdminSettings({ folded = false }: { folded?: boolean }) {
const { t } = useTranslation();
const email = useEmailSettings(true);
const save = useSaveEmailSettings();
Expand Down Expand Up @@ -68,8 +68,7 @@ export function AdminSettings() {

return (
<>
<Card className={styles.group}>
<h2 className={styles.subheading}>{t("admin.emailTitle")}</h2>
<Group folded={folded} id="email" title={t("admin.emailTitle")} hint={t("admin.emailHint")}>
<p className="text-secondary">{t("admin.emailIntro")}</p>
<form className={styles.form} onSubmit={submit}>
<TextField label={t("admin.host")} value={form.host} onChange={set("host")} autoComplete="off" />
Expand Down Expand Up @@ -138,14 +137,10 @@ export function AdminSettings() {
</form>
{test.error && <Notice kind="error">{test.error.message}</Notice>}
{test.isSuccess && <Notice kind="success">{t("admin.testSent")}</Notice>}
</Card>
<WebhookSettings />
<BackupSettings />
<Card className={styles.group}>
<Link to="/evaluation">{t("admin.evaluationLink")}</Link>
</Card>
<Card className={styles.group}>
<h2 className={styles.subheading}>{t("admin.instanceTitle")}</h2>
</Group>
<WebhookSettings folded={folded} />
<BackupSettings folded={folded} />
<Group folded={folded} id="instance" title={t("admin.instanceTitle")} hint={t("admin.instanceHint")}>
<label className={styles.row}>
<span>{t("admin.defaultLanguage")}</span>
<select
Expand All @@ -159,7 +154,8 @@ export function AdminSettings() {
))}
</select>
</label>
</Card>
<Link to="/evaluation">{t("admin.evaluationLink")}</Link>
</Group>
</>
);
}
9 changes: 4 additions & 5 deletions frontend/src/features/admin/BackupSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/design-system/components/Button";
import { Card } from "@/design-system/components/Card";
import { Notice } from "@/design-system/components/Notice";
import { useBackupNow, useBackupStatus, useVerifyBackupNow } from "@/lib/admin";
import { formatBytes } from "@/lib/data";
import { Group } from "@/features/settings/Group";
import styles from "@/features/settings/settings.module.css";

/** For administrators: whether the nightly backup runs, the latest one, and whether it would restore. */
export function BackupSettings() {
export function BackupSettings({ folded = false }: { folded?: boolean }) {
const { t, i18n } = useTranslation();
const status = useBackupStatus(true);
const backUp = useBackupNow();
Expand All @@ -20,8 +20,7 @@ export function BackupSettings() {
const failure = status.error ?? backUp.error ?? verify.error;

return (
<Card className={styles.group}>
<h2 className={styles.subheading}>{t("backups.title")}</h2>
<Group folded={folded} id="backups" title={t("backups.title")} hint={t("backups.hint")}>
{!backups.enabled ? (
<p className="text-secondary">{t("backups.off")}</p>
) : (
Expand Down Expand Up @@ -87,6 +86,6 @@ export function BackupSettings() {
</>
)}
{failure && <Notice kind="error">{failure.message}</Notice>}
</Card>
</Group>
);
}
9 changes: 4 additions & 5 deletions frontend/src/features/admin/UsersAdmin.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/design-system/components/Button";
import { Card } from "@/design-system/components/Card";
import { Notice } from "@/design-system/components/Notice";
import { TextField } from "@/design-system/components/TextField";
import {
Expand All @@ -13,12 +12,13 @@ import {
type UserOut,
} from "@/lib/admin";
import { useInstance, useSession } from "@/lib/auth/session";
import { Group } from "@/features/settings/Group";
import styles from "@/features/settings/settings.module.css";

const MIN_PASSWORD = 10;

/** The household's accounts: who can sign in, with what role, and the ways back in when something is lost. */
export function UsersAdmin() {
export function UsersAdmin({ folded = false }: { folded?: boolean }) {
const { t, i18n } = useTranslation();
const local = useInstance().data?.auth_mode !== "proxy";
const me = useSession().data?.user;
Expand Down Expand Up @@ -63,8 +63,7 @@ export function UsersAdmin() {
}

return (
<Card className={styles.group}>
<h2 className={styles.subheading}>{t("accounts.title")}</h2>
<Group folded={folded} id="accounts" title={t("accounts.title")} hint={t("accounts.hint")}>
<p className="text-secondary">{local ? t("accounts.intro") : t("accounts.introProxy")}</p>
<ul className={styles.users}>
{(users.data ?? []).map((user) => (
Expand Down Expand Up @@ -175,6 +174,6 @@ export function UsersAdmin() {
<Notice kind="success">{t("accounts.passwordSet")}</Notice>
)}
{failure && <Notice kind="error">{failure.message}</Notice>}
</Card>
</Group>
);
}
9 changes: 4 additions & 5 deletions frontend/src/features/admin/WebhookSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/design-system/components/Button";
import { Card } from "@/design-system/components/Card";
import { Notice } from "@/design-system/components/Notice";
import { TextField } from "@/design-system/components/TextField";
import {
Expand All @@ -13,10 +12,11 @@ import {
useWebhooks,
type Preset,
} from "@/lib/notifications";
import { Group } from "@/features/settings/Group";
import styles from "@/features/settings/settings.module.css";

/** For administrators: the daily list of due marks, sent to Home Assistant, ntfy, Gotify, n8n or any address. */
export function WebhookSettings() {
export function WebhookSettings({ folded = false }: { folded?: boolean }) {
const { t, i18n } = useTranslation();
const hooks = useWebhooks();
const create = useCreateWebhook();
Expand Down Expand Up @@ -47,8 +47,7 @@ export function WebhookSettings() {
}

return (
<Card className={styles.group}>
<h2 className={styles.subheading}>{t("webhooks.title")}</h2>
<Group folded={folded} id="webhooks" title={t("webhooks.title")} hint={t("webhooks.hint")}>
<p className="text-secondary">{t("webhooks.intro")}</p>
{(hooks.data ?? []).length > 0 && (
<ul className={styles.hooks}>
Expand Down Expand Up @@ -144,6 +143,6 @@ export function WebhookSettings() {
</Button>
</form>
{create.error && <Notice kind="error">{create.error.message}</Notice>}
</Card>
</Group>
);
}
2 changes: 1 addition & 1 deletion frontend/src/features/auth/AuthLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function AuthLayout({ children }: { children: ReactNode }) {
return (
<div className={styles.page}>
<header className={styles.brand}>
<img src="/brand/wordmark-dark.svg" alt={t("app.name")} width={192} height={90} />
<img src="/brand/wordmark-dark.svg" alt={t("app.name")} width={160} height={75} />
</header>
<main className={styles.main}>{children}</main>
<footer className={styles.tagline}>{t("app.tagline")}</footer>
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/features/images/PhotoGallery.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useRef, useState } from "react";
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/design-system/components/Button";
import { FileButton } from "@/design-system/components/FileButton";
Expand All @@ -17,10 +17,14 @@ interface Props {
isPending: boolean;
error: Error | null;
};
/** Shown right under the photographs: their quality note. */
caption?: ReactNode;
/** Whether taking a photo is the page's primary action (no photograph yet). */
primary?: boolean;
}

/** Thumbnails with a preview dialog, plus the camera and file buttons when the viewer may add photos. */
export function PhotoGallery({ images, canEdit, upload, previousImageId }: Props) {
export function PhotoGallery({ images, canEdit, upload, previousImageId, caption, primary = true }: Props) {
const { t, i18n } = useTranslation();
const remove = useDeleteImage();
const [role, setRole] = useState<ImageRole>("close_up");
Expand Down Expand Up @@ -74,6 +78,7 @@ export function PhotoGallery({ images, canEdit, upload, previousImageId }: Props
))}
</ul>
)}
{caption}
{canEdit && upload && (
<div className={styles.roleRow}>
<label>
Expand All @@ -88,6 +93,7 @@ export function PhotoGallery({ images, canEdit, upload, previousImageId }: Props
</label>
<FileButton
label={upload.isPending ? t("images.uploading") : t("images.takePhoto")}
variant={primary ? "primary" : "secondary"}
capture="environment"
disabled={upload.isPending}
onFiles={(files) => files.forEach((file) => upload.mutate({ file, role }))}
Expand Down
5 changes: 4 additions & 1 deletion frontend/src/features/lesions/LesionPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Link, useNavigate, useParams } from "react-router";
import { Button } from "@/design-system/components/Button";
import { Disclosure } from "@/design-system/components/Disclosure";
import { EmptyState } from "@/design-system/components/EmptyState";
import { Notice } from "@/design-system/components/Notice";
import { TextField } from "@/design-system/components/TextField";
Expand Down Expand Up @@ -203,7 +204,9 @@ export function LesionPage() {

<SizeChart lesionId={data.id} />
<Sightings lesionId={data.id} />
<Reports personId={data.person_id} lesionId={data.id} titleOf={() => lesionTitle(data, t)} />
<Disclosure id="mark-reports" title={t("reports.title")} hint={t("reports.hintLesion")}>
<Reports personId={data.person_id} lesionId={data.id} titleOf={() => lesionTitle(data, t)} embedded />
</Disclosure>

{canEdit && (
<div className={styles.dangerZone}>
Expand Down
Loading
Loading