@kolkrabbi/kol-component 0.7.0 → 0.9.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.
Files changed (131) hide show
  1. package/README.md +8 -0
  2. package/package.json +2 -5
  3. package/src/atoms/RotaryDial.jsx +35 -21
  4. package/src/hooks/colorMath.js +214 -0
  5. package/src/hooks/useEyedropper.js +124 -0
  6. package/src/index.js +15 -18
  7. package/src/molecules/ButtonGroup.jsx +45 -0
  8. package/src/molecules/ColorInputRow.jsx +146 -126
  9. package/src/molecules/PaletteHarmonyWheel.jsx +192 -0
  10. package/src/molecules/Slider.jsx +29 -14
  11. package/src/molecules/SplitToolButton.jsx +133 -0
  12. package/src/organisms/ContentFilters.jsx +2 -2
  13. package/src/organisms/GalleryCarousel.jsx +2 -1
  14. package/src/organisms/LoaderOverlay.jsx +11 -14
  15. package/src/organisms/MediaTileGallery.jsx +57 -0
  16. package/src/organisms/MediaViewer.jsx +89 -64
  17. package/src/atoms/PriceDisplay.jsx +0 -34
  18. package/src/atoms/TextPressure.jsx +0 -331
  19. package/src/atoms/TypeSample.jsx +0 -50
  20. package/src/atoms/TypeSpecCard.jsx +0 -42
  21. package/src/chess/ChessPiece.jsx +0 -191
  22. package/src/chess/apparatus/AlternativeControlsMock.jsx +0 -460
  23. package/src/chess/apparatus/ChessAnalysisLayout.jsx +0 -24
  24. package/src/chess/apparatus/ChessBoard.jsx +0 -210
  25. package/src/chess/apparatus/ChessBoardFullscreen.jsx +0 -49
  26. package/src/chess/apparatus/ChessBoardWithControls.jsx +0 -41
  27. package/src/chess/apparatus/ChessBoardWithSidebar.jsx +0 -122
  28. package/src/chess/apparatus/ChessSidebar.jsx +0 -131
  29. package/src/chess/apparatus/GameArchiveTable.jsx +0 -493
  30. package/src/chess/apparatus/GameSelector.jsx +0 -47
  31. package/src/chess/apparatus/NotationPanel.jsx +0 -70
  32. package/src/chess/apparatus/PlaybackControls.jsx +0 -52
  33. package/src/chess/apparatus/VariationTree.jsx +0 -137
  34. package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +0 -6
  35. package/src/chess/assets/chess-extra-set/set-1/bishop.svg +0 -6
  36. package/src/chess/assets/chess-extra-set/set-1/king-1.svg +0 -9
  37. package/src/chess/assets/chess-extra-set/set-1/king.svg +0 -7
  38. package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +0 -8
  39. package/src/chess/assets/chess-extra-set/set-1/knight.svg +0 -7
  40. package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +0 -3
  41. package/src/chess/assets/chess-extra-set/set-1/pawn.svg +0 -3
  42. package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +0 -11
  43. package/src/chess/assets/chess-extra-set/set-1/queen.svg +0 -6
  44. package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +0 -7
  45. package/src/chess/assets/chess-extra-set/set-1/rook.svg +0 -11
  46. package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +0 -4
  47. package/src/chess/assets/chess-extra-set/set-2/bishop.svg +0 -4
  48. package/src/chess/assets/chess-extra-set/set-2/king-1.svg +0 -4
  49. package/src/chess/assets/chess-extra-set/set-2/king.svg +0 -4
  50. package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +0 -5
  51. package/src/chess/assets/chess-extra-set/set-2/knight.svg +0 -5
  52. package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +0 -4
  53. package/src/chess/assets/chess-extra-set/set-2/pawn.svg +0 -4
  54. package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +0 -4
  55. package/src/chess/assets/chess-extra-set/set-2/queen.svg +0 -4
  56. package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +0 -5
  57. package/src/chess/assets/chess-extra-set/set-2/rook.svg +0 -5
  58. package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +0 -10
  59. package/src/chess/assets/chess-extra-set/set-3/bishop.svg +0 -3
  60. package/src/chess/assets/chess-extra-set/set-3/king-1.svg +0 -12
  61. package/src/chess/assets/chess-extra-set/set-3/king.svg +0 -3
  62. package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +0 -3
  63. package/src/chess/assets/chess-extra-set/set-3/knight.svg +0 -10
  64. package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +0 -10
  65. package/src/chess/assets/chess-extra-set/set-3/pawn.svg +0 -10
  66. package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +0 -10
  67. package/src/chess/assets/chess-extra-set/set-3/queen.svg +0 -3
  68. package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +0 -3
  69. package/src/chess/assets/chess-extra-set/set-3/rook.svg +0 -11
  70. package/src/chess/assets/chess-vector-set/ABoard.svg +0 -164
  71. package/src/chess/assets/chess-vector-set/BBiskup.svg +0 -12
  72. package/src/chess/assets/chess-vector-set/BKing.svg +0 -15
  73. package/src/chess/assets/chess-vector-set/BKnight.svg +0 -13
  74. package/src/chess/assets/chess-vector-set/BPawn.svg +0 -7
  75. package/src/chess/assets/chess-vector-set/BQueen.svg +0 -6
  76. package/src/chess/assets/chess-vector-set/BRook.svg +0 -12
  77. package/src/chess/assets/chess-vector-set/WBiskup.svg +0 -14
  78. package/src/chess/assets/chess-vector-set/WKing.svg +0 -13
  79. package/src/chess/assets/chess-vector-set/WKnight.svg +0 -10
  80. package/src/chess/assets/chess-vector-set/WPawn.svg +0 -8
  81. package/src/chess/assets/chess-vector-set/WQueen.svg +0 -17
  82. package/src/chess/assets/chess-vector-set/WRook.svg +0 -13
  83. package/src/chess/context/ChessControlsContext.jsx +0 -379
  84. package/src/chess/dashboards/ChessHero.jsx +0 -303
  85. package/src/chess/index.js +0 -32
  86. package/src/chess/utils/parsePgnTree.js +0 -97
  87. package/src/dashboards/cards/DashAlertCard.jsx +0 -44
  88. package/src/dashboards/cards/DashChartCard.jsx +0 -44
  89. package/src/dashboards/cards/DashFeaturedCard.jsx +0 -42
  90. package/src/dashboards/cards/DashListCard.jsx +0 -87
  91. package/src/dashboards/cards/DashMetricCard.jsx +0 -59
  92. package/src/dashboards/cards/DashSlotCard.jsx +0 -45
  93. package/src/dashboards/cards/DashStackedBarCard.jsx +0 -70
  94. package/src/dashboards/cards/DashTableCard.jsx +0 -31
  95. package/src/dashboards/cards/_shared/CardHeader.jsx +0 -15
  96. package/src/dashboards/cards/_shared/LegendRow.jsx +0 -25
  97. package/src/dashboards/charts/Candlestick.jsx +0 -79
  98. package/src/dashboards/charts/DonutChart.jsx +0 -87
  99. package/src/dashboards/charts/Heatmap.jsx +0 -79
  100. package/src/dashboards/charts/Histogram.jsx +0 -56
  101. package/src/dashboards/charts/LineChart.jsx +0 -157
  102. package/src/dashboards/charts/ScatterPlot.jsx +0 -93
  103. package/src/dashboards/charts/Sparkline.jsx +0 -63
  104. package/src/dashboards/index.js +0 -28
  105. package/src/dashboards/layout/DashboardGrid.jsx +0 -30
  106. package/src/dashboards/layout/GridCard.jsx +0 -44
  107. package/src/dashboards/shared/DashTooltip.jsx +0 -6
  108. package/src/dashboards/shared/formatMetric.js +0 -26
  109. package/src/dashboards/shared/useChartTooltip.js +0 -25
  110. package/src/dashboards/shared/useCountUp.js +0 -107
  111. package/src/molecules/ArticleCard.jsx +0 -178
  112. package/src/molecules/WorkListItem.jsx +0 -83
  113. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  114. package/src/organisms/ArticleHeader.jsx +0 -95
  115. package/src/organisms/ColorLoader.jsx +0 -155
  116. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  117. package/src/organisms/ParallaxShelf.jsx +0 -141
  118. package/src/organisms/PortableTextRenderer.jsx +0 -115
  119. package/src/organisms/ProductDetailLayout.jsx +0 -189
  120. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  121. package/src/organisms/StackHero.jsx +0 -83
  122. package/src/organisms/WorkCard.jsx +0 -120
  123. package/src/organisms/WorkViewToggle.jsx +0 -170
  124. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  125. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  126. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  127. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  128. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  129. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  130. package/src/organisms/foundry/glyphData.js +0 -30
  131. package/src/organisms/foundry/index.js +0 -21
