@broberg/bodymap 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/index.cjs +10 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +13 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +10 -1
- package/dist/index.js.map +1 -1
- package/dist/react.cjs +3 -3
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +13 -0
- package/dist/react.d.ts +13 -0
- package/dist/react.js +3 -3
- package/dist/react.js.map +1 -1
- package/dist/three.cjs +32 -11
- package/dist/three.cjs.map +1 -1
- package/dist/three.d.cts +13 -0
- package/dist/three.d.ts +13 -0
- package/dist/three.js +32 -11
- package/dist/three.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -382,6 +382,37 @@ is invisible on a desktop monitor and obvious on a phone.
|
|
|
382
382
|
|
|
383
383
|
3D only — the 2D renderer has no canvas and no fullscreen, so it ignores it.
|
|
384
384
|
|
|
385
|
+
### `ui.accent` — the controls read your palette now (0.9.0)
|
|
386
|
+
|
|
387
|
+
Until 0.9.0 the buttons carried their colours inline, so a consumer palette
|
|
388
|
+
reached the body and the card but **not the controls inside the card**. Three of
|
|
389
|
+
those colours already existed as tokens and were simply not read.
|
|
390
|
+
|
|
391
|
+
```ts
|
|
392
|
+
ui: {
|
|
393
|
+
accent: "#141969", // the ACTIVE control — chosen intensity, active sex toggle
|
|
394
|
+
accentText: "#f5f5f5", // text on the accent
|
|
395
|
+
border: "#d4d4d8", // now reaches the BUTTONS, not only the card
|
|
396
|
+
dangerBorder: "#f6c9c9",
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
**`accent` is not `palette.selected`.** `selected` is the colour of a mark **on
|
|
401
|
+
skin**; `accent` is the colour of an **active button**. Keeping them apart is what
|
|
402
|
+
lets you have a teal mark and a navy button.
|
|
403
|
+
|
|
404
|
+
⚠️ **The default accent changed in 0.9.0** — `#0e8f8a` → `#0c7d77`, the same teal
|
|
405
|
+
a shade deeper. Not a taste decision: white on the old one measured **3.95:1**
|
|
406
|
+
where WCAG AA needs **4.5**, on the selected-intensity button. It had shipped
|
|
407
|
+
since 0.2.0 and no check could see it, because the colour was an inline literal
|
|
408
|
+
and the contrast suite only read the defaults object. Making it a token is what
|
|
409
|
+
exposed it.
|
|
410
|
+
|
|
411
|
+
**We do NOT contrast-check the accent you supply.** We cannot — it is your colour,
|
|
412
|
+
resolved at your runtime. If this component sits on a surface with an
|
|
413
|
+
accessibility duty (ours does), measure your own pair: normal text needs **4.5:1**,
|
|
414
|
+
large text **3:1**. The 0–10 digits and the toggle labels are normal text.
|
|
415
|
+
|
|
385
416
|
The **WebGL-unsupported message** deliberately does *not* follow `stageBg`. It is
|
|
386
417
|
a message, not a stage, so it uses `panelBg` + `mutedText` — a pair whose contrast
|
|
387
418
|
the test suite asserts. Tied to the stage it would become unreadable the moment
|
package/dist/index.cjs
CHANGED
|
@@ -109,7 +109,16 @@ var defaultUi = {
|
|
|
109
109
|
stageBg: STAGE_BG,
|
|
110
110
|
border: "#e2e8f0",
|
|
111
111
|
badgeBg: "#f1f5f9",
|
|
112
|
-
danger: "#dc2626"
|
|
112
|
+
danger: "#dc2626",
|
|
113
|
+
dangerBorder: "#f6c9c9",
|
|
114
|
+
// F052.30 — was #0e8f8a, which measured 3.95:1 against white. AA needs 4.5,
|
|
115
|
+
// and this is the SELECTED intensity button on a surface where AA is a legal
|
|
116
|
+
// duty. It shipped from 0.2.0 and no check could see it: the colour was an
|
|
117
|
+
// inline literal, and the F052.19 contrast sweep only read this object.
|
|
118
|
+
// #0c7d77 is the same teal a shade deeper — 4.98:1, with headroom rather than
|
|
119
|
+
// sitting on the 4.5 boundary where any later tweak reopens it.
|
|
120
|
+
accent: "#0c7d77",
|
|
121
|
+
accentText: "#fff"
|
|
113
122
|
};
|
|
114
123
|
function uiColors(palette) {
|
|
115
124
|
return { ...defaultUi, ...palette?.ui ?? {} };
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":["z"],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/**\n * What a pick on a region should DO (F052.20).\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts. This repo measured the cost of that\n * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling\n * branch that carried the same defect with no test on it.\n *\n * \"clear\" the region is already marked → picking it again removes the mark\n * \"select\" unmarked → open it for marking\n * \"ignore\" not selectable (read-only or config) → nothing happens\n *\n * Three outcomes, not a boolean: \"nothing happened because it is locked\" and\n * \"nothing happened because we removed the mark\" must never look alike to a\n * caller.\n */\nexport type PickOutcome = \"clear\" | \"select\" | \"ignore\";\n\nexport function decidePick(\n region: string,\n report: PainReport,\n config: RegionConfig = {},\n): PickOutcome {\n if (!isSelectable(region, config)) return \"ignore\";\n return report.some((p) => p.region === region) ? \"clear\" : \"select\";\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- feedback signal: sound + haptics (F052.22) -----------------------------\n\n/**\n * What a pick did, handed to the consuming app so it can make a sound or a buzz.\n *\n * The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap\n * — so a tap on a locked region can not announce itself as a removal, and a tap\n * the pan/pinch guard swallowed emits nothing at all (it never gets here).\n *\n * It deliberately reuses `PickOutcome` rather than introducing a second\n * three-word vocabulary. Two enums meaning the same thing is a drift bug waiting\n * for the first person who adds a fourth outcome to only one of them.\n */\nexport interface FeedbackSignal {\n outcome: PickOutcome;\n /** The region key that was picked. */\n region: string;\n}\n\nexport type FeedbackFn = (signal: FeedbackSignal) => void;\n\n/**\n * What the BROWSER did with a vibration request.\n *\n * ⚠️ `requested` does NOT mean the phone buzzed. `navigator.vibrate()` returns\n * true for a request it accepted, and all of these accept it and then produce\n * nothing: silent / do-not-disturb mode, a device with no vibration motor (most\n * laptops), a page that has not yet had a qualifying user gesture.\n *\n * Same lesson `@broberg/webpush` 0.3.1 recorded — a push that provably ARRIVED\n * on a device that never SHOWED it. \"Accepted\" and \"happened\" are two claims,\n * and only one of them is observable from here.\n */\nexport type VibrateOutcome = \"unsupported\" | \"skipped\" | \"declined\" | \"requested\";\n\n/**\n * The buzz for each outcome.\n *\n * `ignore` is empty ON PURPOSE: a tap that changed nothing must not feel like it\n * changed something. That is the whole reason this is keyed by outcome and not\n * fired from the tap handler.\n */\nexport const VIBRATION_PATTERNS: Record<PickOutcome, readonly number[]> = {\n select: [12],\n clear: [8, 40, 8],\n ignore: [],\n};\n\ninterface Vibrator {\n vibrate?: (pattern: number | number[]) => boolean;\n}\n\n/**\n * Ask the browser to vibrate. Never throws, never claims delivery.\n *\n * `nav` is injectable so a test can supply all four cases; it defaults to the\n * real `navigator` and is `unsupported` when there is not one (SSR, and every\n * browser on iPhone — WebKit has no `vibrate` at all).\n */\nexport function requestVibration(\n pattern: readonly number[],\n nav: Vibrator | undefined = (globalThis as { navigator?: Vibrator }).navigator,\n): VibrateOutcome {\n if (pattern.length === 0) return \"skipped\";\n if (typeof nav?.vibrate !== \"function\") return \"unsupported\";\n try {\n return nav.vibrate([...pattern]) ? \"requested\" : \"declined\";\n } catch {\n // Embedded webviews and cross-origin iframes throw rather than return false.\n // A refused buzz must never take the pain report down with it.\n return \"declined\";\n }\n}\n\nexport interface FeedbackOptions {\n onFeedback?: FeedbackFn;\n /** Web vibration on select/clear. Default true; inert where the API is absent. */\n haptics?: boolean;\n /** Injectable for tests. */\n nav?: unknown;\n}\n\n/**\n * Emit one pick's feedback: always the signal, optionally the buzz.\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts — the same reason `decidePick` is\n * here (F052.20).\n *\n * Sound is NOT here and never will be: `@broberg/soundkit` already exists, and\n * pulling Web Audio into a component that is often rendered read-only (a\n * journal, a PDF, a clinician view) is a cost every consumer would pay for a\n * feature most will not switch on. Wire `onFeedback` to it in four lines.\n */\nexport function emitFeedback(\n outcome: PickOutcome,\n region: string,\n opts: FeedbackOptions = {},\n): VibrateOutcome {\n opts.onFeedback?.({ outcome, region });\n if (opts.haptics === false) return \"skipped\";\n // `undefined` falls through to requestVibration's own default (the real\n // navigator) — so an omitted `nav` and a passed-in one take the same path.\n return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav as Vibrator | undefined);\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background — the card, its buttons, the empty hint. */\n panelBg?: string;\n /**\n * THE STAGE: the fullscreen backdrop, the canvas frame, and the three.js\n * scene behind the body. A different surface from `panelBg` with an opposite\n * need — see F052.29, where one field paints both and no value is right for\n * either.\n *\n * 3D only. The 2D renderer has no canvas and no fullscreen, so it ignores\n * this; it is declared here because a stage is a shared idea, not because\n * every renderer has one.\n *\n * Defaults to {@link STAGE_BG}. Pass `'#fff'` for the pre-0.8.0 white\n * fullscreen backdrop.\n */\n stageBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\n/**\n * The stage colour — the ONE place this value lives.\n *\n * It used to be a literal in three places in `three.tsx` (the three.js scene,\n * the canvas frame, the unsupported placeholder), which meant a consumer could\n * recolour the backdrop and still get OUR navy inside it. That seam is\n * invisible on a desktop and obvious on a phone. (F052.29)\n */\nexport const STAGE_BG = \"#0e1424\";\n\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n stageBg: STAGE_BG,\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":["z"],"mappings":"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","file":"index.cjs","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/**\n * What a pick on a region should DO (F052.20).\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts. This repo measured the cost of that\n * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling\n * branch that carried the same defect with no test on it.\n *\n * \"clear\" the region is already marked → picking it again removes the mark\n * \"select\" unmarked → open it for marking\n * \"ignore\" not selectable (read-only or config) → nothing happens\n *\n * Three outcomes, not a boolean: \"nothing happened because it is locked\" and\n * \"nothing happened because we removed the mark\" must never look alike to a\n * caller.\n */\nexport type PickOutcome = \"clear\" | \"select\" | \"ignore\";\n\nexport function decidePick(\n region: string,\n report: PainReport,\n config: RegionConfig = {},\n): PickOutcome {\n if (!isSelectable(region, config)) return \"ignore\";\n return report.some((p) => p.region === region) ? \"clear\" : \"select\";\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- feedback signal: sound + haptics (F052.22) -----------------------------\n\n/**\n * What a pick did, handed to the consuming app so it can make a sound or a buzz.\n *\n * The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap\n * — so a tap on a locked region can not announce itself as a removal, and a tap\n * the pan/pinch guard swallowed emits nothing at all (it never gets here).\n *\n * It deliberately reuses `PickOutcome` rather than introducing a second\n * three-word vocabulary. Two enums meaning the same thing is a drift bug waiting\n * for the first person who adds a fourth outcome to only one of them.\n */\nexport interface FeedbackSignal {\n outcome: PickOutcome;\n /** The region key that was picked. */\n region: string;\n}\n\nexport type FeedbackFn = (signal: FeedbackSignal) => void;\n\n/**\n * What the BROWSER did with a vibration request.\n *\n * ⚠️ `requested` does NOT mean the phone buzzed. `navigator.vibrate()` returns\n * true for a request it accepted, and all of these accept it and then produce\n * nothing: silent / do-not-disturb mode, a device with no vibration motor (most\n * laptops), a page that has not yet had a qualifying user gesture.\n *\n * Same lesson `@broberg/webpush` 0.3.1 recorded — a push that provably ARRIVED\n * on a device that never SHOWED it. \"Accepted\" and \"happened\" are two claims,\n * and only one of them is observable from here.\n */\nexport type VibrateOutcome = \"unsupported\" | \"skipped\" | \"declined\" | \"requested\";\n\n/**\n * The buzz for each outcome.\n *\n * `ignore` is empty ON PURPOSE: a tap that changed nothing must not feel like it\n * changed something. That is the whole reason this is keyed by outcome and not\n * fired from the tap handler.\n */\nexport const VIBRATION_PATTERNS: Record<PickOutcome, readonly number[]> = {\n select: [12],\n clear: [8, 40, 8],\n ignore: [],\n};\n\ninterface Vibrator {\n vibrate?: (pattern: number | number[]) => boolean;\n}\n\n/**\n * Ask the browser to vibrate. Never throws, never claims delivery.\n *\n * `nav` is injectable so a test can supply all four cases; it defaults to the\n * real `navigator` and is `unsupported` when there is not one (SSR, and every\n * browser on iPhone — WebKit has no `vibrate` at all).\n */\nexport function requestVibration(\n pattern: readonly number[],\n nav: Vibrator | undefined = (globalThis as { navigator?: Vibrator }).navigator,\n): VibrateOutcome {\n if (pattern.length === 0) return \"skipped\";\n if (typeof nav?.vibrate !== \"function\") return \"unsupported\";\n try {\n return nav.vibrate([...pattern]) ? \"requested\" : \"declined\";\n } catch {\n // Embedded webviews and cross-origin iframes throw rather than return false.\n // A refused buzz must never take the pain report down with it.\n return \"declined\";\n }\n}\n\nexport interface FeedbackOptions {\n onFeedback?: FeedbackFn;\n /** Web vibration on select/clear. Default true; inert where the API is absent. */\n haptics?: boolean;\n /** Injectable for tests. */\n nav?: unknown;\n}\n\n/**\n * Emit one pick's feedback: always the signal, optionally the buzz.\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts — the same reason `decidePick` is\n * here (F052.20).\n *\n * Sound is NOT here and never will be: `@broberg/soundkit` already exists, and\n * pulling Web Audio into a component that is often rendered read-only (a\n * journal, a PDF, a clinician view) is a cost every consumer would pay for a\n * feature most will not switch on. Wire `onFeedback` to it in four lines.\n */\nexport function emitFeedback(\n outcome: PickOutcome,\n region: string,\n opts: FeedbackOptions = {},\n): VibrateOutcome {\n opts.onFeedback?.({ outcome, region });\n if (opts.haptics === false) return \"skipped\";\n // `undefined` falls through to requestVibration's own default (the real\n // navigator) — so an omitted `nav` and a passed-in one take the same path.\n return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav as Vibrator | undefined);\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background — the card, its buttons, the empty hint. */\n panelBg?: string;\n /**\n * THE STAGE: the fullscreen backdrop, the canvas frame, and the three.js\n * scene behind the body. A different surface from `panelBg` with an opposite\n * need — see F052.29, where one field paints both and no value is right for\n * either.\n *\n * 3D only. The 2D renderer has no canvas and no fullscreen, so it ignores\n * this; it is declared here because a stage is a shared idea, not because\n * every renderer has one.\n *\n * Defaults to {@link STAGE_BG}. Pass `'#fff'` for the pre-0.8.0 white\n * fullscreen backdrop.\n */\n stageBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n /** Border on the destructive control. Its TEXT was already themeable and its\n * border was not — half a control. (F052.30) */\n dangerBorder?: string;\n /**\n * THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.\n *\n * NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the\n * colour of an active button. Collapsing them would stop a consumer having a\n * teal mark and a navy button, which is a normal thing to want. (F052.30)\n */\n accent?: string;\n /** Text ON the accent. Check it against your accent — see the README. */\n accentText?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\n/**\n * The stage colour — the ONE place this value lives.\n *\n * It used to be a literal in three places in `three.tsx` (the three.js scene,\n * the canvas frame, the unsupported placeholder), which meant a consumer could\n * recolour the backdrop and still get OUR navy inside it. That seam is\n * invisible on a desktop and obvious on a phone. (F052.29)\n */\nexport const STAGE_BG = \"#0e1424\";\n\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n stageBg: STAGE_BG,\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n dangerBorder: \"#f6c9c9\",\n // F052.30 — was #0e8f8a, which measured 3.95:1 against white. AA needs 4.5,\n // and this is the SELECTED intensity button on a surface where AA is a legal\n // duty. It shipped from 0.2.0 and no check could see it: the colour was an\n // inline literal, and the F052.19 contrast sweep only read this object.\n // #0c7d77 is the same teal a shade deeper — 4.98:1, with headroom rather than\n // sitting on the 4.5 boundary where any later tweak reopens it.\n accent: \"#0c7d77\",\n accentText: \"#fff\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -230,6 +230,19 @@ interface BodymapUiColors {
|
|
|
230
230
|
badgeBg?: string;
|
|
231
231
|
/** The destructive action (remove a marked region). */
|
|
232
232
|
danger?: string;
|
|
233
|
+
/** Border on the destructive control. Its TEXT was already themeable and its
|
|
234
|
+
* border was not — half a control. (F052.30) */
|
|
235
|
+
dangerBorder?: string;
|
|
236
|
+
/**
|
|
237
|
+
* THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.
|
|
238
|
+
*
|
|
239
|
+
* NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the
|
|
240
|
+
* colour of an active button. Collapsing them would stop a consumer having a
|
|
241
|
+
* teal mark and a navy button, which is a normal thing to want. (F052.30)
|
|
242
|
+
*/
|
|
243
|
+
accent?: string;
|
|
244
|
+
/** Text ON the accent. Check it against your accent — see the README. */
|
|
245
|
+
accentText?: string;
|
|
233
246
|
}
|
|
234
247
|
/**
|
|
235
248
|
* Default panel chrome. Contrast against `panelBg` (#fff), asserted by
|
package/dist/index.d.ts
CHANGED
|
@@ -230,6 +230,19 @@ interface BodymapUiColors {
|
|
|
230
230
|
badgeBg?: string;
|
|
231
231
|
/** The destructive action (remove a marked region). */
|
|
232
232
|
danger?: string;
|
|
233
|
+
/** Border on the destructive control. Its TEXT was already themeable and its
|
|
234
|
+
* border was not — half a control. (F052.30) */
|
|
235
|
+
dangerBorder?: string;
|
|
236
|
+
/**
|
|
237
|
+
* THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.
|
|
238
|
+
*
|
|
239
|
+
* NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the
|
|
240
|
+
* colour of an active button. Collapsing them would stop a consumer having a
|
|
241
|
+
* teal mark and a navy button, which is a normal thing to want. (F052.30)
|
|
242
|
+
*/
|
|
243
|
+
accent?: string;
|
|
244
|
+
/** Text ON the accent. Check it against your accent — see the README. */
|
|
245
|
+
accentText?: string;
|
|
233
246
|
}
|
|
234
247
|
/**
|
|
235
248
|
* Default panel chrome. Contrast against `panelBg` (#fff), asserted by
|
package/dist/index.js
CHANGED
|
@@ -107,7 +107,16 @@ var defaultUi = {
|
|
|
107
107
|
stageBg: STAGE_BG,
|
|
108
108
|
border: "#e2e8f0",
|
|
109
109
|
badgeBg: "#f1f5f9",
|
|
110
|
-
danger: "#dc2626"
|
|
110
|
+
danger: "#dc2626",
|
|
111
|
+
dangerBorder: "#f6c9c9",
|
|
112
|
+
// F052.30 — was #0e8f8a, which measured 3.95:1 against white. AA needs 4.5,
|
|
113
|
+
// and this is the SELECTED intensity button on a surface where AA is a legal
|
|
114
|
+
// duty. It shipped from 0.2.0 and no check could see it: the colour was an
|
|
115
|
+
// inline literal, and the F052.19 contrast sweep only read this object.
|
|
116
|
+
// #0c7d77 is the same teal a shade deeper — 4.98:1, with headroom rather than
|
|
117
|
+
// sitting on the 4.5 boundary where any later tweak reopens it.
|
|
118
|
+
accent: "#0c7d77",
|
|
119
|
+
accentText: "#fff"
|
|
111
120
|
};
|
|
112
121
|
function uiColors(palette) {
|
|
113
122
|
return { ...defaultUi, ...palette?.ui ?? {} };
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":";;;AA6BO,IAAM,OAAA,GAAiC;AAAA;AAAA,EAE5C,EAAE,GAAA,EAAK,MAAA,EAAQ,KAAA,EAAO,OAAA,EAAS,MAAM,MAAA,EAAO;AAAA,EAC5C,EAAE,GAAA,EAAK,MAAA,EAAQ,KAAA,EAAO,OAAA,EAAS,MAAM,MAAA,EAAO;AAAA,EAC5C,EAAE,GAAA,EAAK,OAAA,EAAS,KAAA,EAAO,OAAA,EAAS,MAAM,OAAA,EAAQ;AAAA,EAC9C,EAAE,GAAA,EAAK,OAAA,EAAS,KAAA,EAAO,wBAAA,EAAuB,MAAM,OAAA,EAAQ;AAAA,EAC5D,EAAE,GAAA,EAAK,QAAA,EAAU,KAAA,EAAO,kBAAA,EAAiB,MAAM,QAAA,EAAS;AAAA,EACxD,EAAE,GAAA,EAAK,OAAA,EAAS,KAAA,EAAO,OAAA,EAAS,MAAM,OAAA,EAAQ;AAAA;AAAA,EAE9C,EAAE,KAAK,eAAA,EAAiB,KAAA,EAAO,oBAAoB,IAAA,EAAM,UAAA,EAAY,MAAM,MAAA,EAAO;AAAA,EAClF,EAAE,KAAK,gBAAA,EAAkB,KAAA,EAAO,qBAAkB,IAAA,EAAM,UAAA,EAAY,MAAM,OAAA,EAAQ;AAAA,EAClF,EAAE,KAAK,WAAA,EAAa,KAAA,EAAO,oBAAoB,IAAA,EAAM,MAAA,EAAQ,MAAM,MAAA,EAAO;AAAA,EAC1E,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,qBAAkB,IAAA,EAAM,MAAA,EAAQ,MAAM,OAAA,EAAQ;AAAA,EAC1E,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,kBAAkB,IAAA,EAAM,OAAA,EAAS,MAAM,MAAA,EAAO;AAAA,EAC1E,EAAE,KAAK,aAAA,EAAe,KAAA,EAAO,mBAAgB,IAAA,EAAM,OAAA,EAAS,MAAM,OAAA,EAAQ;AAAA,EAC1E,EAAE,KAAK,WAAA,EAAa,KAAA,EAAO,qBAAqB,IAAA,EAAM,MAAA,EAAQ,MAAM,MAAA,EAAO;AAAA,EAC3E,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,sBAAmB,IAAA,EAAM,MAAA,EAAQ,MAAM,OAAA,EAAQ;AAAA,EAC3E,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,uBAAoB,IAAA,EAAM,OAAA,EAAS,MAAM,MAAA,EAAO;AAAA,EAC5E,EAAE,KAAK,aAAA,EAAe,KAAA,EAAO,wBAAkB,IAAA,EAAM,OAAA,EAAS,MAAM,OAAA,EAAQ;AAAA,EAC5E,EAAE,KAAK,WAAA,EAAa,KAAA,EAAO,oBAAiB,IAAA,EAAM,MAAA,EAAQ,MAAM,MAAA,EAAO;AAAA,EACvE,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,qBAAe,IAAA,EAAM,MAAA,EAAQ,MAAM,OAAA,EAAQ;AAAA,EACvE,EAAE,KAAK,UAAA,EAAY,KAAA,EAAO,kBAAkB,IAAA,EAAM,KAAA,EAAO,MAAM,MAAA,EAAO;AAAA,EACtE,EAAE,KAAK,WAAA,EAAa,KAAA,EAAO,mBAAgB,IAAA,EAAM,KAAA,EAAO,MAAM,OAAA,EAAQ;AAAA,EACtE,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,mBAAgB,IAAA,EAAM,OAAA,EAAS,MAAM,MAAA,EAAO;AAAA,EACxE,EAAE,KAAK,aAAA,EAAe,KAAA,EAAO,oBAAc,IAAA,EAAM,OAAA,EAAS,MAAM,OAAA,EAAQ;AAAA,EACxE,EAAE,KAAK,WAAA,EAAa,KAAA,EAAO,mBAAgB,IAAA,EAAM,MAAA,EAAQ,MAAM,MAAA,EAAO;AAAA,EACtE,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,oBAAc,IAAA,EAAM,MAAA,EAAQ,MAAM,OAAA,EAAQ;AAAA,EACtE,EAAE,KAAK,aAAA,EAAe,KAAA,EAAO,qBAAqB,IAAA,EAAM,QAAA,EAAU,MAAM,MAAA,EAAO;AAAA,EAC/E,EAAE,KAAK,cAAA,EAAgB,KAAA,EAAO,sBAAmB,IAAA,EAAM,QAAA,EAAU,MAAM,OAAA,EAAQ;AAAA,EAC/E,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,kBAAkB,IAAA,EAAM,OAAA,EAAS,MAAM,MAAA,EAAO;AAAA,EAC1E,EAAE,KAAK,aAAA,EAAe,KAAA,EAAO,mBAAgB,IAAA,EAAM,OAAA,EAAS,MAAM,OAAA,EAAQ;AAAA,EAC1E,EAAE,KAAK,WAAA,EAAa,KAAA,EAAO,gBAAgB,IAAA,EAAM,MAAA,EAAQ,MAAM,MAAA,EAAO;AAAA,EACtE,EAAE,KAAK,YAAA,EAAc,KAAA,EAAO,iBAAc,IAAA,EAAM,MAAA,EAAQ,MAAM,OAAA;AAChE;AAEA,IAAM,cAAA,GAAiB,IAAI,GAAA,CAAI,OAAA,CAAQ,IAAI,CAAC,CAAA,KAAM,CAAA,CAAE,GAAG,CAAC,CAAA;AACjD,IAAM,cAAiC,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,EAAE,GAAG;AAG/D,SAAS,UAAU,GAAA,EAAqC;AAC7D,EAAA,OAAO,QAAQ,IAAA,CAAK,CAAC,CAAA,KAAM,CAAA,CAAE,QAAQ,GAAG,CAAA;AAC1C;AAIO,IAAM,UAAA,GAAa,CAAC,WAAA,EAAa,MAAA,EAAQ,YAAY,SAAS;AAK9D,IAAM,eAAA,GAAkB,EAAE,MAAA,CAAO;AAAA,EACtC,MAAA,EAAQ,CAAA,CAAE,MAAA,EAAO,CAAE,OAAO,CAAC,CAAA,KAAM,cAAA,CAAe,GAAA,CAAI,CAAC,CAAA,EAAG,EAAE,OAAA,EAAS,kBAAkB,CAAA;AAAA,EACrF,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,GAAM,GAAA,CAAI,CAAC,CAAA,CAAE,GAAA,CAAI,EAAE,CAAA;AAAA,EACzC,IAAA,EAAM,CAAA,CAAE,IAAA,CAAK,UAAU,EAAE,QAAA,EAAS;AAAA,EAClC,SAAA,EAAW,EAAE,MAAA;AACf,CAAC;AAGM,IAAM,gBAAA,GAAmB,CAAA,CAAE,KAAA,CAAM,eAAe;AAuBhD,SAAS,oBACd,OAAA,GAAsB,EAAC,EACvB,IAAA,GAA6B,EAAC,EACf;AACf,EAAA,MAAM,MAAM,IAAA,CAAK,GAAA,KAAQ,uBAAM,IAAI,IAAA,IAAO,WAAA,EAAY,CAAA;AACtD,EAAA,MAAM,GAAA,uBAAU,GAAA,EAAuB;AACvC,EAAA,KAAA,MAAW,KAAK,OAAA,EAAS;AACvB,IAAA,MAAM,CAAA,GAAI,eAAA,CAAgB,KAAA,CAAM,CAAC,CAAA;AACjC,IAAA,GAAA,CAAI,GAAA,CAAI,CAAA,CAAE,MAAA,EAAQ,CAAC,CAAA;AAAA,EACrB;AACA,EAAA,OAAO;AAAA,IACL,GAAA,CAAI,MAAA,EAAQ,SAAA,EAAW,IAAA,EAAM;AAC3B,MAAA,MAAM,KAAA,GAAQ,eAAA,CAAgB,KAAA,CAAM,EAAE,MAAA,EAAQ,WAAW,IAAA,EAAM,SAAA,EAAW,GAAA,EAAI,EAAG,CAAA;AACjF,MAAA,GAAA,CAAI,GAAA,CAAI,KAAA,CAAM,MAAA,EAAQ,KAAK,CAAA;AAC3B,MAAA,OAAO,KAAA;AAAA,IACT,CAAA;AAAA,IACA,MAAA,EAAQ,CAAC,MAAA,KAAW,GAAA,CAAI,OAAO,MAAM,CAAA;AAAA,IACrC,GAAA,EAAK,CAAC,MAAA,KAAW,GAAA,CAAI,IAAI,MAAM,CAAA;AAAA,IAC/B,GAAA,EAAK,CAAC,MAAA,KAAW,GAAA,CAAI,IAAI,MAAM,CAAA;AAAA,IAC/B,KAAA,EAAO,MAAM,GAAA,CAAI,KAAA,EAAM;AAAA,IACvB,SAAA,EAAW,MAAM,gBAAA,CAAiB,KAAA,CAAM,MAAM,IAAA,CAAK,GAAA,CAAI,MAAA,EAAQ,CAAC;AAAA,GAClE;AACF;AAeO,SAAS,cAAA,CAAe,MAAA,GAAuB,EAAC,EAAiB;AACtE,EAAA,OAAO,OAAA,CAAQ,OAAO,CAAC,CAAA,KAAM,OAAO,CAAA,CAAE,GAAG,CAAA,EAAG,OAAA,IAAW,IAAI,CAAA;AAC7D;AAoBO,SAAS,UAAA,CACd,MAAA,EACA,MAAA,EACA,MAAA,GAAuB,EAAC,EACX;AACb,EAAA,IAAI,CAAC,YAAA,CAAa,MAAA,EAAQ,MAAM,GAAG,OAAO,QAAA;AAC1C,EAAA,OAAO,MAAA,CAAO,KAAK,CAAC,CAAA,KAAM,EAAE,MAAA,KAAW,MAAM,IAAI,OAAA,GAAU,QAAA;AAC7D;AAGO,SAAS,YAAA,CAAa,GAAA,EAAa,MAAA,GAAuB,EAAC,EAAY;AAC5E,EAAA,MAAM,CAAA,GAAI,OAAO,GAAG,CAAA;AACpB,EAAA,IAAI,CAAA,EAAG,OAAA,KAAY,KAAA,EAAO,OAAO,KAAA;AACjC,EAAA,OAAO,GAAG,UAAA,IAAc,IAAA;AAC1B;AA4CO,IAAM,kBAAA,GAA6D;AAAA,EACxE,MAAA,EAAQ,CAAC,EAAE,CAAA;AAAA,EACX,KAAA,EAAO,CAAC,CAAA,EAAG,EAAA,EAAI,CAAC,CAAA;AAAA,EAChB,QAAQ;AACV;AAaO,SAAS,gBAAA,CACd,OAAA,EACA,GAAA,GAA6B,UAAA,CAAwC,SAAA,EACrD;AAChB,EAAA,IAAI,OAAA,CAAQ,MAAA,KAAW,CAAA,EAAG,OAAO,SAAA;AACjC,EAAA,IAAI,OAAO,GAAA,EAAK,OAAA,KAAY,UAAA,EAAY,OAAO,aAAA;AAC/C,EAAA,IAAI;AACF,IAAA,OAAO,IAAI,OAAA,CAAQ,CAAC,GAAG,OAAO,CAAC,IAAI,WAAA,GAAc,UAAA;AAAA,EACnD,CAAA,CAAA,MAAQ;AAGN,IAAA,OAAO,UAAA;AAAA,EACT;AACF;AAsBO,SAAS,YAAA,CACd,OAAA,EACA,MAAA,EACA,IAAA,GAAwB,EAAC,EACT;AAChB,EAAA,IAAA,CAAK,UAAA,GAAa,EAAE,OAAA,EAAS,MAAA,EAAQ,CAAA;AACrC,EAAA,IAAI,IAAA,CAAK,OAAA,KAAY,KAAA,EAAO,OAAO,SAAA;AAGnC,EAAA,OAAO,gBAAA,CAAiB,kBAAA,CAAmB,OAAO,CAAA,EAAG,KAAK,GAA2B,CAAA;AACvF;AAyEO,IAAM,QAAA,GAAW;AAEjB,IAAM,SAAA,GAAuC;AAAA,EAClD,IAAA,EAAM,SAAA;AAAA,EACN,SAAA,EAAW,SAAA;AAAA,EACX,OAAA,EAAS,MAAA;AAAA,EACT,OAAA,EAAS,QAAA;AAAA,EACT,MAAA,EAAQ,SAAA;AAAA,EACR,OAAA,EAAS,SAAA;AAAA,EACT,MAAA,EAAQ;AACV;AAGO,SAAS,SAAS,OAAA,EAAqD;AAC5E,EAAA,OAAO,EAAE,GAAG,SAAA,EAAW,GAAI,OAAA,EAAS,EAAA,IAAM,EAAC,EAAG;AAChD;AAGO,IAAM,cAAA,GAAiC;AAAA,EAC5C,IAAA,EAAM,SAAA;AAAA,EACN,KAAA,EAAO,SAAA;AAAA,EACP,QAAA,EAAU,SAAA;AAAA,EACV,MAAM,EAAE,GAAA,EAAK,WAAW,GAAA,EAAK,SAAA,EAAW,MAAM,SAAA;AAChD;AAGO,SAAS,OAAA,CAAQ,SAAA,EAAmB,OAAA,GAA0B,cAAA,EAAwB;AAC3F,EAAA,OAAO,SAAA,IAAa,CAAA,GAAI,OAAA,CAAQ,IAAA,CAAK,IAAA,GAAO,SAAA,IAAa,CAAA,GAAI,OAAA,CAAQ,IAAA,CAAK,GAAA,GAAM,OAAA,CAAQ,IAAA,CAAK,GAAA;AAC/F;AAGO,SAAS,YAAA,CAAa,SAAA,EAAmB,OAAA,GAA0B,cAAA,EAAwB;AAChG,EAAA,OAAO,OAAA,CAAQ,OAAA,GAAU,SAAS,CAAA,IAAK,OAAA,CAAQ,IAAA;AACjD;AA2BO,IAAM,qBAAA,GAAwB,EAAE,MAAA,CAAO;AAAA,EAC5C,MAAA,EAAQ,CAAA,CAAE,OAAA,CAAQ,YAAY,CAAA;AAAA,EAC9B,IAAA,EAAM,EAAE,IAAA,CAAK,CAAC,SAAS,MAAA,EAAQ,MAAA,EAAQ,OAAO,CAAC,CAAA;AAAA,EAC/C,QAAQ,CAAA,CAAE,KAAA;AAAA,IACR,EAAE,MAAA,CAAO;AAAA,MACP,MAAA,EAAQ,EAAE,MAAA,EAAO;AAAA,MACjB,MAAM,CAAA,CAAE,IAAA,CAAK,CAAC,MAAA,EAAQ,OAAA,EAAS,QAAQ,CAAC,CAAA;AAAA,MACxC,SAAA,EAAW,CAAA,CAAE,MAAA,EAAO,CAAE,GAAA,GAAM,GAAA,CAAI,CAAC,CAAA,CAAE,GAAA,CAAI,EAAE,CAAA;AAAA,MACzC,OAAA,EAAS,CAAA,CAAE,IAAA,CAAK,UAAU,EAAE,QAAA;AAAS,KACtC;AAAA;AAEL,CAAC;AAGM,SAAS,eAAA,CACd,MAAA,EACA,IAAA,GAA4B,EAAC,EACZ;AACjB,EAAA,OAAO;AAAA,IACL,MAAA,EAAQ,YAAA;AAAA,IACR,IAAA,EAAM,KAAK,IAAA,IAAQ,OAAA;AAAA,IACnB,MAAA,EAAQ,MAAA,CAAO,GAAA,CAAI,CAAC,CAAA,KAAM;AACxB,MAAA,MAAM,CAAA,GAAI,SAAA,CAAU,CAAA,CAAE,MAAM,CAAA;AAC5B,MAAA,OAAO;AAAA,QACL,MAAA,EAAQ,CAAA,EAAG,IAAA,IAAQ,CAAA,CAAE,MAAA;AAAA,QACrB,IAAA,EAAO,GAAG,IAAA,IAAQ,QAAA;AAAA,QAClB,WAAW,CAAA,CAAE,SAAA;AAAA,QACb,SAAS,CAAA,CAAE;AAAA,OACb;AAAA,IACF,CAAC;AAAA,GACH;AACF;AAIO,SAAS,iBAAA,CACd,KACA,GAAA,GAAoB,MAAA,qBAAU,IAAA,EAAK,EAAE,aAAY,EACrC;AACZ,EAAA,MAAM,MAAA,GAAS,qBAAA,CAAsB,KAAA,CAAM,GAAG,CAAA;AAE9C,EAAA,MAAM,aAAa,IAAI,GAAA;AAAA,IACrB,OAAA,CAAQ,GAAA,CAAI,CAAC,CAAA,KAAM,CAAC,CAAA,EAAG,CAAA,CAAE,IAAI,CAAA,CAAA,EAAI,EAAE,IAAA,IAAQ,QAAQ,CAAA,CAAA,EAAI,CAAA,CAAE,GAAG,CAAU;AAAA,GACxE;AACA,EAAA,MAAM,MAAkB,EAAC;AACzB,EAAA,KAAA,MAAW,EAAA,IAAM,OAAO,MAAA,EAAQ;AAC9B,IAAA,MAAM,GAAA,GAAM,WAAW,GAAA,CAAI,CAAA,EAAG,GAAG,MAAM,CAAA,CAAA,EAAI,EAAA,CAAG,IAAI,CAAA,CAAE,CAAA;AACpD,IAAA,IAAI,CAAC,GAAA,EAAK;AACV,IAAA,GAAA,CAAI,IAAA;AAAA,MACF,gBAAgB,KAAA,CAAM;AAAA,QACpB,MAAA,EAAQ,GAAA;AAAA,QACR,WAAW,EAAA,CAAG,SAAA;AAAA,QACd,MAAM,EAAA,CAAG,OAAA;AAAA,QACT,WAAW,GAAA;AAAI,OAChB;AAAA,KACH;AAAA,EACF;AACA,EAAA,OAAO,GAAA;AACT","file":"index.js","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/**\n * What a pick on a region should DO (F052.20).\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts. This repo measured the cost of that\n * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling\n * branch that carried the same defect with no test on it.\n *\n * \"clear\" the region is already marked → picking it again removes the mark\n * \"select\" unmarked → open it for marking\n * \"ignore\" not selectable (read-only or config) → nothing happens\n *\n * Three outcomes, not a boolean: \"nothing happened because it is locked\" and\n * \"nothing happened because we removed the mark\" must never look alike to a\n * caller.\n */\nexport type PickOutcome = \"clear\" | \"select\" | \"ignore\";\n\nexport function decidePick(\n region: string,\n report: PainReport,\n config: RegionConfig = {},\n): PickOutcome {\n if (!isSelectable(region, config)) return \"ignore\";\n return report.some((p) => p.region === region) ? \"clear\" : \"select\";\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- feedback signal: sound + haptics (F052.22) -----------------------------\n\n/**\n * What a pick did, handed to the consuming app so it can make a sound or a buzz.\n *\n * The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap\n * — so a tap on a locked region can not announce itself as a removal, and a tap\n * the pan/pinch guard swallowed emits nothing at all (it never gets here).\n *\n * It deliberately reuses `PickOutcome` rather than introducing a second\n * three-word vocabulary. Two enums meaning the same thing is a drift bug waiting\n * for the first person who adds a fourth outcome to only one of them.\n */\nexport interface FeedbackSignal {\n outcome: PickOutcome;\n /** The region key that was picked. */\n region: string;\n}\n\nexport type FeedbackFn = (signal: FeedbackSignal) => void;\n\n/**\n * What the BROWSER did with a vibration request.\n *\n * ⚠️ `requested` does NOT mean the phone buzzed. `navigator.vibrate()` returns\n * true for a request it accepted, and all of these accept it and then produce\n * nothing: silent / do-not-disturb mode, a device with no vibration motor (most\n * laptops), a page that has not yet had a qualifying user gesture.\n *\n * Same lesson `@broberg/webpush` 0.3.1 recorded — a push that provably ARRIVED\n * on a device that never SHOWED it. \"Accepted\" and \"happened\" are two claims,\n * and only one of them is observable from here.\n */\nexport type VibrateOutcome = \"unsupported\" | \"skipped\" | \"declined\" | \"requested\";\n\n/**\n * The buzz for each outcome.\n *\n * `ignore` is empty ON PURPOSE: a tap that changed nothing must not feel like it\n * changed something. That is the whole reason this is keyed by outcome and not\n * fired from the tap handler.\n */\nexport const VIBRATION_PATTERNS: Record<PickOutcome, readonly number[]> = {\n select: [12],\n clear: [8, 40, 8],\n ignore: [],\n};\n\ninterface Vibrator {\n vibrate?: (pattern: number | number[]) => boolean;\n}\n\n/**\n * Ask the browser to vibrate. Never throws, never claims delivery.\n *\n * `nav` is injectable so a test can supply all four cases; it defaults to the\n * real `navigator` and is `unsupported` when there is not one (SSR, and every\n * browser on iPhone — WebKit has no `vibrate` at all).\n */\nexport function requestVibration(\n pattern: readonly number[],\n nav: Vibrator | undefined = (globalThis as { navigator?: Vibrator }).navigator,\n): VibrateOutcome {\n if (pattern.length === 0) return \"skipped\";\n if (typeof nav?.vibrate !== \"function\") return \"unsupported\";\n try {\n return nav.vibrate([...pattern]) ? \"requested\" : \"declined\";\n } catch {\n // Embedded webviews and cross-origin iframes throw rather than return false.\n // A refused buzz must never take the pain report down with it.\n return \"declined\";\n }\n}\n\nexport interface FeedbackOptions {\n onFeedback?: FeedbackFn;\n /** Web vibration on select/clear. Default true; inert where the API is absent. */\n haptics?: boolean;\n /** Injectable for tests. */\n nav?: unknown;\n}\n\n/**\n * Emit one pick's feedback: always the signal, optionally the buzz.\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts — the same reason `decidePick` is\n * here (F052.20).\n *\n * Sound is NOT here and never will be: `@broberg/soundkit` already exists, and\n * pulling Web Audio into a component that is often rendered read-only (a\n * journal, a PDF, a clinician view) is a cost every consumer would pay for a\n * feature most will not switch on. Wire `onFeedback` to it in four lines.\n */\nexport function emitFeedback(\n outcome: PickOutcome,\n region: string,\n opts: FeedbackOptions = {},\n): VibrateOutcome {\n opts.onFeedback?.({ outcome, region });\n if (opts.haptics === false) return \"skipped\";\n // `undefined` falls through to requestVibration's own default (the real\n // navigator) — so an omitted `nav` and a passed-in one take the same path.\n return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav as Vibrator | undefined);\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background — the card, its buttons, the empty hint. */\n panelBg?: string;\n /**\n * THE STAGE: the fullscreen backdrop, the canvas frame, and the three.js\n * scene behind the body. A different surface from `panelBg` with an opposite\n * need — see F052.29, where one field paints both and no value is right for\n * either.\n *\n * 3D only. The 2D renderer has no canvas and no fullscreen, so it ignores\n * this; it is declared here because a stage is a shared idea, not because\n * every renderer has one.\n *\n * Defaults to {@link STAGE_BG}. Pass `'#fff'` for the pre-0.8.0 white\n * fullscreen backdrop.\n */\n stageBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\n/**\n * The stage colour — the ONE place this value lives.\n *\n * It used to be a literal in three places in `three.tsx` (the three.js scene,\n * the canvas frame, the unsupported placeholder), which meant a consumer could\n * recolour the backdrop and still get OUR navy inside it. That seam is\n * invisible on a desktop and obvious on a phone. (F052.29)\n */\nexport const STAGE_BG = \"#0e1424\";\n\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n stageBg: STAGE_BG,\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["// @broberg/bodymap — headless core (F052.1).\n//\n// Framework-neutral: the region taxonomy + the PainReport data model (zod) + a\n// selection engine + per-app region config. NO React/Preact/DOM import — the 2D\n// (SVG) and 3D (Three.js) renderers, and all three FD apps, share this one\n// contract. Output is a structured PainReport, never a bare image.\n\nimport { z } from \"zod\";\n\nexport type Side = \"left\" | \"right\";\n\nexport interface BodyRegion {\n /** Stable, unique identifier (snake_case) — the key used in a PainReport. */\n key: string;\n /** Human label (Danish). */\n label: string;\n /** Clinical short code (unique). */\n code: string;\n /** Body side, when the region is paired. */\n side?: Side;\n}\n\n/** The canonical body regions — the AUTHORITATIVE fd-sundhed clinical taxonomy\n * (docs/BODYMAP-TAKSONOMI.md, broberg-ai/fd-sundhed @360842f): 18 SIDE-LESS\n * clinical codes + a separate `side` field (L/R on limbs, C=center on axis\n * regions). The `key` is a unique per-side identifier; `code` is the side-less\n * clinical code that goes on the bodymap/v1 wire. NOT an anatomical atlas —\n * ~30 named surface regions for a pain-map. The 2D front renderer draws the\n * front-visible subset; the 3D body (F052.6) drives them all. */\nexport const REGIONS: readonly BodyRegion[] = [\n // axis / centre-line (serialised side \"center\")\n { key: \"head\", label: \"Hoved\", code: \"HEAD\" },\n { key: \"neck\", label: \"Nakke\", code: \"NECK\" },\n { key: \"chest\", label: \"Bryst\", code: \"CHEST\" },\n { key: \"thora\", label: \"Øvre ryg (thorakal)\", code: \"THORA\" },\n { key: \"lumbar\", label: \"Lænd (lumbal)\", code: \"LUMBAR\" },\n { key: \"groin\", label: \"Lyske\", code: \"GROIN\" },\n // paired limbs / sides (L / R)\n { key: \"shoulder_left\", label: \"Skulder, venstre\", code: \"SHOULDER\", side: \"left\" },\n { key: \"shoulder_right\", label: \"Skulder, højre\", code: \"SHOULDER\", side: \"right\" },\n { key: \"uarm_left\", label: \"Overarm, venstre\", code: \"UARM\", side: \"left\" },\n { key: \"uarm_right\", label: \"Overarm, højre\", code: \"UARM\", side: \"right\" },\n { key: \"elbow_left\", label: \"Albue, venstre\", code: \"ELBOW\", side: \"left\" },\n { key: \"elbow_right\", label: \"Albue, højre\", code: \"ELBOW\", side: \"right\" },\n { key: \"farm_left\", label: \"Underarm, venstre\", code: \"FARM\", side: \"left\" },\n { key: \"farm_right\", label: \"Underarm, højre\", code: \"FARM\", side: \"right\" },\n { key: \"wrist_left\", label: \"Håndled, venstre\", code: \"WRIST\", side: \"left\" },\n { key: \"wrist_right\", label: \"Håndled, højre\", code: \"WRIST\", side: \"right\" },\n { key: \"hand_left\", label: \"Hånd, venstre\", code: \"HAND\", side: \"left\" },\n { key: \"hand_right\", label: \"Hånd, højre\", code: \"HAND\", side: \"right\" },\n { key: \"hip_left\", label: \"Hofte, venstre\", code: \"HIP\", side: \"left\" },\n { key: \"hip_right\", label: \"Hofte, højre\", code: \"HIP\", side: \"right\" },\n { key: \"thigh_left\", label: \"Lår, venstre\", code: \"THIGH\", side: \"left\" },\n { key: \"thigh_right\", label: \"Lår, højre\", code: \"THIGH\", side: \"right\" },\n { key: \"knee_left\", label: \"Knæ, venstre\", code: \"KNEE\", side: \"left\" },\n { key: \"knee_right\", label: \"Knæ, højre\", code: \"KNEE\", side: \"right\" },\n { key: \"lowleg_left\", label: \"Underben, venstre\", code: \"LOWLEG\", side: \"left\" },\n { key: \"lowleg_right\", label: \"Underben, højre\", code: \"LOWLEG\", side: \"right\" },\n { key: \"ankle_left\", label: \"Ankel, venstre\", code: \"ANKLE\", side: \"left\" },\n { key: \"ankle_right\", label: \"Ankel, højre\", code: \"ANKLE\", side: \"right\" },\n { key: \"foot_left\", label: \"Fod, venstre\", code: \"FOOT\", side: \"left\" },\n { key: \"foot_right\", label: \"Fod, højre\", code: \"FOOT\", side: \"right\" },\n];\n\nconst REGION_KEY_SET = new Set(REGIONS.map((r) => r.key));\nexport const REGION_KEYS: readonly string[] = REGIONS.map((r) => r.key);\n\n/** Look up a region by its key. */\nexport function getRegion(key: string): BodyRegion | undefined {\n return REGIONS.find((r) => r.key === key);\n}\n\n// ---- PainReport model ---------------------------------------------------\n\nexport const PAIN_TYPES = [\"stikkende\", \"dump\", \"konstant\", \"jagende\"] as const;\nexport type PainType = (typeof PAIN_TYPES)[number];\n\n/** One marked pain point. `region` MUST be a known region key; `intensity` is a\n * 0-10 integer; `type` is optional but constrained; `timestamp` is an ISO string. */\nexport const painPointSchema = z.object({\n region: z.string().refine((k) => REGION_KEY_SET.has(k), { message: \"unknown region\" }),\n intensity: z.number().int().min(0).max(10),\n type: z.enum(PAIN_TYPES).optional(),\n timestamp: z.string(),\n});\nexport type PainPoint = z.infer<typeof painPointSchema>;\n\nexport const painReportSchema = z.array(painPointSchema);\nexport type PainReport = PainPoint[];\n\n// ---- Selection engine (framework-agnostic) ------------------------------\n\nexport interface PainSelection {\n /** Mark (or update) pain on a region. One point per region — latest wins. */\n set(region: string, intensity: number, type?: PainType): PainPoint;\n remove(region: string): boolean;\n get(region: string): PainPoint | undefined;\n has(region: string): boolean;\n clear(): void;\n /** The current, validated PainReport. */\n getReport(): PainReport;\n}\n\nexport interface PainSelectionOptions {\n /** Injectable clock (ISO string) — defaults to `new Date().toISOString()`. */\n now?: () => string;\n}\n\n/** Create a selection engine seeded with an optional report. Pure state — no\n * DOM, no framework, no network. One point per region. */\nexport function createPainSelection(\n initial: PainReport = [],\n opts: PainSelectionOptions = {},\n): PainSelection {\n const now = opts.now ?? (() => new Date().toISOString());\n const map = new Map<string, PainPoint>();\n for (const p of initial) {\n const v = painPointSchema.parse(p);\n map.set(v.region, v);\n }\n return {\n set(region, intensity, type) {\n const point = painPointSchema.parse({ region, intensity, type, timestamp: now() });\n map.set(point.region, point);\n return point;\n },\n remove: (region) => map.delete(region),\n get: (region) => map.get(region),\n has: (region) => map.has(region),\n clear: () => map.clear(),\n getReport: () => painReportSchema.parse(Array.from(map.values())),\n };\n}\n\n// ---- Per-app region config (the toggle) ---------------------------------\n\nexport interface RegionSetting {\n /** Render this region at all. Default true. */\n visible?: boolean;\n /** Allow marking pain on this region. Default true. */\n selectable?: boolean;\n}\n\n/** Per-app config keyed by region key. An absent key ⇒ visible + selectable. */\nexport type RegionConfig = Record<string, RegionSetting>;\n\n/** The regions an app should render, honouring `visible` (default true). */\nexport function resolveRegions(config: RegionConfig = {}): BodyRegion[] {\n return REGIONS.filter((r) => config[r.key]?.visible ?? true);\n}\n\n/**\n * What a pick on a region should DO (F052.20).\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts. This repo measured the cost of that\n * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling\n * branch that carried the same defect with no test on it.\n *\n * \"clear\" the region is already marked → picking it again removes the mark\n * \"select\" unmarked → open it for marking\n * \"ignore\" not selectable (read-only or config) → nothing happens\n *\n * Three outcomes, not a boolean: \"nothing happened because it is locked\" and\n * \"nothing happened because we removed the mark\" must never look alike to a\n * caller.\n */\nexport type PickOutcome = \"clear\" | \"select\" | \"ignore\";\n\nexport function decidePick(\n region: string,\n report: PainReport,\n config: RegionConfig = {},\n): PickOutcome {\n if (!isSelectable(region, config)) return \"ignore\";\n return report.some((p) => p.region === region) ? \"clear\" : \"select\";\n}\n\n/** Whether a region may be marked. A hidden region is never selectable. */\nexport function isSelectable(key: string, config: RegionConfig = {}): boolean {\n const s = config[key];\n if (s?.visible === false) return false;\n return s?.selectable ?? true;\n}\n\n// ---- feedback signal: sound + haptics (F052.22) -----------------------------\n\n/**\n * What a pick did, handed to the consuming app so it can make a sound or a buzz.\n *\n * The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap\n * — so a tap on a locked region can not announce itself as a removal, and a tap\n * the pan/pinch guard swallowed emits nothing at all (it never gets here).\n *\n * It deliberately reuses `PickOutcome` rather than introducing a second\n * three-word vocabulary. Two enums meaning the same thing is a drift bug waiting\n * for the first person who adds a fourth outcome to only one of them.\n */\nexport interface FeedbackSignal {\n outcome: PickOutcome;\n /** The region key that was picked. */\n region: string;\n}\n\nexport type FeedbackFn = (signal: FeedbackSignal) => void;\n\n/**\n * What the BROWSER did with a vibration request.\n *\n * ⚠️ `requested` does NOT mean the phone buzzed. `navigator.vibrate()` returns\n * true for a request it accepted, and all of these accept it and then produce\n * nothing: silent / do-not-disturb mode, a device with no vibration motor (most\n * laptops), a page that has not yet had a qualifying user gesture.\n *\n * Same lesson `@broberg/webpush` 0.3.1 recorded — a push that provably ARRIVED\n * on a device that never SHOWED it. \"Accepted\" and \"happened\" are two claims,\n * and only one of them is observable from here.\n */\nexport type VibrateOutcome = \"unsupported\" | \"skipped\" | \"declined\" | \"requested\";\n\n/**\n * The buzz for each outcome.\n *\n * `ignore` is empty ON PURPOSE: a tap that changed nothing must not feel like it\n * changed something. That is the whole reason this is keyed by outcome and not\n * fired from the tap handler.\n */\nexport const VIBRATION_PATTERNS: Record<PickOutcome, readonly number[]> = {\n select: [12],\n clear: [8, 40, 8],\n ignore: [],\n};\n\ninterface Vibrator {\n vibrate?: (pattern: number | number[]) => boolean;\n}\n\n/**\n * Ask the browser to vibrate. Never throws, never claims delivery.\n *\n * `nav` is injectable so a test can supply all four cases; it defaults to the\n * real `navigator` and is `unsupported` when there is not one (SSR, and every\n * browser on iPhone — WebKit has no `vibrate` at all).\n */\nexport function requestVibration(\n pattern: readonly number[],\n nav: Vibrator | undefined = (globalThis as { navigator?: Vibrator }).navigator,\n): VibrateOutcome {\n if (pattern.length === 0) return \"skipped\";\n if (typeof nav?.vibrate !== \"function\") return \"unsupported\";\n try {\n return nav.vibrate([...pattern]) ? \"requested\" : \"declined\";\n } catch {\n // Embedded webviews and cross-origin iframes throw rather than return false.\n // A refused buzz must never take the pain report down with it.\n return \"declined\";\n }\n}\n\nexport interface FeedbackOptions {\n onFeedback?: FeedbackFn;\n /** Web vibration on select/clear. Default true; inert where the API is absent. */\n haptics?: boolean;\n /** Injectable for tests. */\n nav?: unknown;\n}\n\n/**\n * Emit one pick's feedback: always the signal, optionally the buzz.\n *\n * Lives in the core because the 2D and 3D renderers share no click code, and a\n * rule written twice is a rule that drifts — the same reason `decidePick` is\n * here (F052.20).\n *\n * Sound is NOT here and never will be: `@broberg/soundkit` already exists, and\n * pulling Web Audio into a component that is often rendered read-only (a\n * journal, a PDF, a clinician view) is a cost every consumer would pay for a\n * feature most will not switch on. Wire `onFeedback` to it in four lines.\n */\nexport function emitFeedback(\n outcome: PickOutcome,\n region: string,\n opts: FeedbackOptions = {},\n): VibrateOutcome {\n opts.onFeedback?.({ outcome, region });\n if (opts.haptics === false) return \"skipped\";\n // `undefined` falls through to requestVibration's own default (the real\n // navigator) — so an omitted `nav` and a passed-in one take the same path.\n return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav as Vibrator | undefined);\n}\n\n// ---- palette (consumer-defined colours — shared by the 2D + 3D renderers) ---\n\n/** Colour control for the body renderers. Consumers pass a palette to theme the\n * body base colour, the hover + selected highlights, the pain-heat colours, and\n * optional per-region base colours. All values are CSS/hex colour strings. */\nexport interface BodymapPalette {\n /** Base body colour (an unmarked region). */\n body: string;\n /** Region highlight on hover (before click). */\n hover: string;\n /** A region selected (clicked) but not yet given an intensity. */\n selected: string;\n /** Pain-intensity heat colours: low (0-3), mid (4-6), high (7-10). */\n heat: { low: string; mid: string; high: string };\n /** Optional per-region base-colour overrides (region key → colour). */\n regions?: Record<string, string>;\n /**\n * Optional panel-chrome colours (the selection panel, labels, hint box) —\n * NOT the body itself. All optional; anything omitted falls back to\n * {@link defaultUi}. A palette that only themed the body was half a palette:\n * a consumer passing brand colours still got hardcoded chrome. (F052.19)\n */\n ui?: BodymapUiColors;\n}\n\n/** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */\nexport interface BodymapUiColors {\n /** Primary text (headings, values). */\n text?: string;\n /** Secondary text — section labels, the empty-state hint. */\n mutedText?: string;\n /** Panel background — the card, its buttons, the empty hint. */\n panelBg?: string;\n /**\n * THE STAGE: the fullscreen backdrop, the canvas frame, and the three.js\n * scene behind the body. A different surface from `panelBg` with an opposite\n * need — see F052.29, where one field paints both and no value is right for\n * either.\n *\n * 3D only. The 2D renderer has no canvas and no fullscreen, so it ignores\n * this; it is declared here because a stage is a shared idea, not because\n * every renderer has one.\n *\n * Defaults to {@link STAGE_BG}. Pass `'#fff'` for the pre-0.8.0 white\n * fullscreen backdrop.\n */\n stageBg?: string;\n /** Panel + control borders. */\n border?: string;\n /** Background behind the region-code badge. */\n badgeBg?: string;\n /** The destructive action (remove a marked region). */\n danger?: string;\n /** Border on the destructive control. Its TEXT was already themeable and its\n * border was not — half a control. (F052.30) */\n dangerBorder?: string;\n /**\n * THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.\n *\n * NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the\n * colour of an active button. Collapsing them would stop a consumer having a\n * teal mark and a navy button, which is a normal thing to want. (F052.30)\n */\n accent?: string;\n /** Text ON the accent. Check it against your accent — see the README. */\n accentText?: string;\n}\n\n/**\n * Default panel chrome. Contrast against `panelBg` (#fff), asserted by\n * `test/contrast.test.ts`:\n * text #1e293b 14.8:1\n * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)\n * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)\n * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).\n */\n/**\n * The stage colour — the ONE place this value lives.\n *\n * It used to be a literal in three places in `three.tsx` (the three.js scene,\n * the canvas frame, the unsupported placeholder), which meant a consumer could\n * recolour the backdrop and still get OUR navy inside it. That seam is\n * invisible on a desktop and obvious on a phone. (F052.29)\n */\nexport const STAGE_BG = \"#0e1424\";\n\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\n stageBg: STAGE_BG,\n border: \"#e2e8f0\",\n badgeBg: \"#f1f5f9\",\n danger: \"#dc2626\",\n dangerBorder: \"#f6c9c9\",\n // F052.30 — was #0e8f8a, which measured 3.95:1 against white. AA needs 4.5,\n // and this is the SELECTED intensity button on a surface where AA is a legal\n // duty. It shipped from 0.2.0 and no check could see it: the colour was an\n // inline literal, and the F052.19 contrast sweep only read this object.\n // #0c7d77 is the same teal a shade deeper — 4.98:1, with headroom rather than\n // sitting on the 4.5 boundary where any later tweak reopens it.\n accent: \"#0c7d77\",\n accentText: \"#fff\",\n};\n\n/** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */\nexport function uiColors(palette?: BodymapPalette): Required<BodymapUiColors> {\n return { ...defaultUi, ...(palette?.ui ?? {}) };\n}\n\n/** The fleet default palette. Override any field per consumer. */\nexport const defaultPalette: BodymapPalette = {\n body: \"#d2d7de\",\n hover: \"#8fd0cd\",\n selected: \"#5cc4b7\",\n heat: { low: \"#fcd34d\", mid: \"#fb923c\", high: \"#ef4444\" },\n};\n\n/** The heat colour for a pain intensity, honouring the palette. */\nexport function heatFor(intensity: number, palette: BodymapPalette = defaultPalette): string {\n return intensity >= 7 ? palette.heat.high : intensity >= 4 ? palette.heat.mid : palette.heat.low;\n}\n\n/** The base colour for a region (a per-region override, else the body colour). */\nexport function baseColorFor(regionKey: string, palette: BodymapPalette = defaultPalette): string {\n return palette.regions?.[regionKey] ?? palette.body;\n}\n\n// ---- bodymap/v1 serialization (the shared cross-app / native wire format) ---\n//\n// The shape every consumer + the native mobile apps read (aligned with\n// fd-sundhed's bodymap/v1: region CODE + side + intensity + quality + view).\n// The internal PainReport keys on the region KEY; this maps key -> clinical CODE\n// so the report is portable and human-readable on the wire.\n\nexport type BodyView = \"front\" | \"back\" | \"left\" | \"right\";\n/** Side in the serialized report — a midline region (no side) becomes \"center\". */\nexport type SerializedSide = \"left\" | \"right\" | \"center\";\n\nexport interface SerializedPainPoint {\n /** Clinical region CODE (e.g. \"LUMB\"). */\n region: string;\n side: SerializedSide;\n intensity: number;\n quality?: PainType;\n}\n\nexport interface BodymapReportV1 {\n schema: \"bodymap/v1\";\n view: BodyView;\n points: SerializedPainPoint[];\n}\n\nexport const bodymapReportV1Schema = z.object({\n schema: z.literal(\"bodymap/v1\"),\n view: z.enum([\"front\", \"back\", \"left\", \"right\"]),\n points: z.array(\n z.object({\n region: z.string(),\n side: z.enum([\"left\", \"right\", \"center\"]),\n intensity: z.number().int().min(0).max(10),\n quality: z.enum(PAIN_TYPES).optional(),\n }),\n ),\n});\n\n/** Serialize a PainReport to the shared `bodymap/v1` wire format. */\nexport function serializeReport(\n report: PainReport,\n opts: { view?: BodyView } = {},\n): BodymapReportV1 {\n return {\n schema: \"bodymap/v1\",\n view: opts.view ?? \"front\",\n points: report.map((p) => {\n const r = getRegion(p.region);\n return {\n region: r?.code ?? p.region,\n side: (r?.side ?? \"center\") as SerializedSide,\n intensity: p.intensity,\n quality: p.type,\n };\n }),\n };\n}\n\n/** Parse a `bodymap/v1` report back into an internal PainReport. Region CODE →\n * key; a point whose code is unknown to this taxonomy is dropped. */\nexport function deserializeReport(\n env: unknown,\n now: () => string = () => new Date().toISOString(),\n): PainReport {\n const parsed = bodymapReportV1Schema.parse(env);\n // code is side-less, so a point is identified by code + side.\n const byCodeSide = new Map(\n REGIONS.map((r) => [`${r.code}|${r.side ?? \"center\"}`, r.key] as const),\n );\n const out: PainReport = [];\n for (const sp of parsed.points) {\n const key = byCodeSide.get(`${sp.region}|${sp.side}`);\n if (!key) continue;\n out.push(\n painPointSchema.parse({\n region: key,\n intensity: sp.intensity,\n type: sp.quality,\n timestamp: now(),\n }),\n );\n }\n return out;\n}\n"]}
|
package/dist/react.cjs
CHANGED
|
@@ -332,7 +332,7 @@ function chromeStyle(palette) {
|
|
|
332
332
|
}
|
|
333
333
|
var STYLE_ID = "broberg-bodymap-styles";
|
|
334
334
|
var STYLE = `
|
|
335
|
-
.bmap{--bmap-accent:var(--primary,#
|
|
335
|
+
.bmap{--bmap-accent:var(--primary,#0c7d77);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#475569;--bmap-badge-bg:#f1f5f9;--bmap-danger:#dc2626;
|
|
336
336
|
display:flex;gap:18px;flex-wrap:wrap;align-items:flex-start;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}
|
|
337
337
|
.bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}
|
|
338
338
|
.bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
|
@@ -361,7 +361,7 @@ var STYLE = `
|
|
|
361
361
|
.bmap__close{margin-left:auto;font:inherit;font-size:20px;line-height:1;cursor:pointer;background:none;border:1px solid transparent;border-radius:8px;width:32px;height:32px;color:var(--bmap-muted,#475569)}
|
|
362
362
|
.bmap__close:hover{background:var(--bmap-badge-bg,#f1f5f9)}
|
|
363
363
|
.bmap__close:active{transform:scale(.94)}
|
|
364
|
-
.bmap__close:focus-visible{outline:2px solid var(--bmap-accent,#
|
|
364
|
+
.bmap__close:focus-visible{outline:2px solid var(--bmap-accent,#0c7d77);outline-offset:2px}
|
|
365
365
|
.bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:var(--bmap-badge-bg);border-radius:6px;padding:2px 7px}
|
|
366
366
|
.bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}
|
|
367
367
|
.bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
|
|
@@ -378,7 +378,7 @@ var STYLE = `
|
|
|
378
378
|
.bmap__rm:hover{background:#fef2f2}
|
|
379
379
|
.bmap__rm:active{transform:scale(.97)}
|
|
380
380
|
.bmap__i:focus-visible,.bmap__chip:focus-visible,.bmap__vbtn:focus-visible,.bmap__rm:focus-visible,.bmap__zoom button:focus-visible{outline:2px solid var(--bmap-accent);outline-offset:2px}
|
|
381
|
-
.bmapc{--bmap-accent:var(--primary,#
|
|
381
|
+
.bmapc{--bmap-accent:var(--primary,#0c7d77);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#475569;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}
|
|
382
382
|
.bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}
|
|
383
383
|
.bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}
|
|
384
384
|
.bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}
|