hpo-react-visualizer 0.0.1

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.
Files changed (45) hide show
  1. package/README.md +59 -0
  2. package/dist/index.cjs +1483 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +244 -0
  5. package/dist/index.d.ts +244 -0
  6. package/dist/index.js +1473 -0
  7. package/dist/index.js.map +1 -0
  8. package/package.json +60 -0
  9. package/src/HpoVisualizer.tsx +210 -0
  10. package/src/OrganSvg.tsx +141 -0
  11. package/src/__tests__/colorScheme.test.tsx +146 -0
  12. package/src/__tests__/createStrictColorPalette.test.ts +135 -0
  13. package/src/__tests__/renderOrder.test.tsx +146 -0
  14. package/src/__tests__/setup.ts +1 -0
  15. package/src/constants.ts +158 -0
  16. package/src/index.ts +34 -0
  17. package/src/lib/createStrictColorPalette.ts +57 -0
  18. package/src/lib/index.ts +1 -0
  19. package/src/svg/Blood.tsx +23 -0
  20. package/src/svg/Body.tsx +24 -0
  21. package/src/svg/Breast.tsx +42 -0
  22. package/src/svg/Cell.tsx +79 -0
  23. package/src/svg/Constitutional.tsx +29 -0
  24. package/src/svg/Digestive.tsx +28 -0
  25. package/src/svg/Ear.tsx +23 -0
  26. package/src/svg/Endocrine.tsx +32 -0
  27. package/src/svg/Eye.tsx +23 -0
  28. package/src/svg/Growth.tsx +23 -0
  29. package/src/svg/Head.tsx +51 -0
  30. package/src/svg/Heart.tsx +23 -0
  31. package/src/svg/Immune.tsx +32 -0
  32. package/src/svg/Integument.tsx +58 -0
  33. package/src/svg/Kidney.tsx +41 -0
  34. package/src/svg/Limbs.tsx +46 -0
  35. package/src/svg/Lung.tsx +23 -0
  36. package/src/svg/Metabolism.tsx +41 -0
  37. package/src/svg/Muscle.tsx +225 -0
  38. package/src/svg/Neoplasm.tsx +23 -0
  39. package/src/svg/Nervous.tsx +49 -0
  40. package/src/svg/Prenatal.tsx +23 -0
  41. package/src/svg/ThoracicCavity.tsx +28 -0
  42. package/src/svg/Voice.tsx +23 -0
  43. package/src/svg/index.ts +54 -0
  44. package/src/types.ts +162 -0
  45. package/src/useOrganInteraction.ts +130 -0
