@broberg/bodymap 0.2.4 → 0.2.6

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/dist/index.cjs CHANGED
@@ -73,11 +73,26 @@ function createPainSelection(initial = [], opts = {}) {
73
73
  function resolveRegions(config = {}) {
74
74
  return REGIONS.filter((r) => config[r.key]?.visible ?? true);
75
75
  }
76
+ function decidePick(region, report, config = {}) {
77
+ if (!isSelectable(region, config)) return "ignore";
78
+ return report.some((p) => p.region === region) ? "clear" : "select";
79
+ }
76
80
  function isSelectable(key, config = {}) {
77
81
  const s = config[key];
78
82
  if (s?.visible === false) return false;
79
83
  return s?.selectable ?? true;
80
84
  }
85
+ var defaultUi = {
86
+ text: "#1e293b",
87
+ mutedText: "#475569",
88
+ panelBg: "#fff",
89
+ border: "#e2e8f0",
90
+ badgeBg: "#f1f5f9",
91
+ danger: "#dc2626"
92
+ };
93
+ function uiColors(palette) {
94
+ return { ...defaultUi, ...palette?.ui ?? {} };
95
+ }
81
96
  var defaultPalette = {
82
97
  body: "#d2d7de",
83
98
  hover: "#8fd0cd",
@@ -144,7 +159,9 @@ exports.REGION_KEYS = REGION_KEYS;
144
159
  exports.baseColorFor = baseColorFor;
145
160
  exports.bodymapReportV1Schema = bodymapReportV1Schema;
146
161
  exports.createPainSelection = createPainSelection;
162
+ exports.decidePick = decidePick;
147
163
  exports.defaultPalette = defaultPalette;
164
+ exports.defaultUi = defaultUi;
148
165
  exports.deserializeReport = deserializeReport;
149
166
  exports.getRegion = getRegion;
150
167
  exports.heatFor = heatFor;
@@ -153,5 +170,6 @@ exports.painPointSchema = painPointSchema;
153
170
  exports.painReportSchema = painReportSchema;
154
171
  exports.resolveRegions = resolveRegions;
155
172
  exports.serializeReport = serializeReport;
173
+ exports.uiColors = uiColors;
156
174
  //# sourceMappingURL=index.cjs.map
157
175
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"names":["z"],"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,GAAkBA,MAAE,MAAA,CAAO;AAAA,EACtC,MAAA,EAAQA,KAAA,CAAE,MAAA,EAAO,CAAE,OAAO,CAAC,CAAA,KAAM,cAAA,CAAe,GAAA,CAAI,CAAC,CAAA,EAAG,EAAE,OAAA,EAAS,kBAAkB,CAAA;AAAA,EACrF,SAAA,EAAWA,KAAA,CAAE,MAAA,EAAO,CAAE,GAAA,GAAM,GAAA,CAAI,CAAC,CAAA,CAAE,GAAA,CAAI,EAAE,CAAA;AAAA,EACzC,IAAA,EAAMA,KAAA,CAAE,IAAA,CAAK,UAAU,EAAE,QAAA,EAAS;AAAA,EAClC,SAAA,EAAWA,MAAE,MAAA;AACf,CAAC;AAGM,IAAM,gBAAA,GAAmBA,KAAA,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;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;AAqBO,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,GAAwBA,MAAE,MAAA,CAAO;AAAA,EAC5C,MAAA,EAAQA,KAAA,CAAE,OAAA,CAAQ,YAAY,CAAA;AAAA,EAC9B,IAAA,EAAMA,MAAE,IAAA,CAAK,CAAC,SAAS,MAAA,EAAQ,MAAA,EAAQ,OAAO,CAAC,CAAA;AAAA,EAC/C,QAAQA,KAAA,CAAE,KAAA;AAAA,IACRA,MAAE,MAAA,CAAO;AAAA,MACP,MAAA,EAAQA,MAAE,MAAA,EAAO;AAAA,MACjB,MAAMA,KAAA,CAAE,IAAA,CAAK,CAAC,MAAA,EAAQ,OAAA,EAAS,QAAQ,CAAC,CAAA;AAAA,MACxC,SAAA,EAAWA,KAAA,CAAE,MAAA,EAAO,CAAE,GAAA,GAAM,GAAA,CAAI,CAAC,CAAA,CAAE,GAAA,CAAI,EAAE,CAAA;AAAA,MACzC,OAAA,EAASA,KAAA,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.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/** 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\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// ---- 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
@@ -87,6 +87,24 @@ interface RegionSetting {
87
87
  type RegionConfig = Record<string, RegionSetting>;
88
88
  /** The regions an app should render, honouring `visible` (default true). */
89
89
  declare function resolveRegions(config?: RegionConfig): BodyRegion[];
90
+ /**
91
+ * What a pick on a region should DO (F052.20).
92
+ *
93
+ * Lives in the core because the 2D and 3D renderers share no click code, and a
94
+ * rule written twice is a rule that drifts. This repo measured the cost of that
95
+ * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling
96
+ * branch that carried the same defect with no test on it.
97
+ *
98
+ * "clear" the region is already marked → picking it again removes the mark
99
+ * "select" unmarked → open it for marking
100
+ * "ignore" not selectable (read-only or config) → nothing happens
101
+ *
102
+ * Three outcomes, not a boolean: "nothing happened because it is locked" and
103
+ * "nothing happened because we removed the mark" must never look alike to a
104
+ * caller.
105
+ */
106
+ type PickOutcome = "clear" | "select" | "ignore";
107
+ declare function decidePick(region: string, report: PainReport, config?: RegionConfig): PickOutcome;
90
108
  /** Whether a region may be marked. A hidden region is never selectable. */
91
109
  declare function isSelectable(key: string, config?: RegionConfig): boolean;
92
110
  /** Colour control for the body renderers. Consumers pass a palette to theme the
@@ -107,7 +125,40 @@ interface BodymapPalette {
107
125
  };
108
126
  /** Optional per-region base-colour overrides (region key → colour). */
109
127
  regions?: Record<string, string>;
128
+ /**
129
+ * Optional panel-chrome colours (the selection panel, labels, hint box) —
130
+ * NOT the body itself. All optional; anything omitted falls back to
131
+ * {@link defaultUi}. A palette that only themed the body was half a palette:
132
+ * a consumer passing brand colours still got hardcoded chrome. (F052.19)
133
+ */
134
+ ui?: BodymapUiColors;
110
135
  }
136
+ /** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */
137
+ interface BodymapUiColors {
138
+ /** Primary text (headings, values). */
139
+ text?: string;
140
+ /** Secondary text — section labels, the empty-state hint. */
141
+ mutedText?: string;
142
+ /** Panel background. */
143
+ panelBg?: string;
144
+ /** Panel + control borders. */
145
+ border?: string;
146
+ /** Background behind the region-code badge. */
147
+ badgeBg?: string;
148
+ /** The destructive action (remove a marked region). */
149
+ danger?: string;
150
+ }
151
+ /**
152
+ * Default panel chrome. Contrast against `panelBg` (#fff), asserted by
153
+ * `test/contrast.test.ts`:
154
+ * text #1e293b 14.8:1
155
+ * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)
156
+ * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)
157
+ * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).
158
+ */
159
+ declare const defaultUi: Required<BodymapUiColors>;
160
+ /** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */
161
+ declare function uiColors(palette?: BodymapPalette): Required<BodymapUiColors>;
111
162
  /** The fleet default palette. Override any field per consumer. */
112
163
  declare const defaultPalette: BodymapPalette;
113
164
  /** The heat colour for a pain intensity, honouring the palette. */
@@ -175,4 +226,4 @@ declare function serializeReport(report: PainReport, opts?: {
175
226
  * key; a point whose code is unknown to this taxonomy is dropped. */
176
227
  declare function deserializeReport(env: unknown, now?: () => string): PainReport;
177
228
 
178
- export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, defaultPalette, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport };
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 };
package/dist/index.d.ts CHANGED
@@ -87,6 +87,24 @@ interface RegionSetting {
87
87
  type RegionConfig = Record<string, RegionSetting>;
88
88
  /** The regions an app should render, honouring `visible` (default true). */
89
89
  declare function resolveRegions(config?: RegionConfig): BodyRegion[];
90
+ /**
91
+ * What a pick on a region should DO (F052.20).
92
+ *
93
+ * Lives in the core because the 2D and 3D renderers share no click code, and a
94
+ * rule written twice is a rule that drifts. This repo measured the cost of that
95
+ * twice on 2026-08-28 alone: a fix applied to one half of a pair, and a sibling
96
+ * branch that carried the same defect with no test on it.
97
+ *
98
+ * "clear" the region is already marked → picking it again removes the mark
99
+ * "select" unmarked → open it for marking
100
+ * "ignore" not selectable (read-only or config) → nothing happens
101
+ *
102
+ * Three outcomes, not a boolean: "nothing happened because it is locked" and
103
+ * "nothing happened because we removed the mark" must never look alike to a
104
+ * caller.
105
+ */
106
+ type PickOutcome = "clear" | "select" | "ignore";
107
+ declare function decidePick(region: string, report: PainReport, config?: RegionConfig): PickOutcome;
90
108
  /** Whether a region may be marked. A hidden region is never selectable. */
91
109
  declare function isSelectable(key: string, config?: RegionConfig): boolean;
92
110
  /** Colour control for the body renderers. Consumers pass a palette to theme the
@@ -107,7 +125,40 @@ interface BodymapPalette {
107
125
  };
108
126
  /** Optional per-region base-colour overrides (region key → colour). */
109
127
  regions?: Record<string, string>;
128
+ /**
129
+ * Optional panel-chrome colours (the selection panel, labels, hint box) —
130
+ * NOT the body itself. All optional; anything omitted falls back to
131
+ * {@link defaultUi}. A palette that only themed the body was half a palette:
132
+ * a consumer passing brand colours still got hardcoded chrome. (F052.19)
133
+ */
134
+ ui?: BodymapUiColors;
110
135
  }
136
+ /** Panel-chrome colours. Every default is WCAG-AA (>=4.5:1) on its own background. */
137
+ interface BodymapUiColors {
138
+ /** Primary text (headings, values). */
139
+ text?: string;
140
+ /** Secondary text — section labels, the empty-state hint. */
141
+ mutedText?: string;
142
+ /** Panel background. */
143
+ panelBg?: string;
144
+ /** Panel + control borders. */
145
+ border?: string;
146
+ /** Background behind the region-code badge. */
147
+ badgeBg?: string;
148
+ /** The destructive action (remove a marked region). */
149
+ danger?: string;
150
+ }
151
+ /**
152
+ * Default panel chrome. Contrast against `panelBg` (#fff), asserted by
153
+ * `test/contrast.test.ts`:
154
+ * text #1e293b 14.8:1
155
+ * mutedText #475569 7.6:1 (was #94a3b8 at 2.56:1 — WCAG AA failure)
156
+ * danger #dc2626 4.8:1 (was #ef4444 at 3.76:1 — WCAG AA failure)
157
+ * `mutedText` on `badgeBg` (#f1f5f9) is 6.9:1 (was #64748b at 4.34:1).
158
+ */
159
+ declare const defaultUi: Required<BodymapUiColors>;
160
+ /** Resolve a palette's chrome colours, filling every gap from {@link defaultUi}. */
161
+ declare function uiColors(palette?: BodymapPalette): Required<BodymapUiColors>;
111
162
  /** The fleet default palette. Override any field per consumer. */
112
163
  declare const defaultPalette: BodymapPalette;
113
164
  /** The heat colour for a pain intensity, honouring the palette. */
@@ -175,4 +226,4 @@ declare function serializeReport(report: PainReport, opts?: {
175
226
  * key; a point whose code is unknown to this taxonomy is dropped. */
176
227
  declare function deserializeReport(env: unknown, now?: () => string): PainReport;
177
228
 
178
- export { type BodyRegion, type BodyView, type BodymapPalette, type BodymapReportV1, PAIN_TYPES, type PainPoint, type PainReport, type PainSelection, type PainSelectionOptions, type PainType, REGIONS, REGION_KEYS, type RegionConfig, type RegionSetting, type SerializedPainPoint, type SerializedSide, type Side, baseColorFor, bodymapReportV1Schema, createPainSelection, defaultPalette, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport };
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 };
package/dist/index.js CHANGED
@@ -71,11 +71,26 @@ function createPainSelection(initial = [], opts = {}) {
71
71
  function resolveRegions(config = {}) {
72
72
  return REGIONS.filter((r) => config[r.key]?.visible ?? true);
73
73
  }
74
+ function decidePick(region, report, config = {}) {
75
+ if (!isSelectable(region, config)) return "ignore";
76
+ return report.some((p) => p.region === region) ? "clear" : "select";
77
+ }
74
78
  function isSelectable(key, config = {}) {
75
79
  const s = config[key];
76
80
  if (s?.visible === false) return false;
77
81
  return s?.selectable ?? true;
78
82
  }
83
+ var defaultUi = {
84
+ text: "#1e293b",
85
+ mutedText: "#475569",
86
+ panelBg: "#fff",
87
+ border: "#e2e8f0",
88
+ badgeBg: "#f1f5f9",
89
+ danger: "#dc2626"
90
+ };
91
+ function uiColors(palette) {
92
+ return { ...defaultUi, ...palette?.ui ?? {} };
93
+ }
79
94
  var defaultPalette = {
80
95
  body: "#d2d7de",
81
96
  hover: "#8fd0cd",
@@ -136,6 +151,6 @@ function deserializeReport(env, now = () => (/* @__PURE__ */ new Date()).toISOSt
136
151
  return out;
137
152
  }
138
153
 
139
- export { PAIN_TYPES, REGIONS, REGION_KEYS, baseColorFor, bodymapReportV1Schema, createPainSelection, defaultPalette, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport };
154
+ export { PAIN_TYPES, REGIONS, REGION_KEYS, baseColorFor, bodymapReportV1Schema, createPainSelection, decidePick, defaultPalette, defaultUi, deserializeReport, getRegion, heatFor, isSelectable, painPointSchema, painReportSchema, resolveRegions, serializeReport, uiColors };
140
155
  //# sourceMappingURL=index.js.map
141
156
  //# 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/** 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\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// ---- 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/react.cjs CHANGED
@@ -55,6 +55,10 @@ zod.z.array(painPointSchema);
55
55
  function resolveRegions(config = {}) {
56
56
  return REGIONS.filter((r) => config[r.key]?.visible ?? true);
57
57
  }
58
+ function decidePick(region, report, config = {}) {
59
+ if (!isSelectable(region, config)) return "ignore";
60
+ return report.some((p) => p.region === region) ? "clear" : "select";
61
+ }
58
62
  function isSelectable(key, config = {}) {
59
63
  const s = config[key];
60
64
  if (s?.visible === false) return false;
@@ -293,9 +297,21 @@ function shapeEl(s, props) {
293
297
  if (s.el === "ellipse") return /* @__PURE__ */ jsxRuntime.jsx("ellipse", { cx: s.cx, cy: s.cy, rx: s.rx, ry: s.ry, ...props });
294
298
  return /* @__PURE__ */ jsxRuntime.jsx("rect", { x: s.x, y: s.y, width: s.width, height: s.height, rx: s.rx, ...props });
295
299
  }
300
+ function chromeStyle(palette) {
301
+ if (!palette) return void 0;
302
+ const ui = palette.ui ?? {};
303
+ const vars = { "--bmap-accent": palette.selected };
304
+ if (ui.text) vars["--bmap-ink"] = ui.text;
305
+ if (ui.mutedText) vars["--bmap-muted"] = ui.mutedText;
306
+ if (ui.panelBg) vars["--bmap-panel"] = ui.panelBg;
307
+ if (ui.border) vars["--bmap-line"] = ui.border;
308
+ if (ui.badgeBg) vars["--bmap-badge-bg"] = ui.badgeBg;
309
+ if (ui.danger) vars["--bmap-danger"] = ui.danger;
310
+ return vars;
311
+ }
296
312
  var STYLE_ID = "broberg-bodymap-styles";
297
313
  var STYLE = `
298
- .bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#64748b;
314
+ .bmap{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-panel:#fff;--bmap-ink:#1e293b;--bmap-muted:#475569;--bmap-badge-bg:#f1f5f9;--bmap-danger:#dc2626;
299
315
  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)}
300
316
  .bmap__stage{display:flex;flex-direction:column;gap:10px;flex:0 1 340px;min-width:0;max-width:360px}
301
317
  .bmap__bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
@@ -321,7 +337,7 @@ var STYLE = `
321
337
  .bmap__panel--empty{color:var(--bmap-muted);font-size:13.5px}
322
338
  .bmap__ph{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:12px}
323
339
  .bmap__ph b{font-size:16px}
324
- .bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:#f1f5f9;border-radius:6px;padding:2px 7px}
340
+ .bmap__code{font:11px ui-monospace,monospace;color:var(--bmap-muted);background:var(--bmap-badge-bg);border-radius:6px;padding:2px 7px}
325
341
  .bmap__lbl{font-size:11px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;color:var(--bmap-muted);margin:0 0 7px}
326
342
  .bmap__scale{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:14px}
327
343
  .bmap__i{font:inherit;font-size:14px;font-weight:600;width:38px;height:38px;border-radius:9px;border:1px solid var(--bmap-line);background:#fff;color:var(--bmap-ink);cursor:pointer;transition:border-color .1s,background .1s,transform .1s}
@@ -333,11 +349,11 @@ var STYLE = `
333
349
  .bmap__chip:hover{border-color:var(--bmap-accent);color:var(--bmap-accent)}
334
350
  .bmap__chip:active{transform:scale(.96)}
335
351
  .bmap__chip--on{background:var(--bmap-ink);border-color:var(--bmap-ink);color:#fff}
336
- .bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:#ef4444;border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}
352
+ .bmap__rm{font:inherit;font-size:14px;font-weight:600;border:1px solid #f6c9c9;background:#fff;color:var(--bmap-danger);border-radius:9px;padding:10px 15px;cursor:pointer;transition:.1s}
337
353
  .bmap__rm:hover{background:#fef2f2}
338
354
  .bmap__rm:active{transform:scale(.97)}
339
355
  .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}
340
- .bmapc{--bmap-accent:var(--primary,#0e8f8a);--bmap-line:#e2e8f0;--bmap-ink:#1e293b;--bmap-muted:#64748b;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,sans-serif;color:var(--bmap-ink)}
356
+ .bmapc{--bmap-accent:var(--primary,#0e8f8a);--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)}
341
357
  .bmapc__bodies{display:flex;gap:14px;flex-wrap:wrap}
342
358
  .bmapc__fig{flex:1 1 150px;min-width:130px;max-width:220px;margin:0;text-align:center}
343
359
  .bmapc__cap{font-size:12px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:#556274;margin-bottom:6px}
@@ -492,18 +508,27 @@ function BodyMap({
492
508
  commit(report.filter((p) => p.region !== key));
493
509
  setSelected(null);
494
510
  };
511
+ const applyPick = (key) => {
512
+ const what = decidePick(key, report, config);
513
+ if (what === "ignore") return;
514
+ if (what === "clear") {
515
+ removePain(key);
516
+ return;
517
+ }
518
+ setSelected(key);
519
+ };
495
520
  const pickRegion = (key) => {
496
521
  if (suppressClick.current) {
497
522
  suppressClick.current = false;
498
523
  return;
499
524
  }
500
- setSelected(key);
525
+ applyPick(key);
501
526
  };
502
527
  const regions = resolveRegions(config);
503
528
  const region = selected ? REGIONS.find((r) => r.key === selected) : void 0;
504
529
  const current = selected ? pointOf(selected) : void 0;
505
530
  const rootCls = ["bmap", className].filter(Boolean).join(" ");
506
- const rootStyle = palette ? { "--bmap-accent": palette.selected } : void 0;
531
+ const rootStyle = chromeStyle(palette);
507
532
  const ariaFor = (r, marked, interactive = true) => {
508
533
  if (marked) return L.ariaMarked(nameOf(r.key), marked.intensity, marked.type ? L.qualities[marked.type] : void 0);
509
534
  return interactive ? L.ariaUnmarked(nameOf(r.key)) : nameOf(r.key);
@@ -576,7 +601,7 @@ function BodyMap({
576
601
  if (e.key === "Enter" || e.key === " ") {
577
602
  e.preventDefault();
578
603
  suppressClick.current = false;
579
- setSelected(r.key);
604
+ applyPick(r.key);
580
605
  }
581
606
  } : void 0
582
607
  }),
@@ -669,7 +694,7 @@ function BodyMapCompare({
669
694
  const [view, setView] = react.useState(defaultView);
670
695
  const L = resolveLabels(locale, labels);
671
696
  const nameOf = (k) => L.regions[k] ?? getRegion(k)?.label ?? k;
672
- const rootStyle = palette ? { "--bmap-accent": palette.selected } : void 0;
697
+ const rootStyle = chromeStyle(palette);
673
698
  const keys = Array.from(new Set([...before, ...after].map((p) => p.region)));
674
699
  const changes = keys.map((k) => ({
675
700
  key: k,