@broberg/bodymap 0.8.0 → 0.10.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/dist/three.d.cts CHANGED
@@ -119,6 +119,19 @@ interface BodymapUiColors {
119
119
  badgeBg?: string;
120
120
  /** The destructive action (remove a marked region). */
121
121
  danger?: string;
122
+ /** Border on the destructive control. Its TEXT was already themeable and its
123
+ * border was not — half a control. (F052.30) */
124
+ dangerBorder?: string;
125
+ /**
126
+ * THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.
127
+ *
128
+ * NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the
129
+ * colour of an active button. Collapsing them would stop a consumer having a
130
+ * teal mark and a navy button, which is a normal thing to want. (F052.30)
131
+ */
132
+ accent?: string;
133
+ /** Text ON the accent. Check it against your accent — see the README. */
134
+ accentText?: string;
122
135
  }
123
136
  type BodyView = "front" | "back" | "left" | "right";
124
137
  /** Side in the serialized report — a midline region (no side) becomes "center". */
@@ -192,6 +205,16 @@ interface BodyMap3DUiLabels {
192
205
  female: string;
193
206
  hoverHint: string;
194
207
  }
208
+ interface BodyMap3DHint {
209
+ /** Overrides the localized default. `ui.hoverHint` still works too. */
210
+ text?: string;
211
+ /**
212
+ * `after-canvas` (default) keeps today's flow position.
213
+ * `stage-bottom` pins it inside the fullscreen surface, above the home
214
+ * indicator — and falls back to the flow position when not fullscreen.
215
+ */
216
+ placement?: "after-canvas" | "stage-bottom";
217
+ }
195
218
  /**
196
219
  * How much of the RUNNER-UP region bleeds into this vertex (F052.21).
197
220
  *
@@ -252,6 +275,24 @@ interface BodyMap3DProps {
252
275
  palette?: BodymapPalette;
253
276
  locale?: BodyMapLocale;
254
277
  labels?: Partial<BodyMapLabels>;
278
+ /**
279
+ * The help note under the body. F052.31.
280
+ *
281
+ * `false` renders NOTHING — no box, no border, no empty element. That path did
282
+ * not exist before: `ui.hoverHint = ""` produced an empty bordered box, which
283
+ * is worse than the sentence it replaced.
284
+ *
285
+ * `placement` is what fixes the defect rather than moving it. In the boxed
286
+ * layout the note belongs after the canvas; in FULLSCREEN that column wraps
287
+ * beneath a full-height canvas and the note lands past the bottom edge —
288
+ * measured by a consumer at 858-912 in an 852px window, i.e. entirely
289
+ * offscreen. `stage-bottom` anchors it inside the fullscreen surface instead,
290
+ * clear of the home indicator.
291
+ *
292
+ * A consumer cannot make that call in CSS without knowing our internal
293
+ * structure, which is exactly why two of them had to write wedges against it.
294
+ */
295
+ hint?: false | BodyMap3DHint;
255
296
  /** Override the 3D-only control strings (male/female/hoverHint). */
256
297
  ui?: Partial<BodyMap3DUiLabels>;
257
298
  defaultSex?: BodyMap3DSex;
@@ -312,4 +353,4 @@ interface BodyMap3DProps {
312
353
  }
313
354
  declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
314
355
 