@@ -0,0 +1,210 @@
1
+ import { type CSSProperties, useId, useMemo } from "react";
2
+ import { BODY_VIEWBOX, DEFAULT_COLOR_NAME, ORGAN_IDS } from "./constants";
3
+ import { createStrictColorPalette } from "./lib";
4
+ import { OrganSvg } from "./OrganSvg";
5
+ import { Body } from "./svg/Body";
6
+ import type { HpoVisualizerProps, OrganConfig, OrganId, StrictColorPalette } from "./types";
7
+ import { useOrganInteraction } from "./useOrganInteraction";
8
+
9
+ /**
10
+ * Default positions and viewBox dimensions for each organ in the visualizer
11
+ * Positions are relative to Body SVG coordinate system (122x325 viewBox)
12
+ * viewBox: original coordinate system of the organ SVG
13
+ */
14
+ const ORGAN_POSITIONS: Record<
15
+ OrganId,
16
+ { x: number; y: number; width: number; height: number; viewBox: string }
17
+ > = {
18
+ growth: { x: 104, y: 1, width: 12, height: 349, viewBox: "0 0 12 349" },
19
+ constitutional: { x: 0, y: 0, width: 122, height: 358, viewBox: "0 0 122 358" },
20
+ thoracicCavity: { x: 31, y: 79, width: 60, height: 50, viewBox: "0 0 60 50" },
21
+ lung: { x: 35, y: 85, width: 52, height: 41, viewBox: "0 0 52 41" },
22
+ breast: { x: 26, y: 103, width: 70, height: 30, viewBox: "0 0 70 30" },
23
+ heart: { x: 50, y: 91, width: 22, height: 23, viewBox: "0 0 22 23" },
24
+ digestive: { x: 41, y: 119, width: 42, height: 86, viewBox: "0 0 42 86" },
25
+ kidney: { x: 49, y: 162, width: 24, height: 30, viewBox: "0 0 24 30" },
26
+ limbs: { x: 31, y: 146, width: 87, height: 203, viewBox: "0 0 87 203" },
27
+ integument: { x: 6, y: 91, width: 90, height: 235, viewBox: "0 0 90 235" },
28
+ head: { x: 35, y: 4, width: 52, height: 68, viewBox: "0 0 52 68" },
29
+ eye: { x: 69, y: 24, width: 12, height: 12, viewBox: "0 0 12 12" },
30
+ ear: { x: 34, y: 30, width: 13, height: 13, viewBox: "0 0 13 13" },
31
+ nervous: { x: 27, y: 4, width: 68, height: 185, viewBox: "0 0 68 185" },
32
+ voice: { x: 54, y: 56, width: 14, height: 22, viewBox: "0 0 14 22" },
33
+ metabolism: { x: 50, y: 128, width: 22, height: 21, viewBox: "0 0 22 21" },
34
+ cell: { x: 27, y: 185, width: 11, height: 18, viewBox: "0 0 11 18" },
35
+ endocrine: { x: 50, y: 64, width: 26, height: 99, viewBox: "0 0 26 99" },
36
+ neoplasm: { x: 81, y: 80, width: 11, height: 12, viewBox: "0 0 11 12" },
37
+ immune: { x: 57, y: 81, width: 30, height: 58, viewBox: "0 0 30 58" },
38
+ muscle: { x: 13, y: 79, width: 83, height: 182, viewBox: "0 0 83 182" },
39
+ prenatal: { x: 44, y: 170, width: 34, height: 24, viewBox: "0 0 34 24" },
40
+ blood: { x: 6, y: 182, width: 6, height: 10, viewBox: "0 0 6 10" },
41
+ };
42
+
43
+ const BACKGROUND_ORGANS: readonly OrganId[] = ["growth"] as const;
44
+ const FOREGROUND_ORGANS: readonly OrganId[] = [
45
+ "constitutional",
46
+ "thoracicCavity",
47
+ "lung",
48
+ "breast",
49
+ "heart",
50
+ "digestive",
51
+ "kidney",
52
+ "limbs",
53
+ "integument",
54
+ "head",
55
+ "eye",
56
+ "ear",
57
+ "nervous",
58
+ "voice",
59
+ "metabolism",
60
+ "cell",
61
+ "endocrine",
62
+ "neoplasm",
63
+ "immune",
64
+ "muscle",
65
+ "prenatal",
66
+ "blood",
67
+ ] as const;
68
+
69
+ /**
70
+ * HPO Visualizer - Interactive human organ visualization component
71
+ *
72
+ * Features:
73
+ * - Displays organs as interactive SVG graphics
74
+ * - Hover effect with visual highlighting
75
+ * - Click to select/deselect organs
76
+ * - Hover effects work independently even when an organ is selected
77
+ * - Exposes hover and selection state via callbacks
78
+ */
79
+ export function HpoVisualizer({
80
+ organs,
81
+ visibleOrgans,
82
+ hoveredOrgan: controlledHovered,
83
+ selectedOrgan: controlledSelected,
84
+ onHover,
85
+ onSelect,
86
+ colorPalette: inputColorPalette,
87
+ width = BODY_VIEWBOX.width,
88
+ height = BODY_VIEWBOX.height,
89
+ className,
90
+ style,
91
+ }: HpoVisualizerProps) {
92
+ const visualizerID = useId();
93
+
94
+ // Create strict color palette with all required colors and alpha values
95
+ const colorPalette: StrictColorPalette = useMemo(
96
+ () => createStrictColorPalette(inputColorPalette),
97
+ [inputColorPalette],
98
+ );
99
+ // Determine which organs are visible based on visibleOrgans prop
100
+ // Priority: visibleOrgans > organs > all organs
101
+ // - visibleOrgans (array): only the specified organs are visible
102
+ // - visibleOrgans (empty array): no organs are visible
103
+ // - visibleOrgans (undefined) + organs (array): organs from organs prop are visible
104
+ // - both undefined: all organs are visible
105
+ const visibleOrganIds = useMemo((): OrganId[] => {
106
+ if (visibleOrgans !== undefined) {
107
+ return visibleOrgans;
108
+ }
109
+ if (organs !== undefined) {
110
+ return organs.map((o) => o.id);
111
+ }
112
+ return [...ORGAN_IDS];
113
+ }, [visibleOrgans, organs]);
114
+
115
+ // Create organ config map for quick lookup
116
+ const organConfigMap = useMemo((): Map<OrganId, OrganConfig> => {
117
+ const map = new Map<OrganId, OrganConfig>();
118
+ if (organs) {
119
+ for (const config of organs) {
120
+ map.set(config.id, config);
121
+ }
122
+ }
123
+ return map;
124
+ }, [organs]);
125
+
126
+ // Use the interaction hook
127
+ const { handlers, isHovered, isSelected } = useOrganInteraction({
128
+ hoveredOrgan: controlledHovered,
129
+ selectedOrgan: controlledSelected,
130
+ onHover,
131
+ onSelect,
132
+ });
133
+
134
+ const containerStyle: CSSProperties = {
135
+ display: "flex",
136
+ justifyContent: "center",
137
+ alignItems: "flex-end",
138
+ userSelect: "none",
139
+ position: "relative",
140
+ width: width,
141
+ height: height,
142
+ ...style,
143
+ };
144
+
145
+ const viewBox = `0 0 ${BODY_VIEWBOX.width} ${BODY_VIEWBOX.height}`;
146
+ const scale = Math.min(Number(width) / BODY_VIEWBOX.width, Number(height) / BODY_VIEWBOX.height);
147
+ const translateX = (Number(width) - BODY_VIEWBOX.width * scale) / 2;
148
+
149
+ // Helper function to render an organ
150
+ const renderOrgan = (organId: OrganId, isVisible: boolean) => {
151
+ const position = ORGAN_POSITIONS[organId];
152
+ const config = organConfigMap.get(organId);
153
+
154
+ // Check if organ should be visible based on config style (different from visibleOrgans)
155
+ if (config?.style?.visible === false) {
156
+ return null;
157
+ }
158
+
159
+ const x = translateX + position.x * scale;
160
+ const y = position.y * scale;
161
+ const width = position.width * scale;
162
+ const height = position.height * scale;
163
+
164
+ return (
165
+ <OrganSvg
166
+ key={`${visualizerID}-${organId}`}
167
+ organId={organId}
168
+ config={config}
169
+ colorPalette={colorPalette}
170
+ isHovered={isHovered(organId)}
171
+ isSelected={isSelected(organId)}
172
+ onMouseEnter={() => handlers.handleMouseEnter(organId)}
173
+ onMouseLeave={handlers.handleMouseLeave}
174
+ onClick={() => handlers.handleClick(organId)}
175
+ x={x}
176
+ y={y}
177
+ width={width}
178
+ height={height}
179
+ viewBox={position.viewBox}
180
+ showOutline={config?.showOutline}
181
+ isVisible={isVisible}
182
+ />
183
+ );
184
+ };
185
+
186
+ return (
187
+ <div className={className} style={containerStyle}>
188
+ {BACKGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))}
189
+ {/* Background Body - rendered as nested SVG for flat structure */}
190
+ <svg
191
+ x={translateX}
192
+ y="0"
193
+ width={width}
194
+ height={height}
195
+ viewBox={viewBox}
196
+ style={{ position: "absolute", top: "0", left: "0" }}
197
+ pointerEvents="none"
198
+ >
199
+ <title>Human body</title>
200
+ <Body
201
+ colorScale={colorPalette[DEFAULT_COLOR_NAME]}
202
+ style={{
203
+ fill: "#fff",
204
+ }}
205
+ />
206
+ </svg>
207
+ {FOREGROUND_ORGANS.map((organId) => renderOrgan(organId, visibleOrganIds.includes(organId)))}
208
+ </div>
209
+ );
210
+ }
@@ -0,0 +1,141 @@
1
+ import { type CSSProperties, useMemo } from "react";
2
+ import {
3
+ DEFAULT_COLOR_NAME,
4
+ DEFAULT_STROKE_COLOR,
5
+ DEFAULT_STROKE_WIDTH,
6
+ TRANSITION_STYLE,
7
+ } from "./constants";
8
+ import { ORGAN_COMPONENTS } from "./svg";
9
+ import type { OrganConfig, OrganId, OrganStyle, StrictColorPalette } from "./types";
10
+
11
+ export interface OrganSvgWrapperProps {
12
+ /** Organ identifier */
13
+ organId: OrganId;
14
+ /** Organ configuration */
15
+ config?: OrganConfig;
16
+ /** Color palette to use for this organ (with guaranteed alpha values) */
17
+ colorPalette: StrictColorPalette;
18
+ /** Whether the organ is currently hovered */
19
+ isHovered: boolean;
20
+ /** Whether the organ is currently selected */
21
+ isSelected: boolean;
22
+ /** Mouse enter handler */
23
+ onMouseEnter: () => void;
24
+ /** Mouse leave handler */
25
+ onMouseLeave: () => void;
26
+ /** Click handler */
27
+ onClick: () => void;
28
+ /** X position in parent coordinate system */
29
+ x: number;
30
+ /** Y position in parent coordinate system */
31
+ y: number;
32
+ /** Width of the organ SVG viewport */
33
+ width: number;
34
+ /** Height of the organ SVG viewport */
35
+ height: number;
36
+ /** ViewBox for the organ SVG (local coordinate system) */
37
+ viewBox: string;
38
+ /** Show red outline around this organ */
39
+ showOutline?: boolean;
40
+ /** Whether the organ is visible (controls opacity for animation) */
41
+ isVisible?: boolean;
42
+ }
43
+
44
+ /**
45
+ * Merge multiple OrganStyle objects with priority (later objects override earlier)
46
+ */
47
+ function mergeStyles(...styles: (OrganStyle | undefined)[]): OrganStyle {
48
+ const result: OrganStyle = {};
49
+ for (const style of styles) {
50
+ if (style) {
51
+ if (style.fill !== undefined) result.fill = style.fill;
52
+ if (style.stroke !== undefined) result.stroke = style.stroke;
53
+ if (style.strokeWidth !== undefined) result.strokeWidth = style.strokeWidth;
54
+ if (style.opacity !== undefined) result.opacity = style.opacity;
55
+ if (style.visible !== undefined) result.visible = style.visible;
56
+ if (style.blur !== undefined) result.blur = style.blur;
57
+ }
58
+ }
59
+ return result;
60
+ }
61
+
62
+ /**
63
+ * Wrapper component for organ SVG with interaction handling
64
+ */
65
+ export function OrganSvg({
66
+ organId,
67
+ config,
68
+ colorPalette,
69
+ isHovered,
70
+ isSelected,
71
+ onMouseEnter,
72
+ onMouseLeave,
73
+ onClick,
74
+ x,
75
+ y,
76
+ width,
77
+ height,
78
+ viewBox,
79
+ showOutline = false,
80
+ isVisible = true,
81
+ }: OrganSvgWrapperProps) {
82
+ const OrganComponent = ORGAN_COMPONENTS[organId];
83
+ const isActive = isHovered || isSelected;
84
+
85
+ // Compute the final style based on state and colorScheme
86
+ const computedStyle = useMemo((): OrganStyle => {
87
+ const userStyle = config?.style;
88
+ const strokeStyle = {
89
+ stroke: DEFAULT_STROKE_COLOR,
90
+ strokeWidth: showOutline ? DEFAULT_STROKE_WIDTH : 0,
91
+ };
92
+
93
+ return mergeStyles(userStyle, strokeStyle);
94
+ }, [config?.style, showOutline]);
95
+
96
+ const svgStyle: CSSProperties = {
97
+ position: "absolute",
98
+ left: x,
99
+ top: y,
100
+ transition: `${TRANSITION_STYLE}, visibility 0s`,
101
+ zIndex: isSelected ? 1 : 0,
102
+ };
103
+ const filter = isActive ? "blur(1px)" : undefined;
104
+
105
+ return (
106
+ <svg
107
+ width={width}
108
+ height={height}
109
+ viewBox={viewBox}
110
+ style={svgStyle}
111
+ filter={filter}
112
+ aria-label={organId}
113
+ overflow="visible"
114
+ pointerEvents="none"
115
+ opacity={isVisible ? 1 : 0}
116
+ visibility={isVisible ? "visible" : "hidden"}
117
+ >
118
+ <title>{organId}</title>
119
+ <g
120
+ role="menuitem"
121
+ onMouseEnter={onMouseEnter}
122
+ onMouseLeave={onMouseLeave}
123
+ onClick={onClick}
124
+ onKeyDown={(e) => {
125
+ if (e.key === "Enter" || e.key === " ") {
126
+ e.preventDefault();
127
+ onClick();
128
+ }
129
+ }}
130
+ cursor="pointer"
131
+ pointerEvents="auto"
132
+ >
133
+ <OrganComponent
134
+ isActive={isActive}
135
+ style={computedStyle}
136
+ colorScale={colorPalette[config?.colorName ?? DEFAULT_COLOR_NAME]}
137
+ />
138
+ </g>
139
+ </svg>
140
+ );
141
+ }
@@ -0,0 +1,146 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { DEFAULT_COLOR_PALETTE } from "../constants";
4
+ import { createStrictColorPalette } from "../lib";
5
+ import { OrganSvg } from "../OrganSvg";
6
+
7
+ const strictColorPalette = createStrictColorPalette(DEFAULT_COLOR_PALETTE);
8
+
9
+ describe("COLOR_PALETTES", () => {
10
+ it("should have blue, yellow, and gray color schemes", () => {
11
+ expect(DEFAULT_COLOR_PALETTE.blue).toBeDefined();
12
+ expect(DEFAULT_COLOR_PALETTE.yellow).toBeDefined();
13
+ expect(DEFAULT_COLOR_PALETTE.gray).toBeDefined();
14
+ });
15
+
16
+ it("each palette should have 100, 200, and 300 colors", () => {
17
+ for (const scheme of ["blue", "yellow", "gray"] as const) {
18
+ const palette = DEFAULT_COLOR_PALETTE[scheme];
19
+ expect(palette[100]).toBeDefined();
20
+ expect(palette[200]).toBeDefined();
21
+ expect(palette[300]).toBeDefined();
22
+ }
23
+ });
24
+ });
25
+
26
+ describe("OrganSvg", () => {
27
+ const defaultProps = {
28
+ organId: "heart" as const,
29
+ isHovered: false,
30
+ isSelected: false,
31
+ onMouseEnter: () => {},
32
+ onMouseLeave: () => {},
33
+ onClick: () => {},
34
+ x: 0,
35
+ y: 0,
36
+ width: 22,
37
+ height: 23,
38
+ viewBox: "0 0 22 23",
39
+ };
40
+
41
+ it("should render with default blue color scheme when no colorScheme is provided", () => {
42
+ render(
43
+ <svg>
44
+ <title>Heart</title>
45
+ <OrganSvg colorPalette={strictColorPalette} {...defaultProps} />
46
+ </svg>,
47
+ );
48
+
49
+ const organElement = screen.getByLabelText("heart");
50
+ expect(organElement).toBeInTheDocument();
51
+
52
+ // Check that the path has the blue base color
53
+ const path = organElement.querySelector("path");
54
+ expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.blue[200]);
55
+ });
56
+
57
+ it("should use yellow color scheme when specified", () => {
58
+ render(
59
+ <svg>
60
+ <title>Heart</title>
61
+ <OrganSvg
62
+ colorPalette={strictColorPalette}
63
+ {...defaultProps}
64
+ config={{ id: "heart", colorName: "yellow" }}
65
+ />
66
+ </svg>,
67
+ );
68
+
69
+ const organElement = screen.getByLabelText("heart");
70
+ const path = organElement.querySelector("path");
71
+ expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.yellow[200]);
72
+ });
73
+
74
+ it("should use gray color scheme when specified", () => {
75
+ render(
76
+ <svg>
77
+ <title>Heart</title>
78
+ <OrganSvg
79
+ colorPalette={strictColorPalette}
80
+ {...defaultProps}
81
+ config={{ id: "heart", colorName: "gray" }}
82
+ />
83
+ </svg>,
84
+ );
85
+
86
+ const organElement = screen.getByLabelText("heart");
87
+ const path = organElement.querySelector("path");
88
+ expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.gray[200]);
89
+ });
90
+
91
+ it("should use hover color when isHovered is true", () => {
92
+ render(
93
+ <svg>
94
+ <title>Heart</title>
95
+ <OrganSvg
96
+ colorPalette={strictColorPalette}
97
+ {...defaultProps}
98
+ isHovered={true}
99
+ config={{ id: "heart", colorName: "blue" }}
100
+ />
101
+ </svg>,
102
+ );
103
+
104
+ const organElement = screen.getByLabelText("heart");
105
+ const path = organElement.querySelector("path");
106
+ expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.blue[300]);
107
+ });
108
+
109
+ it("should use active color when isSelected is true", () => {
110
+ render(
111
+ <svg>
112
+ <title>Heart</title>
113
+ <OrganSvg
114
+ colorPalette={strictColorPalette}
115
+ {...defaultProps}
116
+ isSelected={true}
117
+ config={{ id: "heart", colorName: "yellow" }}
118
+ />
119
+ </svg>,
120
+ );
121
+
122
+ const organElement = screen.getByLabelText("heart");
123
+ const path = organElement.querySelector("path");
124
+ expect(path).toHaveAttribute("fill", DEFAULT_COLOR_PALETTE.yellow[300]);
125
+ });
126
+
127
+ it("should allow style override via config.style", () => {
128
+ const customFill = "#ff0000";
129
+ render(
130
+ <svg>
131
+ <title>Heart</title>
132
+ <OrganSvg
133
+ colorPalette={strictColorPalette}
134
+ {...defaultProps}
135
+ config={{ id: "heart", colorName: "blue", style: { fill: customFill } }}
136
+ />
137
+ </svg>,
138
+ );
139
+
140
+ const organElement = screen.getByLabelText("heart");
141
+ const path = organElement.querySelector("path");
142
+ // When not hovered/selected, user style is applied after base default
143
+ // But since it's merged, fill should still be customFill
144
+ expect(path).toHaveAttribute("fill", customFill);
145
+ });
146
+ });
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DEFAULT_COLOR_PALETTE } from "../constants";
3
+ import { createStrictColorPalette } from "../lib/createStrictColorPalette";
4
+ import type { ColorPalette, StrictColorPalette } from "../types";
5
+
6
+ describe("createStrictColorPalette", () => {
7
+ describe("when palette is undefined", () => {
8
+ it("should return StrictColorPalette with all colors from DEFAULT_COLOR_PALETTE", () => {
9
+ const result = createStrictColorPalette(undefined);
10
+
11
+ expect(result.blue[100]).toBe(DEFAULT_COLOR_PALETTE.blue[100]);
12
+ expect(result.blue[200]).toBe(DEFAULT_COLOR_PALETTE.blue[200]);
13
+ expect(result.blue[300]).toBe(DEFAULT_COLOR_PALETTE.blue[300]);
14
+ expect(result.yellow[100]).toBe(DEFAULT_COLOR_PALETTE.yellow[100]);
15
+ expect(result.gray[100]).toBe(DEFAULT_COLOR_PALETTE.gray[100]);
16
+ });
17
+
18
+ it("should generate alpha colors using color-mix", () => {
19
+ const result = createStrictColorPalette(undefined);
20
+
21
+ expect(result.blue.alpha[100]).toBe(
22
+ `color-mix(in srgb, ${DEFAULT_COLOR_PALETTE.blue[100]} 50%, transparent)`,
23
+ );
24
+ expect(result.blue.alpha[200]).toBe(
25
+ `color-mix(in srgb, ${DEFAULT_COLOR_PALETTE.blue[200]} 50%, transparent)`,
26
+ );
27
+ expect(result.blue.alpha[300]).toBe(
28
+ `color-mix(in srgb, ${DEFAULT_COLOR_PALETTE.blue[300]} 50%, transparent)`,
29
+ );
30
+ });
31
+ });
32
+
33
+ describe("when palette is empty object", () => {
34
+ it("should return StrictColorPalette with all colors from DEFAULT_COLOR_PALETTE", () => {
35
+ const result = createStrictColorPalette({});
36
+
37
+ expect(result.blue[100]).toBe(DEFAULT_COLOR_PALETTE.blue[100]);
38
+ expect(result.yellow[200]).toBe(DEFAULT_COLOR_PALETTE.yellow[200]);
39
+ expect(result.gray[300]).toBe(DEFAULT_COLOR_PALETTE.gray[300]);
40
+ });
41
+ });
42
+
43
+ describe("when palette has partial colors", () => {
44
+ it("should use provided colors and fallback to DEFAULT for missing ones", () => {
45
+ const customBlue = {
46
+ 100: "#custom100",
47
+ 200: "#custom200",
48
+ 300: "#custom300",
49
+ };
50
+ const palette: Partial<ColorPalette> = {
51
+ blue: customBlue,
52
+ };
53
+
54
+ const result = createStrictColorPalette(palette);
55
+
56
+ // Custom blue colors should be used
57
+ expect(result.blue[100]).toBe("#custom100");
58
+ expect(result.blue[200]).toBe("#custom200");
59
+ expect(result.blue[300]).toBe("#custom300");
60
+
61
+ // Missing yellow and gray should fallback to DEFAULT
62
+ expect(result.yellow[100]).toBe(DEFAULT_COLOR_PALETTE.yellow[100]);
63
+ expect(result.gray[100]).toBe(DEFAULT_COLOR_PALETTE.gray[100]);
64
+ });
65
+
66
+ it("should generate alpha for custom colors using color-mix", () => {
67
+ const customBlue = {
68
+ 100: "#custom100",
69
+ 200: "#custom200",
70
+ 300: "#custom300",
71
+ };
72
+ const palette: Partial<ColorPalette> = {
73
+ blue: customBlue,
74
+ };
75
+
76
+ const result = createStrictColorPalette(palette);
77
+
78
+ expect(result.blue.alpha[100]).toBe("color-mix(in srgb, #custom100 50%, transparent)");
79
+ expect(result.blue.alpha[200]).toBe("color-mix(in srgb, #custom200 50%, transparent)");
80
+ });
81
+ });
82
+
83
+ describe("when palette has alpha colors", () => {
84
+ it("should use provided alpha colors instead of generating", () => {
85
+ const customAlpha = {
86
+ 100: "#alpha100",
87
+ 200: "#alpha200",
88
+ 300: "#alpha300",
89
+ };
90
+ const palette: Partial<ColorPalette> = {
91
+ blue: {
92
+ 100: "#blue100",
93
+ 200: "#blue200",
94
+ 300: "#blue300",
95
+ alpha: customAlpha,
96
+ },
97
+ };
98
+
99
+ const result = createStrictColorPalette(palette);
100
+
101
+ expect(result.blue.alpha[100]).toBe("#alpha100");
102
+ expect(result.blue.alpha[200]).toBe("#alpha200");
103
+ expect(result.blue.alpha[300]).toBe("#alpha300");
104
+ });
105
+ });
106
+
107
+ describe("return type validation", () => {
108
+ it("should return a valid StrictColorPalette", () => {
109
+ const result = createStrictColorPalette(undefined);
110
+
111
+ // Check that all required properties exist
112
+ const colorNames = ["blue", "yellow", "gray"] as const;
113
+ const steps = [100, 200, 300] as const;
114
+
115
+ for (const colorName of colorNames) {
116
+ expect(result[colorName]).toBeDefined();
117
+
118
+ for (const step of steps) {
119
+ expect(result[colorName][step]).toBeDefined();
120
+ expect(typeof result[colorName][step]).toBe("string");
121
+ }
122
+
123
+ expect(result[colorName].alpha).toBeDefined();
124
+ for (const step of steps) {
125
+ expect(result[colorName].alpha[step]).toBeDefined();
126
+ expect(typeof result[colorName].alpha[step]).toBe("string");
127
+ }
128
+ }
129
+
130
+ // Type check - this should compile without errors
131
+ const _typeCheck: StrictColorPalette = result;
132
+ expect(_typeCheck).toBeDefined();
133
+ });
134
+ });
135
+ });