@broberg/bodymap 0.2.8 → 0.4.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 CHANGED
@@ -136,6 +136,81 @@ import { BodyMap3D } from "@broberg/bodymap/three";
136
136
  `bodymap3d-intensity-<n>`, `bodymap3d-type-<quality>`, `bodymap3d-region-code`,
137
137
  `bodymap3d-ready`).
138
138
 
139
+ ## Sound + haptics — the package emits the SIGNAL, you wire the effect
140
+
141
+ Both renderers take `onFeedback`, called after **every** pick with what actually
142
+ happened:
143
+
144
+ ```tsx
145
+ <BodyMap onFeedback={({ outcome, region }) => { /* "select" | "clear" | "ignore" */ }} />
146
+ <BodyMap3D onFeedback={({ outcome, region }) => { /* same three outcomes */ }} />
147
+ ```
148
+
149
+ The outcome is the one the core decision returned, never the intent to tap — so a
150
+ tap on a locked region cannot announce itself as a removal, and a tap the
151
+ pan/pinch guard swallowed emits **nothing at all**.
152
+
153
+ ### What the package ships, and what it deliberately does not
154
+
155
+ | | |
156
+ |---|---|
157
+ | **Web vibration** | shipped, `haptics` prop, **on by default**, inert where the API is absent |
158
+ | **Sound** | **not shipped** — wire `onFeedback` to [`@broberg/soundkit`](https://www.npmjs.com/package/@broberg/soundkit) |
159
+ | **Native haptics** | **not shipped** — wire `onFeedback` to Capacitor `Haptics.impact()` |
160
+
161
+ Sound and native haptics are left to you on purpose. `@broberg/soundkit` already
162
+ exists, and pulling Web Audio (or a Capacitor dependency) into a component that is
163
+ frequently rendered read-only — a journal, a PDF, a clinician view — is a cost
164
+ every consumer would pay for a feature most will not switch on.
165
+
166
+ ```tsx
167
+ // sound: any web app
168
+ import { play } from "@broberg/soundkit";
169
+ <BodyMap onFeedback={({ outcome }) => outcome !== "ignore" && play(outcome === "clear" ? "undo" : "tap")} />
170
+
171
+ // real Taptic: a Capacitor app (the ONLY route that works on an iPhone)
172
+ import { Haptics, ImpactStyle } from "@capacitor/haptics";
173
+ <BodyMap3D
174
+ haptics={false} // the web API is not there on iOS anyway
175
+ onFeedback={({ outcome }) => {
176
+ if (outcome === "ignore") return;
177
+ Haptics.impact({ style: outcome === "clear" ? ImpactStyle.Medium : ImpactStyle.Light });
178
+ }}
179
+ />
180
+ ```
181
+
182
+ ### The platform fact you need before promising a buzz
183
+
184
+ **Measured, with a control:**
185
+
186
+ | Engine | `navigator.vibrate` |
187
+ |---|---|
188
+ | WebKit — Safari, and **every** browser on an iPhone | **absent** |
189
+ | Chromium — Android web | present |
190
+
191
+ So on an **iPhone web page there is no route to a buzz at all.** Not a permission
192
+ you have not asked for — the API is not there. Real Taptic on iOS requires the
193
+ **native app**. Plan the feature accordingly rather than discovering it on a
194
+ device.
195
+
196
+ ### `requestVibration` never claims delivery
197
+
198
+ ```ts
199
+ import { requestVibration, VIBRATION_PATTERNS } from "@broberg/bodymap";
200
+ requestVibration(VIBRATION_PATTERNS.clear);
201
+ // → "unsupported" | "skipped" | "declined" | "requested"
202
+ ```
203
+
204
+ `requested` means **the browser accepted the request**, not that the phone moved.
205
+ Silent mode, a device with no vibration motor, and a page that has not yet had a
206
+ user gesture all return `true` from `navigator.vibrate` and produce nothing. There
207
+ is no word for "delivered" because nothing observable from a web page can support
208
+ one. (Same lesson `@broberg/webpush` 0.3.1 recorded when a consumer proved a push
209
+ had *arrived* on a device that never *showed* it.)
210
+
211
+ `VIBRATION_PATTERNS.ignore` is empty on purpose: a tap that changed nothing must
212
+ not feel like it did.
213
+
139
214
  ## Colour control — `BodymapPalette`
140
215
 
141
216
  Both renderers theme off one palette (consumer-defined):
package/dist/index.cjs CHANGED
@@ -82,6 +82,25 @@ function isSelectable(key, config = {}) {
82
82
  if (s?.visible === false) return false;
83
83
  return s?.selectable ?? true;
84
84
  }
85
+ var VIBRATION_PATTERNS = {
86
+ select: [12],
87
+ clear: [8, 40, 8],
88
+ ignore: []
89
+ };
90
+ function requestVibration(pattern, nav = globalThis.navigator) {
91
+ if (pattern.length === 0) return "skipped";
92
+ if (typeof nav?.vibrate !== "function") return "unsupported";
93
+ try {
94
+ return nav.vibrate([...pattern]) ? "requested" : "declined";
95
+ } catch {
96
+ return "declined";
97
+ }
98
+ }
99
+ function emitFeedback(outcome, region, opts = {}) {
100
+ opts.onFeedback?.({ outcome, region });
101
+ if (opts.haptics === false) return "skipped";
102
+ return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav);
103
+ }
85
104
  var defaultUi = {
86
105
  text: "#1e293b",
87
106
  mutedText: "#475569",
@@ -156,6 +175,7 @@ function deserializeReport(env, now = () => (/* @__PURE__ */ new Date()).toISOSt
156
175
  exports.PAIN_TYPES = PAIN_TYPES;
157
176
  exports.REGIONS = REGIONS;
158
177
  exports.REGION_KEYS = REGION_KEYS;
178
+ exports.VIBRATION_PATTERNS = VIBRATION_PATTERNS;
159
179
  exports.baseColorFor = baseColorFor;
160
180
  exports.bodymapReportV1Schema = bodymapReportV1Schema;
161
181
  exports.createPainSelection = createPainSelection;
@@ -163,11 +183,13 @@ exports.decidePick = decidePick;
163
183
  exports.defaultPalette = defaultPalette;
164
184
  exports.defaultUi = defaultUi;
165
185
  exports.deserializeReport = deserializeReport;
186
+ exports.emitFeedback = emitFeedback;
166
187
  exports.getRegion = getRegion;
167
188
  exports.heatFor = heatFor;
168
189
  exports.isSelectable = isSelectable;
169
190
  exports.painPointSchema = painPointSchema;
170
191
  exports.painReportSchema = painReportSchema;
192
+ exports.requestVibration = requestVibration;
171
193
  exports.resolveRegions = resolveRegions;
172
194
  exports.serializeReport = serializeReport;
173
195
  exports.uiColors = uiColors;
@@ -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// ---- 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. */\n panelBg?: 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 */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\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. */\n panelBg?: 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 */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\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"]}
package/dist/index.d.cts CHANGED
@@ -107,6 +107,75 @@ type PickOutcome = "clear" | "select" | "ignore";
107
107
  declare function decidePick(region: string, report: PainReport, config?: RegionConfig): PickOutcome;
108
108
  /** Whether a region may be marked. A hidden region is never selectable. */
109
109
  declare function isSelectable(key: string, config?: RegionConfig): boolean;
110
+ /**
111
+ * What a pick did, handed to the consuming app so it can make a sound or a buzz.
112
+ *
113
+ * The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap
114
+ * — so a tap on a locked region can not announce itself as a removal, and a tap
115
+ * the pan/pinch guard swallowed emits nothing at all (it never gets here).
116
+ *
117
+ * It deliberately reuses `PickOutcome` rather than introducing a second
118
+ * three-word vocabulary. Two enums meaning the same thing is a drift bug waiting
119
+ * for the first person who adds a fourth outcome to only one of them.
120
+ */
121
+ interface FeedbackSignal {
122
+ outcome: PickOutcome;
123
+ /** The region key that was picked. */
124
+ region: string;
125
+ }
126
+ type FeedbackFn = (signal: FeedbackSignal) => void;
127
+ /**
128
+ * What the BROWSER did with a vibration request.
129
+ *
130
+ * ⚠️ `requested` does NOT mean the phone buzzed. `navigator.vibrate()` returns
131
+ * true for a request it accepted, and all of these accept it and then produce
132
+ * nothing: silent / do-not-disturb mode, a device with no vibration motor (most
133
+ * laptops), a page that has not yet had a qualifying user gesture.
134
+ *
135
+ * Same lesson `@broberg/webpush` 0.3.1 recorded — a push that provably ARRIVED
136
+ * on a device that never SHOWED it. "Accepted" and "happened" are two claims,
137
+ * and only one of them is observable from here.
138
+ */
139
+ type VibrateOutcome = "unsupported" | "skipped" | "declined" | "requested";
140
+ /**
141
+ * The buzz for each outcome.
142
+ *
143
+ * `ignore` is empty ON PURPOSE: a tap that changed nothing must not feel like it
144
+ * changed something. That is the whole reason this is keyed by outcome and not
145
+ * fired from the tap handler.
146
+ */
147
+ declare const VIBRATION_PATTERNS: Record<PickOutcome, readonly number[]>;
148
+ interface Vibrator {
149
+ vibrate?: (pattern: number | number[]) => boolean;
150
+ }
151
+ /**
152
+ * Ask the browser to vibrate. Never throws, never claims delivery.
153
+ *
154
+ * `nav` is injectable so a test can supply all four cases; it defaults to the
155
+ * real `navigator` and is `unsupported` when there is not one (SSR, and every
156
+ * browser on iPhone — WebKit has no `vibrate` at all).
157
+ */
158
+ declare function requestVibration(pattern: readonly number[], nav?: Vibrator | undefined): VibrateOutcome;
159
+ interface FeedbackOptions {
160
+ onFeedback?: FeedbackFn;
161
+ /** Web vibration on select/clear. Default true; inert where the API is absent. */
162
+ haptics?: boolean;
163
+ /** Injectable for tests. */
164
+ nav?: unknown;
165
+ }
166
+ /**
167
+ * Emit one pick's feedback: always the signal, optionally the buzz.
168
+ *
169
+ * Lives in the core because the 2D and 3D renderers share no click code, and a
170
+ * rule written twice is a rule that drifts — the same reason `decidePick` is
171
+ * here (F052.20).
172
+ *
173
+ * Sound is NOT here and never will be: `@broberg/soundkit` already exists, and
174
+ * pulling Web Audio into a component that is often rendered read-only (a
175
+ * journal, a PDF, a clinician view) is a cost every consumer would pay for a
176
+ * feature most will not switch on. Wire `onFeedback` to it in four lines.
177
+ */
178
+ declare function emitFeedback(outcome: PickOutcome, region: string, opts?: FeedbackOptions): VibrateOutcome;
110
179
  /** Colour control for the body renderers. Consumers pass a palette to theme the
111
180
  * body base colour, the hover + selected highlights, the pain-heat colours, and
112
181
  * optional per-region base colours. All values are CSS/hex colour strings. */
@@ -226,4 +295,4 @@ declare function serializeReport(report: PainReport, opts?: {
226
295
  * key; a point whose code is unknown to this taxonomy is dropped. */
227
296
  declare function deserializeReport(env: unknown, now?: () => string): PainReport;
228
297
 
229
- export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, type PickOutcome, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
298
+ export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, type FeedbackFn, type FeedbackOptions, type FeedbackSignal, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, type PickOutcome, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, VIBRATION_PATTERNS, type VibrateOutcome, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, emitFeedback, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, requestVibration, resolveRegions, serializeReport, uiColors };
package/dist/index.d.ts CHANGED
@@ -107,6 +107,75 @@ type PickOutcome = "clear" | "select" | "ignore";
107
107
  declare function decidePick(region: string, report: PainReport, config?: RegionConfig): PickOutcome;
108
108
  /** Whether a region may be marked. A hidden region is never selectable. */
109
109
  declare function isSelectable(key: string, config?: RegionConfig): boolean;
110
+ /**
111
+ * What a pick did, handed to the consuming app so it can make a sound or a buzz.
112
+ *
113
+ * The outcome is the one `decidePick` ACTUALLY returned, never the intent to tap
114
+ * — so a tap on a locked region can not announce itself as a removal, and a tap
115
+ * the pan/pinch guard swallowed emits nothing at all (it never gets here).
116
+ *
117
+ * It deliberately reuses `PickOutcome` rather than introducing a second
118
+ * three-word vocabulary. Two enums meaning the same thing is a drift bug waiting
119
+ * for the first person who adds a fourth outcome to only one of them.
120
+ */
121
+ interface FeedbackSignal {
122
+ outcome: PickOutcome;
123
+ /** The region key that was picked. */
124
+ region: string;
125
+ }
126
+ type FeedbackFn = (signal: FeedbackSignal) => void;
127
+ /**
128
+ * What the BROWSER did with a vibration request.
129
+ *
130
+ * ⚠️ `requested` does NOT mean the phone buzzed. `navigator.vibrate()` returns
131
+ * true for a request it accepted, and all of these accept it and then produce
132
+ * nothing: silent / do-not-disturb mode, a device with no vibration motor (most
133
+ * laptops), a page that has not yet had a qualifying user gesture.
134
+ *
135
+ * Same lesson `@broberg/webpush` 0.3.1 recorded — a push that provably ARRIVED
136
+ * on a device that never SHOWED it. "Accepted" and "happened" are two claims,
137
+ * and only one of them is observable from here.
138
+ */
139
+ type VibrateOutcome = "unsupported" | "skipped" | "declined" | "requested";
140
+ /**
141
+ * The buzz for each outcome.
142
+ *
143
+ * `ignore` is empty ON PURPOSE: a tap that changed nothing must not feel like it
144
+ * changed something. That is the whole reason this is keyed by outcome and not
145
+ * fired from the tap handler.
146
+ */
147
+ declare const VIBRATION_PATTERNS: Record<PickOutcome, readonly number[]>;
148
+ interface Vibrator {
149
+ vibrate?: (pattern: number | number[]) => boolean;
150
+ }
151
+ /**
152
+ * Ask the browser to vibrate. Never throws, never claims delivery.
153
+ *
154
+ * `nav` is injectable so a test can supply all four cases; it defaults to the
155
+ * real `navigator` and is `unsupported` when there is not one (SSR, and every
156
+ * browser on iPhone — WebKit has no `vibrate` at all).
157
+ */
158
+ declare function requestVibration(pattern: readonly number[], nav?: Vibrator | undefined): VibrateOutcome;
159
+ interface FeedbackOptions {
160
+ onFeedback?: FeedbackFn;
161
+ /** Web vibration on select/clear. Default true; inert where the API is absent. */
162
+ haptics?: boolean;
163
+ /** Injectable for tests. */
164
+ nav?: unknown;
165
+ }
166
+ /**
167
+ * Emit one pick's feedback: always the signal, optionally the buzz.
168
+ *
169
+ * Lives in the core because the 2D and 3D renderers share no click code, and a
170
+ * rule written twice is a rule that drifts — the same reason `decidePick` is
171
+ * here (F052.20).
172
+ *
173
+ * Sound is NOT here and never will be: `@broberg/soundkit` already exists, and
174
+ * pulling Web Audio into a component that is often rendered read-only (a
175
+ * journal, a PDF, a clinician view) is a cost every consumer would pay for a
176
+ * feature most will not switch on. Wire `onFeedback` to it in four lines.
177
+ */
178
+ declare function emitFeedback(outcome: PickOutcome, region: string, opts?: FeedbackOptions): VibrateOutcome;
110
179
  /** Colour control for the body renderers. Consumers pass a palette to theme the
111
180
  * body base colour, the hover + selected highlights, the pain-heat colours, and
112
181
  * optional per-region base colours. All values are CSS/hex colour strings. */
@@ -226,4 +295,4 @@ declare function serializeReport(report: PainReport, opts?: {
226
295
  * key; a point whose code is unknown to this taxonomy is dropped. */
227
296
  declare function deserializeReport(env: unknown, now?: () => string): PainReport;
228
297
 
229
- export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, type PickOutcome, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
298
+ export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, type BodymapUiColors, type FeedbackFn, type FeedbackOptions, type FeedbackSignal, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, type PickOutcome, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, VIBRATION_PATTERNS, type VibrateOutcome, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, emitFeedback, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, requestVibration, resolveRegions, serializeReport, uiColors };
package/dist/index.js CHANGED
@@ -80,6 +80,25 @@ function isSelectable(key, config = {}) {
80
80
  if (s?.visible === false) return false;
81
81
  return s?.selectable ?? true;
82
82
  }
83
+ var VIBRATION_PATTERNS = {
84
+ select: [12],
85
+ clear: [8, 40, 8],
86
+ ignore: []
87
+ };
88
+ function requestVibration(pattern, nav = globalThis.navigator) {
89
+ if (pattern.length === 0) return "skipped";
90
+ if (typeof nav?.vibrate !== "function") return "unsupported";
91
+ try {
92
+ return nav.vibrate([...pattern]) ? "requested" : "declined";
93
+ } catch {
94
+ return "declined";
95
+ }
96
+ }
97
+ function emitFeedback(outcome, region, opts = {}) {
98
+ opts.onFeedback?.({ outcome, region });
99
+ if (opts.haptics === false) return "skipped";
100
+ return requestVibration(VIBRATION_PATTERNS[outcome], opts.nav);
101
+ }
83
102
  var defaultUi = {
84
103
  text: "#1e293b",
85
104
  mutedText: "#475569",
@@ -151,6 +170,6 @@ function deserializeReport(env, now = () => (/* @__PURE__ */ new Date()).toISOSt
151
170
  return out;
152
171
  }
153
172
 
154
- export { PAIN_TYPES, REGIONS, REGION_KEYS, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
173
+ export { PAIN_TYPES, REGIONS, REGION_KEYS, VIBRATION_PATTERNS, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, emitFeedback, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, requestVibration, resolveRegions, serializeReport, uiColors };
155
174
  //# sourceMappingURL=index.js.map
156
175
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
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// ---- 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. */\n panelBg?: 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 */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\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. */\n panelBg?: 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 */\nexport const defaultUi: Required<BodymapUiColors> = {\n text: \"#1e293b\",\n mutedText: \"#475569\",\n panelBg: \"#fff\",\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"]}