315
- export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
356
+ export { BodyMap3D, type BodyMap3DHint, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
package/dist/three.d.ts CHANGED
@@ -119,6 +119,19 @@ interface BodymapUiColors {
119
119
  badgeBg?: string;
120
120
  /** The destructive action (remove a marked region). */
121
121
  danger?: string;
122
+ /** Border on the destructive control. Its TEXT was already themeable and its
123
+ * border was not — half a control. (F052.30) */
124
+ dangerBorder?: string;
125
+ /**
126
+ * THE ACTIVE CONTROL colour — the chosen intensity, the active sex toggle.
127
+ *
128
+ * NOT `palette.selected`. That is the colour of a MARK ON SKIN; this is the
129
+ * colour of an active button. Collapsing them would stop a consumer having a
130
+ * teal mark and a navy button, which is a normal thing to want. (F052.30)
131
+ */
132
+ accent?: string;
133
+ /** Text ON the accent. Check it against your accent — see the README. */
134
+ accentText?: string;
122
135
  }
123
136
  type BodyView = "front" | "back" | "left" | "right";
124
137
  /** Side in the serialized report — a midline region (no side) becomes "center". */
@@ -192,6 +205,16 @@ interface BodyMap3DUiLabels {
192
205
  female: string;
193
206
  hoverHint: string;
194
207
  }
208
+ interface BodyMap3DHint {
209
+ /** Overrides the localized default. `ui.hoverHint` still works too. */
210
+ text?: string;
211
+ /**
212
+ * `after-canvas` (default) keeps today's flow position.
213
+ * `stage-bottom` pins it inside the fullscreen surface, above the home
214
+ * indicator — and falls back to the flow position when not fullscreen.
215
+ */
216
+ placement?: "after-canvas" | "stage-bottom";
217
+ }
195
218
  /**
196
219
  * How much of the RUNNER-UP region bleeds into this vertex (F052.21).
197
220
  *
@@ -252,6 +275,24 @@ interface BodyMap3DProps {
252
275
  palette?: BodymapPalette;
253
276
  locale?: BodyMapLocale;
254
277
  labels?: Partial<BodyMapLabels>;
278
+ /**
279
+ * The help note under the body. F052.31.
280
+ *
281
+ * `false` renders NOTHING — no box, no border, no empty element. That path did
282
+ * not exist before: `ui.hoverHint = ""` produced an empty bordered box, which
283
+ * is worse than the sentence it replaced.
284
+ *
285
+ * `placement` is what fixes the defect rather than moving it. In the boxed
286
+ * layout the note belongs after the canvas; in FULLSCREEN that column wraps
287
+ * beneath a full-height canvas and the note lands past the bottom edge —
288
+ * measured by a consumer at 858-912 in an 852px window, i.e. entirely
289
+ * offscreen. `stage-bottom` anchors it inside the fullscreen surface instead,
290
+ * clear of the home indicator.
291
+ *
292
+ * A consumer cannot make that call in CSS without knowing our internal
293
+ * structure, which is exactly why two of them had to write wedges against it.
294
+ */
295
+ hint?: false | BodyMap3DHint;
255
296
  /** Override the 3D-only control strings (male/female/hoverHint). */
256
297
  ui?: Partial<BodyMap3DUiLabels>;
257
298
  defaultSex?: BodyMap3DSex;
@@ -312,4 +353,4 @@ interface BodyMap3DProps {
312
353
  }
313
354
  declare function BodyMap3D(props: BodyMap3DProps): react.JSX.Element;
314
355
 
315
- export { BodyMap3D, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
356
+ export { BodyMap3D, type BodyMap3DHint, type BodyMap3DModels, type BodyMap3DProps, type BodyMap3DSex, type BodyMap3DUiLabels, ensureNormals, seamBlend, seamWeight, serializeReport };
package/dist/three.js CHANGED
@@ -89,7 +89,16 @@ var defaultUi = {
89
89
  stageBg: STAGE_BG,
90
90
  border: "#e2e8f0",
91
91
  badgeBg: "#f1f5f9",
92
- danger: "#dc2626"
92
+ danger: "#dc2626",
93
+ dangerBorder: "#f6c9c9",
94
+ // F052.30 — was #0e8f8a, which measured 3.95:1 against white. AA needs 4.5,
95
+ // and this is the SELECTED intensity button on a surface where AA is a legal
96
+ // duty. It shipped from 0.2.0 and no check could see it: the colour was an
97
+ // inline literal, and the F052.19 contrast sweep only read this object.
98
+ // #0c7d77 is the same teal a shade deeper — 4.98:1, with headroom rather than
99
+ // sitting on the 4.5 boundary where any later tweak reopens it.
100
+ accent: "#0c7d77",
101
+ accentText: "#fff"
93
102
  };
94
103
  function uiColors(palette) {
95
104
  return { ...defaultUi, ...palette?.ui ?? {} };
@@ -203,8 +212,8 @@ var LABELS_EN = {
203
212
  zoomOut: "Zoom out",
204
213
  zoomReset: "Reset zoom"
205
214
  };
206
- var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Hover for at fremh\xE6ve \xB7 klik en kropsdel for at markere smerte.", expand: "Vis stor", collapse: "Luk stor visning" };
207
- var UI_EN = { male: "Male", female: "Female", hoverHint: "Hover to highlight \xB7 tap a body part to mark pain.", expand: "Expand", collapse: "Close" };
215
+ var UI_DA = { male: "Mand", female: "Kvinde", hoverHint: "Tr\xE6k for at dreje \xB7 tryk en kropsdel for at markere smerte.", expand: "Vis stor", collapse: "Luk stor visning" };
216
+ var UI_EN = { male: "Male", female: "Female", hoverHint: "Drag to rotate \xB7 tap a body part to mark pain.", expand: "Expand", collapse: "Close" };
208
217
  var ANCHORS = {
209
218
  head: [0, 1.79, 0.02],
210
219
  neck: [0, 1.57, 0],
@@ -276,8 +285,20 @@ function webglAvailable() {
276
285
  return false;
277
286
  }
278
287
  }
279
- var btn = { font: "inherit", cursor: "pointer", borderRadius: 8, border: "1px solid #e2e8f0", background: "#fff", padding: "6px 9px" };
280
- var seg = (on) => ({ ...btn, background: on ? "#0e8f8a" : "#fff", color: on ? "#fff" : "#1e293b", fontWeight: 600 });
288
+ var btn = (c) => ({
289
+ font: "inherit",
290
+ cursor: "pointer",
291
+ borderRadius: 8,
292
+ border: `1px solid ${c.border}`,
293
+ background: c.panelBg,
294
+ padding: "6px 9px"
295
+ });
296
+ var seg = (c, on) => ({
297
+ ...btn(c),
298
+ background: on ? c.accent : c.panelBg,
299
+ color: on ? c.accentText : c.text,
300
+ fontWeight: 600
301
+ });
281
302
  function BodyMap3D(props) {
282
303
  const {
283
304
  models,
@@ -299,6 +320,7 @@ function BodyMap3D(props) {
299
320
  onFeedback,
300
321
  haptics,
301
322
  seam = false,
323
+ hint,
302
324
  fullscreen: fullscreenProp,
303
325
  onFullscreenChange,
304
326
  showFullscreenButton = true,
@@ -658,7 +680,9 @@ function BodyMap3D(props) {
658
680
  const region = selected ? REGIONS.find((r) => r.key === selected) : null;
659
681
  const current = selected ? pointOf(selected) : void 0;
660
682
  const anySelectable = REGIONS.some((r) => isSelectable(r.key, config ?? {}));
661
- const showEmptyHint = anySelectable || ui?.hoverHint !== void 0;
683
+ const showEmptyHint = hint === false ? false : anySelectable || ui?.hoverHint !== void 0;
684
+ const hintText = (hint ? hint.text : void 0) ?? UI.hoverHint;
685
+ const hintAtStage = isFullscreen && !!hint && hint.placement === "stage-bottom";
662
686
  return /* @__PURE__ */ jsxs(
663
687
  "div",
664
688
  {
@@ -667,7 +691,7 @@ function BodyMap3D(props) {
667
691
  "data-fullscreen": isFullscreen ? "true" : void 0,
668
692
  style: {
669
693
  fontFamily: "system-ui, sans-serif",
670
- color: "#1e293b",
694
+ color: chrome.text,
671
695
  // A viewport fill, not the Fullscreen API — see the `fullscreen` prop.
672
696
  // `fixed`+`inset:0` behaves the same on iOS Safari, where element
673
697
  // fullscreen does not exist at all, as it does everywhere else.
@@ -699,8 +723,8 @@ function BodyMap3D(props) {
699
723
  },
700
724
  children: [
701
725
  showSexToggle && /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 12, fontSize: 13 }, children: /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 6, alignItems: "center" }, children: [
702
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(sex === "male"), children: UI.male }),
703
- /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(sex === "female"), children: UI.female })
726
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-male", onClick: () => changeSex("male"), style: seg(chrome, sex === "male"), children: UI.male }),
727
+ /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-sex-female", onClick: () => changeSex("female"), style: seg(chrome, sex === "female"), children: UI.female })
704
728
  ] }) }),
705
729
  /* @__PURE__ */ jsx("span", { ref: loadedRef, "data-testid": "bodymap3d-loaded", style: { display: "none" } }),
706
730
  ready && /* @__PURE__ */ jsx("span", { "data-testid": "bodymap3d-ready", style: { position: "absolute", width: 1, height: 1, opacity: 0, pointerEvents: "none" } }),
@@ -713,7 +737,7 @@ function BodyMap3D(props) {
713
737
  "aria-label": isFullscreen ? UI.collapse ?? "Close" : UI.expand ?? "Expand",
714
738
  onClick: () => setFullscreen(!isFullscreen),
715
739
  style: {
716
- ...btn,
740
+ ...btn(chrome),
717
741
  color: chrome.text,
718
742
  borderColor: chrome.border,
719
743
  background: chrome.panelBg,
@@ -764,18 +788,43 @@ function BodyMap3D(props) {
764
788
  "data-testid": "bodymap3d-close",
765
789
  "aria-label": L.close,
766
790
  onClick: () => setSelected(null),
767
- style: { ...btn, width: 32, height: 32, padding: 0, fontSize: 20, lineHeight: 1, color: chrome.mutedText, borderColor: chrome.border, background: chrome.panelBg },
791
+ style: { ...btn(chrome), width: 32, height: 32, padding: 0, fontSize: 20, lineHeight: 1, color: chrome.mutedText, borderColor: chrome.border, background: chrome.panelBg },
768
792
  children: "\xD7"
769
793
  }
770
794
  )
771
795
  ] }),
