@pixel-point/toolcraft 0.0.2

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 (257) hide show
  1. package/LICENSE.md +98 -0
  2. package/README.md +41 -0
  3. package/bin/create-toolcraft-app.mjs +8 -0
  4. package/bin/toolcraft.mjs +8 -0
  5. package/package.json +24 -0
  6. package/scripts/prepare-pack.mjs +29 -0
  7. package/src/cli.mjs +392 -0
  8. package/src/cli.test.mjs +284 -0
  9. package/src/copy-recursive.mjs +86 -0
  10. package/src/generate.mjs +212 -0
  11. package/src/generate.test.mjs +322 -0
  12. package/src/import-map.mjs +14 -0
  13. package/src/package-json.mjs +80 -0
  14. package/src/package-json.test.mjs +67 -0
  15. package/src/rewrite-imports.mjs +85 -0
  16. package/src/rewrite-imports.test.mjs +58 -0
  17. package/templates/runtime/contracts/component-contracts.test.ts +1165 -0
  18. package/templates/runtime/contracts/component-contracts.ts +1340 -0
  19. package/templates/runtime/contracts/decision-contracts.test.ts +206 -0
  20. package/templates/runtime/contracts/decision-contracts.ts +283 -0
  21. package/templates/runtime/contracts/index.test.ts +14 -0
  22. package/templates/runtime/contracts/index.ts +3 -0
  23. package/templates/runtime/contracts/types.ts +56 -0
  24. package/templates/runtime/export/export.test.ts +203 -0
  25. package/templates/runtime/export/export.ts +132 -0
  26. package/templates/runtime/export/index.ts +1 -0
  27. package/templates/runtime/index.ts +14 -0
  28. package/templates/runtime/react/canvas-shell.test.tsx +424 -0
  29. package/templates/runtime/react/canvas-shell.tsx +408 -0
  30. package/templates/runtime/react/control-renderers.ts +31 -0
  31. package/templates/runtime/react/controls-panel.test.tsx +3736 -0
  32. package/templates/runtime/react/controls-panel.tsx +2327 -0
  33. package/templates/runtime/react/curve-geometry.test.ts +70 -0
  34. package/templates/runtime/react/index.ts +15 -0
  35. package/templates/runtime/react/layer-tree.ts +96 -0
  36. package/templates/runtime/react/layers-panel.test.tsx +487 -0
  37. package/templates/runtime/react/layers-panel.tsx +1348 -0
  38. package/templates/runtime/react/media-file.ts +82 -0
  39. package/templates/runtime/react/panel-host-config.ts +80 -0
  40. package/templates/runtime/react/panel-host-geometry.test.ts +66 -0
  41. package/templates/runtime/react/panel-host-geometry.ts +109 -0
  42. package/templates/runtime/react/panel-host-types.ts +74 -0
  43. package/templates/runtime/react/panel-host.test.tsx +102 -0
  44. package/templates/runtime/react/panel-host.tsx +353 -0
  45. package/templates/runtime/react/runtime-public-api.test.tsx +132 -0
  46. package/templates/runtime/react/settings-transfer.test.ts +150 -0
  47. package/templates/runtime/react/settings-transfer.ts +279 -0
  48. package/templates/runtime/react/storage-key-migration.ts +48 -0
  49. package/templates/runtime/react/theme-runtime.tsx +177 -0
  50. package/templates/runtime/react/timeline-panel.test.tsx +668 -0
  51. package/templates/runtime/react/timeline-panel.tsx +2953 -0
  52. package/templates/runtime/react/toolbar-panel.test.tsx +212 -0
  53. package/templates/runtime/react/toolbar-panel.tsx +205 -0
  54. package/templates/runtime/react/toolcraft-app.integration.test.tsx +350 -0
  55. package/templates/runtime/react/toolcraft-app.test.tsx +339 -0
  56. package/templates/runtime/react/toolcraft-app.tsx +81 -0
  57. package/templates/runtime/react/toolcraft-root.test.tsx +347 -0
  58. package/templates/runtime/react/toolcraft-root.tsx +203 -0
  59. package/templates/runtime/react/use-toolcraft.ts +41 -0
  60. package/templates/runtime/schema/define-toolcraft.test.ts +1524 -0
  61. package/templates/runtime/schema/define-toolcraft.ts +1442 -0
  62. package/templates/runtime/schema/keyframe-capability.test.ts +90 -0
  63. package/templates/runtime/schema/keyframe-capability.ts +51 -0
  64. package/templates/runtime/schema/runtime-targets.ts +40 -0
  65. package/templates/runtime/schema/types.ts +370 -0
  66. package/templates/runtime/state/canvas-zoom.ts +8 -0
  67. package/templates/runtime/state/create-template-state.test.ts +242 -0
  68. package/templates/runtime/state/create-template-state.ts +95 -0
  69. package/templates/runtime/state/keyframe-evaluation.test.ts +141 -0
  70. package/templates/runtime/state/keyframe-evaluation.ts +203 -0
  71. package/templates/runtime/state/persistence.test.ts +217 -0
  72. package/templates/runtime/state/persistence.ts +511 -0
  73. package/templates/runtime/state/reducer.test.ts +937 -0
  74. package/templates/runtime/state/reducer.ts +1212 -0
  75. package/templates/runtime/state/timeline-readiness.ts +43 -0
  76. package/templates/runtime/state/types.ts +242 -0
  77. package/templates/runtime/styles.css +125 -0
  78. package/templates/runtime/testing/performance.test.ts +1058 -0
  79. package/templates/runtime/testing/performance.ts +1078 -0
  80. package/templates/starter/AGENTS.md +186 -0
  81. package/templates/starter/LICENSE.md +98 -0
  82. package/templates/starter/NOTICE.md +8 -0
  83. package/templates/starter/docs/toolcraft/README.md +41 -0
  84. package/templates/starter/docs/toolcraft/acceptance-testing.md +205 -0
  85. package/templates/starter/docs/toolcraft/agent-worklog.md +81 -0
  86. package/templates/starter/docs/toolcraft/assembly-workflow.md +206 -0
  87. package/templates/starter/docs/toolcraft/component-rules.md +299 -0
  88. package/templates/starter/docs/toolcraft/custom-controls.md +71 -0
  89. package/templates/starter/docs/toolcraft/decision-contract.md +71 -0
  90. package/templates/starter/docs/toolcraft/performance.md +112 -0
  91. package/templates/starter/docs/toolcraft/renderer-technique.md +48 -0
  92. package/templates/starter/docs/toolcraft/schema-reference.md +265 -0
  93. package/templates/starter/docs/toolcraft/workflow.md +87 -0
  94. package/templates/starter/e2e/app-browser-acceptance.spec.ts +785 -0
  95. package/templates/starter/e2e/app-controls.spec.ts +41 -0
  96. package/templates/starter/e2e/app-performance.spec.ts +326 -0
  97. package/templates/starter/e2e/canvas-handle-helpers.ts +244 -0
  98. package/templates/starter/e2e/performance-helpers.ts +612 -0
  99. package/templates/starter/e2e/product-observable-helpers.ts +170 -0
  100. package/templates/starter/index.html +12 -0
  101. package/templates/starter/package.json +52 -0
  102. package/templates/starter/playwright.config.ts +43 -0
  103. package/templates/starter/scripts/check-ai-skills.mjs +95 -0
  104. package/templates/starter/scripts/check-toolcraft-docs.mjs +159 -0
  105. package/templates/starter/scripts/check-toolcraft-integrity.mjs +232 -0
  106. package/templates/starter/scripts/run-vite-on-free-port.mjs +48 -0
  107. package/templates/starter/scripts/toolcraft-port.mjs +54 -0
  108. package/templates/starter/scripts/toolcraft-port.test.mjs +73 -0
  109. package/templates/starter/src/app/starter-acceptance.test.ts +5959 -0
  110. package/templates/starter/src/app/starter-acceptance.ts +2646 -0
  111. package/templates/starter/src/app/starter-performance.test.ts +1390 -0
  112. package/templates/starter/src/app/starter-performance.ts +12 -0
  113. package/templates/starter/src/app/starter-schema.test.ts +70 -0
  114. package/templates/starter/src/app/starter-schema.ts +15 -0
  115. package/templates/starter/src/main.tsx +18 -0
  116. package/templates/starter/src/router.tsx +16 -0
  117. package/templates/starter/src/routes/index.tsx +7 -0
  118. package/templates/starter/src/routes/root.tsx +19 -0
  119. package/templates/starter/src/styles.css +120 -0
  120. package/templates/starter/tsconfig.json +11 -0
  121. package/templates/starter/vite.config.ts +13 -0
  122. package/templates/ui/components/composites/accordion.tsx +73 -0
  123. package/templates/ui/components/composites/alert-dialog.tsx +190 -0
  124. package/templates/ui/components/composites/alert.tsx +74 -0
  125. package/templates/ui/components/composites/aspect-ratio.tsx +22 -0
  126. package/templates/ui/components/composites/avatar.tsx +98 -0
  127. package/templates/ui/components/composites/badge.tsx +69 -0
  128. package/templates/ui/components/composites/breadcrumb.tsx +106 -0
  129. package/templates/ui/components/composites/card.tsx +91 -0
  130. package/templates/ui/components/composites/combobox.tsx +486 -0
  131. package/templates/ui/components/composites/command.tsx +296 -0
  132. package/templates/ui/components/composites/context-menu.tsx +247 -0
  133. package/templates/ui/components/composites/dialog.tsx +282 -0
  134. package/templates/ui/components/composites/dropdown-menu.tsx +299 -0
  135. package/templates/ui/components/composites/empty.tsx +110 -0
  136. package/templates/ui/components/composites/hover-card.tsx +44 -0
  137. package/templates/ui/components/composites/index.ts +30 -0
  138. package/templates/ui/components/composites/menubar.tsx +214 -0
  139. package/templates/ui/components/composites/navigation-menu.tsx +167 -0
  140. package/templates/ui/components/composites/pagination.tsx +131 -0
  141. package/templates/ui/components/composites/progress.tsx +72 -0
  142. package/templates/ui/components/composites/radio-group.tsx +84 -0
  143. package/templates/ui/components/composites/resizable.tsx +42 -0
  144. package/templates/ui/components/composites/sheet.tsx +153 -0
  145. package/templates/ui/components/composites/sidebar-structural.tsx +310 -0
  146. package/templates/ui/components/composites/sidebar.tsx +431 -0
  147. package/templates/ui/components/composites/sonner.tsx +35 -0
  148. package/templates/ui/components/composites/spinner.tsx +43 -0
  149. package/templates/ui/components/composites/table.tsx +108 -0
  150. package/templates/ui/components/composites/tabs.tsx +83 -0
  151. package/templates/ui/components/control-layout/index.tsx +437 -0
  152. package/templates/ui/components/controls/actions/actions-control.tsx +139 -0
  153. package/templates/ui/components/controls/actions/index.ts +9 -0
  154. package/templates/ui/components/controls/anchor-grid/anchor-grid-control.tsx +107 -0
  155. package/templates/ui/components/controls/anchor-grid/index.ts +4 -0
  156. package/templates/ui/components/controls/boolean/boolean-controls.tsx +79 -0
  157. package/templates/ui/components/controls/boolean/index.ts +4 -0
  158. package/templates/ui/components/controls/channel-mixer/channel-mixer-control.tsx +95 -0
  159. package/templates/ui/components/controls/channel-mixer/index.ts +4 -0
  160. package/templates/ui/components/controls/channel-tabs/channel-tabs.tsx +42 -0
  161. package/templates/ui/components/controls/channel-tabs/index.ts +6 -0
  162. package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +90 -0
  163. package/templates/ui/components/controls/code-textarea/index.ts +4 -0
  164. package/templates/ui/components/controls/color/color-control.tsx +571 -0
  165. package/templates/ui/components/controls/color/color-picker-popover.tsx +104 -0
  166. package/templates/ui/components/controls/color/index.ts +41 -0
  167. package/templates/ui/components/controls/color/palette-control-data.ts +436 -0
  168. package/templates/ui/components/controls/color/palette-control.tsx +535 -0
  169. package/templates/ui/components/controls/color/style-guide-color-picker-channel-utils.ts +162 -0
  170. package/templates/ui/components/controls/color/style-guide-color-picker-interactions.ts +190 -0
  171. package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +485 -0
  172. package/templates/ui/components/controls/color/style-guide-color-picker-parts.tsx +710 -0
  173. package/templates/ui/components/controls/color/style-guide-color-picker.tsx +503 -0
  174. package/templates/ui/components/controls/control-types.ts +43 -0
  175. package/templates/ui/components/controls/curves/curve-geometry.ts +355 -0
  176. package/templates/ui/components/controls/curves/curve-graph.tsx +390 -0
  177. package/templates/ui/components/controls/curves/curves-control.tsx +445 -0
  178. package/templates/ui/components/controls/curves/index.ts +6 -0
  179. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +191 -0
  180. package/templates/ui/components/controls/file-drop/index.ts +5 -0
  181. package/templates/ui/components/controls/font-picker/font-catalog.json +15360 -0
  182. package/templates/ui/components/controls/font-picker/font-catalog.ts +116 -0
  183. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1202 -0
  184. package/templates/ui/components/controls/font-picker/font-preview-loader.ts +336 -0
  185. package/templates/ui/components/controls/font-picker/index.ts +24 -0
  186. package/templates/ui/components/controls/font-picker/use-hover-intent.ts +46 -0
  187. package/templates/ui/components/controls/gradient/gradient-control-utils.ts +190 -0
  188. package/templates/ui/components/controls/gradient/gradient-control.tsx +612 -0
  189. package/templates/ui/components/controls/gradient/gradient-stop-list.tsx +400 -0
  190. package/templates/ui/components/controls/gradient/gradient-toolbar.tsx +152 -0
  191. package/templates/ui/components/controls/gradient/index.ts +4 -0
  192. package/templates/ui/components/controls/image-picker/image-picker-control.tsx +139 -0
  193. package/templates/ui/components/controls/image-picker/index.ts +7 -0
  194. package/templates/ui/components/controls/index.ts +192 -0
  195. package/templates/ui/components/controls/range-input/index.ts +4 -0
  196. package/templates/ui/components/controls/range-input/range-input-control.tsx +173 -0
  197. package/templates/ui/components/controls/range-slider/index.ts +4 -0
  198. package/templates/ui/components/controls/range-slider/range-slider-control.tsx +122 -0
  199. package/templates/ui/components/controls/range-slider/range-slider-value.ts +61 -0
  200. package/templates/ui/components/controls/segmented/index.ts +8 -0
  201. package/templates/ui/components/controls/segmented/segmented-control.tsx +94 -0
  202. package/templates/ui/components/controls/select/index.ts +4 -0
  203. package/templates/ui/components/controls/select/select-control.tsx +223 -0
  204. package/templates/ui/components/controls/slider/index.ts +4 -0
  205. package/templates/ui/components/controls/slider/slider-control.tsx +150 -0
  206. package/templates/ui/components/controls/slider/slider-value.ts +56 -0
  207. package/templates/ui/components/controls/text-input/index.ts +4 -0
  208. package/templates/ui/components/controls/text-input/text-input-control.tsx +158 -0
  209. package/templates/ui/components/controls/use-measured-element-width.ts +42 -0
  210. package/templates/ui/components/controls/vector/index.ts +8 -0
  211. package/templates/ui/components/controls/vector/vector-control.tsx +401 -0
  212. package/templates/ui/components/panel/index.ts +19 -0
  213. package/templates/ui/components/panel/panel-actions.tsx +165 -0
  214. package/templates/ui/components/panel/panel-header.tsx +61 -0
  215. package/templates/ui/components/panel/panel-icon-button.tsx +96 -0
  216. package/templates/ui/components/panel/panel-section.tsx +168 -0
  217. package/templates/ui/components/panel/panel-surface.tsx +206 -0
  218. package/templates/ui/components/panel/panel.tsx +210 -0
  219. package/templates/ui/components/primitives/animated-loader.tsx +61 -0
  220. package/templates/ui/components/primitives/button-group.tsx +134 -0
  221. package/templates/ui/components/primitives/button.tsx +429 -0
  222. package/templates/ui/components/primitives/checkbox.tsx +62 -0
  223. package/templates/ui/components/primitives/editable-slider-value-label.tsx +337 -0
  224. package/templates/ui/components/primitives/field.tsx +225 -0
  225. package/templates/ui/components/primitives/index.ts +82 -0
  226. package/templates/ui/components/primitives/input-group.tsx +298 -0
  227. package/templates/ui/components/primitives/input.tsx +61 -0
  228. package/templates/ui/components/primitives/internal/button-loading.tsx +178 -0
  229. package/templates/ui/components/primitives/label.tsx +16 -0
  230. package/templates/ui/components/primitives/popover.tsx +126 -0
  231. package/templates/ui/components/primitives/portal-layer-context.tsx +33 -0
  232. package/templates/ui/components/primitives/primitive-arrow-icon.tsx +38 -0
  233. package/templates/ui/components/primitives/scroll-fade-logic.ts +441 -0
  234. package/templates/ui/components/primitives/scroll-fade-render.tsx +75 -0
  235. package/templates/ui/components/primitives/scroll-fade-types.ts +41 -0
  236. package/templates/ui/components/primitives/scroll-fade.tsx +72 -0
  237. package/templates/ui/components/primitives/select.tsx +408 -0
  238. package/templates/ui/components/primitives/selection-state.ts +31 -0
  239. package/templates/ui/components/primitives/separator.tsx +21 -0
  240. package/templates/ui/components/primitives/slider/index.ts +4 -0
  241. package/templates/ui/components/primitives/slider/slider-interaction.tsx +96 -0
  242. package/templates/ui/components/primitives/slider/slider-parts.tsx +303 -0
  243. package/templates/ui/components/primitives/slider/slider-reset.ts +152 -0
  244. package/templates/ui/components/primitives/slider/slider-value.ts +114 -0
  245. package/templates/ui/components/primitives/slider/slider.tsx +511 -0
  246. package/templates/ui/components/primitives/switch.tsx +35 -0
  247. package/templates/ui/components/primitives/textarea.tsx +49 -0
  248. package/templates/ui/components/primitives/toggle-group.tsx +114 -0
  249. package/templates/ui/components/primitives/toggle.tsx +46 -0
  250. package/templates/ui/components/primitives/tooltip.tsx +100 -0
  251. package/templates/ui/hooks/use-mobile.ts +21 -0
  252. package/templates/ui/index.ts +31 -0
  253. package/templates/ui/lib/control-outline.ts +3 -0
  254. package/templates/ui/lib/input-control-style.ts +131 -0
  255. package/templates/ui/lib/style-guide-color-utils.ts +111 -0
  256. package/templates/ui/lib/utils.ts +6 -0
  257. package/templates/ui/styles.css +291 -0