package/README.md CHANGED
@@ -11,6 +11,14 @@ npm i @kolkrabbi/kol-component @kolkrabbi/kol-theme
11
11
 
12
12
  Requires a **Vite + Tailwind v4** app and the theme CSS imported (see the theme package).
13
13
 
14
+ ## Tailwind v4 consumers
15
+
16
+ Tailwind skips `node_modules` when scanning, so point it at this package's source — otherwise the utility classes inside the components never generate and layouts collapse:
17
+
18
+ ```css
19
+ @source "../node_modules/@kolkrabbi/kol-component/src";
20
+ ```
21
+
14
22
  ## Use
15
23
 
16
24
  ```jsx
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "./src/index.js",
8
8
  "module": "./src/index.js",
9
9
  "exports": {
10
- ".": "./src/index.js",
11
- "./foundry": "./src/organisms/foundry/index.js",
12
- "./dashboards": "./src/dashboards/index.js",
13
- "./chess": "./src/chess/index.js"
10
+ ".": "./src/index.js"
14
11
  },
15
12
  "files": [
16
13
  "src",
@@ -7,22 +7,29 @@ const snapTo = (v, min, max, step) => {
7
7
  }
8
8
 
9
9
  /**
10
- * RotaryDial — drag-to-set rotary knob numeric input. Vertical pointer drag
11
- * rotates the whole dial over a 270° sweep (−135° to +135°) mapped onto
12
- * min–max; a full sweep is ~200px of drag, movement measured absolute from
13
- * drag start. Controlled (`value`/`onChange`) with a local visual buffer so
14
- * rotation stays smooth while parent updates are RAF-throttled; the final
15
- * value fires on release. Focus + arrow keys step the value
16
- * (role="slider" with aria-valuemin/max/now). The label and `%` readout
17
- * rows render only when `label` is set; label text renders as authored.
10
+ * RotaryDial — drag-to-set rotary knob numeric input. The KNOB VARIANT of
11
+ * Slider (molecules/Slider.jsx): both implement the shared value-control
12
+ * contract `value` / `min` / `max` / `step` / `onChange(next: number)` /
13
+ * `label` / `size` / `disabled` / `formatValue`. Controlled; onChange always
14
+ * fires with the plain number.
18
15
  *
19
- * @param {string} label text under the dial; also gates the label + readout rows
20
- * @param {number} value controlled value within min–max
21
- * @param {Function} onChange (next: number) => void RAF-throttled while dragging, final on release
22
- * @param {number} min minimum value (default 0)
23
- * @param {number} max maximum value (default 100)
24
- * @param {number} step drag snap / arrow-key increment (default 1)
25
- * @param {number} size dial px size (default 80); derives ring + disc radii
16
+ * Vertical pointer drag rotates the whole dial over a 270° sweep (−135° to
17
+ * +135°) mapped onto min–max; a full sweep is ~200px of drag, movement
18
+ * measured absolute from drag start. A local visual buffer keeps rotation
19
+ * smooth while parent updates are RAF-throttled; the final value fires on
20
+ * release. Focus + arrow keys step the value (role="slider" with
21
+ * aria-valuemin/max/now). The label and readout rows render only when
22
+ * `label` is set; label text renders as authored.
23
+ *
24
+ * @param {string} label text under the dial; also gates the label + readout rows
25
+ * @param {number} value controlled value within min–max
26
+ * @param {Function} onChange (next: number) => void — RAF-throttled while dragging, final on release
27
+ * @param {number} min minimum value (default 0)
28
+ * @param {number} max maximum value (default 100)
29
+ * @param {number} step drag snap / arrow-key increment (default 1)
30
+ * @param {number} size dial px size (default 80); derives ring + disc radii
31
+ * @param {boolean} disabled blocks drag + keyboard and dims the control (default false)
32
+ * @param {Function} formatValue optional readout formatter (value: number) => string; default `${value}%`
26
33
  */
27
34
  export default function RotaryDial({
28
35
  label,
@@ -32,6 +39,8 @@ export default function RotaryDial({
32
39
  max = 100,
33
40
  step = 1,
34
41
  size = 80,
42
+ disabled = false,
43
+ formatValue,
35
44
  }) {
36
45
  const [isDragging, setIsDragging] = useState(false)
37
46
  const [localValue, setLocalValue] = useState(value) // visual buffer — updates every move
@@ -99,23 +108,24 @@ export default function RotaryDial({
99
108
  const angle = -135 + ((localValue - min) / (max - min || 1)) * 270
100
109
 
101
110
  return (
102
- <div className="flex flex-col items-center gap-2">
111
+ <div className={`flex flex-col items-center gap-2 ${disabled ? 'opacity-50' : ''}`}>
103
112
  <div
104
113
  role="slider"
105
- tabIndex={0}
114
+ tabIndex={disabled ? -1 : 0}
106
115
  aria-label={label}
107
116
  aria-valuemin={min}
108
117
  aria-valuemax={max}
109
118
  aria-valuenow={localValue}
110
- className="relative cursor-pointer select-none touch-none"
119
+ aria-disabled={disabled || undefined}
120
+ className={`relative select-none touch-none ${disabled ? 'cursor-default' : 'cursor-pointer'}`}
111
121
  style={{
112
122
  width: size,
113
123
  height: size,
114
124
  transform: `rotate(${angle}deg)`,
115
125
  willChange: isDragging ? 'transform' : 'auto',
116
126
  }}
117
- onPointerDown={handlePointerDown}
118
- onKeyDown={handleKeyDown}
127
+ onPointerDown={disabled ? undefined : handlePointerDown}
128
+ onKeyDown={disabled ? undefined : handleKeyDown}
119
129
  >
120
130
  <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} style={{ overflow: 'visible' }}>
121
131
  {/* outer dashed tick ring */}
@@ -144,7 +154,11 @@ export default function RotaryDial({
144
154
  </svg>
145
155
  </div>
146
156
  {label && <div className="kol-helper-12 text-fg-64">{label}</div>}
147
- {label && <div className="kol-helper-12 text-fg-96">{value}%</div>}
157
+ {label && (
158
+ <div className="kol-helper-12 text-fg-96">
159
+ {formatValue ? String(formatValue(value)) : `${value}%`}
160
+ </div>
161
+ )}
148
162
  </div>
149
163
  )
150
164
  }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * colorMath — HSL/hex conversion + harmony generation for the KOL color
3
+ * pickers (SpectrumControls, PaletteHarmonyWheel).
4
+ *
5
+ * Plain functions, not a hook — it lives in src/hooks/ because that is the
6
+ * taxonomy's only non-component folder (same rationale as cssVar.js).
7
+ *
8
+ * Two harmony paths:
9
+ * - Deterministic — `harmonyColors(hue, harmony, {saturation, lightness})`
10
+ * / `generateHarmony(baseHex, harmony)`: role-offset hues off a base,
11
+ * no randomness, so dragging the harmony wheel is smooth + repeatable.
12
+ * - Seeded/jittered — `seedHarmony(baseHex, mode)`: slot 0 = the base,
13
+ * slots 1–5 derived via a mode with small random jitter, so consecutive
14
+ * "randomize" clicks produce related-but-different variants around a seed.
15
+ */
16
+
17
+ /* ── HSL / hex conversion ─────────────────────────────────────────────── */
18
+
19
+ /** '#RRGGBB' → { h: 0–360, s: 0–100, l: 0–100 }. */
20
+ export function hexToHsl(hex) {
21
+ const r = parseInt(hex.slice(1, 3), 16) / 255
22
+ const g = parseInt(hex.slice(3, 5), 16) / 255
23
+ const b = parseInt(hex.slice(5, 7), 16) / 255
24
+ const max = Math.max(r, g, b)
25
+ const min = Math.min(r, g, b)
26
+ let h = 0, s = 0
27
+ const l = (max + min) / 2
28
+ if (max !== min) {
29
+ const d = max - min
30
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
31
+ switch (max) {
32
+ case r: h = (g - b) / d + (g < b ? 6 : 0); break
33
+ case g: h = (b - r) / d + 2; break
34
+ case b: h = (r - g) / d + 4; break
35
+ }
36
+ h *= 60
37
+ }
38
+ return { h, s: s * 100, l: l * 100 }
39
+ }
40
+
41
+ /** (h: 0–360, s: 0–100, l: 0–100) → uppercase '#RRGGBB'. */
42
+ export function hslToHex(h, s, l) {
43
+ h = normHue(h)
44
+ s = Math.max(0, Math.min(100, s)) / 100
45
+ l = Math.max(0, Math.min(100, l)) / 100
46
+ const c = (1 - Math.abs(2 * l - 1)) * s
47
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
48
+ const m = l - c / 2
49
+ let r = 0, g = 0, b = 0
50
+ if (h < 60) { r = c; g = x; b = 0 }
51
+ else if (h < 120) { r = x; g = c; b = 0 }
52
+ else if (h < 180) { r = 0; g = c; b = x }
53
+ else if (h < 240) { r = 0; g = x; b = c }
54
+ else if (h < 300) { r = x; g = 0; b = c }
55
+ else { r = c; g = 0; b = x }
56
+ const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')
57
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toUpperCase()
58
+ }
59
+
60
+ /** RGB components (0–255) → uppercase '#RRGGBB'. Shared with the eyedropper. */
61
+ export function rgbToHex(r, g, b) {
62
+ return '#' + [r, g, b]
63
+ .map((n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'))
64
+ .join('')
65
+ .toUpperCase()
66
+ }
67
+
68
+ /** Wrap any hue into [0, 360). */
69
+ export const normHue = (h) => ((h % 360) + 360) % 360
70
+
71
+ /* ── Harmony schemes ──────────────────────────────────────────────────── */
72
+
73
+ /**
74
+ * Hue offsets for the five palette roles (Primary / Secondary / Light /
75
+ * Dark / Accent) relative to the base hue. The wheel draws a satellite
76
+ * marker per unique offset; the generators emit one color per role.
77
+ */
78
+ export const HARMONIES = [
79
+ { id: 'analogous', label: 'Analogous', roleOffsets: [0, -30, -15, 15, 30] },
80
+ { id: 'complementary', label: 'Complementary', roleOffsets: [0, 0, 0, 180, 180] },
81
+ { id: 'split', label: 'Split complementary', roleOffsets: [0, 150, 210, 0, 150] },
82
+ { id: 'triadic', label: 'Triadic', roleOffsets: [0, 120, 240, 0, 120] },
83
+ { id: 'tetradic', label: 'Tetradic', roleOffsets: [0, 90, 180, 270, 0] },
84
+ ]
85
+
86
+ /** Resolve a harmony id string OR a harmony object to the object. */
87
+ export const harmonyById = (harmony) => {
88
+ if (harmony && typeof harmony === 'object') return harmony
89
+ return HARMONIES.find((h) => h.id === harmony) ?? HARMONIES[0]
90
+ }
91
+
92
+ /**
93
+ * Deterministic role colors for a harmony, driven by a hue + fixed S/L.
94
+ * Preserves saturation/lightness across roles (only hue rotates), so the
95
+ * output tracks the wheel's satellite markers exactly.
96
+ *
97
+ * @param {number} hue base hue, 0–360
98
+ * @param {string|object} harmony harmony id or object
99
+ * @param {{saturation?: number, lightness?: number}} opts base S/L (0–100)
100
+ * @returns {string[]} one '#RRGGBB' per role offset
101
+ */
102
+ export function harmonyColors(hue, harmony, { saturation = 100, lightness = 50 } = {}) {
103
+ const { roleOffsets } = harmonyById(harmony)
104
+ return roleOffsets.map((off) => hslToHex(normHue(hue + off), saturation, lightness))
105
+ }
106
+
107
+ /**
108
+ * Deterministic role colors derived from a base hex (S/L taken from the hex).
109
+ * Convenience wrapper over `harmonyColors` for callers holding a color, not
110
+ * a hue.
111
+ */
112
+ export function generateHarmony(baseHex, harmony) {
113
+ const { h, s, l } = hexToHsl(baseHex)
114
+ return harmonyColors(h, harmony, { saturation: s, lightness: l })
115
+ }
116
+
117
+ /* ── Seeded (jittered) modes ──────────────────────────────────────────── */
118
+
119
+ const jitter = (range) => (Math.random() - 0.5) * 2 * range
120
+
121
+ /**
122
+ * Seed-based generators — slot 0 = the base, slots 1–5 derived via the mode.
123
+ * Each call jitters parameters so repeated "randomize" produces visibly
124
+ * different but harmonically-related variants around the same seed.
125
+ */
126
+ const SEED_MODES = {
127
+ random: (base) => {
128
+ const { h, s, l } = hexToHsl(base)
129
+ return [
130
+ base,
131
+ hslToHex(normHue(h + Math.random() * 360), s, l),
132
+ hslToHex(normHue(h + Math.random() * 360), s, l),
133
+ hslToHex(h, s, Math.max(10, l - 25 + jitter(10))),
134
+ hslToHex(h, s, Math.min(90, l + 25 + jitter(10))),
135
+ hslToHex(h, s * (0.4 + Math.random() * 0.4), l),
136
+ ]
137
+ },
138
+
139
+ monochromatic: (base) => {
140
+ const { h, s, l } = hexToHsl(base)
141
+ const j = 8
142
+ return [
143
+ base,
144
+ hslToHex(h, s, Math.max(10, l - 30 + jitter(j))),
145
+ hslToHex(h, s, Math.max(20, l - 15 + jitter(j))),
146
+ hslToHex(h, s, Math.min(85, l + 15 + jitter(j))),
147
+ hslToHex(h, s, Math.min(95, l + 30 + jitter(j))),
148
+ hslToHex(h, s, Math.min(98, l + 45 + jitter(j))),
149
+ ]
150
+ },
151
+
152
+ analogous: (base) => {
153
+ const { h, s, l } = hexToHsl(base)
154
+ return [
155
+ base,
156
+ hslToHex(normHue(h - 30 + jitter(8)), s, l),
157
+ hslToHex(normHue(h - 15 + jitter(8)), s, l),
158
+ hslToHex(normHue(h + 15 + jitter(8)), s, l),
159
+ hslToHex(normHue(h + 30 + jitter(8)), s, l),
160
+ hslToHex(normHue(h + 45 + jitter(8)), s, l),
161
+ ]
162
+ },
163
+
164
+ complementary: (base) => {
165
+ const { h, s, l } = hexToHsl(base)
166
+ return [
167
+ base,
168
+ hslToHex(h, s, Math.max(15, l - 25 + jitter(8))),
169
+ hslToHex(h, s, Math.min(85, l + 25 + jitter(8))),
170
+ hslToHex(normHue(h + 180 + jitter(10)), s, l),
171
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 15 + jitter(8))),
172
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.min(85, l + 15 + jitter(8))),
173
+ ]
174
+ },
175
+
176
+ triadic: (base) => {
177
+ const { h, s, l } = hexToHsl(base)
178
+ return [
179
+ base,
180
+ hslToHex(normHue(h + 120 + jitter(10)), s, l),
181
+ hslToHex(normHue(h + 240 + jitter(10)), s, l),
182
+ hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
183
+ hslToHex(normHue(h + 120 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
184
+ hslToHex(normHue(h + 240 + jitter(10)), s, Math.min(85, l + 20 + jitter(8))),
185
+ ]
186
+ },
187
+
188
+ doubleComplementary: (base) => {
189
+ const { h, s, l } = hexToHsl(base)
190
+ return [
191
+ base,
192
+ hslToHex(normHue(h + 90 + jitter(10)), s, l),
193
+ hslToHex(normHue(h + 180 + jitter(10)), s, l),
194
+ hslToHex(normHue(h + 270 + jitter(10)), s, l),
195
+ hslToHex(h, s, Math.min(85, l + 20 + jitter(8))),
196
+ hslToHex(normHue(h + 180 + jitter(10)), s, Math.max(15, l - 20 + jitter(8))),
197
+ ]
198
+ },
199
+ }
200
+
201
+ /** The available seed-mode ids (for a consumer's mode selector). */
202
+ export const SEED_MODE_IDS = Object.keys(SEED_MODES)
203
+
204
+ /**
205
+ * Generate a jittered 6-color palette from a seed hex.
206
+ *
207
+ * @param {string} baseHex the seed color, occupies slot 0
208
+ * @param {string} mode one of SEED_MODE_IDS (falls back to 'random')
209
+ * @returns {string[]} six '#RRGGBB' values
210
+ */
211
+ export function seedHarmony(baseHex, mode = 'random') {
212
+ const gen = SEED_MODES[mode] ?? SEED_MODES.random
213
+ return gen(baseHex)
214
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * useEyedropper — cross-browser color sampling.
3
+ *
4
+ * Primary path is the native `window.EyeDropper` API (Chromium): one call,
5
+ * samples any pixel on screen, returns an sRGB hex. Where it is unavailable
6
+ * (Firefox, Safari) the hook falls back to an injected `fallback` sampler —
7
+ * e.g. `pickFromCanvasElement`, which samples a pixel from a <canvas> the
8
+ * consumer renders. The DS stays decoupled: it never reaches into an app's
9
+ * canvas/scene — the consumer supplies the fallback.
10
+ *
11
+ * Pairs with the EyedropPick affordance in SwatchControls: gate the button on
12
+ * `supported`, wire the button's `onPick` to `pick`.
13
+ *
14
+ * const { supported, pick } = useEyedropper({ fallback })
15
+ * // supported → render the eyedropper button
16
+ * const hex = await pick() // '#RRGGBB' or null (cancelled / unavailable)
17
+ */
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
19
+ import { rgbToHex } from './colorMath.js'
20
+
21
+ /* Native support probe — read at call time so it is correct even before the
22
+ * post-mount state (below) has settled. */
23
+ function hasNativeEyeDropper() {
24
+ return typeof window !== 'undefined' && 'EyeDropper' in window
25
+ }
26
+
27
+ /**
28
+ * Generic canvas-region sampler — the reusable core of a canvas eyedropper.
29
+ * Sets a crosshair cursor on `canvas`, awaits one pointer press on it, reads
30
+ * the pixel under the cursor via getImageData, and resolves the hex. Escape,
31
+ * a press outside the canvas bounds, or an aborted `signal` resolve to null.
32
+ *
33
+ * Backing-store aware: scales client coords by canvas.width/height ÷ CSS box,
34
+ * so a HiDPI or CSS-scaled canvas still samples the right pixel.
35
+ *
36
+ * @param {HTMLCanvasElement} canvas the canvas to sample from
37
+ * @param {{signal?: AbortSignal}} [opts]
38
+ * @returns {Promise<string|null>} uppercase '#RRGGBB' or null
39
+ */
40
+ export async function pickFromCanvasElement(canvas, { signal } = {}) {
41
+ if (!canvas || typeof canvas.getContext !== 'function') return null
42
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
43
+ if (!ctx) return null
44
+ if (signal?.aborted) return null
45
+
46
+ const prevCursor = canvas.style.cursor
47
+ canvas.style.cursor = 'crosshair'
48
+
49
+ return new Promise((resolve) => {
50
+ const cleanup = () => {
51
+ canvas.removeEventListener('pointerdown', onDown, true)
52
+ document.removeEventListener('keydown', onKey, true)
53
+ signal?.removeEventListener('abort', onAbort)
54
+ canvas.style.cursor = prevCursor
55
+ }
56
+
57
+ const onDown = (e) => {
58
+ e.preventDefault()
59
+ e.stopPropagation()
60
+ const rect = canvas.getBoundingClientRect()
61
+ const sx = rect.width ? canvas.width / rect.width : 1
62
+ const sy = rect.height ? canvas.height / rect.height : 1
63
+ const vx = Math.floor((e.clientX - rect.left) * sx)
64
+ const vy = Math.floor((e.clientY - rect.top) * sy)
65
+ if (vx < 0 || vy < 0 || vx >= canvas.width || vy >= canvas.height) {
66
+ cleanup(); resolve(null); return
67
+ }
68
+ let data
69
+ try { data = ctx.getImageData(vx, vy, 1, 1).data }
70
+ catch { cleanup(); resolve(null); return } /* tainted canvas */
71
+ cleanup()
72
+ resolve(rgbToHex(data[0], data[1], data[2]))
73
+ }
74
+
75
+ const onKey = (e) => {
76
+ if (e.key === 'Escape') { e.preventDefault(); cleanup(); resolve(null) }
77
+ }
78
+
79
+ const onAbort = () => { cleanup(); resolve(null) }
80
+
81
+ /* Capture phase so we beat the consumer's own canvas handlers. */
82
+ canvas.addEventListener('pointerdown', onDown, true)
83
+ document.addEventListener('keydown', onKey, true)
84
+ signal?.addEventListener('abort', onAbort)
85
+ })
86
+ }
87
+
88
+ /**
89
+ * @param {{fallback?: (opts?: {signal?: AbortSignal}) => Promise<string|null>}} [options]
90
+ * fallback — sampler used when the native EyeDropper API is absent. Return
91
+ * a hex or null. Without a fallback, unsupported browsers report
92
+ * `supported: false` and `pick` resolves null.
93
+ * @returns {{supported: boolean, nativeSupported: boolean,
94
+ * pick: (opts?: {signal?: AbortSignal}) => Promise<string|null>}}
95
+ */
96
+ export function useEyedropper({ fallback } = {}) {
97
+ /* Resolved after mount so SSR / first paint stays stable (starts false,
98
+ * flips true only where the native API exists). */
99
+ const [nativeSupported, setNativeSupported] = useState(false)
100
+ useEffect(() => { setNativeSupported(hasNativeEyeDropper()) }, [])
101
+
102
+ const fallbackRef = useRef(fallback)
103
+ fallbackRef.current = fallback
104
+
105
+ const supported = nativeSupported || !!fallback
106
+
107
+ const pick = useCallback(async ({ signal } = {}) => {
108
+ if (hasNativeEyeDropper()) {
109
+ try {
110
+ const res = await new window.EyeDropper().open(signal ? { signal } : undefined)
111
+ return res?.sRGBHex ? res.sRGBHex.toUpperCase() : null
112
+ } catch {
113
+ return null /* user cancelled (Escape) rejects the promise */
114
+ }
115
+ }
116
+ if (fallbackRef.current) return (await fallbackRef.current({ signal })) ?? null
117
+ return null
118
+ }, [])
119
+
120
+ return useMemo(
121
+ () => ({ supported, nativeSupported, pick }),
122
+ [supported, nativeSupported, pick],
123
+ )
124
+ }
package/src/index.js CHANGED
@@ -38,7 +38,6 @@ export { default as LabeledControl } from './atoms/LabeledControl.jsx'
38
38
  export { default as OverlayGlassPanel } from './atoms/OverlayGlassPanel.jsx'
39
39
  export { default as Pill } from './atoms/Pill.jsx'
40
40
  export { usePopover, PopoverPanel, Tooltip } from './atoms/Popover.jsx'
41
- export { default as PriceDisplay } from './atoms/PriceDisplay.jsx'
42
41
  export { default as ProsePreview } from './atoms/ProsePreview.jsx'
43
42
  export { default as QuantityInput } from './atoms/QuantityInput.jsx'
44
43
  export { default as RotaryDial } from './atoms/RotaryDial.jsx'
@@ -52,22 +51,18 @@ export { default as Textarea } from './atoms/Textarea.jsx'
52
51
  export { default as ToggleBracket } from './atoms/ToggleBracket.jsx'
53
52
  export { default as ToggleCheckbox } from './atoms/ToggleCheckbox.jsx'
54
53
  export { default as ToggleSwitch } from './atoms/ToggleSwitch.jsx'
55
- export { default as TextPressure } from './atoms/TextPressure.jsx'
56
54
  export { default as TiltCard } from './atoms/TiltCard.jsx'
57
55
  export { default as TransparentX } from './atoms/TransparentX.jsx'
58
- export { default as TypeSample } from './atoms/TypeSample.jsx'
59
- export { default as TypeSpecCard } from './atoms/TypeSpecCard.jsx'
60
56
  export { default as ViewToggle } from './atoms/ViewToggle.jsx'
61
57
 
62
58
  // molecules
63
59
  export { Accordion, AccordionPanel } from './molecules/Accordion.jsx'
60
+ export { default as ButtonGroup } from './molecules/ButtonGroup.jsx'
64
61
  /* monorepo sets (P6–P10) — molecule members */
65
62
  export { default as AlignmentGrid } from './molecules/AlignmentGrid.jsx'
66
- export { default as ArticleCard } from './molecules/ArticleCard.jsx'
67
63
  export { default as ImageBlock } from './molecules/ImageBlock.jsx'
68
64
  export { default as SelectionOverlay } from './molecules/SelectionOverlay.jsx'
69
65
  export { default as VideoBlock, getEmbedUrl } from './molecules/VideoBlock.jsx'
70
- export { default as WorkListItem } from './molecules/WorkListItem.jsx'
71
66
  export { default as CardFeatureItem } from './molecules/CardFeatureItem.jsx'
72
67
  export { default as CodeBlock } from './molecules/CodeBlock.jsx'
73
68
  export { default as ColorInputRow } from './molecules/ColorInputRow.jsx'
@@ -81,6 +76,7 @@ export { default as MediaRow } from './molecules/MediaRow.jsx'
81
76
  export { MenuItem, MenuDropdownItem, MenuDropdownDivider, MenuDropdownNest } from './molecules/MenuItem.jsx'
82
77
  export { MenuPopover } from './molecules/MenuPopover.jsx'
83
78
  export { ModalProvider, useModal } from './molecules/Modal.jsx'
79
+ export { default as PaletteHarmonyWheel } from './molecules/PaletteHarmonyWheel.jsx'
84
80
  export { default as PropertyInput } from './molecules/PropertyInput.jsx'
85
81
  export { default as ShapeDropdown } from './molecules/ShapeDropdown.jsx'
86
82
  export { default as ShellDrawer } from './molecules/ShellDrawer.jsx'
@@ -88,28 +84,20 @@ export { default as ShellSearchOverlay } from './molecules/ShellSearchOverlay.js
88
84
  export { default as Slider } from './molecules/Slider.jsx'
89
85
  export { default as SpecList } from './molecules/SpecList.jsx'
90
86
  export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './molecules/SpectrumControls.jsx'
87
+ export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
91
88
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
92
89
  export { default as TabsRow } from './molecules/TabsRow.jsx'
93
90
 
94
91
  // organisms
95
- /* monorepo sets (P6–P10) — organism members. Foundry members are isolated
96
- under the './foundry' subpath (opentype-heavy) not re-exported here. */
97
- export { default as ArticleHeader } from './organisms/ArticleHeader.jsx'
92
+ /* monorepo sets (P6–P10) — organism members. Foundry members live in the
93
+ standalone @kolkrabbi/kol-foundry package (with the type-specimen kit +
94
+ live-font effects moved there 2026-07-09) never re-exported here. */
98
95
  export { default as Canvas, CanvasFrame, PanViewport, CANVAS_VIRTUAL_W, DEFAULT_ASPECTS, CANVAS_DEFAULTS } from './organisms/Canvas.jsx'
99
- export { default as DiagonalMarqueeRiver } from './organisms/DiagonalMarqueeRiver.jsx'
100
96
  export { default as EditorShell } from './organisms/EditorShell.jsx'
101
97
  export { default as GalleryCarousel } from './organisms/GalleryCarousel.jsx'
102
- export { default as ParallaxShelf } from './organisms/ParallaxShelf.jsx'
103
- export { default as PortableTextRenderer, slugify } from './organisms/PortableTextRenderer.jsx'
104
- export { default as ProductDetailLayout } from './organisms/ProductDetailLayout.jsx'
105
- export { default as ScrollDriftGallery } from './organisms/ScrollDriftGallery.jsx'
106
- export { default as StackHero } from './organisms/StackHero.jsx'
107
- export { default as WorkCard } from './organisms/WorkCard.jsx'
108
- export { default as WorkViewToggle } from './organisms/WorkViewToggle.jsx'
109
98
  export { default as AsciiCursor } from './organisms/AsciiCursor.jsx'
110
99
  export { default as BentoCard } from './organisms/BentoCard.jsx'
111
100
  export { default as Carousel } from './organisms/Carousel.jsx'
112
- export { default as ColorLoader } from './organisms/ColorLoader.jsx'
113
101
  export { default as ContentFilters } from './organisms/ContentFilters.jsx'
114
102
  export { default as CtaGlobal } from './organisms/CtaGlobal.jsx'
115
103
  export { default as ErrorBoundary } from './organisms/ErrorBoundary.jsx'
@@ -118,6 +106,7 @@ export { default as FeaturedCarousel } from './organisms/FeaturedCarousel.jsx'
118
106
  export { default as FeaturesCardSection } from './organisms/FeaturesCardSection.jsx'
119
107
  export { default as FullBleedHero } from './organisms/FullBleedHero.jsx'
120
108
  export { default as LoaderOverlay } from './organisms/LoaderOverlay.jsx'
109
+ export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
121
110
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
122
111
  export { default as NewsletterBand } from './organisms/NewsletterBand.jsx'
123
112
  export { default as SpectrumGrid } from './organisms/SpectrumGrid.jsx'
@@ -135,4 +124,12 @@ export { default as useReveal } from './hooks/useReveal.js'
135
124
  export { default as useScrollSpy } from './hooks/useScrollSpy.js'
136
125
  export { default as useTilt } from './hooks/useTilt.js'
137
126
  export { default as useAxisAnimation } from './hooks/useAxisAnimation.js'
127
+ export { useEyedropper, pickFromCanvasElement } from './hooks/useEyedropper.js'
138
128
  export { resolveCssVar, resolveCssColor, isLight } from './hooks/cssVar.js'
129
+
130
+ // color math (support module — HSL/hex conversion + harmony generation)
131
+ export {
132
+ hexToHsl, hslToHex, rgbToHex, normHue,
133
+ HARMONIES, harmonyById, harmonyColors, generateHarmony,
134
+ SEED_MODE_IDS, seedHarmony,
135
+ } from './hooks/colorMath.js'
@@ -0,0 +1,45 @@
1
+ /* taxonomy-ok: composes consumer-passed Buttons via children — nesting happens at the call site */
2
+
3
+ /**
4
+ * ButtonGroup — responsive layout wrapper for a group of Buttons: stacked
5
+ * full-width on mobile, horizontal row from `sm` up, aligned left / center /
6
+ * right. Pure layout — children compose at the call site and render as-is
7
+ * (nothing is cloned, no props injected); all button behavior lives on each
8
+ * Button. `center` (default) hugs its content and self-centers; `left` /
9
+ * `right` stay full-width block. Alignment classes come from static lookup
10
+ * maps of complete literals — never interpolated — so Tailwind's scanner
11
+ * sees them.
12
+ *
13
+ * @param {'left'|'center'|'right'} align horizontal alignment + container layout mode (default 'center')
14
+ * @param {string} title optional heading rendered above the group, authored at the call site
15
+ * @param {string} className extra container classes (appended)
16
+ * @param {ReactNode} children the Buttons
17
+ */
18
+
19
+ const CONTAINER = {
20
+ left: 'flex flex-col w-full',
21
+ center: 'flex sm:inline-flex flex-col w-full sm:w-auto sm:mx-auto',
22
+ right: 'flex flex-col w-full',
23
+ }
24
+
25
+ const JUSTIFY = {
26
+ left: 'sm:justify-start',
27
+ center: 'sm:justify-center',
28
+ right: 'sm:justify-end',
29
+ }
30
+
31
+ export default function ButtonGroup({ align = 'center', title, className = '', children }) {
32
+ return (
33
+ <div className={[CONTAINER[align] || CONTAINER.center, className].filter(Boolean).join(' ')}>
34
+ {title && <h3 className="kol-sans-heading-05 text-emphasis mb-6">{title}</h3>}
35
+ <div
36
+ className={[
37
+ 'flex flex-col gap-4 sm:flex-row sm:items-center',
38
+ JUSTIFY[align] || JUSTIFY.center,
39
+ ].join(' ')}
40
+ >
41
+ {children}
42
+ </div>
43
+ </div>
44
+ )
45
+ }