772
796
  /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.intensity }),
773
- /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn, display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 30, height: 30, padding: 0, background: current?.intensity === i ? "#0e8f8a" : "#fff", color: current?.intensity === i ? "#fff" : "#1e293b" }, children: i }, i)) }),
797
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 12 }, children: Array.from({ length: 11 }, (_, i) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-intensity-${i}`, onClick: () => setPain(region.key, i, current?.type), style: { ...btn(chrome), display: "inline-flex", alignItems: "center", justifyContent: "center", minWidth: 30, height: 30, padding: 0, background: current?.intensity === i ? chrome.accent : chrome.panelBg, color: current?.intensity === i ? chrome.accentText : chrome.text }, children: i }, i)) }),
774
798
  /* @__PURE__ */ jsx("div", { style: { fontSize: 11, fontWeight: 700, letterSpacing: ".06em", textTransform: "uppercase", color: chrome.mutedText, marginBottom: 7 }, children: L.quality }),
775
- /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 }, children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-type-${t}`, onClick: () => setPain(region.key, current?.intensity ?? 5, t), style: { ...btn, borderRadius: 999, padding: "6px 12px", background: current?.type === t ? "#1e293b" : "#fff", color: current?.type === t ? "#fff" : "#64748b" }, children: L.qualities[t] ?? t }, t)) }),
776
- current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn, color: chrome.danger, borderColor: "#f6c9c9" }, children: L.remove })
777
- ] }) : showEmptyHint ? /* @__PURE__ */ jsx("div", { "data-testid": "bodymap3d-empty", style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg, color: chrome.mutedText, fontSize: 13.5 }, children: UI.hoverHint }) : null })
778
- ] })
799
+ /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginBottom: 14 }, children: PAIN_TYPES.map((t) => /* @__PURE__ */ jsx("button", { "data-testid": `bodymap3d-type-${t}`, onClick: () => setPain(region.key, current?.intensity ?? 5, t), style: { ...btn(chrome), borderRadius: 999, padding: "6px 12px", background: current?.type === t ? chrome.text : chrome.panelBg, color: current?.type === t ? chrome.panelBg : chrome.mutedText }, children: L.qualities[t] ?? t }, t)) }),
800
+ current && /* @__PURE__ */ jsx("button", { "data-testid": "bodymap3d-remove", onClick: () => removePain(region.key), style: { ...btn(chrome), color: chrome.danger, borderColor: chrome.dangerBorder }, children: L.remove })
801
+ ] }) : showEmptyHint && !hintAtStage ? /* @__PURE__ */ jsx("div", { "data-testid": "bodymap3d-empty", style: { border: `1px solid ${chrome.border}`, borderRadius: 14, padding: 16, background: chrome.panelBg, color: chrome.mutedText, fontSize: 13.5 }, children: hintText }) : null })
802
+ ] }),
803
+ showEmptyHint && hintAtStage && /* F052.31 — pinned INSIDE the fullscreen surface, not in the column that
804
+ wraps beneath a full-height canvas. That wrap is what put the note at
805
+ 858-912 in an 852px window on a real iPhone: entirely offscreen, and
806
+ it reads as a bug in the consumer's app rather than in ours.
807
+ The bottom inset clears the home indicator, exactly as the exit
808
+ control's does (F052.27). */
809
+ /* @__PURE__ */ jsx(
810
+ "div",
811
+ {
812
+ "data-testid": "bodymap3d-empty",
813
+ "data-placement": "stage-bottom",
814
+ style: {
815
+ flex: "0 0 auto",
816
+ marginTop: 12,
817
+ marginBottom: "env(safe-area-inset-bottom)",
818
+ border: `1px solid ${chrome.border}`,
819
+ borderRadius: 14,
820
+ padding: 16,
821
+ background: chrome.panelBg,
822
+ color: chrome.mutedText,
823
+ fontSize: 13.5
824
+ },
825
+ children: hintText
826
+ }
827
+ )
779
828
  ]
780
829
  }
781
830
  );