@@ -0,0 +1,535 @@
1
+ "use client";
2
+
3
+ import * as React from "react";
4
+
5
+ import { cn } from "../../../lib/utils";
6
+ import {
7
+ PALETTE_SHADE_STEPS,
8
+ STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS,
9
+ TAILWIND_COLOR_PALETTE,
10
+ getPaletteHex,
11
+ type PaletteColorFamily,
12
+ type PaletteControlValue,
13
+ type PaletteShadeStep,
14
+ } from "./palette-control-data";
15
+
16
+ export {
17
+ PALETTE_SHADE_STEPS,
18
+ STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS,
19
+ TAILWIND_COLOR_PALETTE,
20
+ getPaletteHex,
21
+ };
22
+ export type { PaletteColorFamily, PaletteControlValue, PaletteShadeStep };
23
+
24
+ export type PaletteControlChangeMeta = {
25
+ stage: "live" | "commit";
26
+ hex: string;
27
+ };
28
+
29
+ export type PaletteControlProps = {
30
+ value?: PaletteControlValue;
31
+ defaultValue?: PaletteControlValue;
32
+ disabled?: boolean;
33
+ ariaLabel?: string;
34
+ title?: string;
35
+ variant?: "popover" | "panel";
36
+ className?: string;
37
+ onValueChange?: (
38
+ nextValue: PaletteControlValue,
39
+ meta: PaletteControlChangeMeta,
40
+ ) => void;
41
+ onCommit?: (nextValue: PaletteControlValue, hex: string) => void;
42
+ onInteractionStateChange?: (isInteracting: boolean) => void;
43
+ };
44
+
45
+ const PALETTE_COLUMNS = 5;
46
+ const PALETTE_CELL_SIZE = 28;
47
+ const PALETTE_GAP = 12;
48
+ const SHADE_RAIL_WIDTH = 20;
49
+ const CLICK_COMMIT_IDLE_MS = 250;
50
+ const PERSIST_SETTLE_MS = 160;
51
+ const DEFAULT_PALETTE_CONTROL_VALUE: PaletteControlValue = {
52
+ family: "Amber",
53
+ shade: "500",
54
+ };
55
+
56
+ function valuesEqual(left: PaletteControlValue, right: PaletteControlValue) {
57
+ return left.family === right.family && left.shade === right.shade;
58
+ }
59
+
60
+ function clamp(value: number, min: number, max: number) {
61
+ return Math.min(max, Math.max(min, value));
62
+ }
63
+
64
+ function getShadeIndicatorTopPercent(shade: PaletteShadeStep) {
65
+ const index = PALETTE_SHADE_STEPS.indexOf(shade);
66
+
67
+ return Math.max(0, index) * (100 / PALETTE_SHADE_STEPS.length);
68
+ }
69
+
70
+ export function PaletteControl({
71
+ value,
72
+ defaultValue = DEFAULT_PALETTE_CONTROL_VALUE,
73
+ disabled = false,
74
+ ariaLabel = "Primary color palette",
75
+ title = "Color palette",
76
+ variant = "panel",
77
+ className,
78
+ onValueChange,
79
+ onCommit,
80
+ onInteractionStateChange,
81
+ }: PaletteControlProps): React.JSX.Element {
82
+ const initialValue = value ?? defaultValue;
83
+ const [optimisticValue, setOptimisticValue] =
84
+ React.useState<PaletteControlValue>(initialValue);
85
+ const [isShadeDragging, setIsShadeDragging] = React.useState(false);
86
+ const [indicatorTopPercent, setIndicatorTopPercent] = React.useState(() =>
87
+ getShadeIndicatorTopPercent(initialValue.shade),
88
+ );
89
+ const optimisticValueRef = React.useRef(initialValue);
90
+ const pendingCommitRef = React.useRef<PaletteControlValue | null>(null);
91
+ const pendingPersistRef = React.useRef<PaletteControlValue | null>(null);
92
+ const clickCommitTimeoutRef = React.useRef<number | null>(null);
93
+ const persistTimeoutRef = React.useRef<number | null>(null);
94
+ const isInteractingRef = React.useRef(false);
95
+ const shadeTrackRef = React.useRef<HTMLDivElement | null>(null);
96
+ const onValueChangeRef = React.useRef(onValueChange);
97
+ const onCommitRef = React.useRef(onCommit);
98
+ const onInteractionStateChangeRef = React.useRef(onInteractionStateChange);
99
+
100
+ const paletteRows = Math.ceil(
101
+ STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS.length / PALETTE_COLUMNS,
102
+ );
103
+ const paletteBlockHeight =
104
+ paletteRows * PALETTE_CELL_SIZE + Math.max(0, paletteRows - 1) * PALETTE_GAP;
105
+
106
+ const activePalette =
107
+ TAILWIND_COLOR_PALETTE.find(
108
+ (palette) => palette.name === optimisticValue.family,
109
+ ) ?? TAILWIND_COLOR_PALETTE[0];
110
+ const shadeSegmentPercent = 100 / PALETTE_SHADE_STEPS.length;
111
+
112
+ React.useEffect(() => {
113
+ onValueChangeRef.current = onValueChange;
114
+ }, [onValueChange]);
115
+
116
+ React.useEffect(() => {
117
+ onCommitRef.current = onCommit;
118
+ }, [onCommit]);
119
+
120
+ React.useEffect(() => {
121
+ onInteractionStateChangeRef.current = onInteractionStateChange;
122
+ }, [onInteractionStateChange]);
123
+
124
+ const setInteractionState = React.useCallback((nextIsInteracting: boolean) => {
125
+ if (isInteractingRef.current === nextIsInteracting) {
126
+ return;
127
+ }
128
+
129
+ isInteractingRef.current = nextIsInteracting;
130
+ onInteractionStateChangeRef.current?.(nextIsInteracting);
131
+ }, []);
132
+
133
+ const clearClickCommitTimeout = React.useCallback(() => {
134
+ if (clickCommitTimeoutRef.current === null) {
135
+ return;
136
+ }
137
+
138
+ window.clearTimeout(clickCommitTimeoutRef.current);
139
+ clickCommitTimeoutRef.current = null;
140
+ }, []);
141
+
142
+ const clearPersistTimeout = React.useCallback(() => {
143
+ if (persistTimeoutRef.current === null) {
144
+ return;
145
+ }
146
+
147
+ window.clearTimeout(persistTimeoutRef.current);
148
+ persistTimeoutRef.current = null;
149
+ }, []);
150
+
151
+ const emitChange = React.useCallback(
152
+ (nextValue: PaletteControlValue, stage: PaletteControlChangeMeta["stage"]) => {
153
+ const hex = getPaletteHex(nextValue);
154
+ onValueChangeRef.current?.(nextValue, { stage, hex });
155
+ },
156
+ [],
157
+ );
158
+
159
+ const syncOptimisticValue = React.useCallback((nextValue: PaletteControlValue) => {
160
+ optimisticValueRef.current = nextValue;
161
+ setOptimisticValue(nextValue);
162
+ }, []);
163
+
164
+ const flushPendingPersist = React.useCallback(
165
+ (options?: { immediate?: boolean }) => {
166
+ const pendingPersist = pendingPersistRef.current;
167
+
168
+ if (!pendingPersist) {
169
+ return false;
170
+ }
171
+
172
+ clearPersistTimeout();
173
+
174
+ if (options?.immediate) {
175
+ pendingPersistRef.current = null;
176
+ onCommitRef.current?.(pendingPersist, getPaletteHex(pendingPersist));
177
+ setInteractionState(false);
178
+ return true;
179
+ }
180
+
181
+ persistTimeoutRef.current = window.setTimeout(() => {
182
+ persistTimeoutRef.current = null;
183
+ const nextPersist = pendingPersistRef.current;
184
+ pendingPersistRef.current = null;
185
+
186
+ if (!nextPersist) {
187
+ setInteractionState(false);
188
+ return;
189
+ }
190
+
191
+ onCommitRef.current?.(nextPersist, getPaletteHex(nextPersist));
192
+ setInteractionState(false);
193
+ }, PERSIST_SETTLE_MS);
194
+
195
+ return true;
196
+ },
197
+ [clearPersistTimeout, setInteractionState],
198
+ );
199
+
200
+ const flushPendingCommit = React.useCallback((options?: { persistImmediately?: boolean }) => {
201
+ const pendingCommit = pendingCommitRef.current;
202
+ clearClickCommitTimeout();
203
+
204
+ if (!pendingCommit) {
205
+ if (!pendingPersistRef.current) {
206
+ setInteractionState(false);
207
+ }
208
+ return false;
209
+ }
210
+
211
+ pendingCommitRef.current = null;
212
+ emitChange(pendingCommit, "commit");
213
+ pendingPersistRef.current = pendingCommit;
214
+ flushPendingPersist({ immediate: options?.persistImmediately });
215
+
216
+ return true;
217
+ }, [clearClickCommitTimeout, emitChange, flushPendingPersist, setInteractionState]);
218
+
219
+ const scheduleClickCommit = React.useCallback(
220
+ (nextValue: PaletteControlValue) => {
221
+ pendingCommitRef.current = nextValue;
222
+ clearClickCommitTimeout();
223
+ clearPersistTimeout();
224
+ pendingPersistRef.current = null;
225
+ setInteractionState(true);
226
+ clickCommitTimeoutRef.current = window.setTimeout(() => {
227
+ flushPendingCommit();
228
+ }, CLICK_COMMIT_IDLE_MS);
229
+ },
230
+ [clearClickCommitTimeout, clearPersistTimeout, flushPendingCommit, setInteractionState],
231
+ );
232
+
233
+ const applyLiveSelection = React.useCallback(
234
+ (nextValue: PaletteControlValue, source: "click" | "drag") => {
235
+ if (disabled || valuesEqual(nextValue, optimisticValueRef.current)) {
236
+ return null;
237
+ }
238
+
239
+ syncOptimisticValue(nextValue);
240
+ emitChange(nextValue, "live");
241
+
242
+ if (source === "click") {
243
+ scheduleClickCommit(nextValue);
244
+ } else {
245
+ clearClickCommitTimeout();
246
+ clearPersistTimeout();
247
+ pendingPersistRef.current = null;
248
+ pendingCommitRef.current = nextValue;
249
+ setInteractionState(true);
250
+ }
251
+
252
+ return getPaletteHex(nextValue);
253
+ },
254
+ [
255
+ clearClickCommitTimeout,
256
+ clearPersistTimeout,
257
+ disabled,
258
+ emitChange,
259
+ scheduleClickCommit,
260
+ setInteractionState,
261
+ syncOptimisticValue,
262
+ ],
263
+ );
264
+
265
+ const updateDraggedShade = React.useCallback(
266
+ (clientY: number) => {
267
+ const trackBounds = shadeTrackRef.current?.getBoundingClientRect();
268
+
269
+ if (!trackBounds || trackBounds.height === 0) {
270
+ return;
271
+ }
272
+
273
+ const segmentHeight = trackBounds.height / PALETTE_SHADE_STEPS.length;
274
+ const maxTop = trackBounds.height - segmentHeight;
275
+ const nextTop = clamp(
276
+ clientY - trackBounds.top - segmentHeight / 2,
277
+ 0,
278
+ maxTop,
279
+ );
280
+ const nextIndex = clamp(
281
+ Math.round(nextTop / segmentHeight),
282
+ 0,
283
+ PALETTE_SHADE_STEPS.length - 1,
284
+ );
285
+ const nextShade = PALETTE_SHADE_STEPS[nextIndex]!;
286
+
287
+ setIndicatorTopPercent((nextTop / trackBounds.height) * 100);
288
+ applyLiveSelection(
289
+ {
290
+ family: optimisticValueRef.current.family,
291
+ shade: nextShade,
292
+ },
293
+ "drag",
294
+ );
295
+ },
296
+ [applyLiveSelection],
297
+ );
298
+
299
+ React.useEffect(() => {
300
+ if (isShadeDragging) {
301
+ return;
302
+ }
303
+
304
+ setIndicatorTopPercent(getShadeIndicatorTopPercent(optimisticValue.shade));
305
+ }, [isShadeDragging, optimisticValue.shade]);
306
+
307
+ React.useEffect(() => {
308
+ if (!value) {
309
+ return;
310
+ }
311
+
312
+ if (isShadeDragging || pendingCommitRef.current) {
313
+ return;
314
+ }
315
+
316
+ syncOptimisticValue(value);
317
+ }, [isShadeDragging, syncOptimisticValue, value]);
318
+
319
+ React.useEffect(() => {
320
+ if (!isShadeDragging) {
321
+ return;
322
+ }
323
+
324
+ const handlePointerMove = (event: PointerEvent) => {
325
+ updateDraggedShade(event.clientY);
326
+ };
327
+
328
+ const handlePointerFinish = () => {
329
+ setIsShadeDragging(false);
330
+ flushPendingCommit();
331
+ };
332
+
333
+ window.addEventListener("pointermove", handlePointerMove);
334
+ window.addEventListener("pointerup", handlePointerFinish);
335
+ window.addEventListener("pointercancel", handlePointerFinish);
336
+
337
+ return () => {
338
+ window.removeEventListener("pointermove", handlePointerMove);
339
+ window.removeEventListener("pointerup", handlePointerFinish);
340
+ window.removeEventListener("pointercancel", handlePointerFinish);
341
+ };
342
+ }, [flushPendingCommit, isShadeDragging, updateDraggedShade]);
343
+
344
+ React.useEffect(() => {
345
+ return () => {
346
+ const didCommit = flushPendingCommit({ persistImmediately: true });
347
+ if (!didCommit) {
348
+ flushPendingPersist({ immediate: true });
349
+ }
350
+ };
351
+ }, [flushPendingCommit, flushPendingPersist]);
352
+
353
+ const paletteGrid = (
354
+ <div
355
+ className="grid content-start gap-x-3 gap-y-3"
356
+ style={{
357
+ gridAutoRows: `${PALETTE_CELL_SIZE}px`,
358
+ gridTemplateColumns: `repeat(${PALETTE_COLUMNS}, ${PALETTE_CELL_SIZE}px)`,
359
+ }}
360
+ >
361
+ {STYLE_GUIDE_PRIMARY_FAMILY_OPTIONS.map((palette) => {
362
+ const isSelected = palette.name === optimisticValue.family;
363
+
364
+ return (
365
+ <button
366
+ key={palette.name}
367
+ type="button"
368
+ aria-label={`Primary family ${palette.name}`}
369
+ aria-pressed={isSelected}
370
+ disabled={disabled}
371
+ className={cn(
372
+ "relative size-[26px] place-self-center rounded-full border border-[color:color-mix(in_oklab,var(--foreground)_10%,transparent)] transition-[box-shadow,transform,opacity] duration-150 ease-out hover:scale-[1.04] active:scale-[0.98]",
373
+ "focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_oklab,var(--ring)_40%,transparent)] focus-visible:outline-hidden",
374
+ isSelected &&
375
+ "after:pointer-events-none after:absolute after:-inset-[6px] after:rounded-full after:border-2 after:border-[color:var(--foreground)] after:content-['']",
376
+ disabled && "cursor-not-allowed opacity-60",
377
+ )}
378
+ style={{ backgroundColor: palette.shades["500"] }}
379
+ onClick={() => {
380
+ applyLiveSelection(
381
+ {
382
+ family: palette.name,
383
+ shade: optimisticValueRef.current.shade,
384
+ },
385
+ "click",
386
+ );
387
+ }}
388
+ />
389
+ );
390
+ })}
391
+ </div>
392
+ );
393
+
394
+ const shadeRail = (
395
+ <div className="flex items-stretch">
396
+ <div
397
+ ref={shadeTrackRef}
398
+ data-slot="palette-shade-track"
399
+ data-testid="palette-shade-track"
400
+ className="relative flex min-h-0 flex-col"
401
+ style={{ height: `${paletteBlockHeight}px`, width: `${SHADE_RAIL_WIDTH}px` }}
402
+ >
403
+ <div
404
+ data-slot="palette-shade-indicator"
405
+ data-testid="palette-shade-indicator"
406
+ aria-hidden="true"
407
+ className={cn(
408
+ "absolute inset-x-0 z-10 touch-none",
409
+ isShadeDragging
410
+ ? "cursor-grabbing transition-none"
411
+ : "cursor-grab transition-[top] duration-130 ease-out",
412
+ disabled && "cursor-not-allowed",
413
+ )}
414
+ style={{
415
+ height: `${shadeSegmentPercent}%`,
416
+ top: `${indicatorTopPercent}%`,
417
+ }}
418
+ onPointerDown={(event) => {
419
+ if (disabled) {
420
+ return;
421
+ }
422
+
423
+ event.preventDefault();
424
+ clearClickCommitTimeout();
425
+ clearPersistTimeout();
426
+ pendingPersistRef.current = null;
427
+ setInteractionState(true);
428
+ setIsShadeDragging(true);
429
+ }}
430
+ >
431
+ <div className="absolute inset-[-3px] rounded-[7px] border-[3px] border-[color:var(--foreground)] [box-shadow:0_0_4px_rgba(0,0,0,0.3),inset_0_0_4px_rgba(0,0,0,0.3)]" />
432
+ </div>
433
+
434
+ {PALETTE_SHADE_STEPS.map((shade, index) => {
435
+ const isSelected = shade === optimisticValue.shade;
436
+ const isFirst = index === 0;
437
+ const isLast = index === PALETTE_SHADE_STEPS.length - 1;
438
+
439
+ return (
440
+ <button
441
+ key={shade}
442
+ type="button"
443
+ aria-label={`Primary shade ${shade}`}
444
+ aria-pressed={isSelected}
445
+ disabled={disabled}
446
+ className={cn(
447
+ "relative inline-flex min-h-0 w-5 flex-1 rounded-none border border-transparent transition-[opacity,transform] duration-150 ease-out hover:scale-[1.02]",
448
+ "focus-visible:ring-2 focus-visible:ring-[color:color-mix(in_oklab,var(--ring)_40%,transparent)] focus-visible:outline-hidden",
449
+ isFirst && "rounded-t-[3px]",
450
+ isLast && "rounded-b-[3px]",
451
+ disabled && "cursor-not-allowed opacity-60",
452
+ )}
453
+ style={{ backgroundColor: activePalette.shades[shade] }}
454
+ onClick={() => {
455
+ applyLiveSelection(
456
+ {
457
+ family: optimisticValueRef.current.family,
458
+ shade,
459
+ },
460
+ "click",
461
+ );
462
+ }}
463
+ >
464
+ <span className="sr-only">{shade}</span>
465
+ </button>
466
+ );
467
+ })}
468
+ </div>
469
+ </div>
470
+ );
471
+
472
+ if (variant === "panel") {
473
+ return (
474
+ <div
475
+ aria-label={ariaLabel}
476
+ data-slot="palette-control"
477
+ className={cn("flex w-full justify-center py-[12px]", className)}
478
+ role="group"
479
+ >
480
+ <div className="inline-flex w-fit items-stretch">
481
+ <div
482
+ data-slot="palette-control-palette-block"
483
+ data-testid="palette-control-palette-block"
484
+ className="shrink-0"
485
+ >
486
+ {paletteGrid}
487
+ </div>
488
+ <div
489
+ aria-hidden="true"
490
+ className="mx-5 w-px shrink-0 bg-[color:color-mix(in_oklab,var(--border)_8%,transparent)]"
491
+ />
492
+ <div
493
+ data-slot="palette-control-slider-block"
494
+ data-testid="palette-control-slider-block"
495
+ className="flex min-w-0 items-stretch"
496
+ >
497
+ {shadeRail}
498
+ </div>
499
+ </div>
500
+ </div>
501
+ );
502
+ }
503
+
504
+ return (
505
+ <div
506
+ aria-label={ariaLabel}
507
+ data-slot="palette-control"
508
+ className={cn(
509
+ "inline-flex flex-col overflow-hidden rounded-lg border border-[color:color-mix(in_oklab,var(--muted-foreground)_20%,transparent)] bg-[color:color-mix(in_oklab,var(--popover)_98%,transparent)] text-[color:var(--popover-foreground)] shadow-[0_10px_16px_color-mix(in_oklab,var(--background)_40%,transparent)]",
510
+ className,
511
+ )}
512
+ role="group"
513
+ >
514
+ <div className="flex h-10 items-center border-b border-[color:color-mix(in_oklab,var(--muted-foreground)_20%,transparent)] px-4">
515
+ <div className="text-[14px] leading-none font-semibold text-[color:var(--foreground)]">
516
+ {title}
517
+ </div>
518
+ </div>
519
+ <div className="inline-grid grid-cols-[auto_1px_auto] items-stretch">
520
+ <div className="px-4 py-4">
521
+ {paletteGrid}
522
+ </div>
523
+
524
+ <div
525
+ aria-hidden="true"
526
+ className="h-full w-px bg-[color:color-mix(in_oklab,var(--muted-foreground)_20%,transparent)]"
527
+ />
528
+
529
+ <div className="flex items-stretch px-4 py-4">
530
+ {shadeRail}
531
+ </div>
532
+ </div>
533
+ </div>
534
+ );
535
+ }
@@ -0,0 +1,162 @@
1
+ import {
2
+ clampNumber,
3
+ hexToHsv,
4
+ hsvToHex,
5
+ normalizeHexColor,
6
+ } from "../../../lib/style-guide-color-utils";
7
+
8
+ export type ColorFormatMode = "hex" | "rgb" | "css" | "hsl" | "hsb";
9
+ export type ColorSurfaceModel = "rgb" | "hsl" | "hsb";
10
+
11
+ export type ColorChannels = {
12
+ hex: string;
13
+ rgb: [number, number, number];
14
+ hsl: [number, number, number];
15
+ hsb: [number, number, number];
16
+ };
17
+
18
+ function getRgbChannels(hex: string): [number, number, number] {
19
+ const normalizedHex = normalizeHexColor(hex) ?? "#000000";
20
+ const value = normalizedHex.slice(1);
21
+
22
+ return [
23
+ Number.parseInt(value.slice(0, 2), 16),
24
+ Number.parseInt(value.slice(2, 4), 16),
25
+ Number.parseInt(value.slice(4, 6), 16),
26
+ ];
27
+ }
28
+
29
+ function getHexChannel(value: number): string {
30
+ return value.toString(16).padStart(2, "0");
31
+ }
32
+
33
+ export function rgbChannelsToHex([red, green, blue]: [number, number, number]): string {
34
+ return `#${getHexChannel(red)}${getHexChannel(green)}${getHexChannel(blue)}`;
35
+ }
36
+
37
+ export function hslChannelsToHex([hue, saturation, lightness]: [number, number, number]): string {
38
+ const normalizedHue = ((hue % 360) + 360) % 360;
39
+ const s = clampNumber(saturation, 0, 100) / 100;
40
+ const l = clampNumber(lightness, 0, 100) / 100;
41
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
42
+ const hueSegment = normalizedHue / 60;
43
+ const x = chroma * (1 - Math.abs((hueSegment % 2) - 1));
44
+ const match = l - chroma / 2;
45
+ const [red, green, blue] = getHslRgbChannels(hueSegment, chroma, x);
46
+
47
+ return rgbChannelsToHex([
48
+ Math.round((red + match) * 255),
49
+ Math.round((green + match) * 255),
50
+ Math.round((blue + match) * 255),
51
+ ]);
52
+ }
53
+
54
+ function getHslRgbChannels(hueSegment: number, chroma: number, x: number) {
55
+ if (hueSegment < 1) return [chroma, x, 0] as const;
56
+ if (hueSegment < 2) return [x, chroma, 0] as const;
57
+ if (hueSegment < 3) return [0, chroma, x] as const;
58
+ if (hueSegment < 4) return [0, x, chroma] as const;
59
+ if (hueSegment < 5) return [x, 0, chroma] as const;
60
+
61
+ return [chroma, 0, x] as const;
62
+ }
63
+
64
+ function getHslChannels([red, green, blue]: [number, number, number]): [number, number, number] {
65
+ const r = red / 255;
66
+ const g = green / 255;
67
+ const b = blue / 255;
68
+ const max = Math.max(r, g, b);
69
+ const min = Math.min(r, g, b);
70
+ const lightness = (max + min) / 2;
71
+ const delta = max - min;
72
+ const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1));
73
+
74
+ return [
75
+ Math.round(hexToHsv(rgbChannelsToHex([red, green, blue])).h),
76
+ Math.round(saturation * 100),
77
+ Math.round(lightness * 100),
78
+ ];
79
+ }
80
+
81
+ export function getColorChannels(hex: string): ColorChannels {
82
+ const normalizedHex = (normalizeHexColor(hex) ?? "#000000").toUpperCase();
83
+ const rgb = getRgbChannels(normalizedHex);
84
+ const hsv = hexToHsv(normalizedHex);
85
+
86
+ return {
87
+ hex: normalizedHex,
88
+ rgb,
89
+ hsl: getHslChannels(rgb),
90
+ hsb: [Math.round(hsv.h), Math.round(hsv.s * 100), Math.round(hsv.v * 100)],
91
+ };
92
+ }
93
+
94
+ export function getEditableChannelHex({
95
+ channels,
96
+ mode,
97
+ channelIndex,
98
+ rawValue,
99
+ }: {
100
+ channels: ColorChannels;
101
+ mode: ColorFormatMode;
102
+ channelIndex: number;
103
+ rawValue: string;
104
+ }): string | null {
105
+ if (channelIndex > 2) return null;
106
+
107
+ const parsedValue = Number.parseInt(rawValue, 10);
108
+ if (!Number.isFinite(parsedValue)) return null;
109
+
110
+ if (mode === "rgb") return getEditableRgbHex(channels, channelIndex, parsedValue);
111
+ if (mode === "hsl") return getEditableHslHex(channels, channelIndex, parsedValue);
112
+ if (mode === "hsb") return getEditableHsbHex(channels, channelIndex, parsedValue);
113
+
114
+ return null;
115
+ }
116
+
117
+ export function getColorSurfaceModel(mode: ColorFormatMode): ColorSurfaceModel {
118
+ // Figma keeps Hex, RGB, and HSB on the hue/saturation/brightness surface.
119
+ // HSL is the one editable format with a distinct saturation/lightness plane.
120
+ return mode === "hsl" ? "hsl" : "hsb";
121
+ }
122
+
123
+ function getEditableRgbHex(
124
+ channels: ColorChannels,
125
+ channelIndex: number,
126
+ parsedValue: number,
127
+ ): string {
128
+ const nextChannels: [number, number, number] = [...channels.rgb];
129
+ nextChannels[channelIndex] = Math.round(clampNumber(parsedValue, 0, 255));
130
+
131
+ return rgbChannelsToHex(nextChannels);
132
+ }
133
+
134
+ function getEditableHslHex(
135
+ channels: ColorChannels,
136
+ channelIndex: number,
137
+ parsedValue: number,
138
+ ): string {
139
+ const nextChannels: [number, number, number] = [...channels.hsl];
140
+ nextChannels[channelIndex] = Math.round(getClampedColorValue(channelIndex, parsedValue));
141
+
142
+ return hslChannelsToHex(nextChannels);
143
+ }
144
+
145
+ function getEditableHsbHex(
146
+ channels: ColorChannels,
147
+ channelIndex: number,
148
+ parsedValue: number,
149
+ ): string {
150
+ const nextChannels: [number, number, number] = [...channels.hsb];
151
+ nextChannels[channelIndex] = Math.round(getClampedColorValue(channelIndex, parsedValue));
152
+
153
+ return hsvToHex({
154
+ h: nextChannels[0],
155
+ s: nextChannels[1] / 100,
156
+ v: nextChannels[2] / 100,
157
+ });
158
+ }
159
+
160
+ function getClampedColorValue(channelIndex: number, parsedValue: number): number {
161
+ return channelIndex === 0 ? clampNumber(parsedValue, 0, 360) : clampNumber(parsedValue, 0, 100);
162
+ }