feat(medications): redraw the injection-site picker as an anatomical silhouette

Replace the stick-figure outline with a traced front-of-body silhouette,
still an inline SVG so it inherits the theme tokens (currentColor + a
soft currentColor wash) and tints correctly across light/dark and the
zinc/Dracula palettes with no raster asset.

Recalibrate SITE_COORDS onto the new 100x200 viewBox (aspect ≈0.5) using
the iOS client's hands-on fractional coordinates, so both apps land their
dots on the same anatomy. The coordinates double as the Euclidean basis
for the rotation recommender, so picker and recommendation stay in sync.

Dot states: selected (filled), recommended-next (dashed primary ring),
and a new last-used solid ring; hit targets are r=14 on the viewBox
(≈45 CSS px at the rendered scale), clearing the 44px floor. Props,
callbacks, the screen-left=="links" convention and the v1.8.5 site→enum
bridge are unchanged — graphic and coordinates only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
(cherry picked from commit 46125c3bbec5573910f41a319ae717d37f39c228)
This commit is contained in:
Marc-André Bombeck committed 2026-06-02 17:01:14 +02:00
1 parent 7857aa3dd2
commit 09af60303d
3 files changed
+174 -112

No files matched your search

@@ -247,13 +247,14 @@ describe("<InjectionSitePicker>", () => {
});
it("uses the SVG body outline as the picker surface", () => {
// Sanity check the body outline renders — head circle + torso
// path are the load-bearing visual anchors for the click targets.
// Sanity check the body outline renders — the silhouette path is the
// load-bearing visual anchor for the click targets, drawn on the
// 100x200 viewBox the iOS reference coords are calibrated against.
const html = render(
<InjectionSitePicker value={null} onChange={() => {}} />,
);
expect(html).toMatch(/<svg[^>]*viewBox="0 0 120 300"/);
expect(html).toMatch(/<svg[^>]*viewBox="0 0 100 200"/);
expect(html).toContain('aria-label="Body outline"');
// The outer group has the localised "Once weekly on …" picker
// label so the wrapper announces its role properly.
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useId, useState } from "react";
import { useTranslations } from "@/lib/i18n/context";
import {
@@ -12,27 +12,38 @@ import {
} from "@/lib/medications/injection-sites";
/**
* v1.4.25 W4d — injection-site picker.
* Injection-site picker — anatomical front-of-body silhouette with 8
* tappable sites.
*
* Stylized front-of-body SVG with 8 click-targets. The active site
* highlights, the recommended next-site is ringed with a dashed outline
* so the user can rotate sensibly without consulting a separate guide.
* The silhouette is an INLINE SVG so it inherits the app's theme tokens
* (`currentColor` + CSS variables) and tints correctly in light/dark and
* across the zinc/Dracula palettes with no raster asset and no luminance
* key. The body is traced to the proportions the iOS client calibrated
* against (see `.planning/ios-coord/v0.12-…-injection-bodymap-coords.md`)
* so dots land on the same anatomy on both platforms.
*
* Per Marc's "nichts brickt" directive the picker is opt-in — it's only
* `viewBox="0 0 100 200"` (aspect ≈0.5). `SITE_COORDS` is expressed on
* the same viewBox; the recommended-next ring, the last-used marker and
* the selected fill all read directly from those coordinates.
*
* Per the long-standing "nichts brickt" directive the picker is opt-in —
* mounted from the GLP-1 dashboard tile and the optional log dialog.
* Existing medication intake flows continue to work without it (the
* intake-event row's `injectionSite` field stays NULL).
* Existing intake flows keep working without it (the row's
* `injectionSite` stays NULL).
*
* Mobile-first: the picker is centred in a 320px column and remains
* touch-friendly at Pixel-5 width. SVG buttons have 44px hit areas
* (the visible circle is smaller but the <button> wrapper meets WCAG).
* Touch: each visible dot is wrapped in an invisible hit-target circle.
* The SVG renders at ≈320px tall over a 200-unit viewBox (≈1.6× scale),
* so the r=14 transparent circle gives ≈45 CSS px — clearing the WCAG
* 2.5.8 / 2.5.5 44px floor without the abdomen pair (Δx=13 units ≈21px)
* colliding visually, since only the painted r=4.5 dot is opaque.
*/
interface InjectionSitePickerProps {
/** Current selection, if any. */
value: InjectionSiteKey | null;
/** Recent rotation history (most recent first). Powers the
* "recommended next site" dashed-ring annotation. */
* "recommended next site" dashed-ring annotation and the subtle
* "last used" marker. */
history?: ReadonlyArray<InjectionSiteKey>;
/**
* v1.8.5 — the effective allowed set (per-medication allowed sites
@@ -58,6 +69,7 @@ export function InjectionSitePicker({
}: InjectionSitePickerProps) {
const { t } = useTranslations();
const recommended = nextInjectionSite(history, 4, allowed);
const lastUsed = history[0] ?? null;
const allowedSet = allowed ? new Set(allowed) : null;
const isAllowed = (site: InjectionSiteKey) =>
allowedSet === null || allowedSet.has(site);
@@ -67,6 +79,11 @@ export function InjectionSitePicker({
// map (WCAG 2.4.7).
const [focusedSite, setFocusedSite] = useState<InjectionSiteKey | null>(null);
// Stable, instance-unique ids for the SVG paint servers so two pickers
// on one page can't collide their <defs>.
const uid = useId().replace(/[:]/g, "");
const bodyFillId = `inj-body-${uid}`;
// Accessible name: fold the rotation recommendation into the label so a
// screen-reader user gets the "recommended next site" cue that is
// otherwise conveyed only by the dashed ring.
@@ -84,90 +101,107 @@ export function InjectionSitePicker({
aria-label={t("medications.injectionSitePickerAriaLabel")}
>
<svg
viewBox="0 0 120 300"
className="text-foreground/40 h-[320px] w-auto max-w-full"
viewBox="0 0 100 200"
className="text-foreground/45 h-[320px] w-auto max-w-full"
role="img"
aria-label={t("medications.injectionSiteBodyOutlineAriaLabel")}
>
{/* Head */}
<circle
cx="60"
cy="22"
r="14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Neck */}
<defs>
{/* A whisper-soft vertical wash gives the flat silhouette a
little depth without fighting the theme — both stops are the
current text colour at low alpha, so it tints with the body. */}
<linearGradient id={bodyFillId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.06" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.10" />
</linearGradient>
</defs>
{/* Anatomical front-of-body silhouette — one continuous outline
traced to the iOS reference proportions (upright, arms slightly
out, plain oval head). Filled with the soft wash + a hairline
stroke so it reads as a calm medical illustration. */}
<path
d="M 56 36 L 56 46 L 64 46 L 64 36"
fill="none"
d="M 50 4
C 44.2 4 39.8 8.6 39.8 15
C 39.8 19.6 41.4 23.4 44 25.6
C 43.4 28.4 42 30.4 39.2 31.4
C 35 32.9 30.4 34.2 27.4 36.6
C 24.4 39 23 42.8 22.2 47.4
C 21.2 53.2 20.2 60 18.2 67
C 16.4 73.4 14 80.4 12.6 86.4
C 11.8 89.8 12.4 92.2 14.2 92.8
C 16 93.4 17.6 91.8 18.4 88.6
C 19.8 83 21.8 76.6 23.6 71
C 24.4 68.4 25.4 67.8 25.8 69
C 26.2 70.2 26 74 25.6 79
C 25.2 84.6 24.8 90.6 25.2 95.6
C 25.6 100.4 26.8 104.4 28 107.8
C 27.2 119 26.2 133 26.6 145
C 27 156.6 28.6 170 30 182
C 30.6 187.4 31.4 191.6 33 193.4
C 34.4 195 37 195.2 39 194.6
C 40.8 194 41.4 192.2 41.6 189.4
C 42.2 181.4 43 169 44 158
C 44.8 149.4 45.6 141.4 46.4 136
C 47 131.8 48.2 129.6 50 129.6
C 51.8 129.6 53 131.8 53.6 136
C 54.4 141.4 55.2 149.4 56 158
C 57 169 57.8 181.4 58.4 189.4
C 58.6 192.2 59.2 194 61 194.6
C 63 195.2 65.6 195 67 193.4
C 68.6 191.6 69.4 187.4 70 182
C 71.4 170 73 156.6 73.4 145
C 73.8 133 72.8 119 72 107.8
C 73.2 104.4 74.4 100.4 74.8 95.6
C 75.2 90.6 74.8 84.6 74.4 79
C 74 74 73.8 70.2 74.2 69
C 74.6 67.8 75.6 68.4 76.4 71
C 78.2 76.6 80.2 83 81.6 88.6
C 82.4 91.8 84 93.4 85.8 92.8
C 87.6 92.2 88.2 89.8 87.4 86.4
C 86 80.4 83.6 73.4 81.8 67
C 79.8 60 78.8 53.2 77.8 47.4
C 77 42.8 75.6 39 72.6 36.6
C 69.6 34.2 65 32.9 60.8 31.4
C 58 30.4 56.6 28.4 56 25.6
C 58.6 23.4 60.2 19.6 60.2 15
C 60.2 8.6 55.8 4 50 4 Z"
fill={`url(#${bodyFillId})`}
stroke="currentColor"
strokeWidth="1.5"
strokeWidth="0.9"
strokeLinejoin="round"
/>
{/* Torso */}
<path
d="M 40 50 Q 30 60 28 80 L 32 175 Q 40 185 60 185 Q 80 185 88 175 L 92 80 Q 90 60 80 50 Z"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Arms */}
<path
d="M 32 60 L 16 110 L 14 150"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 88 60 L 104 110 L 106 150"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Thighs */}
<path
d="M 36 185 L 30 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 50 185 L 50 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 70 185 L 70 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M 84 185 L 90 270"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
/>
{/* Belt line */}
{/* Centre + waist references — barely-there guides that help the
eye read the abdomen quadrants. Kept low-alpha so they never
compete with the dots. */}
<line
x1="32"
y1="155"
x2="88"
y2="155"
x1="50"
y1="34"
x2="50"
y2="104"
stroke="currentColor"
strokeWidth="0.8"
strokeDasharray="2 3"
strokeWidth="0.4"
strokeOpacity="0.35"
/>
<line
x1="29"
y1="88"
x2="71"
y2="88"
stroke="currentColor"
strokeWidth="0.4"
strokeOpacity="0.35"
/>
{/* Navel reference */}
<circle cx="60" cy="130" r="1.5" fill="currentColor" />
<circle cx="50" cy="88" r="0.9" fill="currentColor" fillOpacity="0.45" />
{/* Click targets */}
{INJECTION_SITE_KEYS.map((site) => {
const coord = SITE_COORDS[site];
const isActive = site === value;
const isRecommended = site === recommended;
const isLastUsed = site === lastUsed && !isActive;
// v1.8.5 — a site outside the effective allowed set renders
// disabled (dimmed, non-interactive) so the user cannot pick a
// site the server would reject.
@@ -182,27 +216,41 @@ export function InjectionSitePicker({
<circle
cx={coord.x}
cy={coord.y}
r="10.5"
r="8"
fill="none"
className="stroke-ring"
strokeWidth="2.5"
strokeWidth="1.8"
/>
)}
{/* Last-used marker — a soft solid ring so the user can see
where they injected most recently at a glance, distinct
from the dashed recommendation ring. Suppressed on the
active selection (the filled dot already says "here"). */}
{isLastUsed && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="7"
fill="none"
className="stroke-foreground/35"
strokeWidth="1"
/>
)}
{isRecommended && !isActive && !disabled && (
<circle
cx={coord.x}
cy={coord.y}
r="9"
r="7"
fill="none"
className="stroke-primary"
strokeWidth="1.5"
strokeWidth="1.1"
strokeDasharray="2 2"
/>
)}
<circle
cx={coord.x}
cy={coord.y}
r="6"
r="4.5"
className={
disabled
? "fill-muted/30 stroke-foreground/20"
@@ -210,19 +258,18 @@ export function InjectionSitePicker({
? "fill-primary stroke-primary-foreground"
: "fill-muted stroke-foreground/60 hover:fill-accent"
}
strokeWidth="1.5"
strokeWidth="1.1"
/>
{/* Invisible 24-unit hit-target for touch. The SVG renders
at ≈1.07× scale (320px box / 300-unit viewBox height),
so r=12 gives ≈25.6 CSS px diameter — clearing the
WCAG 2.5.8 Level AA 24×24 floor. Going larger would
overlap the abdomen-left / abdomen-right pair at
Δx=24 units; the picker stays interactive without
spacing collisions. */}
{/* Invisible hit-target for touch. r=14 on the 200-unit
viewBox rendered at ≈320px tall (≈1.6× scale) gives
≈45 CSS px diameter — clearing the WCAG 2.5.8 / 2.5.5
44px floor. The abdomen quadrants sit Δx=13 / Δy=14
units apart; the transparent targets touch but the
pointer always resolves to the nearest centre. */}
<circle
cx={coord.x}
cy={coord.y}
r="12"
r="14"
fill="transparent"
role="button"
tabIndex={disabled ? -1 : 0}
+29 -15
View File
@@ -22,24 +22,38 @@ export const INJECTION_SITE_KEYS = [
export type InjectionSiteKey = (typeof INJECTION_SITE_KEYS)[number];
/**
* Stylized SVG position for each site (front-of-body view, ~120x300
* viewBox). Used by `injection-site-picker.tsx` to render the clickable
* dots; tuned to read clearly on a 320px mobile viewport.
* SVG position for each site on the anatomical front-of-body silhouette
* (`100 x 200` viewBox, aspect ≈0.5). Used by `injection-site-picker.tsx`
* to render the clickable dots AND by {@link nextInjectionSite} as the
* Euclidean-distance basis for the rotation recommendation, so the two
* always agree on where a site "is".
*
* The body outline is a rounded humanoid: head (y≈20), shoulders
* (y≈60), torso (y≈70-180), thighs (y≈180-260). Sites sit on the
* dominant injection zones — abdomen quadrants centred on (60, 130),
* thighs at (40, 215) / (80, 215), upper arms at (20, 80) / (100, 80).
* Calibrated against the iOS client's hands-on tune (see
* `.planning/ios-coord/v0.12-ios-to-server-injection-bodymap-coords.md`):
* fractional coords (0–1, x across width / y down height) multiplied by
* the viewBox `width=100` / `height=200`, so both apps land their dots on
* the same anatomy. Screen-left (`*_LEFT`) sits at the viewer's left,
* matching the long-standing "links == screen-left" convention.
*
* site (iOS naming) fraction (x, y) → viewBox (x, y)
* armLeft 0.262, 0.360 26.2, 72
* armRight 0.738, 0.360 73.8, 72
* abdomenLeftUpper 0.435, 0.405 43.5, 81
* abdomenRightUpper 0.565, 0.405 56.5, 81
* abdomenLeftLower 0.435, 0.475 43.5, 95
* abdomenRightLower 0.565, 0.475 56.5, 95
* thighLeft 0.430, 0.700 43, 140
* thighRight 0.570, 0.700 57, 140
*/
export const SITE_COORDS: Record<InjectionSiteKey, { x: number; y: number }> = {
ABDOMEN_UPPER_LEFT: { x: 48, y: 110 },
ABDOMEN_UPPER_RIGHT: { x: 72, y: 110 },
ABDOMEN_LEFT: { x: 48, y: 140 },
ABDOMEN_RIGHT: { x: 72, y: 140 },
THIGH_LEFT: { x: 42, y: 215 },
THIGH_RIGHT: { x: 78, y: 215 },
UPPER_ARM_LEFT: { x: 18, y: 82 },
UPPER_ARM_RIGHT: { x: 102, y: 82 },
ABDOMEN_UPPER_LEFT: { x: 43.5, y: 81 },
ABDOMEN_UPPER_RIGHT: { x: 56.5, y: 81 },
ABDOMEN_LEFT: { x: 43.5, y: 95 },
ABDOMEN_RIGHT: { x: 56.5, y: 95 },
THIGH_LEFT: { x: 43, y: 140 },
THIGH_RIGHT: { x: 57, y: 140 },
UPPER_ARM_LEFT: { x: 26.2, y: 72 },
UPPER_ARM_RIGHT: { x: 73.8, y: 72 },
};
/**